794 lines
No EOL
46 KiB
Python
794 lines
No EOL
46 KiB
Python
"""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("-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 — только сводка)")
|
||
|
||
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 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']}")
|
||
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 == "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() |