Initial commit: Cosmo voice assistant

Полностью локальный голосовой ассистент на Python.

Стек:
- Wake word: openWakeWord (onnxruntime)
- STT: RealtimeSTT + faster-whisper + Silero VAD (CUDA)
- LLM-агент: smolagents ToolCallingAgent + Ollama qwen2.5:7b
- TTS: Silero V4 (torch.hub) + sounddevice
- Shell: Git Bash (Windows) / bash (macOS)

Поддерживает Windows и macOS. Агент с памятью и tool calling —
находит программы самостоятельно, запоминает пути, выполняет
произвольные shell-команды.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
d.klimov
2026-04-10 15:58:12 +03:00
commit 6010816f1d
23 changed files with 1969 additions and 0 deletions

136
cosmo/memory.py Normal file
View File

@@ -0,0 +1,136 @@
"""
Персистентная память ассистента.
Хранится в data/memory.json — LLM читает и пишет её через инструменты.
Структура:
{
"facts": {
"user.name": "Даниил",
"app.webstorm": "C:/Program Files/JetBrains/WebStorm.../webstorm64.exe",
"user.browser": "chrome",
...
},
"history": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]
}
"""
import json
import os
import threading
from datetime import datetime
from loguru import logger
MEMORY_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "memory.json"
)
class Memory:
def __init__(self, path: str = MEMORY_PATH, history_limit: int = 20):
self.path = path
self.history_limit = history_limit
self._lock = threading.Lock()
self._data = {"facts": {}, "history": []}
self._load()
# ------------------------------------------------------------------
# Персистентность
# ------------------------------------------------------------------
def _load(self):
os.makedirs(os.path.dirname(self.path), exist_ok=True)
if os.path.exists(self.path):
try:
with open(self.path, "r", encoding="utf-8") as f:
self._data = json.load(f)
logger.info(
f"Память загружена: {len(self._data.get('facts', {}))} фактов, "
f"{len(self._data.get('history', []))} сообщений в истории"
)
except Exception as e:
logger.warning(f"Не удалось загрузить память: {e}. Начинаю с чистой.")
self._data = {"facts": {}, "history": []}
else:
logger.info("Файл памяти не найден — создаю новый")
def _save(self):
try:
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Не удалось сохранить память: {e}")
# ------------------------------------------------------------------
# Факты (key-value долгосрочная память)
# ------------------------------------------------------------------
def get(self, key: str) -> str | None:
"""Получить факт по ключу. Возвращает None если не найден."""
with self._lock:
return self._data["facts"].get(key)
def set(self, key: str, value: str):
"""Сохранить факт. Перезаписывает если уже существует."""
with self._lock:
self._data["facts"][key] = value
self._save()
logger.debug(f"Память: сохранено [{key}] = {value!r}")
def delete(self, key: str) -> bool:
"""Удалить факт. Возвращает True если был."""
with self._lock:
existed = key in self._data["facts"]
if existed:
del self._data["facts"][key]
self._save()
return existed
def list_facts(self, prefix: str = "") -> dict:
"""Вернуть все факты, опционально отфильтрованные по префиксу."""
with self._lock:
facts = self._data["facts"]
if prefix:
return {k: v for k, v in facts.items() if k.startswith(prefix)}
return dict(facts)
def facts_as_text(self) -> str:
"""Все факты в виде читаемого текста для системного промпта."""
facts = self.list_facts()
if not facts:
return "Память пуста."
lines = [f" {k}: {v}" for k, v in sorted(facts.items())]
return "\n".join(lines)
# ------------------------------------------------------------------
# История разговора (краткосрочная, последние N сообщений)
# ------------------------------------------------------------------
def add_message(self, role: str, content: str):
"""Добавить сообщение в историю (role: user/assistant/tool)."""
with self._lock:
self._data["history"].append({
"role": role,
"content": content,
"ts": datetime.now().isoformat(timespec="seconds"),
})
# Ограничиваем длину истории
if len(self._data["history"]) > self.history_limit:
self._data["history"] = self._data["history"][-self.history_limit:]
self._save()
def get_history(self) -> list[dict]:
"""Вернуть историю в формате для LLM API (без поля ts)."""
with self._lock:
return [
{"role": m["role"], "content": m["content"]}
for m in self._data["history"]
]
def clear_history(self):
with self._lock:
self._data["history"] = []
self._save()