/** * pepa monitor — fullscreen, read-only TUI. * * Replaces the old action-heavy tui/tui.tsx with a pure observability * dashboard inspired by opencode's full-screen layout. No hotkeys to * dispatch skills / send chat / approve proposals — operator does that * via scripts/* or by writing to the IPC sock directly. This screen * just shows what the bot is doing, in colour. * * Panels (top → bottom): * 1. Header — connection / position / hp / food / time * 2. Storyline — current step + the 11-step quest map * 3. Activity — last N dispatches (colour-coded by outcome) * + MC chat — last N lines * 4. Advisor — last 6 LLM recommendations (trigger / outcome / tokens) * + Improvements — open queue from improvement_requests * 5. Footer — token usage today, advisor stats, q to quit * * Live data sources: * - IPC sock (snapshot frames, log frames, chat frames) * - SQLite (knowledge.db) polled every 5s for advisor + improvements */ import React, { useEffect, useReducer, useState } from "react"; import { render, Box, Text, useApp, useInput, useStdout } from "ink"; import { createIpcClient } from "./ipc-client.js"; import { EVENT_TYPES } from "../runtime/ipc-protocol.js"; import { stateDir } from "../runtime/config.js"; import { initKnowledge, isAvailable as knowledgeReady, recentRecommendations, listImprovements, recommendationStats } from "../runtime/knowledge/index.js"; // --- types ------------------------------------------------------------------ type LogEntry = { ts: string; level: string; source: string; text: string }; type ChatEntry = { uid: number; ts: string; from: string; text: string; kind: string }; type Snapshot = Record; type Dispatch = { uid: number; ts: number; kind: "start" | "end"; label: string; ok?: boolean; code?: string; detail?: string }; let _uidSeq = 0; function nextUid() { _uidSeq += 1; return _uidSeq; } type State = { connectedIpc: boolean; snapshot: Snapshot; logs: LogEntry[]; chat: ChatEntry[]; dispatches: Dispatch[]; startedAt: number; }; type Action = | { type: "ipc-connected" } | { type: "ipc-disconnected" } | { type: "snapshot"; payload: Snapshot } | { type: "log"; payload: LogEntry } | { type: "chat"; payload: { from: string; text: string; kind: string }; ts: string }; const MAX_LOGS = 200; const MAX_CHAT = 50; const MAX_DISPATCHES = 30; // --- reducer ---------------------------------------------------------------- function reducer(state: State, action: Action): State { switch (action.type) { case "ipc-connected": return { ...state, connectedIpc: true }; case "ipc-disconnected": return { ...state, connectedIpc: false }; case "snapshot": return { ...state, snapshot: action.payload }; case "log": { const logs = [...state.logs, action.payload].slice(-MAX_LOGS); // also derive a dispatches view: lines like "→ label" or "← label ok/fail (...)" const dispatches = extractDispatch(state.dispatches, action.payload); return { ...state, logs, dispatches }; } case "chat": { const chat = [...state.chat, { uid: nextUid(), ts: action.ts, ...action.payload }].slice(-MAX_CHAT); return { ...state, chat }; } default: return state; } } function extractDispatch(prev: Dispatch[], log: LogEntry): Dispatch[] { if (log.source !== "dispatch") return prev; const ts = Date.parse(log.ts) || Date.now(); // "→ label" — skill starting const startMatch = /^→\s+(\S+)/.exec(log.text); if (startMatch) { return [...prev, { uid: nextUid(), ts, kind: "start", label: startMatch[1] }].slice(-MAX_DISPATCHES); } // "← label ok/fail (...)" — skill ending const endMatch = /^←\s+(\S+)\s+(ok|fail)(?:\s+\((.*)\))?/.exec(log.text); if (endMatch) { const [, label, outcome, detail] = endMatch; return [...prev, { uid: nextUid(), ts, kind: "end", label, ok: outcome === "ok", detail }].slice(-MAX_DISPATCHES); } return prev; } // --- helpers ---------------------------------------------------------------- function formatAge(ts: number) { const dt = Math.max(0, Date.now() - ts); if (dt < 60_000) return `${Math.floor(dt / 1000)}s`; if (dt < 3600_000) return `${Math.floor(dt / 60_000)}m`; const h = Math.floor(dt / 3600_000); const m = Math.floor((dt % 3600_000) / 60_000); return `${h}h${m}m`; } function formatDuration(ms: number) { const s = Math.floor(ms / 1000) % 60; const m = Math.floor(ms / 60_000) % 60; const h = Math.floor(ms / 3600_000); if (h > 0) return `${h}h${m.toString().padStart(2, "0")}m`; if (m > 0) return `${m}m${s.toString().padStart(2, "0")}s`; return `${s}s`; } function shortTime(ts: number) { const d = new Date(ts); return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`; } function hpColor(hp: number | undefined) { if (hp == null) return "gray"; if (hp <= 5) return "red"; if (hp <= 12) return "yellow"; return "green"; } function foodColor(food: number | undefined) { if (food == null) return "gray"; if (food <= 4) return "red"; if (food <= 10) return "yellow"; return "green"; } // --- components ------------------------------------------------------------- function Header({ snapshot, connectedIpc, width, startedAt }: { snapshot: Snapshot; connectedIpc: boolean; width: number; startedAt: number }) { const pos = snapshot.position ? `(${Math.round(snapshot.position.x)}, ${Math.round(snapshot.position.y)}, ${Math.round(snapshot.position.z)})` : "?"; const hp = snapshot.health; const food = snapshot.food; const day = snapshot.isDay ? "☀ day" : "🌙 night"; const session = formatDuration(Date.now() - startedAt); const mcOnline = snapshot.connected; return ( pepa-monitor · {mcOnline ? "● MC" : "○ MC"} · {connectedIpc ? "● IPC" : "○ IPC"} · session {session} · user {snapshot.username ?? "?"} pos {pos} HP {hp ?? "?"}/20 food {food ?? "?"}/20 {day} hostiles 0 ? "red" : "gray"}>{snapshot.hostileCount ?? 0} {snapshot.closestHostile ? closest {snapshot.closestHostile.name}@{snapshot.closestHostile.distance}b : null} ); } function StorylinePanel({ snapshot, width }: { snapshot: Snapshot; width: number }) { const story = snapshot.storyStep; if (!story) { return ( storyline not available (bot offline or storyline disabled) ); } const STEPS = [ "orient_self", "first_wood", "crafting_basics", "first_tools", "first_food", "shelter_minimal", "stone_tier", "food_security", "iron_age", "settle_base", "village_grow", ]; const TITLES: Record = { orient_self: "Понять где я", first_wood: "Собрать 8 поленьев", crafting_basics: "Сделать верстак и палки", first_tools: "Деревянные орудия", first_food: "Найти первую еду", shelter_minimal: "Простой шелтер с кроватью", stone_tier: "Каменные орудия", food_security: "Запас еды на 16+", iron_age: "Железо и печь", settle_base: "Постоянная база", village_grow: "Развивать деревню", }; const idx = story.index ?? 0; const cur = story.step; const want = story.suggestion?.skillId; const emergency = story.emergency; return ( storyline step {idx + 1}/11 {cur?.id} {cur?.title} {emergency ? [EMERGENCY PAUSE] : null} {want ? ( wants: {want} ) : null} {STEPS.map((id, i) => { const done = i < idx; const current = i === idx; const mark = done ? "✓" : current ? "→" : "○"; const color = done ? "green" : current ? "yellow" : "gray"; return ( {` ${mark} ${(i + 1).toString().padStart(2)}. ${id.padEnd(18)} ${TITLES[id] ?? ""}`} ); })} ); } function ActivityPanel({ dispatches, width, height }: { dispatches: Dispatch[]; width: number; height: number }) { const visible = dispatches.slice(-height); return ( Activity (last {visible.length}) {visible.map((d) => { if (d.kind === "start") { return ( {shortTime(d.ts)} → {d.label} ); } const color = d.ok ? "green" : "red"; const tail = d.detail ? ` (${String(d.detail).slice(0, 30)})` : ""; return ( {shortTime(d.ts)} ← {d.label} {d.ok ? "ok" : "fail"} {tail} ); })} {visible.length === 0 ? (waiting for activity…) : null} ); } function ChatPanel({ chat, width, height }: { chat: ChatEntry[]; width: number; height: number }) { const visible = chat.slice(-height); return ( MC Chat (last {visible.length}) {visible.map((c) => ( {c.ts?.slice(11, 16) ?? ""} {c.from}: {c.text.slice(0, width - 12)} ))} {visible.length === 0 ? (no chat yet…) : null} ); } function AdvisorPanel({ recs, width, height }: { recs: any[]; width: number; height: number }) { const visible = recs.slice(0, height); return ( Advisor (last {visible.length}) {visible.map((r) => { const ok = r.outcome_ok; const outcomeMark = ok == null ? "·" : ok ? "✓" : "✗"; const outcomeColor = ok == null ? "gray" : ok ? "green" : "red"; const tail = `${r.tokens_in ?? "?"}/${r.tokens_out ?? "?"}t ${r.latency_ms ?? "?"}ms`; return ( {outcomeMark} {r.trigger_reason} {r.recommended_skill ?? r.action} {tail}{r.rationale ? ` · ${String(r.rationale).slice(0, width - 14)}` : ""} ); })} {visible.length === 0 ? (no advisor calls yet) : null} ); } function ImprovementsPanel({ items, width, height }: { items: any[]; width: number; height: number }) { const visible = items.slice(0, height); return ( Improvements (open: {items.length}) {visible.map((r) => ( #{r.id} P{r.priority} ×{r.votes} {String(r.title).slice(0, width - 14)} {r.description ? ( {String(r.description).slice(0, width - 8)} ) : null} ))} {visible.length === 0 ? (no improvement requests yet) : null} ); } function Footer({ stats, width }: { stats: any[]; width: number }) { const total = stats.reduce( (acc, s) => ({ calls: acc.calls + (s.total ?? 0), succ: acc.succ + (s.succeeded ?? 0), fail: acc.fail + (s.failed ?? 0), in: acc.in + (s.avg_in ?? 0) * (s.total ?? 0), out: acc.out + (s.avg_out ?? 0) * (s.total ?? 0), }), { calls: 0, succ: 0, fail: 0, in: 0, out: 0 }, ); const priceInRub = Number(process.env.TIMEWEB_PRICE_IN_RUB_PER_M) || 101; const priceOutRub = Number(process.env.TIMEWEB_PRICE_OUT_RUB_PER_M) || 608; const costRub = (total.in * priceInRub + total.out * priceOutRub) / 1_000_000; return ( Last 24h: {total.calls} advisor calls ( {total.succ} ok / {total.fail} fail ) tokens {Math.round(total.in / 1000)}K in / {Math.round(total.out / 1000)}K out ≈ {costRub.toFixed(2)} ₽ q — quit (bot keeps running) ); } // --- main app --------------------------------------------------------------- function App() { const { exit } = useApp(); const { stdout } = useStdout(); // Sample stdout dimensions periodically. Subscribing directly to // stdout.on('resize') from a React hook conflicts with ink's own // listener and produces "MaxListenersExceededWarning" / reconciler // errors. A 1s poll is cheap and good enough — terminals don't // resize often. const [cols, setCols] = useState((stdout as any)?.columns ?? 120); const [rows, setRows] = useState((stdout as any)?.rows ?? 30); useEffect(() => { const t = setInterval(() => { setCols((stdout as any)?.columns ?? 120); setRows((stdout as any)?.rows ?? 30); }, 1000); return () => clearInterval(t); }, [stdout]); const [state, dispatch] = useReducer(reducer, { connectedIpc: false, snapshot: {}, logs: [], chat: [], dispatches: [], startedAt: Date.now(), }); const [client] = useState(() => createIpcClient()); const [knowledgeOk, setKnowledgeOk] = useState(false); const [recs, setRecs] = useState([]); const [improvements, setImprovements] = useState([]); const [stats, setStats] = useState([]); // IPC useEffect(() => { const onConnected = () => dispatch({ type: "ipc-connected" }); const onDisconnected = () => dispatch({ type: "ipc-disconnected" }); const onFrame = (frame: any) => { switch (frame.type) { case EVENT_TYPES.STATUS: dispatch({ type: "snapshot", payload: frame.payload }); break; case EVENT_TYPES.LOG: dispatch({ type: "log", payload: frame.payload }); break; case EVENT_TYPES.CHAT: dispatch({ type: "chat", payload: frame.payload, ts: frame.ts }); break; case EVENT_TYPES.HELLO: if (frame.payload?.snapshot) dispatch({ type: "snapshot", payload: frame.payload.snapshot }); if (frame.payload?.recentLogs) { for (const lg of frame.payload.recentLogs) dispatch({ type: "log", payload: lg }); } break; } }; (client as any).on("connected", onConnected); (client as any).on("disconnected", onDisconnected); (client as any).on("frame", onFrame); return () => { (client as any).off("connected", onConnected); (client as any).off("disconnected", onDisconnected); (client as any).off("frame", onFrame); client.close(); }; }, [client]); // Knowledge DB init + polling useEffect(() => { let mounted = true; (async () => { try { await initKnowledge({ stateDir }); if (!mounted) return; setKnowledgeOk(knowledgeReady()); } catch {} })(); const poll = () => { if (!knowledgeReady()) return; try { setRecs(recentRecommendations({ limit: 12 })); setImprovements(listImprovements({ status: "open", limit: 12 })); setStats(recommendationStats({ sinceHours: 24 })); } catch {} }; poll(); const t = setInterval(poll, 5000); return () => { mounted = false; clearInterval(t); }; }, [knowledgeOk]); // useInput requires TTY raw mode; skip it when stdin isn't a TTY // (e.g. when piped during a smoke test). Real `npm run tui` is always TTY. const isTty = !!process.stdin.isTTY; if (isTty) { // eslint-disable-next-line react-hooks/rules-of-hooks useInput((input, key) => { if (input === "q" || (key.ctrl && input === "c") || key.escape) { client.close(); exit(); } }); } // Layout math: full-width header & storyline, then split 50/50. const totalWidth = Math.max(80, cols); const halfWidth = Math.floor(totalWidth / 2); const totalRows = Math.max(24, rows); // rough allocation: header 4, storyline ~14, footer 4. Rest split for middle panels. const middleRows = Math.max(8, Math.floor((totalRows - 4 - 14 - 4) / 2)); return (