#!/usr/bin/env python3 # -*- coding: utf-8 -*- """TUI-чат с работающим агентом в веб-терминале (t-261004132716). Задача Alex (m-516173688): терминал портала должен сразу давать полноценный чат с агентом (как Claude Code/Clide/Hermes), не требуя запуска чего-то руками. Агент уже работает демоном (systemd simbiot-harness) — TUI к нему НЕ лезет процессом: он кладёт сообщение в event store агента (чат 'tui:*'), мин-тик агента (unreplied_inbox → critical-сигнал гейта) отвечает через tg_inbox.send_reply → ReplySent пишется в стор, TUI его читает и печатает. Транспорт: только локальный event store. Никаких внешних API. Выход: Ctrl-D / 'exit' (после выхода в терминале остаётся bash). """ import os import select import sys import time import uuid from datetime import datetime REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, REPO) from harness import es # noqa: E402 AGENT = os.environ.get("SIMBIOT_AGENT_NAME") or "agent" TUI_CHAT = "tui:local" POLL_SEC = 2.0 REPLY_TIMEOUT = 240 # сек ожидания ответа на своё сообщение WRAP = 100 # ширина переноса C_BOLD, C_DIM, C_CYAN, C_GREEN, C_YEL = "\033[1m", "\033[2m", "\033[36m", "\033[32m", "\033[33m" C_RST = "\033[0m" def _wrap(text, width=WRAP, indent=" "): out, line = [], "" for word in str(text or "").split(): if len(line) + len(word) + 1 > width - len(indent): out.append(indent + line) line = word else: line = (line + " " + word).strip() if line: out.append(indent + line) return "\n".join(out) def _model(): return es.run_projections() def _inbox(model): return model.get("inbox") or {} def _ts_sort(v): return v.get("ts") or "" def show_history(limit=3): model = _model() items = [(k, v) for k, v in _inbox(model).items() if str(v.get("chat_id") or "").startswith(("tui:", "portal:"))] items.sort(key=lambda kv: _ts_sort(kv[1])) if not items: return print(f"{C_DIM}— последние сообщения чата с агентом —{C_RST}") for mid, v in items[-limit * 2:]: who = "ты" if (v.get("kind") == "tui") else (v.get("username") or "портал") txt = (v.get("text") or "").replace("\n", " ")[:90] print(f"{C_DIM}{mid}{C_RST} {C_YEL}{who}▸{C_RST} {txt}") if v.get("replied") and v.get("reply_text") and not v["reply_text"].startswith("(сводный"): print(_wrap(v["reply_text"][:200], indent=" ")) print() def resolve_reply(model, mid): """Текст ответа: у головного полный, у остальных — ссылка на головной.""" m = _inbox(model).get(mid) if not m or not m.get("replied"): return None txt = m.get("reply_text") or "" if txt.startswith("(сводный"): import re mt = re.search(r"см\. (\S+)", txt) if mt: head = _inbox(model).get(mt.group(1)) if head and head.get("reply_text"): return head["reply_text"] return txt or "(пустой ответ)" def send_message(text): mid = f"tui-{datetime.utcnow().strftime('%y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}" es.append_event("inbox", mid, "MessageReceived", { "update_id": mid, "chat_id": TUI_CHAT, "user_id": "alex", "username": "alex · tui", "first_name": "Alex", "text": str(text)[:4000], "kind": "tui", "file_id": None, "file_unique_id": None, "duration": None, "date": int(time.time()), "source": "tui", }) return mid def wait_replies(watch, deadline_by, stdin_fd, pending_input): """Ждём ответы на watch (dict mid→deadline), подбирая ввод в очередь.""" while watch: now = time.time() for mid in [m for m, d in watch.items() if now > d]: print(f"{C_DIM}(ответ на {mid} не пришёл за {REPLY_TIMEOUT}s — " f"агент молчит? systemctl status simbiot-harness){C_RST}") watch.pop(mid) if not watch: break try: model = _model() except Exception as e: print(f"{C_DIM}(стор занят: {str(e)[:60]}){C_RST}") model = None if model: for mid in list(watch): txt = resolve_reply(model, mid) if txt is not None: print(f"{C_GREEN}{AGENT}▸{C_RST}") print(_wrap(txt)) watch.pop(mid) r, _, _ = select.select([stdin_fd], [], [], POLL_SEC) if r: line = sys.stdin.readline() if line == "": # EOF return False line = line.strip() if line: pending_input.append(line) return True def main(): fd = sys.stdin.fileno() interactive = sys.stdin.isatty() es.init_db() # агент должен работать демоном; TUI сам ничего не стартует, # но явно скажет, если демон не жив alive = os.system("systemctl is-active --quiet simbiot-harness 2>/dev/null") == 0 \ or bool(os.popen("pgrep -f 'harness.scheduler'").read().strip()) print(f"{C_BOLD}╭─ {AGENT} · TUI-чат {'· агент работает' if alive else C_YEL + '· ДЕМОН НЕ ОТВЕЧАЕТ'}{C_RST}") if not alive: print(f"{C_YEL} демон simbiot-harness не запущен — ответы не придут.{C_RST}") print(f"{C_YEL} запусти: systemctl start simbiot-harness{C_RST}") print(f"{C_DIM}│ пишешь → мин-тик агента отвечает (обычно ≤ минуты; команды " f"/статус /лестница /задачи — мгновенно){C_RST}") print(f"{C_DIM}│ выход: Ctrl-D или 'exit'{C_RST}") print(f"{C_BOLD}╰{'─' * 40}{C_RST}") try: show_history() except Exception: pass # сообщения, оставшиеся без ответа от прошлой TUI-сессии — досмотрим watch = {} try: model = _model() for mid, v in _inbox(model).items(): if str(v.get("chat_id") or "").startswith("tui:") and not v.get("replied"): watch[mid] = time.time() + REPLY_TIMEOUT except Exception: pass pending_input = [] while True: if watch and not wait_replies(watch, None, fd, pending_input): break # EOF во время ожидания if pending_input: line = pending_input.pop(0) else: if interactive: sys.stdout.write(f"{C_CYAN}ты▸{C_RST} ") sys.stdout.flush() r, _, _ = select.select([fd], [], [], POLL_SEC if watch else None) if not r: continue line = sys.stdin.readline() if line == "": break # Ctrl-D line = line.strip() if not line: continue if line.lower() in ("exit", "quit", "/exit", "/quit"): break mid = send_message(line) watch[mid] = time.time() + REPLY_TIMEOUT print(f"{C_DIM}… {mid} (ждём мин-тик){C_RST}") print(f"{C_DIM}пока. (агент продолжает работать демоном; bash — по Ctrl-D/exit){C_RST}") return 0 if __name__ == "__main__": sys.exit(main())