Baseline for the v0.2.0 self-learning iteration. All 205 tests pass on this state. Subsequent commits in this branch layer the knowledge base, post-mortem coach, and persona narration on top. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
/**
|
|
* Ink-based TUI dashboard for the pepa runtime.
|
|
*
|
|
* Layout:
|
|
* ┌─────────── status (HP / food / pos / task / connection / paused) ──────────┐
|
|
* │ ┌───────── event log ─────────┐ ┌──────── MC chat ──────────┐ │
|
|
* │ │ │ │ │ │
|
|
* │ └─────────────────────────────┘ └──────────────────────────┘ │
|
|
* └─────────────────── command bar (hotkeys + chat input) ─────────────────────┘
|
|
*
|
|
* Hotkeys:
|
|
* p — pause / resume reflex loop
|
|
* s — stop bot (sends cmd:stop)
|
|
* r — request fresh snapshot
|
|
* c — enter chat mode (type, Enter to send to MC)
|
|
* a — enter ask-Pi mode (type, Enter to spawn pi -p)
|
|
* q — quit TUI (bot keeps running)
|
|
*/
|
|
|
|
import React, { useEffect, useReducer, useState } from "react";
|
|
import { render, Box, Text, useApp, useInput } from "ink";
|
|
import TextInput from "ink-text-input";
|
|
import { createIpcClient } from "./ipc-client.js";
|
|
import { COMMAND_TYPES, EVENT_TYPES } from "../runtime/ipc-protocol.js";
|
|
|
|
type LogEntry = { ts: string; level: string; source: string; text: string };
|
|
type ChatEntry = { ts: string; from: string; text: string; kind: string };
|
|
type Snapshot = Record<string, any>;
|
|
|
|
type State = {
|
|
connectedToBot: boolean;
|
|
snapshot: Snapshot;
|
|
logs: LogEntry[];
|
|
chat: ChatEntry[];
|
|
paused: boolean;
|
|
piStream: string;
|
|
piRunning: boolean;
|
|
proposal: { filename: string | null; body: string | null; total: number } | null;
|
|
};
|
|
|
|
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 }
|
|
| { type: "death"; payload: any; ts: string }
|
|
| { type: "pi-chunk"; payload: { stream: string; text: string } }
|
|
| { type: "pi-done"; payload: { code: number; durationMs: number } }
|
|
| { type: "set-paused"; paused: boolean }
|
|
| { type: "hello"; payload: { snapshot: Snapshot; recentLogs: LogEntry[] } }
|
|
| { type: "proposal"; payload: { filename: string | null; body: string | null; total: number } }
|
|
| { type: "proposal-close" };
|
|
|
|
const MAX_LOGS = 200;
|
|
const MAX_CHAT = 100;
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "ipc-connected":
|
|
return { ...state, connectedToBot: true };
|
|
case "ipc-disconnected":
|
|
return { ...state, connectedToBot: false };
|
|
case "snapshot":
|
|
return { ...state, snapshot: action.payload || {} };
|
|
case "log":
|
|
return { ...state, logs: [...state.logs, action.payload].slice(-MAX_LOGS) };
|
|
case "chat":
|
|
return { ...state, chat: [...state.chat, { ts: action.ts, ...action.payload }].slice(-MAX_CHAT) };
|
|
case "death":
|
|
return {
|
|
...state,
|
|
logs: [
|
|
...state.logs,
|
|
{ ts: action.ts, level: "warn", source: "mc", text: `death at ${JSON.stringify(action.payload?.position ?? null)}` },
|
|
].slice(-MAX_LOGS),
|
|
};
|
|
case "pi-chunk":
|
|
return { ...state, piRunning: true, piStream: (state.piStream + action.payload.text).slice(-2000) };
|
|
case "pi-done":
|
|
return {
|
|
...state,
|
|
piRunning: false,
|
|
piStream: state.piStream + `\n[pi done code=${action.payload.code} after ${action.payload.durationMs}ms]\n`,
|
|
};
|
|
case "set-paused":
|
|
return { ...state, paused: action.paused };
|
|
case "hello":
|
|
return {
|
|
...state,
|
|
snapshot: action.payload.snapshot || {},
|
|
logs: action.payload.recentLogs || [],
|
|
};
|
|
case "proposal":
|
|
return { ...state, proposal: action.payload };
|
|
case "proposal-close":
|
|
return { ...state, proposal: null };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
const STATE_COLOR: Record<string, string> = {
|
|
emergency: "red",
|
|
working: "cyan",
|
|
recovering: "yellow",
|
|
planning: "magenta",
|
|
social: "blue",
|
|
idle: "gray",
|
|
};
|
|
|
|
function StatusBar({ snapshot, paused, connectedToBot }: { snapshot: Snapshot; paused: boolean; connectedToBot: boolean }) {
|
|
const tone = snapshot.connected ? "green" : "red";
|
|
const stateName: string = snapshot.runtimeState ?? "?";
|
|
const stateColor = STATE_COLOR[stateName] ?? "white";
|
|
const reason: string | null = snapshot.noProgressReason ?? null;
|
|
const lastResult: any = snapshot.lastResult ?? null;
|
|
const milestone: string | null = snapshot.currentMilestone ?? null;
|
|
const failuresByCode: Record<string, number> = snapshot.failuresByCode ?? {};
|
|
const failuresStr = Object.entries(failuresByCode)
|
|
.map(([k, v]) => `${k}:${v}`)
|
|
.join(" ");
|
|
return (
|
|
<Box borderStyle="round" borderColor={tone} flexDirection="column" paddingX={1}>
|
|
<Text>
|
|
<Text color={tone} bold>
|
|
{snapshot.connected ? "● MC online" : "○ MC offline"}
|
|
</Text>
|
|
{" "}
|
|
<Text color={connectedToBot ? "green" : "red"}>{connectedToBot ? "IPC ok" : "IPC down"}</Text>
|
|
{" "}
|
|
{paused ? <Text color="yellow">⏸ reflex paused</Text> : <Text color="green">▶ reflex live</Text>}
|
|
{" "}
|
|
<Text color={stateColor} bold>
|
|
state={stateName}
|
|
</Text>
|
|
</Text>
|
|
<Text>
|
|
user={snapshot.username ?? "?"} hp={snapshot.health ?? "?"} food={snapshot.food ?? "?"}{" "}
|
|
pos=
|
|
{snapshot.position
|
|
? `${snapshot.position.x},${snapshot.position.y},${snapshot.position.z}`
|
|
: "?"}{" "}
|
|
day={String(snapshot.isDay ?? "?")} hostiles={snapshot.hostileCount ?? 0}
|
|
{snapshot.closestHostile ? ` closest=${snapshot.closestHostile.name}@${snapshot.closestHostile.distance}m` : ""}
|
|
{snapshot.pendingProposals ? <Text color="magenta" bold>{` [proposals ${snapshot.pendingProposals}, press y]`}</Text> : null}
|
|
</Text>
|
|
<Text>
|
|
{snapshot.busy ? (
|
|
<Text color="cyan">▸ skill: {snapshot.busy.label}</Text>
|
|
) : snapshot.activeSkill ? (
|
|
<Text dimColor>
|
|
last skill: {snapshot.activeSkill}
|
|
{snapshot.lastReflex?.ts ? ` (${formatAge(snapshot.lastReflex.ts)})` : ""}
|
|
</Text>
|
|
) : (
|
|
<Text dimColor>no skill yet</Text>
|
|
)}
|
|
</Text>
|
|
<Text>
|
|
<Text dimColor>milestone: </Text>
|
|
<Text>{milestone ?? <Text dimColor>(none — planner_empty?)</Text>}</Text>
|
|
{snapshot.curriculum?.plan?.skillId ? (
|
|
<Text dimColor> → suggested: {snapshot.curriculum.plan.skillId}</Text>
|
|
) : null}
|
|
{snapshot.curriculum?.inventoryFull ? (
|
|
<Text color="yellow"> [inventory full]</Text>
|
|
) : null}
|
|
</Text>
|
|
{reason ? (
|
|
<Text>
|
|
<Text color="yellow" bold>
|
|
▲ no-progress:
|
|
</Text>{" "}
|
|
<Text color="yellow">{reason}</Text>
|
|
</Text>
|
|
) : null}
|
|
{lastResult ? (
|
|
<Text>
|
|
<Text dimColor>last result: </Text>
|
|
<Text color={lastResult.ok ? "green" : "red"}>
|
|
{lastResult.label} → {lastResult.code ?? (lastResult.ok ? "ok" : "fail")}
|
|
</Text>
|
|
<Text dimColor>{lastResult.ts ? ` ${formatAge(lastResult.ts)}` : ""}</Text>
|
|
</Text>
|
|
) : null}
|
|
{failuresStr ? (
|
|
<Text dimColor>failures by class: {failuresStr}</Text>
|
|
) : null}
|
|
{snapshot.lastEscalation?.ts ? (
|
|
<Text dimColor>last Pi escalation: {formatAge(snapshot.lastEscalation.ts)}</Text>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function formatAge(tsMs: number): string {
|
|
const ageMs = Date.now() - tsMs;
|
|
if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`;
|
|
if (ageMs < 3600_000) return `${Math.floor(ageMs / 60_000)}m ago`;
|
|
return `${Math.floor(ageMs / 3600_000)}h ago`;
|
|
}
|
|
|
|
function ProposalPanel({
|
|
proposal,
|
|
onClose,
|
|
onApprove,
|
|
}: {
|
|
proposal: { filename: string | null; body: string | null; total: number };
|
|
onClose: () => void;
|
|
onApprove: () => void;
|
|
}) {
|
|
if (!proposal.filename) {
|
|
return (
|
|
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="gray">
|
|
<Text>No pending proposals. ([Esc] to close)</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
const lines = (proposal.body ?? "").split("\n").slice(0, 30);
|
|
return (
|
|
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor="magenta">
|
|
<Text bold color="magenta">
|
|
proposal: {proposal.filename} (total pending: {proposal.total})
|
|
</Text>
|
|
{lines.map((line, i) => (
|
|
<Text key={i}>{line}</Text>
|
|
))}
|
|
<Text dimColor>[y]es approve [n]o close — uses npm run propose:apply afterwards</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function EventLog({ logs }: { logs: LogEntry[] }) {
|
|
const last = logs.slice(-14);
|
|
return (
|
|
<Box borderStyle="round" flexDirection="column" paddingX={1} width="60%">
|
|
<Text bold underline>
|
|
events
|
|
</Text>
|
|
{last.map((l, i) => (
|
|
<Text key={i} color={l.level === "warn" ? "yellow" : l.level === "error" ? "red" : "white"}>
|
|
{l.ts.slice(11, 19)} [{l.source}] {l.text}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function ChatPanel({ chat }: { chat: ChatEntry[] }) {
|
|
const last = chat.slice(-14);
|
|
return (
|
|
<Box borderStyle="round" flexDirection="column" paddingX={1} width="40%">
|
|
<Text bold underline>
|
|
MC chat
|
|
</Text>
|
|
{last.map((c, i) => (
|
|
<Text key={i} color={c.kind === "system" ? "gray" : "cyan"}>
|
|
{c.ts?.slice(11, 19) ?? ""} {c.from}: {c.text}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function PiPanel({ piStream, piRunning }: { piStream: string; piRunning: boolean }) {
|
|
if (!piStream && !piRunning) return null;
|
|
return (
|
|
<Box borderStyle="round" flexDirection="column" paddingX={1} borderColor={piRunning ? "magenta" : "gray"}>
|
|
<Text bold underline>
|
|
pi (escalation) {piRunning ? "● running" : "○ idle"}
|
|
</Text>
|
|
<Text>{piStream || "(no output yet)"}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type Mode = "idle" | "chat" | "ask-pi" | "run-skill" | "incident";
|
|
|
|
function App() {
|
|
const { exit } = useApp();
|
|
const [state, dispatch] = useReducer(reducer, {
|
|
connectedToBot: false,
|
|
snapshot: {},
|
|
logs: [],
|
|
chat: [],
|
|
paused: false,
|
|
piStream: "",
|
|
piRunning: false,
|
|
proposal: null,
|
|
});
|
|
|
|
const [client] = useState(() => createIpcClient());
|
|
const [mode, setMode] = useState<Mode>("idle");
|
|
const [inputValue, setInputValue] = useState("");
|
|
|
|
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.DEATH:
|
|
dispatch({ type: "death", payload: frame.payload, ts: frame.ts });
|
|
break;
|
|
case EVENT_TYPES.HELLO:
|
|
dispatch({ type: "hello", payload: frame.payload });
|
|
break;
|
|
case EVENT_TYPES.ASK_PI_CHUNK:
|
|
dispatch({ type: "pi-chunk", payload: frame.payload });
|
|
break;
|
|
case EVENT_TYPES.ASK_PI_DONE:
|
|
dispatch({ type: "pi-done", payload: frame.payload });
|
|
break;
|
|
case EVENT_TYPES.PROPOSAL:
|
|
dispatch({ type: "proposal", payload: frame.payload });
|
|
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]);
|
|
|
|
useInput((input, key) => {
|
|
if (mode !== "idle") return; // text input has its own handling
|
|
// Proposal panel is open — accept y/n only.
|
|
if (state.proposal) {
|
|
if (input === "y" && state.proposal.filename) {
|
|
client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename });
|
|
dispatch({ type: "proposal-close" });
|
|
} else if (input === "n" || key.escape) {
|
|
dispatch({ type: "proposal-close" });
|
|
}
|
|
return;
|
|
}
|
|
if (input === "q") {
|
|
client.close();
|
|
exit();
|
|
return;
|
|
}
|
|
if (input === "p") {
|
|
const next = !state.paused;
|
|
client.send(next ? COMMAND_TYPES.PAUSE : COMMAND_TYPES.RESUME, {});
|
|
dispatch({ type: "set-paused", paused: next });
|
|
}
|
|
if (input === "s") {
|
|
client.send(COMMAND_TYPES.STOP, {});
|
|
}
|
|
if (input === "r") {
|
|
client.send(COMMAND_TYPES.SNAPSHOT, {});
|
|
}
|
|
if (input === "c") setMode("chat");
|
|
if (input === "a") setMode("ask-pi");
|
|
if (input === "k") setMode("run-skill");
|
|
if (input === "v") client.send(COMMAND_TYPES.SCREENSHOT, { reason: "tui", frames: 1 });
|
|
if (input === "!") setMode("incident");
|
|
if (input === "y") client.send(COMMAND_TYPES.PROPOSAL_LATEST, {});
|
|
});
|
|
|
|
function submit(value: string) {
|
|
const text = value.trim();
|
|
setInputValue("");
|
|
const m = mode;
|
|
setMode("idle");
|
|
if (!text) return;
|
|
if (m === "chat") client.send(COMMAND_TYPES.CHAT, { text });
|
|
else if (m === "ask-pi") client.send(COMMAND_TYPES.ASK_PI, { prompt: text });
|
|
else if (m === "run-skill") {
|
|
const [skillId, ...rest] = text.split(/\s+/);
|
|
let args = {};
|
|
const json = rest.join(" ").trim();
|
|
if (json) {
|
|
try { args = JSON.parse(json); }
|
|
catch {
|
|
client.send(COMMAND_TYPES.ASK_PI, { prompt: `Parse this run-skill argument JSON for ${skillId}: ${json}` });
|
|
return;
|
|
}
|
|
}
|
|
client.send(COMMAND_TYPES.RUN_SKILL, { skillId, args });
|
|
} else if (m === "incident") {
|
|
client.send(COMMAND_TYPES.FORCE_INCIDENT, { reason: text, kind: "operator-forced" });
|
|
}
|
|
}
|
|
|
|
const hotkeyHint =
|
|
mode === "idle"
|
|
? "[p]ause/resume [s]top [r]efresh [c]hat [a]sk-pi [k] skill [v] screenshot [!] incident [y] proposals [q]uit"
|
|
: mode === "chat"
|
|
? "chat → MC (Enter to send, Esc to cancel)"
|
|
: mode === "ask-pi"
|
|
? "ask-pi → spawn pi -p (Enter to send)"
|
|
: mode === "run-skill"
|
|
? 'run-skill → skill.id {"arg":true}'
|
|
: "incident → reason for critic/auto-improve proposal";
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<StatusBar snapshot={state.snapshot} paused={state.paused} connectedToBot={state.connectedToBot} />
|
|
<Box flexDirection="row">
|
|
<EventLog logs={state.logs} />
|
|
<ChatPanel chat={state.chat} />
|
|
</Box>
|
|
<PiPanel piStream={state.piStream} piRunning={state.piRunning} />
|
|
{state.proposal ? (
|
|
<ProposalPanel
|
|
proposal={state.proposal}
|
|
onClose={() => dispatch({ type: "proposal-close" })}
|
|
onApprove={() => {
|
|
if (state.proposal?.filename) {
|
|
client.send(COMMAND_TYPES.PROPOSAL_APPROVE, { filename: state.proposal.filename });
|
|
dispatch({ type: "proposal-close" });
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Box borderStyle="single" paddingX={1}>
|
|
{mode === "idle" ? (
|
|
<Text dimColor>{hotkeyHint}</Text>
|
|
) : (
|
|
<>
|
|
<Text bold color={mode === "chat" ? "cyan" : "magenta"}>
|
|
{mode === "chat" ? "chat> " : mode === "ask-pi" ? "pi> " : mode === "run-skill" ? "skill> " : "incident> "}
|
|
</Text>
|
|
<TextInput
|
|
value={inputValue}
|
|
onChange={setInputValue}
|
|
onSubmit={submit}
|
|
/>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render(<App />);
|