simbiot-harness/cli.py

752 lines
No EOL
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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="таймаут запроса, сек")
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: голое 'list'/'tasks'/'task 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:
tid, size = commands.submit_task(args.title, args.details, args.minutes)
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 == "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'])} дн.)")
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"],
"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()