- Fix install_mac.sh: use venv + Python 3.12 (3.14 incompatible with ML libs) - Fix run_mac.sh: activate venv, add CPU thread optimization env vars - Fix agent.py: remove f-string from SYSTEM_PROMPT template (NameError on import) - Add missing deps: sounddevice, pydub, imageio-ffmpeg, omegaconf - Optimize for M1: torch.inference_mode, set_num_threads, OMP/MKL tuning - Switch to qwen2.5:3b for faster LLM responses on Mac - Switch Whisper to medium model with auto compute (small+int8 had poor Russian) - Add initial_prompt for better Russian transcription - Add open_app tool for native macOS app launching - Fix TTS: sanitize Latin text to Cyrillic for Silero compatibility - Fix wake word echo: add cooldown after TTS, reset model state, raise threshold - Make "Слушаю" TTS synchronous to avoid mic interference - Fix train Dockerfile: remove tensorflow/onnx2tf (only ONNX needed), fix deps - Fix train.sh: use wget for dataset download, add --shm-size=2g - Add trained hey_cosmo.onnx wake word model - Add TODO section to CLAUDE.md (ChatterBox TTS, Ollama Modelfile ideas) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
7.7 KiB
Python
230 lines
7.7 KiB
Python
"""
|
||
Инструменты агента для smolagents — macOS версия.
|
||
Отличия от Windows: нативный bash, поиск через mdfind/Spotlight,
|
||
запуск приложений через 'open -a', нет реестра.
|
||
"""
|
||
|
||
import os
|
||
import subprocess
|
||
import webbrowser
|
||
import urllib.parse
|
||
from loguru import logger
|
||
from smolagents import tool
|
||
|
||
from cosmo.memory import Memory
|
||
|
||
_memory: Memory | None = None
|
||
|
||
def set_memory(mem: Memory):
|
||
global _memory
|
||
_memory = mem
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Инструменты
|
||
# ------------------------------------------------------------------
|
||
|
||
@tool
|
||
def run_shell(command: str) -> str:
|
||
"""
|
||
Выполнить команду в bash и получить вывод.
|
||
На macOS доступны все стандартные unix-команды.
|
||
Для запуска приложений используй: open -a "AppName" или open /path/to/app.
|
||
Для поиска файлов: mdfind, find, which.
|
||
|
||
Args:
|
||
command: bash команда для выполнения
|
||
"""
|
||
logger.info(f"[run_shell] {command}")
|
||
try:
|
||
result = subprocess.run(
|
||
["bash", "-c", command],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=20,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
output = result.stdout.strip()
|
||
stderr = result.stderr.strip()
|
||
if result.returncode != 0 and stderr:
|
||
return f"[returncode={result.returncode}]\nstdout: {output}\nstderr: {stderr}"
|
||
return output if output else f"[выполнено, returncode={result.returncode}]"
|
||
except subprocess.TimeoutExpired:
|
||
return "[таймаут 20с]"
|
||
except Exception as e:
|
||
return f"[ошибка: {e}]"
|
||
|
||
|
||
@tool
|
||
def find_program(name: str) -> str:
|
||
"""
|
||
Найти программу или приложение на macOS по имени.
|
||
Ищет в PATH, /Applications, ~/Applications и через Spotlight (mdfind).
|
||
Возвращает путь. Чтобы запустить — используй open_app.
|
||
|
||
Args:
|
||
name: имя программы, например 'webstorm', 'chrome', 'cursor', 'safari'
|
||
"""
|
||
stem = name.strip()
|
||
logger.info(f"[find_program] {stem}")
|
||
|
||
steps = [
|
||
# which — ищет в PATH
|
||
f"which {stem} 2>/dev/null",
|
||
# Spotlight — самый надёжный способ найти .app
|
||
f"mdfind 'kMDItemKind == \"Application\"' | grep -i '{stem}' | head -3",
|
||
# /Applications напрямую
|
||
f"find /Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3",
|
||
# ~/Applications
|
||
f"find ~/Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3",
|
||
# Homebrew bin
|
||
f"find /opt/homebrew/bin /usr/local/bin -iname '*{stem}*' 2>/dev/null | head -3",
|
||
]
|
||
|
||
results = []
|
||
for cmd in steps:
|
||
out = run_shell(cmd)
|
||
if out and not out.startswith("[") and len(out) > 3:
|
||
results.append(out.strip())
|
||
|
||
if results:
|
||
return "Найдено:\n" + "\n".join(results)
|
||
return f"Программа '{name}' не найдена."
|
||
|
||
|
||
@tool
|
||
def open_app(name: str) -> str:
|
||
"""
|
||
Запустить приложение на macOS по имени или полному пути.
|
||
Примеры: open_app("Safari"), open_app("/Applications/Safari.app"), open_app("Telegram").
|
||
|
||
Args:
|
||
name: имя приложения или полный путь к .app
|
||
"""
|
||
logger.info(f"[open_app] {name}")
|
||
# Если передан полный путь к .app
|
||
if name.endswith(".app") and os.path.exists(name):
|
||
result = run_shell(f'open "{name}"')
|
||
else:
|
||
# Пробуем по имени через open -a
|
||
result = run_shell(f'open -a "{name}"')
|
||
if result.startswith("[ошибка") or "returncode=1" in result:
|
||
return f"Не удалось открыть '{name}'. Попробуй find_program чтобы найти точное имя."
|
||
return f"Приложение '{name}' запущено."
|
||
|
||
|
||
@tool
|
||
def open_browser(url: str, search: bool = False) -> str:
|
||
"""
|
||
Открыть URL в браузере или выполнить поиск в Google.
|
||
|
||
Args:
|
||
url: полный URL (https://...) или поисковый запрос если search=True
|
||
search: если True — выполнить поиск Google по тексту в url
|
||
"""
|
||
if search:
|
||
url = "https://www.google.com/search?q=" + urllib.parse.quote(url)
|
||
elif not url.startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
webbrowser.open(url)
|
||
logger.info(f"[open_browser] {url}")
|
||
return f"Открыт браузер: {url}"
|
||
|
||
|
||
@tool
|
||
def read_file(path: str) -> str:
|
||
"""
|
||
Прочитать содержимое текстового файла.
|
||
|
||
Args:
|
||
path: абсолютный путь к файлу
|
||
"""
|
||
try:
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
return f.read(8000)
|
||
except FileNotFoundError:
|
||
return f"Файл не найден: {path}"
|
||
except Exception as e:
|
||
return f"Ошибка чтения: {e}"
|
||
|
||
|
||
@tool
|
||
def write_file(path: str, content: str) -> str:
|
||
"""
|
||
Записать текст в файл (создаёт или перезаписывает).
|
||
|
||
Args:
|
||
path: абсолютный путь к файлу
|
||
content: содержимое файла
|
||
"""
|
||
try:
|
||
dir_path = os.path.dirname(path)
|
||
if dir_path:
|
||
os.makedirs(dir_path, exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
return f"Файл записан: {path}"
|
||
except Exception as e:
|
||
return f"Ошибка записи: {e}"
|
||
|
||
|
||
@tool
|
||
def memory_set(key: str, value: str) -> str:
|
||
"""
|
||
Сохранить факт в долгосрочную память ассистента.
|
||
Используй для запоминания путей программ, предпочтений пользователя.
|
||
Ключи: 'app.название', 'user.имя', 'pref.что-то'.
|
||
|
||
Args:
|
||
key: ключ факта, например 'app.webstorm'
|
||
value: значение для сохранения
|
||
"""
|
||
if _memory is None:
|
||
return "Память не инициализирована"
|
||
_memory.set(key, value)
|
||
return f"Запомнено: {key} = {value!r}"
|
||
|
||
|
||
@tool
|
||
def memory_get(key: str) -> str:
|
||
"""
|
||
Получить сохранённый факт из долгосрочной памяти по ключу.
|
||
|
||
Args:
|
||
key: ключ факта, например 'app.webstorm'
|
||
"""
|
||
if _memory is None:
|
||
return "Память не инициализирована"
|
||
value = _memory.get(key)
|
||
return f"{key} = {value!r}" if value else f"Факт '{key}' не найден"
|
||
|
||
|
||
@tool
|
||
def memory_list(prefix: str = "") -> str:
|
||
"""
|
||
Показать все факты из памяти, опционально по префиксу.
|
||
|
||
Args:
|
||
prefix: префикс для фильтрации, например 'app.'
|
||
"""
|
||
if _memory is None:
|
||
return "Память не инициализирована"
|
||
facts = _memory.list_facts(prefix)
|
||
if not facts:
|
||
return "Память пуста" if not prefix else f"Нет фактов с префиксом '{prefix}'"
|
||
return "\n".join(f"{k}: {v}" for k, v in sorted(facts.items()))
|
||
|
||
|
||
ALL_TOOLS = [
|
||
run_shell,
|
||
find_program,
|
||
open_app,
|
||
open_browser,
|
||
read_file,
|
||
write_file,
|
||
memory_set,
|
||
memory_get,
|
||
memory_list,
|
||
]
|