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:
136
cosmo/memory.py
Normal file
136
cosmo/memory.py
Normal 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()
|
||||
Reference in New Issue
Block a user