"""Simbiot Harness — CLI: таски, статус, ручные тики.""" import argparse import json import re import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from harness import commands, es from harness import paths as _paths # t-261004113351-2858c1: .env инстанса def main(): ap = argparse.ArgumentParser(prog="simbiot", description="Simbiot Harness CLI") sub = ap.add_subparsers(dest="cmd", required=True) p_task = sub.add_parser("task", help="подать задачу") # t-261003125513/e-261003125707: 'task list' раньше создавал задачу-клон # 'list' — теперь показывает доску (внутри 'list' как слово не валидируем, # чтобы не породить рекурсию справки) p_task.add_argument("title", nargs="?") p_task.add_argument("--list", dest="list_board", action="store_true", help="показать доску задач (не создавать)") p_task.add_argument("-d", "--details", default="") p_task.add_argument("-m", "--minutes", type=int, default=5, help="оценка длительности (класс S≤5м M≤60м L≤8ч XL)") p_task.add_argument("-a", "--await-delivery", action="store_true", help="результат доставить в TG-чат автоматически при готовности") p_pol = sub.add_parser("policy", help="задать политику") p_pol.add_argument("--identity", default=None) p_pol.add_argument("--cpu-threshold", type=int, default=None) p_rq = sub.add_parser("requeue", help="вернуть failed-задачу в очередь") p_rq.add_argument("task_id") p_rq.add_argument("-r", "--reason", default="manual requeue") p_cl = sub.add_parser("close", help="закрыть failed-задачу без работы (архив); failed>24ч схлопываются автоматически") p_cl.add_argument("task_id") p_cl.add_argument("-r", "--reason", default="manual close") p_bl = sub.add_parser("block", help="заблокировать живую задачу (ждёт уточнений); requeue вернёт в очередь") p_bl.add_argument("task_id") p_bl.add_argument("-r", "--reason", default="ожидает уточнений") p_cmp = sub.add_parser("complete", help="закрыть running-задачу с результатом (батч-тик, t-260930101325)") p_cmp.add_argument("task_id") p_cmp.add_argument("-a", "--answer", required=True, help="результат задачи") p_pr = sub.add_parser("promote", help="промоушен ready-задачи на уровень выше (S→M→L)") p_pr.add_argument("task_id") p_pr.add_argument("-r", "--reason", default="manual promote") p_tgr = sub.add_parser("tg-reply", help="ответить на входящее TG-сообщение") p_tgr.add_argument("msg_id", help="id входящего (m-NNN), для батча — несколько через запятую") p_tgr.add_argument("text") p_tgr.add_argument("--followup", action="store_true", help="досылка итога в чат УЖЕ отвеченного входящего (сначала ответ — потом работа); " "повтор с тем же текстом — SKIP") p_tgr.add_argument("-c", "--close", action="store_true", help="батч-режим: один сводный ответ закрывает все указанные входящие " "(t-260930064924)") p_taw = sub.add_parser("task-await", help="пометить задачу: результат доставить в TG при готовности") p_taw.add_argument("task_id") p_taw.add_argument("-r", "--reason", default="заказано из TG") p_tgt = sub.add_parser("tg-transcribe", help="расшифровать голосовое (m-NNN) вручную") p_tgt.add_argument("msg_id", help="id входящего (m-NNN)") p_tgt.add_argument("--force", action="store_true", help="перерасшифровать заново") p_tgl = sub.add_parser("tg-list", help="список входящих TG-сообщений (inbox)") p_tgl.add_argument("--new", action="store_true", help="только без ответа") p_tgl.add_argument("-n", "--limit", type=int, default=25, help="сколько последних показать") p_tgl.add_argument("--all", action="store_true", help="без ограничения по количеству") p_tgs = sub.add_parser("tg-seen", help="emoji-реакция 👀: маркер «сообщение взято в работу»") p_tgs.add_argument("msg_id", help="id входящего (m-NNN), несколько — через запятую") p_tgs.add_argument("-e", "--emoji", default="👀", help="эмодзи реакции (по умолчанию 👀)") p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") p_not.add_argument("text") p_not.add_argument("-k", "--key", dest="key", default=None, help="ключ идемпотентности: сообщение с таким ключом доставляется " "только один раз (дубль/повтор — SKIP; t-260930083937)") p_dfl = sub.add_parser("tg-deliver-file", help="отправить файл в чат (идемпотентно, t-260930083937)") p_dfl.add_argument("file", help="путь к файлу") p_dfl.add_argument("-c", "--caption", default="", help="подпись к файлу") p_dfl.add_argument("-k", "--key", dest="key", default=None, help="ключ идемпотентности (по умолчанию file:<абсолютный путь>)") p_dfl.add_argument("--force", action="store_true", help="отправить даже если уже доставлен (повторная доставка вручную)") p_dfl.add_argument("-m", "--msg", dest="msg", default=None, help="связать доставку с входящим m-NNN (анти-гонка уровней, " "t-261009160906): если на него уже отвечено иным артефактом — SKIP") p_pro = sub.add_parser("proactive", help="инициативная реплика: ритм/тема/отправка (t-260930073419)") p_pro.add_argument("--force", action="store_true", help="отправить сейчас, игнорируя окно/интервал/тишину") p_pro.add_argument("--plan", action="store_true", help="только состояние ритма, без отправки") p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список") p_tgc.add_argument("--register", action="store_true", help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)") p_tgc.add_argument("--list", action="store_true", help="показать текущие команды бота") p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)") p_esc.add_argument("reason") p_esc.add_argument("-f", "--from-level", default="min", choices=["min", "hour"], help="кто эскалирует") p_esc.add_argument("-c", "--context", default="", help="контекст для верхнего уровня") p_esc.add_argument("-s", "--severity", default="warn", choices=["info", "warn", "critical"]) p_esc.add_argument("-t", "--task-id", default=None, help="связанная задача") p_escl = sub.add_parser("escalations", help="список эскалаций") p_escl.add_argument("--open", action="store_true", help="только открытые") p_ack = sub.add_parser("escalation-ack", help="закрыть эскалацию (принята уровнем выше)") p_ack.add_argument("esc_id") p_ack.add_argument("-l", "--level", default="hour", help="кто принял") p_ack.add_argument("-n", "--note", default="", help="что сделали с эскалацией") sub.add_parser("status", help="состояние харнеса") p_aum = sub.add_parser("aum", help="снапшот AUM-метрики (замер) — t-260929161138") p_aum.add_argument("--json", action="store_true", help="полный JSON замера") p_aum.add_argument("--record", action="store_true", help="записать AumMeasured в стор (иначе только показать)") p_aump = sub.add_parser("aum-pilot", help="пилот: симуляции + генетический отбор конфигураций burn (песочница)") p_aump.add_argument("--json", action="store_true", help="полный JSON отчёта") p_ver = sub.add_parser("verify", help="полный аудит стора: подписи ed25519 + тампер-цепочка (t-260930083716)") p_ver.add_argument("--json", action="store_true", help="результат JSON") p_ver.add_argument("--after", type=int, default=0, help="проверять события после id") p_cmd = sub.add_parser("audit-command", help="подписать конверт команды приватным ключом харнеса") p_cmd.add_argument("json", help="команда как JSON-строка, напр. '{\"op\":\"task\",\"title\":...}'") p_vcmd = sub.add_parser("verify-command", help="проверить подписанный конверт команды (audit-command)") p_vcmd.add_argument("json", help="подписанный конверт (JSON-строка из audit-command)") sub.add_parser("audit-key", help="показать публичный ключ аудита (для внешних проверяющих)") sub.add_parser("tasks", help="доска задач") p_ev = sub.add_parser("events", help="хвост стора (последние N событий)") p_ev.add_argument("-n", type=int, default=15) p_ev.add_argument("--after", type=int, default=None, help="вывести события ПОСЛЕ этого id (с головы, по возрастанию)") p_init = sub.add_parser("init", help="инициализация стора") p_init.add_argument("--identity", default="Simbiot Harness — ритмичный агент кооператива") # Визард первого запуска (t-261002193628-1d1e5f, аналогично hermes): # интерактивный конфиг .env (токен TG, ключ 9router, модель) + init стора. p_wiz = sub.add_parser("wizard", help="визард первого запуска: настройка .env и стора") p_wiz.add_argument("--noninteractive", action="store_true", help="без вопросов: значения из ENV/--флагов, init без TG-настройки") p_wiz.add_argument("--tg-token", default=None, help="токен TG-бота (SIMBIOT_TG_TOKEN)") p_wiz.add_argument("--tg-allowed", default=None, help="разрешённые чат-ids через запятую (SIMBIOT_TG_ALLOWED)") p_wiz.add_argument("--router-key", default=None, help="ключ 9router (ROUTER_KEY)") p_wiz.add_argument("--router-base-url", default=None, help="база 9router (ROUTER_BASE_URL, по умолчанию http://127.0.0.1:20128/v1)") p_wiz.add_argument("--model", default=None, help="модель уровней (по умолчанию auto — как в harness/levels.py)") p_wiz.add_argument("--no-init", action="store_true", help="не инициализировать стор") # Подключение Telegram без полного визарда (просьба Alex, 2026-10-04): # токен/чаты в .env + самотест getMe + рестарт TG-поллера + меню команд. p_tgc2 = sub.add_parser("tg-connect", help="подключить Telegram: токен бота в .env, самотест, рестарт поллера") p_tgc2.add_argument("token", nargs="?", default=None, help="токен TG-бота (SIMBIOT_TG_TOKEN)") p_tgc2.add_argument("--token", dest="token_opt", default=None, help="токен TG-бота флагом (то же, что позиционным)") p_tgc2.add_argument("--allowed", default=None, help="разрешённые chat-id через запятую (SIMBIOT_TG_ALLOWED)") p_tgc2.add_argument("--no-restart", action="store_true", help="не перезапускать simbiot-harness-tg (токен подхватится при следующем рестарте)") p_tgc2.add_argument("--no-commands", action="store_true", help="не регистрировать меню команд в чате (setMyCommands)") p_tgs2 = sub.add_parser("tg-status", help="состояние Telegram-подключения: токен, чаты, поллер, меню бота") p_rag = sub.add_parser("rag-query", help="семантический поиск по базе кооператива (вендор /root/rag)") p_rag.add_argument("question") p_rag.add_argument("-k", type=int, default=5, help="сколько чанков (<=5)") p_rag.add_argument("--db", default=None, help="база RAG (по умолчанию rag.db; hermes.db — общая)") p_rag.add_argument("--json", action="store_true", help="вывести машинно-читаемо") p_mcp = sub.add_parser("mcp-call", help="вызов инструмента MCP-портала кооператива (cil.by/mcp, t-261010093054)") p_mcp.add_argument("tool", nargs="?", default="", help="имя тула (booking_slots, booking_create, whoami, …)") p_mcp.add_argument("arguments", nargs="?", default="", help="JSON-аргументы тула, напр. '{\"type\": \"call\"}'") p_mcp.add_argument("--list", action="store_true", help="показать тулы шлюза и выйти") p_mcp.add_argument("--timeout", type=int, default=None, help="таймаут запроса, сек") p_mcs = sub.add_parser("mcp-stats", help="статистика MCP-вызовов по журналу mcp_calls.log (t-261015091108)") p_mcs.add_argument("--tail", type=int, default=10, help="последние N вызовов (0 — только сводка)") p_cmp2 = sub.add_parser("compact", help="сжатие стора: шум сек-уровня → подписанный архив + мост цепочки") p_cmp2.add_argument("--dry-run", action="store_true", help="только посчитать кандидатов") p_cmp2.add_argument("--older-than-hours", type=float, default=1.0, help="трогать только события старше N часов (1)") p_cmp2.add_argument("--vacuum", action="store_true", help="VACUUM стора (эксклюзивный лок: писатели должны стоять)") p_cmp2.add_argument("--verify-archives", action="store_true", help="проверить все архивы: sha256 против StoreCompacted + подпись каждой строки") p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)") p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback", "reflect", "report", "retire"]) p_core.add_argument("--head", default=None, help="голова: addressing | task_fit (train/rollback; без — все для train)") p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика") p_dec.add_argument("-q", "--question", help="вопрос/инструкция (без -o — noul: P(да))") p_dec.add_argument("-o", "--option", action="append", default=[], help="вариант ключ=описание (2–9 штук → choice)") p_dec.add_argument("-s", "--state", default="", help="ситуация/контекст для модели") p_dec.add_argument("--stats", type=int, nargs="?", const=200, metavar="N", help="сводка последних N событий DecisionMade") args = ap.parse_args() if args.cmd == "init": es.init_db() commands.set_policy(identity=args.identity) print("OK: стор инициализирован, политика записана") elif args.cmd == "wizard": _run_wizard(args) elif args.cmd == "tg-connect": _run_tg_connect(args) elif args.cmd == "tg-status": _run_tg_status(args) elif args.cmd == "task": # t-261003125513/e-261003125707: 'task list' раньше создавал задачу-клон # 'list' — теперь показывает доску (внутри 'list' как слово не валидируем, # чтобы не породить рекурсию справки) if getattr(args, "list_board", False) or (args.title or "").strip() in ("list", "tasks", "task list"): es.init_db() for t in commands.list_tasks(): print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") elif not args.title: p_task.print_help() elif re.fullmatch(r"t-\d{6,14}-\w+", (args.title or "").strip()): # e-261004134613-dda90f: title=ID существующей задачи → показать её, # а не плодить клон (агент звал 'cli.py task t-...' для чтения) es.init_db() model = es.run_projections() tid = args.title.strip() t = (model.get("tasks") or {}).get(tid) if t is None: print(f"ERROR: задача {tid} не найдена — чтение по id, создание клона запрещено", file=sys.stderr) sys.exit(2) print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t.get('title','')}") print(f" est: {t.get('est_minutes','?')}м создана: {t.get('ts','?')}") if t.get("details"): print(f" детали: {t['details']}") else: # bc59609: guard мусорных заголовков ('list'/id без деталей) — ValueError наружу try: tid, size = commands.submit_task(args.title, args.details, args.minutes) except ValueError as e: print(f"ERROR: {e}") sys.exit(1) if args.await_delivery: commands.mark_awaiting_delivery(tid, reason="подана из TG с автодоставкой") print(f"OK: задача {tid} [{size}] подана (результат уйдёт в TG при готовности)") else: print(f"OK: задача {tid} [{size}] подана") elif args.cmd == "task-await": es.init_db() commands.mark_awaiting_delivery(args.task_id, reason=args.reason) print(f"OK: {args.task_id} помечена — результат будет доставлен в TG при готовности") elif args.cmd == "policy": pol = commands.set_policy(identity=args.identity, cpu_alert_threshold=args.cpu_threshold) print("OK:", json.dumps(pol, ensure_ascii=False)) elif args.cmd == "requeue": es.init_db() tid = commands.requeue_task(args.task_id, args.reason) print(f"OK: задача {tid} возвращена в очередь") elif args.cmd == "complete": # батч-тик (t-260930101325): исполнитель закрывает задачи пачки # по отдельности; с await_delivery результат уходит в TG (идемпотентно) es.init_db() tid, delivered = commands.complete_task_with_delivery( args.task_id, args.answer, level="manual") print(f"OK: задача {tid} завершена" + (" (доставлено в TG)" if delivered else "")) elif args.cmd == "close": # авто-схлопывание старых фейлов (e-261003094432): failed → closed, # ручное закрытие + демон-архиватор (failed>24ч автоматически) es.init_db() tid = commands.close_task(args.task_id, args.reason) print(f"OK: задача {tid} закрыта (closed — архив; requeue вернёт в очередь)") elif args.cmd == "block": # t-261004122019: blocked — задача есть, но не ready (ждёт уточнений) es.init_db() tid = commands.block_task(args.task_id, args.reason) print(f"OK: задача {tid} заблокирована (blocked; requeue вернёт в очередь)") elif args.cmd == "promote": es.init_db() tid, new_size = commands.promote_task(args.task_id, args.reason) print(f"OK: задача {tid} повышена до [{new_size}]") elif args.cmd == "tg-reply": es.init_db() from harness import tg_inbox low = args.text.strip().lower() if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"): # срез лестницы по запросу из TG — видимостью управляет сам харнес args.text = "📊 " + tg_inbox.ladder_snapshot() ids = [x.strip() for x in args.msg_id.split(",") if x.strip()] if args.followup: import hashlib head = ids[0] # батч — один чат: досылаем к голове aid = f"followup:{head}:{hashlib.sha1(args.text.encode()).hexdigest()[:10]}" ok = commands.deliver_artifact( aid, kind="message", owner="cli:tg-followup", caption=args.text, send=lambda: tg_inbox.send_followup(head, args.text)) print(f"OK: итог дослан в чат {head}" if ok else "SKIP: уже дослано (или чат недоступен)") elif len(ids) > 1 or (args.close and len(ids) == 1): if not args.close: print("ERROR: несколько id без -c/--close — укажите флаг для сводного батч-ответа") sys.exit(1) mid = tg_inbox.send_batch_reply(ids, args.text) print(f"OK: сводный батч-ответ отправлен (голова {mid}, закрыто {len(ids)})") else: mid = tg_inbox.send_reply(args.msg_id, args.text) print(f"OK: ответ на {mid} отправлен") elif args.cmd == "tg-seen": es.init_db() from harness import tg_inbox ids = [x.strip() for x in args.msg_id.split(",") if x.strip()] done = [] for mid in ids: ok = tg_inbox.mark_seen(mid, emoji=args.emoji) done.append(f"{mid}:{'ok' if ok else 'skip(нет/уже seen/Ошибка TG)'}") print("OK: " + ", ".join(done) if done else "OK: пусто") elif args.cmd == "tg-transcribe": es.init_db() from harness import voice from harness import tg_inbox model = es.run_projections() m = (model.get("inbox") or {}).get(args.msg_id) if m is None: print(f"ERROR: входящее {args.msg_id} не найдено") sys.exit(1) if args.force: # сбрасываем прошлую расшифровку в read model (событие поверх) # — transcribe_message идемпотентен по флагу transcribed es.append_event("inbox", args.msg_id, "MessageTranscribed", { "transcript": "", "engine": None, "reason": None, "reset": True, }) print("(force: прошлый транскрипт сброшен)") if m.get("transcribed") and not args.force: print(f"Уже расшифровано ({m.get('engine')}): {m.get('transcript')}") else: text, engine = voice.transcribe_message( args.msg_id, file_id=m.get("file_id") or "", file_unique_id=m.get("file_unique_id") or "", duration=m.get("duration"), kind=m.get("kind") or "voice") if text: print(f"OK ({engine}): {text}") else: reason = m.get("transcribe_reason") print(f"ПУСТО (engine={engine})" + (f", причина: {reason}" if reason else "") + " — транскрипт записан в стор как пустой") elif args.cmd == "tg-list": es.init_db() model = es.run_projections() inbox = model.get("inbox") or {} rows = sorted(inbox.items(), key=lambda kv: int(kv[0].split("-")[1]) if kv[0].split("-")[1].isdigit() else 10**9) total = len(rows) fresh = [kv for kv in rows if not kv[1].get("replied") and not kv[1].get("ignored")] print(f"всего: {total} | без ответа: {len(fresh)} | " f"отвечено: {sum(1 for _, v in rows if v.get('replied'))} | " f"ignored: {sum(1 for _, v in rows if v.get('ignored'))}") shown = [kv for kv in rows if not args.new or (not kv[1].get("replied") and not kv[1].get("ignored"))] if not args.all: shown = shown[-args.limit:] if not shown: print("(нет сообщений по фильтру)") for mid, v in shown: who = v.get("username") or v.get("from_name") or v.get("chat_id") or "?" st = ("replied" if v.get("replied") else "ignored" if v.get("ignored") else "transcribed" if v.get("transcribed") else "new") body = v.get("transcript") or v.get("text") or "" print(f"{mid:12} {str(who)[:12]:12} {st:11} {str(v.get('kind') or 'text'):5} {str(body)[:100]}") elif args.cmd == "ladder": es.init_db() from harness import tg_inbox print(tg_inbox.ladder_snapshot()) elif args.cmd == "tg-notify": es.init_db() from harness import tg_inbox if args.key: # идемпотентное уведомление (t-260930083937): ключ = артефакт-ид aid = f"msg:{args.key}" def _send(): return bool(tg_inbox.notify_chat(args.text)) ok = commands.deliver_artifact(aid, kind="message", send=_send, owner="cli:tg-notify", caption=args.text) print("OK: уведомление отправлено" if ok else "SKIP: уже доставлено (или некому отправить / чужой захват)") else: r = tg_inbox.notify_chat(args.text) print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)") elif args.cmd == "tg-deliver-file": es.init_db() from harness import tg_inbox if not os.path.exists(args.file): print(f"FAIL: файл не найден: {args.file}") sys.exit(1) aid = args.key or f"file:{os.path.abspath(args.file)}" # Анти-гонка уровней (t-261009160906): если доставка привязана к # входящему m-NNN и на него уже отвечено ДРУГИМ артефактом — # не отправляем второй файл на тот же запрос (гонка min/hour). if args.msg and not args.force: model = es.run_projections() m = (model.get("inbox") or {}).get(args.msg) if m is not None and m.get("replied") and m.get("reply_artifact") not in (None, "", aid): es.append_event("harness", aid, "DeliverySkipped", { "artifact_id": aid, "kind": "file", "reason": "msg-already-answered-by-other-artifact", "msg": args.msg, "reply_artifact": m.get("reply_artifact"), }) print(f"SKIP: на {args.msg} уже отвечено другим артефактом " f"({m.get('reply_artifact')}) — используй --force для повтора") sys.exit(0) def _send(): return tg_inbox.send_document_to_chat(args.file, caption=args.caption) ok = commands.deliver_artifact(aid, kind="file", send=_send, owner="cli:tg-deliver-file", caption=args.caption, force=args.force, msg=args.msg) print(f"OK: файл доставлен ({aid})" if ok else f"SKIP: уже доставлен ({aid}) — используй --force для повтора") elif args.cmd == "proactive": es.init_db() from harness import proactive if args.plan: print(proactive.plan_state()) return slot, why = proactive.speak(force=args.force) print(f"OK: {why}" if slot else f"SKIP: {why}") if not slot and not args.force: print("\n" + proactive.plan_state()) elif args.cmd == "tg-commands": es.init_db() from harness import tg_commands if args.register: mode = tg_commands.register_bot_commands() print(f"OK: команды бота зарегистрированы ({mode})" if mode else "ERROR: setMyCommands не прошёл (нет токена / API недоступен)") if args.list or not args.register: cmds = tg_commands.get_my_commands() if not cmds: print("(у бота нет зарегистрированных команд)") for name, desc in cmds: print(f"/{name} — {desc}") elif args.cmd == "rag-query": # без init_db: RAG не пишет в read model, только трейс по уровню env from harness import rag_lookup chunks = rag_lookup.rag_query(args.question, k=args.k, db=args.db) if args.json: print(json.dumps(chunks, ensure_ascii=False)) elif not chunks: print("(ничего не найдено или вендор недоступен)") for c in chunks: print(f"\n═══ {c['score']:.3f} | {c['source']} ═══") print(c["text"][:rag_lookup.TIMEOUT * 100 or 900]) elif args.cmd == "mcp-call": # MCP портала (t-261010093054): ручной вызов тула шлюза cil.by/mcp; # без init_db — клиент не пишет в read model (трейс только с level/trace_id) from harness import portal_mcp if args.list: names = portal_mcp.list_tools(force=True) print("\n".join(names) if names else "(шлюз недоступен или токена нет — проверь SIMBIOT_PORTAL_MCP_TOKEN)") return if not args.tool: ap.error("mcp-call: укажи tool (или --list)") kw = {"timeout": args.timeout} if args.timeout else {} print(portal_mcp.call_tool(args.tool, args.arguments, **kw)) elif args.cmd == "mcp-stats": # t-261015091108: статистика MCP-вызовов из журнала mcp_calls.log import collections log_path = os.path.join(_paths.home(), "mcp_calls.log") if not os.path.exists(log_path): print("(журнала ещё нет — mcp_calls.log пуст, вызовов не было)") return rows = [] with open(log_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: rows.append(json.loads(line)) except ValueError: pass by_tool = collections.Counter(r.get("tool", "?") for r in rows) by_level = collections.Counter(r.get("level", "?") for r in rows) fails = sum(1 for r in rows if not r.get("ok")) ms = [r.get("ms", 0) for r in rows if isinstance(r.get("ms"), int)] print(f"всего вызовов: {len(rows)} (ошибок: {fails})") if ms: print(f"время ответа: медиана {sorted(ms)[len(ms)//2]}мс, макс {max(ms)}мс") print("по тулам: " + ", ".join(f"{k}×{v}" for k, v in by_tool.most_common())) print("по уровням: " + ", ".join(f"{k}×{v}" for k, v in by_level.most_common())) if args.tail: print(f"\nпоследние {min(args.tail, len(rows))}:") for r in rows[-args.tail:]: args_s = json.dumps(r.get("arguments", {}), ensure_ascii=False)[:120] print(f"{r.get('ts','?')} [{r.get('level','?')}] {r.get('tool','?')} " f"{'ok' if r.get('ok') else 'FAIL'} {r.get('ms','?')}мс {args_s}") elif args.cmd == "escalate": es.init_db() eid, to_level = commands.raise_escalation( args.from_level, args.reason, args.context, args.severity, args.task_id) print(f"OK: эскалация {eid} → {to_level} ({args.severity})") elif args.cmd == "escalations": es.init_db() rows = commands.list_escalations(only_open=args.open) if not rows: print("(эскалаций нет)") for e in rows: ctx = f" | {e.get('context','')[:60]}" if e.get("context") else "" print(f"{e['id']} [{e.get('severity','info'):8}] {e.get('status','?'):5} " f"{e.get('from_level')} → {e.get('to_level')} " f"{e.get('reason','')[:70]}{ctx}") elif args.cmd == "escalation-ack": es.init_db() eid = commands.acknowledge_escalation(args.esc_id, args.level, args.note) print(f"OK: эскалация {eid} закрыта ({args.level})") elif args.cmd == "status": es.init_db() print(json.dumps(commands.status(), ensure_ascii=False, indent=1)) elif args.cmd == "aum": # t-260929161138: north-star метрика — замер и (опционально) запись в стор from harness import aum snap = aum.measure() if args.json: print(json.dumps(snap, ensure_ascii=False, indent=1)) else: print(f"AUM: {snap['aum']} ({snap['balances_src']})") print(f"burn 9router: 24ч={snap['burn_24h']} · 7д={snap['burn_7d']} у.е. " f"({len(snap['spend_days'])} дн., источник: {snap.get('burn_src', '9router-db')})") print(aum.build_policy_aum_summary(snap)) if args.record: es.init_db() es.append_event("harness", "aum", "AumMeasured", { "ts": snap["ts"], "aum": snap["aum"], "burn_24h": snap["burn_24h"], "burn_7d": snap["burn_7d"], "burn_src": snap.get("burn_src"), "policy": {"aum_target": aum.build_policy_aum_summary(snap)}, }) print("OK: AumMeasured записан, aum_target обновлён в policy") elif args.cmd == "aum-pilot": # t-260929161138: песочница — симуляции + генетический отбор (адвокат-отчёт) from harness import aum snap = aum.measure() pilot = aum.run_pilot(snap) if args.json: print(json.dumps(pilot, ensure_ascii=False, indent=1)) else: e = pilot["evolution"] print(f"AUM: {pilot['aum']} | burn 7д: {pilot['burn_7d']} у.е.") print(f"базлайн {e['baseline']} → {e['baseline_fitness']} | " f"лучшая {e['best']} → {e['best_fitness']} " f"({'+' if e['improvement_pct'] >= 0 else ''}{e['improvement_pct']}%)") print("вердикт:", e["verdict"]) elif args.cmd == "verify": # t-260930083716: полный аудит всей цепочки событий подписью from harness import audit es.init_db() rep = audit.verify_events(after_id=args.after) if args.json: print(json.dumps(rep, ensure_ascii=False)) else: print(f"событий в аудите: {rep['total']} | подписано криптоключом: " f"{rep['checked']} | genesis: {rep['genesis']} | legacy: {rep['legacy']}" + (f" | сжатий: {rep['compactions']} (мост сверен; архивы — " "cli.py compact --verify-archives)" if rep.get("compactions") else "")) if rep["ok"]: print("OK: подписи и тампер-цепочка валидны" + (f" (публичный ключ {audit.key_id()})" if rep["checked"] else " (подписанных пока нет — только genesis/legacy)")) else: print(f"FAIL: подпись/цепочка сломаны на событии id={rep['first_bad']} " f"— стор скомпрометирован!") sys.exit(0 if rep["ok"] else 2) elif args.cmd == "audit-command": es.init_db() from harness import audit try: command = json.loads(args.json) except ValueError as e: print(f"FAIL: это не JSON: {e}") sys.exit(1) signed = audit.sign_command(command) print(json.dumps(signed, ensure_ascii=False, indent=1)) elif args.cmd == "verify-command": es.init_db() from harness import audit try: signed = json.loads(args.json) except ValueError as e: print(f"FAIL: это не JSON: {e}") sys.exit(1) rep = audit.verify_command(signed) print(json.dumps(rep, ensure_ascii=False, indent=1)) sys.exit(0 if rep.get("ok") else 2) elif args.cmd == "audit-key": es.init_db() from harness import audit info = audit.key_info() print(json.dumps(info, ensure_ascii=False, indent=1)) elif args.cmd == "tasks": es.init_db() for t in commands.list_tasks(): print(f"{t['id']} [{t['size']:2}] {t['status']:7} {t['title'][:70]}") elif args.cmd == "compact": from harness import compact es.init_db() if args.verify_archives: ok_all = True for e in es.get_events(0, 10**9): if e["event_type"] != "StoreCompacted": continue d = json.loads(e["data"]) path = os.path.join(compact.archive_dir(), d["archive"]) if not os.path.exists(path): print(f"{d['compaction']}: АРХИВ НЕ НАЙДЕН {path}") ok_all = False continue r = compact.verify_archive(path, d.get("archive_sha256")) ok_all &= r["ok"] and r["rows"] == d.get("rows") print(f"{d['compaction']}: {'OK' if r['ok'] and r['rows'] == d.get('rows') else 'ПРОВАЛ'} " f"строк {r['rows']}/{d.get('rows')}, подписи {r['signed_ok']}, genesis " f"{r['genesis']}, legacy {r['legacy']}, sha256 {'совпал' if r['sha_ok'] else 'НЕ СОВПАЛ'}" + (f", первая битая id={r['first_bad']}" if r["first_bad"] else "")) sys.exit(0 if ok_all else 2) if args.vacuum: r = compact.vacuum() print(f"VACUUM: {r['before'] / 1e6:.0f} МБ → {r['after'] / 1e6:.0f} МБ") return if args.dry_run: p = compact.plan(args.older_than_hours) print(f"кандидатов {p['candidates']} из {p['total']} (id {p['first_id']}..{p['last_id']}, " f"граница id≤{p['upto_id']}); стор {p['db_bytes'] / 1e6:.0f} МБ") return r = compact.compact(args.older_than_hours) if r.get("compaction"): print(f"OK {r['compaction']}: в архив {r['rows']}, удалено {r['removed']}, " f"мост {r['bridge_count']}; архив {r['archive_path']}") elif args.cmd == "core": from harness import kodi_core es.init_db() if args.action == "status": print(json.dumps(kodi_core.status(), ensure_ascii=False, indent=1)) elif args.action == "train": res = (kodi_core.train(head=args.head) if args.head else kodi_core.train_all()) print(json.dumps(res, ensure_ascii=False, default=str)) elif args.action == "backfill": print(f"меток учителя: {kodi_core.backfill()}") elif args.action == "feedback": print(f"обратная связь: {kodi_core.harvest_feedback()}") elif args.action == "reflect": print(f"задачи рефлексии: {kodi_core.reflect()}") elif args.action == "retire": print(json.dumps(kodi_core.retire(args.head or kodi_core.HEAD, "снят вручную"), ensure_ascii=False)) elif args.action == "report": print(kodi_core.weekly_report(force=True) or "SKIP: уже отправлен на этой неделе") else: print(json.dumps(kodi_core.rollback(args.head or kodi_core.HEAD), ensure_ascii=False)) elif args.cmd == "decide": from harness import decision if args.stats: import collections es.init_db() total = es.count_events() evs = [e for e in es.get_events(max(0, total - 20000), 20000) if e["event_type"] == "DecisionMade"][-args.stats:] by = collections.defaultdict(lambda: {"n": 0, "applied": 0, "err": 0, "ms": []}) for e in evs: d = json.loads(e["data"]) b = by[d.get("purpose", "?")] b["n"] += 1 b["applied"] += bool(d.get("applied")) b["err"] += bool(d.get("error")) if d.get("ms") and not d.get("error"): b["ms"].append(d["ms"]) if not by: print("(решений ещё не было)") for p, b in sorted(by.items()): ms = sorted(b["ms"]) med = ms[len(ms) // 2] if ms else 0 print(f"{p:8} решений {b['n']:4} применено {b['applied']:4} " f"ошибок {b['err']:3} медиана {med} мс") return if args.question: opts = {} for o in args.option: k, _, v = o.partition("=") opts[k.strip()] = v.strip() or k.strip() os.environ.setdefault("SIMBIOT_DECISION_AGENT", "on") if decision.mode("agent") == "off": print("decision-модель выключена (SIMBIOT_DECISION=off) — включи on/shadow") sys.exit(2) if len(opts) > decision.MAX_OPTIONS: print(f"ОШИБКА: вариантов {len(opts)} > {decision.MAX_OPTIONS} — " "сократи шортлист (точность падает до 50% на 10)") sys.exit(2) state = args.state or decision.AUM_GOAL if opts: r = decision.choose("agent", opts, state, args.question) if not r: print("нет решения (модель недоступна или ответ вне вариантов)") sys.exit(1) print(f"ВЫБОР: {r['choice']}") for k, v in sorted(r["probabilities"].items(), key=lambda kv: -kv[1]): print(f" {v:6.1%} {k}") else: r = decision.yes_prob("agent", args.question, state) if not r: print("нет решения (модель недоступна)") sys.exit(1) print(f"P(да) = {r['p']:.1%}") return st = decision.status() print(f"режим: {st['mode']} ({', '.join(f'{k}={v}' for k, v in st['modes'].items())})") for b in st["backends"]: where = "облако" if b["cloud"] else "локально" print(f"{b['name']}: {b['model']} ({where}) {b['url']}") if not b.get("reachable"): print(f" НЕДОСТУПЕН — {b.get('error')}") continue line = f" доступен; модель {'есть' if b.get('installed') else 'НЕ НАЙДЕНА'}" if "loaded" in b: line += f"; в памяти: {'да' if b['loaded'] else 'нет (холодный старт ~20 с)'}" if b["breaker"].get("until", 0) > __import__("time").time(): line += f"; ПРЕДОХРАНИТЕЛЬ: {b['breaker'].get('error')}" print(line) elif args.cmd == "events": es.init_db() if args.after is None: # хвост стора: последние N по возрастанию id total = es.count_events() evs = es.get_events(max(0, total - args.n), args.n) else: evs = es.get_events(args.after, args.n) for ev in evs: d = ev["data"][:110] print(f"{ev['id']:4} {ev['timestamp'][11:19]} {ev['aggregate']:8} {ev['event_type']:16} {d}") # ============================================================================ # Визард первого запуска (t-261002193628-1d1e5f, аналогично hermes): конфиг # .env атомарно (0600) + init стора. Дополнен шагом «модель» — она реально # переключает уровни через SIMBIOT_MODEL (env-оверрайд в harness/levels.py). # ============================================================================ def _mask(v: str) -> str: v = (v or "").strip() return "(не задано)" if not v else (v[:4] + "…" + v[-4:] if len(v) > 12 else "***") def _dotenv_get(env_path: str, key: str) -> str: """Значение key из .env (env процесса сильнее, как в runtime._env_or_file).""" val = os.environ.get(key, "") if val: return val try: with open(env_path, encoding="utf-8") as f: for line in f: if line.startswith(key + "="): return line.split("=", 1)[1].strip().strip('"').strip("'") except OSError: pass return "" def _dotenv_set(env_path: str, key: str, value: str) -> None: """Атомарная запись key=value в .env: обновить/добавить, остальное — как было.""" lines = [] if os.path.exists(env_path): with open(env_path, encoding="utf-8") as f: lines = f.read().splitlines() out, replaced = [], False for ln in lines: if ln.startswith(key + "="): if not replaced: out.append(f"{key}={value}") replaced = True # повторные вхождения key выкидываем else: out.append(ln) if not replaced: out.append(f"{key}={value}") tmp = env_path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write("\n".join(out).rstrip("\n") + "\n") os.replace(tmp, env_path) os.chmod(env_path, 0o600) def _ask(prompt: str, current: str, secret: bool = False) -> str: """Ответ пользователя; Enter = оставить текущее; EOF (не-tty) = ничего.""" hint = _mask(current) if secret else (current if current else "пусто") try: raw = input(f"{prompt} [{hint}]: ").strip() except EOFError: raw = "" return raw if raw else current def _tg_selftest(token: str) -> str: """getMe-самотест (только проверка токена, ничего не отправляет).""" if not token: return "" import urllib.request try: with urllib.request.urlopen( f"https://api.telegram.org/bot{token}/getMe", timeout=5) as r: who = json.loads(r.read().decode()).get("result", {}).get("username") return f"bot @{who}" if who else "ok" except Exception as e: return f"ОШИБКА ({e.__class__.__name__}) — токен не принят, но запись в .env сделал" # ============================================================================ # Подключение Telegram из CLI (просьба Alex, 2026-10-04): раньше токен был # только внутри интерактивного wizard'а — теперь точечные команды. # ============================================================================ def _tg_restart_poller() -> str: """Рестарт TG-поллера, если он под systemd (иначе подхватит при след. старте).""" import subprocess if not os.path.exists("/run/systemd/system"): return "systemd не обнаружен — поллер подхватит токен при следующем старте" r = subprocess.run(["systemctl", "restart", "simbiot-harness-tg"], capture_output=True, text=True, timeout=30) if r.returncode == 0: return "OK: simbiot-harness-tg перезапущен — читает токен из .env" return f"рестарт pollera не удался: {(r.stderr or r.stdout).strip()[:200]}" def _run_tg_connect(args) -> None: token = (args.token or getattr(args, "token_opt", None) or "").strip() env_path = _paths.env_path() vals = {} if token: vals["SIMBIOT_TG_TOKEN"] = token if args.allowed is not None and args.allowed.strip(): vals["SIMBIOT_TG_ALLOWED"] = args.allowed.strip() if not vals: cur = _dotenv_get(env_path, "SIMBIOT_TG_TOKEN") print("Токен не передан." if not cur else f"Токен уже задан ({_mask(cur)}). Передай его аргументом: " "simbiot tg-connect <ТОКЕН> [--allowed 123,456]") return if "SIMBIOT_TG_TOKEN" in vals: print(f" TG-токен: {_tg_selftest(vals['SIMBIOT_TG_TOKEN'])}") for key, val in vals.items(): _dotenv_set(env_path, key, val) print(f" .env ← {key}={_mask(val) if 'TOKEN' in key else val}") if not args.no_commands: from harness import tg_commands reg = tg_commands.register_bot_commands() print(" меню команд: " + ({"cyrillic": "зарегистрировано (кириллица)", "latin": "зарегистрировано (транслит)"}.get(reg, "не удалось")) if reg else " меню команд: пропущено (нет токена/недоступен TG)") if not args.no_restart: print(" " + _tg_restart_poller()) def _run_tg_status(args) -> None: import subprocess import urllib.request from harness import tg_inbox tg_inbox._load_env() tok = tg_inbox.TG_TOKEN print("🔌 Telegram-подключение") if not tok: print(" токен: НЕ задан — simbiot tg-connect <ТОКЕН>") return print(f" токен: задан ({_mask(tok)})") try: with urllib.request.urlopen(f"https://api.telegram.org/bot{tok}/getMe", timeout=5) as r: me = json.loads(r.read().decode()).get("result", {}) print(f" getMe: ок, бот @{me.get('username')} (id {me.get('id')})") except Exception as e: print(f" getMe: ОШИБКА ({e.__class__.__name__}) — токен не принимается") allowed = sorted(tg_inbox.ALLOWED) print(" разрешённые чаты: " + (", ".join(allowed) if allowed else "не заданы")) import subprocess if os.path.exists("/run/systemd/system"): for unit in ("simbiot-harness-tg.service", "simbiot-harness.service"): st = subprocess.run(["systemctl", "is-active", unit], capture_output=True, text=True).stdout.strip() print(f" {unit}: {st}") cmds = [("никак", [])] try: from harness import tg_commands cmds = tg_commands.get_my_commands() or [("нет", [])] except Exception: pass names = ", ".join(c[0] for c in cmds) if cmds and cmds[0][0] not in ("никак", "нет") else "нет" print(f" меню бота: {names}") def _run_wizard(args) -> None: env_path = _paths.env_path() vals = { "SIMBIOT_TG_TOKEN": args.tg_token if args.tg_token is not None else None, "SIMBIOT_TG_ALLOWED": args.tg_allowed, "ROUTER_KEY": args.router_key, "ROUTER_BASE_URL": args.router_base_url, "SIMBIOT_MODEL": args.model, } if not getattr(args, "noninteractive", False): print("⬢ Simbiot Harness — визард первого запуска.") print(" Enter = оставить текущее значение. Всё пишется в .env (0600).") tok = _ask("Токен TG-бота", _dotenv_get(env_path, "SIMBIOT_TG_TOKEN"), secret=True) allowed = _ask("Разрешённые chat-id через запятую", _dotenv_get(env_path, "SIMBIOT_TG_ALLOWED")) key = _ask("Ключ 9router", _dotenv_get(env_path, "ROUTER_KEY"), secret=True) base = _ask("База 9router (URL)", _dotenv_get(env_path, "ROUTER_BASE_URL") or "http://127.0.0.1:20128/v1") model = _ask("Модель уровней (или auto)", _dotenv_get(env_path, "SIMBIOT_MODEL") or "auto") vals = {"SIMBIOT_TG_TOKEN": tok, "SIMBIOT_TG_ALLOWED": allowed, "ROUTER_KEY": key, "ROUTER_BASE_URL": base, "SIMBIOT_MODEL": model} if tok: print(f" TG-токен: {_tg_selftest(tok)}") for key, val in vals.items(): if val not in (None, "",): if key == "ROUTER_BASE_URL" and val == "http://127.0.0.1:20128/v1" \ and _dotenv_get(env_path, "ROUTER_BASE_URL") == val: continue # дефолт уже такой — .env не засоряем _dotenv_set(env_path, key, str(val)) print(f" .env ← {key}={_mask(str(val)) if 'KEY' in key or 'TOKEN' in key else val}") if not args.no_init: es.init_db() print("OK: стор инициализирован.") print("Дальше: simbiot status — проверить; запустить харнес: scripts/install.sh --systemd " "или ./venv/bin/python -m harness.scheduler (см. README).") if __name__ == "__main__": main()