diff --git a/commands.yaml b/commands.yaml index 1286b0f..e969ebf 100644 --- a/commands.yaml +++ b/commands.yaml @@ -1220,3 +1220,58 @@ scheduler_cancel_text: - отмени про - забудь напоминание про action: {type: scheduler_cancel_by_text} + +codebase_set: + phrases: + - укажи проект + - укажи папку проекта + - укажи кодовую базу + - выбери проект + - проект сейчас + action: {type: codebase_set} + +codebase_where: + phrases: + - какой проект сейчас + - где проект + - какая папка проекта + - какая кодовая база + action: {type: codebase_where} + +codebase_ask: + phrases: + - что делает функция + - спроси код + - вопрос по коду + - что в коде + - найди в коде + - найди в проекте + - объясни код + action: {type: codebase_ask} + +github_set_repo: + phrases: + - текущий репо + - установи репо + - выбери репо + - репозиторий + action: {type: github_set_repo} + +github_list_prs: + phrases: + - какие пиары + - какие пулл реквесты + - что в пиарах + - что в pr + - открытые пиары + - список пиаров + action: {type: github_list_prs} + +github_summarize_pr: + phrases: + - разбери последний пиар + - объясни последний пиар + - обзор последнего пиара + - что в последнем pr + - разбор pr + action: {type: github_summarize_pr} diff --git a/dev_handlers.py b/dev_handlers.py new file mode 100644 index 0000000..5a7a0d6 --- /dev/null +++ b/dev_handlers.py @@ -0,0 +1,340 @@ +"""Developer-oriented handlers — port of `codebase_qa/` and `github_pr/` packs. + +Codebase Q&A: point Jarvis at a folder, ask questions; he walks source files +(depth-limited, size-capped) and feeds a digest to the LLM. + +GitHub PR review: requires the `gh` CLI authenticated. Lists open PRs or +reviews the latest one via LLM. + +Both need GROQ_TOKEN (or a configured OpenAI-compat endpoint). +""" + +import json +import os +import re +import subprocess + +import memory_store + +_speak_fn = print + + +def set_speak(fn): + global _speak_fn + _speak_fn = fn + + +def _speak(text): + try: + _speak_fn(text) + except Exception as exc: + print(f"[dev] speak: {exc}") + + +# ── codebase Q&A ──────────────────────────────────────────────────────────── + +_EXT_OK = { + 'rs', 'lua', 'py', 'ts', 'tsx', 'js', 'jsx', 'svelte', + 'go', 'java', 'kt', 'c', 'h', 'cpp', 'hpp', 'cs', + 'rb', 'php', 'sh', 'ps1', 'sql', 'toml', 'yaml', 'yml', + 'json', 'md', +} + +_SKIP_DIR = { + '.git', 'target', 'node_modules', 'dist', 'build', + '__pycache__', '.venv', 'venv', '.idea', '.vscode', +} + +_MAX_FILE_BYTES = 4096 +_MAX_TOTAL_BYTES = 50_000 +_MAX_FILES = 30 +_MAX_DEPTH = 3 + + +def _walk_codebase(root: str) -> list[tuple[str, str]]: + """Return list of (relative_path, content) tuples — capped per the constants above.""" + chunks = [] + total_bytes = 0 + root = os.path.normpath(root) + if not os.path.isdir(root): + return chunks + + for dirpath, dirnames, filenames in os.walk(root): + # Depth gate + rel = os.path.relpath(dirpath, root) + depth = 0 if rel == '.' else rel.count(os.sep) + 1 + if depth > _MAX_DEPTH: + dirnames[:] = [] + continue + # Prune skip dirs in-place + dirnames[:] = [d for d in dirnames + if d not in _SKIP_DIR and not d.startswith('.')] + + for fname in filenames: + if len(chunks) >= _MAX_FILES or total_bytes >= _MAX_TOTAL_BYTES: + return chunks + _, dot_ext = os.path.splitext(fname) + ext = (dot_ext or '').lstrip('.').lower() + if ext not in _EXT_OK: + continue + fp = os.path.join(dirpath, fname) + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as fh: + content = fh.read(_MAX_FILE_BYTES + 1) + except (OSError, ValueError): + continue + if len(content) > _MAX_FILE_BYTES: + content = content[:_MAX_FILE_BYTES] + "\n... [truncated]" + rel_path = os.path.relpath(fp, root) + chunks.append((rel_path, content)) + total_bytes += len(content) + return chunks + + +def do_codebase_set(action, voice): + """'Укажи проект C:\\Jarvis\\rust' — stores root via memory.""" + body = (voice or '').strip() + low = body.lower() + for trig in ('укажи папку проекта', 'укажи кодовую базу', + 'укажи проект', 'выбери проект', 'проект сейчас'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body: + _speak("Укажите путь к папке проекта.") + return + if not os.path.isdir(body): + _speak(f"Папка не найдена: {body}.") + return + memory_store.remember("codebase.root", body) + _speak("Проект установлен.") + + +def do_codebase_where(action, voice): + root = memory_store.recall("codebase.root") + if not root: + _speak("Проект не выбран. Скажите: укажи проект и путь.") + return + _speak(f"Сейчас работаю с проектом {root}.") + + +def do_codebase_ask(action, voice): + """'Что делает функция X' / 'найди в коде Y'.""" + root = memory_store.recall("codebase.root") + if not root: + _speak("Сначала укажите проект.") + return + + body = (voice or '').strip() + low = body.lower() + for trig in ('что делает функция', 'вопрос по коду', + 'спроси код', 'найди в проекте', + 'найди в коде', 'что в коде', 'объясни код'): + idx = low.find(trig) + if idx >= 0: + body = body[idx + len(trig):].strip(' ,.:') + break + + if not body: + _speak("Сформулируйте вопрос.") + return + + chunks = _walk_codebase(root) + if not chunks: + _speak("Не нашёл исходников в проекте.") + return + + digest = "\n\n".join(f"--- {p} ---\n{c}" for p, c in chunks) + + try: + import config as cfg + except ImportError: + cfg = None + token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None + if not token: + _speak("LLM не настроен — нужен GROQ_TOKEN.") + return + + try: + from openai import OpenAI + except ImportError: + _speak("OpenAI клиент не установлен.") + return + + client = OpenAI( + api_key=token, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + + user_prompt = ( + f"Ты — старший разработчик. По digest проекта ответь на вопрос пользователя " + f"кратко (3-5 предложений) на русском. Указывай файлы где уместно.\n\n" + f"=== ВОПРОС ===\n{body}\n\n=== КОД ===\n{digest}" + ) + + try: + resp = client.chat.completions.create( + model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + messages=[ + {'role': 'system', 'content': 'Ты — внимательный код-ревьюер. По существу, без воды.'}, + {'role': 'user', 'content': user_prompt}, + ], + max_tokens=400, temperature=0.2, timeout=45, + ) + answer = resp.choices[0].message.content.strip() + except Exception as exc: + print(f"[dev] codebase LLM: {exc}") + _speak("Не получилось получить ответ.") + return + + if not answer: + _speak("Пустой ответ.") + return + _speak(answer) + + +# ── github PR ─────────────────────────────────────────────────────────────── + +def _gh(*args, timeout=15): + """Run gh CLI, return (success, stdout, stderr).""" + try: + res = subprocess.run( + ['gh', *args], + capture_output=True, text=True, encoding='utf-8', + timeout=timeout, + ) + return res.returncode == 0, res.stdout, res.stderr + except FileNotFoundError: + return False, '', 'gh CLI not installed' + except Exception as exc: + return False, '', str(exc) + + +def do_github_set_repo(action, voice): + body = (voice or '').strip() + low = body.lower() + for trig in ('установи репо', 'выбери репо', 'текущий репо', 'репозиторий'): + if low.startswith(trig): + body = body[len(trig):].strip(' ,.:') + break + if not body or '/' not in body: + _speak("Скажите owner slash repo, например bossiara13 slash J A R V I S.") + return + memory_store.remember("github.repo", body) + _speak(f"Репозиторий {body} установлен.") + + +def do_github_list_prs(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий: текущий репо owner/repo.") + return + + ok, out, err = _gh('pr', 'list', '--repo', repo, '--state', 'open', + '--json', 'number,title,author', '--limit', '10') + if not ok: + print(f"[dev] gh list: {err[:200]}") + _speak("gh CLI не отвечает. Установите gh и выполните gh auth login.") + return + + try: + prs = json.loads(out or '[]') + except json.JSONDecodeError: + prs = [] + if not prs: + _speak("Открытых пиаров нет.") + return + + line = f"{len(prs)} открытых пиаров. " + titles = [p.get('title', '') for p in prs[:3] if p.get('title')] + if titles: + line += "Первые: " + ". ".join(titles) + "." + _speak(line) + + +def do_github_summarize_pr(action, voice): + repo = memory_store.recall("github.repo") + if not repo: + _speak("Сначала укажите репозиторий.") + return + + # Most recent open PR number + ok, out, _ = _gh('pr', 'list', '--repo', repo, '--state', 'open', + '--json', 'number', '--limit', '1') + if not ok: + _speak("gh CLI не отвечает.") + return + try: + items = json.loads(out or '[]') + number = items[0]['number'] if items else None + except (json.JSONDecodeError, KeyError, IndexError): + number = None + if not number: + _speak("Открытых пиаров нет.") + return + + ok, out, _ = _gh('pr', 'view', str(number), '--repo', repo, + '--json', 'title,body,additions,deletions,changedFiles,author') + if not ok: + _speak("Не получилось получить PR.") + return + try: + pr = json.loads(out) + except json.JSONDecodeError: + _speak("gh вернул битый JSON.") + return + + title = pr.get('title', '(no title)') + body = (pr.get('body') or '')[:3000] + additions = pr.get('additions', '?') + deletions = pr.get('deletions', '?') + files = pr.get('changedFiles', '?') + author = (pr.get('author') or {}).get('login', '(unknown)') + + try: + import config as cfg + except ImportError: + cfg = None + token = getattr(cfg, 'GROQ_TOKEN', None) if cfg else None + if not token: + _speak(f"PR номер {number}: {title}. Без LLM сводки — нужен GROQ_TOKEN.") + return + + try: + from openai import OpenAI + except ImportError: + _speak("OpenAI клиент не установлен.") + return + + client = OpenAI( + api_key=token, + base_url=getattr(cfg, 'GROQ_BASE_URL', 'https://api.groq.com/openai/v1'), + ) + + user_prompt = ( + f"Repo: {repo}\nPR #{number}: {title}\nAuthor: {author}\n" + f"Changed files: {files} (+{additions}, -{deletions})\n" + f"Description:\n{body}\n\n" + "Кратко (3-5 предложений) на русском: что меняет этот PR, " + "какие риски, стоит ли мёржить." + ) + + try: + resp = client.chat.completions.create( + model=getattr(cfg, 'GROQ_MODEL', 'llama-3.3-70b-versatile'), + messages=[ + {'role': 'system', 'content': "Ты — старший разработчик, делаешь код-ревью. По делу, без воды."}, + {'role': 'user', 'content': user_prompt}, + ], + max_tokens=400, temperature=0.2, timeout=45, + ) + review = resp.choices[0].message.content.strip() + except Exception as exc: + print(f"[dev] PR LLM: {exc}") + _speak("Не получилось проанализировать.") + return + + if not review: + _speak("Пустая сводка.") + return + _speak(f"Пиар номер {number}: {review}") diff --git a/extensions.py b/extensions.py index 0612faf..b8554ca 100644 --- a/extensions.py +++ b/extensions.py @@ -25,6 +25,7 @@ import profiles_store import macros_store import scheduler_store import vision_handler +import dev_handlers _speak_fn = print _set_clipboard_fn = None @@ -36,6 +37,7 @@ def set_speak(fn): _speak_fn = fn scheduler_store.set_speak(fn) vision_handler.set_speak(fn) + dev_handlers.set_speak(fn) def set_clipboard_fn(fn): @@ -875,3 +877,31 @@ def do_interesting_fact(action, voice): except Exception as exc: print(f"[ext] fact LLM failed: {exc}") _speak("Не получилось получить факт.") + + +# ── codebase Q&A (proxies to dev_handlers) ────────────────────────────────── + +def do_codebase_set(action, voice): + dev_handlers.do_codebase_set(action, voice) + + +def do_codebase_where(action, voice): + dev_handlers.do_codebase_where(action, voice) + + +def do_codebase_ask(action, voice): + dev_handlers.do_codebase_ask(action, voice) + + +# ── GitHub PR (proxies to dev_handlers) ───────────────────────────────────── + +def do_github_set_repo(action, voice): + dev_handlers.do_github_set_repo(action, voice) + + +def do_github_list_prs(action, voice): + dev_handlers.do_github_list_prs(action, voice) + + +def do_github_summarize_pr(action, voice): + dev_handlers.do_github_summarize_pr(action, voice) diff --git a/main.py b/main.py index d6942c7..925f9d7 100644 --- a/main.py +++ b/main.py @@ -1588,6 +1588,44 @@ def run_action(action): extensions.do_profile_set(action, _current_voice or '') elif t == 'profile_what': extensions.do_profile_what(action, _current_voice or '') + elif t == 'vision_describe': + extensions.do_vision_describe(action, _current_voice or '') + elif t == 'vision_read_error': + extensions.do_vision_read_error(action, _current_voice or '') + elif t == 'macros_start': + extensions.do_macros_start(action, _current_voice or '') + elif t == 'macros_save': + extensions.do_macros_save(action, _current_voice or '') + elif t == 'macros_cancel': + extensions.do_macros_cancel(action, _current_voice or '') + elif t == 'macros_replay': + extensions.do_macros_replay(action, _current_voice or '') + elif t == 'macros_list': + extensions.do_macros_list(action, _current_voice or '') + elif t == 'macros_delete': + extensions.do_macros_delete(action, _current_voice or '') + elif t == 'scheduler_add_reminder': + extensions.do_scheduler_add_reminder(action, _current_voice or '') + elif t == 'scheduler_add_recurring': + extensions.do_scheduler_add_recurring(action, _current_voice or '') + elif t == 'scheduler_list': + extensions.do_scheduler_list(action, _current_voice or '') + elif t == 'scheduler_clear': + extensions.do_scheduler_clear(action, _current_voice or '') + elif t == 'scheduler_cancel_by_text': + extensions.do_scheduler_cancel_by_text(action, _current_voice or '') + elif t == 'codebase_set': + extensions.do_codebase_set(action, _current_voice or '') + elif t == 'codebase_where': + extensions.do_codebase_where(action, _current_voice or '') + elif t == 'codebase_ask': + extensions.do_codebase_ask(action, _current_voice or '') + elif t == 'github_set_repo': + extensions.do_github_set_repo(action, _current_voice or '') + elif t == 'github_list_prs': + extensions.do_github_list_prs(action, _current_voice or '') + elif t == 'github_summarize_pr': + extensions.do_github_summarize_pr(action, _current_voice or '') _current_voice: str = ''