feat(replay): standalone reviewer-gate replay server on :5333

Reproduces the real reviewer UI for any recorded session, with no VPN/Pi/
Python/DWH. The server (node:http, zero deps) serves the built SPA and a
tiny SSE/REST shim that re-emits the reviewer gates captured in a Pi
transcript, in their original order, with the reviewer's real 3-Jul
choices shown as comparison badges.

- tools/replay/extract.mjs: extracts gates from one or many transcripts
  (session-id, file, or directory). Handles sessions split across resume
  re-entries by sorting on message timestamp and dropping unanswered
  gates. Reads the question from session_manifest.yaml, resolving the
  sessions dir from any workspace yaml (no hardcoded paths).
- tools/replay/server.mjs: same-origin :5333. SSE streams gates; POST
  /response advances the cursor and pushes info badges (scelta reale).
  POST /resume and the final "Ripeti/Esci" widget close the SSE so the
  browser EventSource reconnects (cursor resets, gate 1 re-emitted) — the
  replay is re-runnable any number of times. GET /sessions/:id/documents
  reads the real session files so GateArtifactBody resolves file-reference
  artifacts. Exit emits system_event {event:"session_exit"} to return to
  the landing.
- scripts/replay.sh: launcher (extract / build / run / all).
- tools/replay/README.md: data flow, commands, fidelity notes.
- .gitignore: ignore tools/replay/web/ (built artifact, like dist/).
This commit is contained in:
2026-07-05 18:24:26 +02:00
parent 2f68b0d109
commit c3a3cb8da5
6 changed files with 2147 additions and 0 deletions
+3
View File
@@ -21,6 +21,9 @@ build/
# === Node ===
node_modules/
# === Replay bundle (built artifact, like dist/) ===
tools/replay/web/
# === Secrets — NEVER commit ===
.env
*.pem
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Replay a recorded ThothII reviewer session on http://localhost:5333, with no
# VPN/Pi/Python/DWH. The real frontend UI is served against a tiny SSE/REST shim
# that re-emits the reviewer gates captured in Pi transcripts.
#
# Usage:
# scripts/replay.sh # extract(default) + build(if missing) + run
# scripts/replay.sh extract [<session>] # (re)build replay.json for a session
# scripts/replay.sh build # (re)build the SPA bundle only
# scripts/replay.sh run # run only (assumes build + fixture present)
#
# <session> a session id (e.g. 2026-07-03-170732-fammi-…)
# a transcript file path
# a directory of transcripts (picks the dominant session)
# default: the 3-Jul 17:07 full session
#
# Examples:
# scripts/replay.sh extract 2026-07-03-132933-fammi-la-lista-dei-pazienti-che-negli-ul
# scripts/replay.sh run
#
# Env:
# PORT default 5333
#
# See tools/replay/README.md for the data flow.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
PORT="${PORT:-5333}"
WEB_DIR="$HERE/tools/replay/web"
FIXTURE="$HERE/tools/replay/replay.json"
action="${1:-all}"
# Positional target for extract: shifts past the action word.
target="${2:-}"
do_extract() {
echo "→ extracting replay.json for ${target:-default session (3-Jul 17:07)}…"
node "$HERE/tools/replay/extract.mjs" "$target"
}
do_build() {
if [[ -d "$WEB_DIR" ]]; then
echo "→ $WEB_DIR exists, skipping build (use 'rm -rf $WEB_DIR' to force)"
return
fi
echo "→ building SPA bundle into $WEB_DIR (VITE_BACKEND_URL=http://localhost:$PORT)…"
(
cd "$HERE/frontend"
VITE_BACKEND_URL="http://localhost:$PORT" npx vite build --outDir ../tools/replay/web
)
}
do_run() {
[[ -f "$FIXTURE" ]] || { echo "✗ missing $FIXTURE — run: scripts/replay.sh extract"; exit 1; }
[[ -d "$WEB_DIR" ]] || { echo "✗ missing $WEB_DIR — run: scripts/replay.sh build"; exit 1; }
echo "→ starting replay server on :$PORT (Ctrl-C to stop)…"
PORT="$PORT" node "$HERE/tools/replay/server.mjs"
}
case "$action" in
extract) do_extract ;;
build) do_build ;;
run) do_run ;;
all)
[[ -f "$FIXTURE" ]] || do_extract
do_build
do_run
;;
*) echo "unknown action: $action"; echo "usage: $0 [extract|build|run|all]"; exit 2 ;;
esac
+115
View File
@@ -0,0 +1,115 @@
# ThothII reviewer replay
Riproduce su `http://localhost:5333` la **vera UI** dei widget reviewer di una
sessione registrata, **senza VPN, Pi, Python, né DWH**. Si usano i descriptor
dei gate così come furono proposti al revisore (catturati nel transcript Pi),
nello stesso ordine, con le scelte realmente fatte mostrate come badge di
confronto.
## Schema del flusso
```
browser (SPA reale, :5333)
│ REST + SSE, same-origin
▼
server.mjs (node:http, zero dipendenze)
├─ static: serve il bundle SPA da tools/replay/web/
└─ replay SSE: GET /sessions/:id/events → event: ui_request (gate N)
POST /sessions/:id/response → badge "scelta reale" + gate N+1
│
legge
▼
replay.json (fixture estratta dal transcript)
│ prodotta da
▼
extract.mjs (legge ~/.pi/agent/sessions/…/*.jsonl + question.md)
```
Niente backend Fastify, niente `pi --mode rpc`, niente `tht`, niente DB: il
server gira direttamente sui descriptor registrati.
## Comandi
```bash
# Estrai la fixture per una sessione (id, file, o directory di transcript):
scripts/replay.sh extract 2026-07-03-132933-fammi-la-lista-dei-pazienti-che-negli-ul
scripts/replay.sh extract ~/.pi/agent/sessions/--Users-mp-projects-ThothII-harness--/
# (una tantum, o dopo modifiche al frontend) costruisci il bundle SPA:
scripts/replay.sh build
# Avvia il server su :5333:
scripts/replay.sh run
# Scorciatoia: extract (default) + build (se manca) + run:
scripts/replay.sh
```
**Cosa può estrarre `extract`:**
| Input | Comportamento |
|---|---|
| *(nessuno)* | La sessione di default (3 lug 17:07, 20 gate). |
| `<session-id>` | Scansiona **tutti** i transcript nella directory Pi per quel session-id. Gestisce automaticamente sessioni spezzate su più file (resume): raccoglie i gate da tutti i transcript, li ordina cronologicamente, e scarta i gate senza risposta (quelli riproposti al resume). |
| `<file.jsonl>` | Un singolo transcript. |
| `<directory>` | Tutti i `*.jsonl` nella directory; se contengono sessioni diverse, tiene quella dominante (con più gate). |
La domanda della sessione è letta da `session_manifest.yaml` (campo `question`),
cercando la directory sessions in ogni workspace di `harness/workspaces/` (via
`paths.sessions`, assoluto o relativo). Niente più path hardcoded.
## Cosa vedi nel browser
1. Apri `http://localhost:5333`. La lista sessioni mostra **una** sessione
(quella riprodotta) con la domanda reale.
2. Cliccala: appare il **gate 1** — un `reviewer_select` (widget select) con il
titolo, l'intro e le opzioni esatte che il modello propose il 3 luglio,
incluso il badge "consigliato" sull'opzione raccomandata.
3. Clicca un'opzione. Sotto la tua scelta compare un `info` con la **scelta
reale del 3 luglio** (`📌 SCELTA REALE (3 lug 2026): …`), e — se coincide — un
`✓ Hai scelto come il 3 luglio.`
4. Avanzi attraverso tutti i gate nell'ordine loggato: `select` (F1 chiarimenti,
F6 conferma piano), `multiselect` (F3 riscrittura, F4 schema linking, F6
piano CTE), `artifact-gate` (F2/F3/F4/F5 phase advance, F6 approvazione CTE
con SQL visibile nell'`ArtifactView`).
5. Dopo l'ultimo gate: `✓ Replay completato — 20/20 gate`.
I 20 gate della sessione 17:07: 4 `select` + 11 `confirm` + 5 `decide`.
## Limiti e fedeltà al log
- **Sessioni multi-file (resume)**: gestite. L'estrattore raccoglie i gate da
tutti i transcript della sessione, li ordina per timestamp del messaggio, e
scarta i gate senza `toolResult` (quelli dove il revisore chiuse Pi senza
rispondere e che furono riproposti al resume). Risultato: la sequenza
esatta dei gate che il revisore ha effettivamente visto e deciso.
- **Workspace discovery**: la domanda è letta da `session_manifest.yaml`,
cercando `paths.sessions` nei workspace di `harness/workspaces/`. Funziona
per qualunque cliente (non solo `psd`), purché il workspace yaml sia
presente e `paths.sessions` sia un path assoluto o relativo (non espanso
con `${VAR}` — in quel caso il path non è risolvibile offline e la domanda
resta vuota).
- **Phase compute disattivata**: la fase corrente (colorazione `F1`…`F8`) non è
ricalcolata dal ledger (richiederebbe `tht phase`); i gate usano un phase-tag
neutro `replay`. L'ordine e i descriptor sono quelli reali.
- **Gate falliti/saltati**: alcuni gate non hanno una "scelta reale" perché
nella sessione live furono saltati (memorie vuote → avanzamento auto) o
falliti (es. `cte_plan` con tipo invalido). Il badge
`⚠ Gate fallito nella sessione reale` lo segnala fedelmente — riflette il
log, non un bug del replay.
- **`text_delta`/ragionamenti del modello**: omessi (scelta "solo gate"). Per
vederli, estendi `extract.mjs` per raccogliere anche i `text_delta`.
- **Reserved controls** (`back`/`exit`/`other`): nel replay sono ammessi come
qualunque altra risposta, ma non hanno semantica (non c'è stato di
navigazione da ripristinare); trattali come "procedi".
- **Single-user, single-session**: il server tiene un solo cursore globale; non
è pensato per più client concorrenti (un refresh del browser riprende dal
gate corrente).
## File
- `extract.mjs` — estrattore fixture dal transcript Pi JSONL.
- `server.mjs` — server HTTP+SSE standalone (node:http, zero deps).
- `replay.json` — fixture generata (committata per comodità).
- `web/` — bundle SPA costruito da `frontend/` con
`VITE_BACKEND_URL=http://localhost:5333` (gitignorato; prodotto da `build`).
+362
View File
@@ -0,0 +1,362 @@
// Extract a replay fixture from one or more Pi agent transcripts.
//
// A single ThothII session can span multiple transcript files (Pi re-enters at
// resume, re-emitting any gate the reviewer left unanswered). To reconstruct
// the "what the reviewer actually saw and decided" sequence we:
// 1. Collect reviewer_* toolCalls across ALL transcripts for the session,
// tagged with the message timestamp (NOT the filename — a transcript's
// own timestamp is the resume moment, not the gate moment).
// 2. Sort chronologically by message timestamp.
// 3. Skip any gate without a toolResult (the reviewer closed Pi without
// answering; the gate was re-presented later in a fresher form). What
// remains is exactly the gates the reviewer acted on, in order.
//
// Usage:
// node tools/replay/extract.mjs [<session-id-or-path>]
//
// <session-id> e.g. 2026-07-03-170732-fammi-...
// Scans every *.jsonl in the Pi agent session dir for that
// session id. Default = the 17:07 full session.
// <path> a transcript file OR a directory of transcripts.
//
// Output: tools/replay/replay.json (descriptor per gate + the reviewer's real
// choice recovered from the toolResult text).
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
const RESERVED = ["back", "exit", "other"];
const SCHEMA_VERSION = 1;
const PI_SESSIONS_DIR = join(
homedir(),
".pi/agent/sessions/--Users-mp-projects-ThothII-harness--",
);
// --- resolve which transcript files to read --------------------------------
function resolveTranscriptFiles(arg) {
if (!arg) {
// default: the 17:07 full session
return readdirSync(PI_SESSIONS_DIR)
.filter((f) => f.endsWith(".jsonl") && f.startsWith("2026-07-03T17-07-32"))
.map((f) => join(PI_SESSIONS_DIR, f))
.sort();
}
if (existsSync(arg)) {
const s = statSync(arg);
if (s.isDirectory()) {
return readdirSync(arg)
.filter((f) => f.endsWith(".jsonl"))
.map((f) => join(arg, f))
.sort();
}
return [arg];
}
// Treat as a session id: scan the Pi session dir for files mentioning it.
// (Cheap prefilter by substring to avoid parsing every historical transcript.)
const hits = [];
for (const f of readdirSync(PI_SESSIONS_DIR)) {
if (!f.endsWith(".jsonl")) continue;
const p = join(PI_SESSIONS_DIR, f);
// Quick text scan; the session id appears in toolCall arguments.
const head = readFileSync(p, "utf8");
if (head.includes(arg)) hits.push(p);
}
if (hits.length === 0) {
throw new Error(
`No transcripts found for "${arg}". Pass a session id, a transcript file, or a directory.`,
);
}
return hits.sort();
}
const files = resolveTranscriptFiles(process.argv[2]);
// --- first pass: gather reviewer calls (with msg timestamp) + toolResults --
let sessionId = null;
let createdAt = null;
const calls = []; // {callId, name, args, ts, file}
const resultsByCallId = new Map(); // callId -> toolResult text
for (const fn of files) {
const raw = readFileSync(fn, "utf8").split("\n").filter(Boolean);
for (const line of raw) {
let m;
try {
m = JSON.parse(line);
} catch {
continue;
}
if (m.type === "session" && !createdAt) createdAt = m.timestamp;
if (m.type !== "message") continue;
const msg = m.message;
if (!msg || !Array.isArray(msg.content)) continue;
const ts = m.timestamp ?? null;
for (const p of msg.content) {
if (p.type !== "toolCall") continue;
if (typeof p.name !== "string" || !p.name.startsWith("reviewer_")) continue;
calls.push({ callId: p.id, name: p.name, args: p.arguments ?? {}, ts, file: fn });
}
if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
if (typeof msg.toolName === "string" && msg.toolName.startsWith("reviewer_")) {
const c = Array.isArray(msg.content) ? msg.content[0] : null;
const text = c && typeof c.text === "string" ? c.text : "";
// First writer wins: a toolResult is unique per callId across files.
if (!resultsByCallId.has(msg.toolCallId)) {
resultsByCallId.set(msg.toolCallId, text);
}
}
}
}
}
// --- chronological order, then drop gates the reviewer never answered -------
// A gate without a toolResult = the reviewer closed Pi at that gate; it was
// re-presented (possibly reworded) at the next resume. Keeping only answered
// gates yields the sequence the reviewer actually experienced end-to-end.
calls.sort((a, b) => (a.ts ?? "").localeCompare(b.ts ?? ""));
// When reading a whole directory, calls may span multiple sessions. Pick the
// session with the most reviewer calls (the "subject" of that directory) and
// keep only its gates — otherwise replay.json would interleave two sessions.
const sessionCounts = new Map();
for (const c of calls) {
const s = c.args.session ?? "(none)";
sessionCounts.set(s, (sessionCounts.get(s) ?? 0) + 1);
}
if (sessionCounts.size > 1) {
let best = null;
let bestN = -1;
for (const [s, n] of sessionCounts) if (n > bestN) { best = s; bestN = n; }
if (best) {
const filtered = calls.filter((c) => (c.args.session ?? "(none)") === best);
console.error(` directory mode: ${sessionCounts.size} sessions found, keeping "${best}" (${filtered.length}/${calls.length} calls)`);
calls.splice(0, calls.length, ...filtered);
if (sessionId && sessionId !== best) sessionId = best;
}
}
const answered = calls.filter((c) => resultsByCallId.has(c.callId));
// --- build descriptors (mirrors harness/.pi/extensions/gate/builders.js) ----
function buildDescriptor(name, args, idx) {
const id = `u${idx}`;
const phase = "replay";
if (name === "reviewer_select") {
const opts = parseOptions(args.options).filter((o) => !isReserved(o.label));
const recommendedId = parseOptions(args.options).find((o) => o.recommended)?.id ?? null;
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "select",
title: args.title,
intro: args.intro ?? null,
recommended: recommendedId,
options: opts.map((o) => ({ id: o.id, label: o.label, recommended: !!o.recommended })),
reserved: RESERVED,
};
}
if (name === "reviewer_decide") {
const opts = parseOptions(args.options).filter((o) => !isReserved(o.label));
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "multiselect",
title: args.title,
allow_empty: args.allow_empty ?? false,
options: opts.map((o) => ({
id: o.id,
label: o.label,
recommended: !!o.recommended,
decision: o.decision,
})),
selected: [],
content: null,
reserved: RESERVED,
};
}
if (name === "reviewer_confirm") {
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "artifact-gate",
title: args.title,
artifact: args.artifact,
action: { kind: "approve_reject", prompt: "Approvi o rifiuti?" },
options: [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
],
reserved: RESERVED,
};
}
return null;
}
function isReserved(label) {
if (typeof label !== "string") return false;
const l = label.toLowerCase();
return RESERVED.some((r) => l === r || l.startsWith(r + ":") || l.startsWith(r + " —"));
}
// Some models serialize arrays as JSON strings; the gate's prepareReviewerArguments
// coerces them back to arrays pre-validation. Mirror that here so a stringified
// `options` (or `names`) doesn't blow up the descriptor builder.
function parseOptions(v) {
if (Array.isArray(v)) return v;
if (typeof v === "string") {
try {
const p = JSON.parse(v);
if (Array.isArray(p)) return p;
} catch { /* not JSON */ }
}
return [];
}
// --- recover the real reviewer choice from the toolResult text -------------
function parseRealChoice(name, descriptor, resultText) {
if (!resultText) return { labels: [], note: "no toolResult captured" };
const t = resultText.trim();
const askOnly = t.match(/^Scelta del reviewer:\s*(.+?)\s*\.\s*$/);
if (name === "reviewer_select") {
let label = null;
const m = t.match(/\):\s*(.+?)\s*\.\s*$/);
if (m) label = m[1];
else if (askOnly) label = askOnly[1];
return { labels: label ? [label] : [], note: t };
}
if (name === "reviewer_decide") {
const m = t.match(/Registrate\s+(\d+)\s+decisioni:\s*(.+?)\.\s*$/);
if (m) return { labels: [], decisionTypes: m[2].split(/,\s*/), note: t };
if (/Nessuna decisione registrata/.test(t)) return { labels: [], decisionTypes: [], note: t };
return { labels: [], note: t };
}
if (name === "reviewer_confirm") {
if (/ERRORE/i.test(t)) return { labels: [], note: t, failed: true };
if (/Nessun CTE in attesa/i.test(t)) return { labels: [], note: t, failed: true };
if (/approvato|approvata/i.test(t)) return { labels: ["Salva e procedi"], note: t };
if (/Rifiutato/i.test(t)) return { labels: ["Rifiuta"], note: t };
return { labels: [], note: t };
}
return { labels: [], note: t };
}
// --- assemble the gates array ----------------------------------------------
// sessionId = the dominant session across the surviving calls (post-filter).
sessionId = sessionId ?? null;
{
const sc = new Map();
for (const c of answered) sc.set(c.args.session, (sc.get(c.args.session) ?? 0) + 1);
let best = null, bn = -1;
for (const [s, n] of sc) if (n > bn) { best = s; bn = n; }
if (best) sessionId = best;
}
const gates = answered.map((c, idx) => {
const descriptor = buildDescriptor(c.name, c.args, idx);
const real = parseRealChoice(c.name, descriptor, resultsByCallId.get(c.callId));
return { callId: c.callId, toolName: c.name, ts: c.ts, descriptor, real };
});
// --- session question: prefer the manifest, fall back to question.md -------
function readSessionQuestion(sid) {
if (!sid) return null;
const sessionDir = findSessionDir(sid);
if (!sessionDir) return null;
// session_manifest.yaml has a top-level `question:` field with the original
// NL question. Cheaper and more authoritative than parsing question.md.
const manifestPath = join(sessionDir, "session_manifest.yaml");
if (existsSync(manifestPath)) {
const m = readFileSync(manifestPath, "utf8");
const q = m.match(/^question:\s*(.+?)\s*$/m);
if (q) return stripYaml(q[1]);
}
const qmd = join(sessionDir, "question.md");
if (existsSync(qmd)) {
const md = readFileSync(qmd, "utf8").replace(/^#.*\n+/, "").trim();
return md.split(/\n\s*\n/)[0].trim();
}
return null;
}
// Resolve a session id to its on-disk directory. We check every workspace yaml
// in harness/workspaces/ for a `paths.sessions` entry (relative OR absolute),
// then look for <sessions>/<session-id>/ in each. Workspaces are symlinks to
// per-client repos (uncommitted), so this works for any client without env.
function findSessionDir(sid) {
const wsDir = join(process.cwd(), "harness/workspaces");
const candidates = [];
if (existsSync(wsDir)) {
for (const f of readdirSync(wsDir)) {
if (!/\.(ya?ml)$/i.test(f)) continue;
const ypath = join(wsDir, f);
let y;
try { y = readFileSync(ypath, "utf8"); } catch { continue; }
const m = y.match(/^paths:\s*\n(?:[ \t]+.*\n)*?[ \t]+sessions:\s*(\S+)/m);
if (!m) continue;
let p = stripYaml(m[1]);
if (!p) continue;
// Strip ${VAR} placeholders (unresolvable here) — those workspaces can't
// be located offline and are skipped.
if (p.includes("${")) continue;
candidates.push(p);
}
}
// Plus the legacy hardcoded PSD location as a last resort.
candidates.push(join(homedir(), "projects", "tht-workspace-psd", "sessions"));
for (const base of candidates) {
const abs = join(base, sid);
if (existsSync(abs)) return abs;
}
return null;
}
function stripYaml(s) {
// drop surrounding quotes + trailing comment
let v = s.trim().replace(/^['"]|['"]$/g, "");
v = v.replace(/\s+#.*$/, "");
return v;
}
const replay = {
source: {
transcripts: files,
extractedAt: new Date().toISOString(),
note: "gates without a reviewer answer (resume re-entries) are dropped",
},
session: {
id: sessionId,
question: readSessionQuestion(sessionId),
created_at: createdAt,
},
stats: {
transcriptFiles: files.length,
totalReviewerCalls: calls.length,
answeredGates: answered.length,
droppedUnanswered: calls.length - answered.length,
},
gates,
};
const outPath = new URL("./replay.json", import.meta.url);
writeFileSync(outPath, JSON.stringify(replay, null, 2) + "\n", "utf8");
// --- coverage summary ------------------------------------------------------
const byTool = {};
let withChoice = 0;
for (const g of gates) {
byTool[g.toolName] = (byTool[g.toolName] ?? 0) + 1;
if (g.real.labels.length || (g.real.decisionTypes && g.real.decisionTypes.length)) withChoice++;
}
console.error(`scanned ${files.length} transcript file(s)`);
console.error(` reviewer calls: ${calls.length} | answered: ${answered.length} | dropped (no answer): ${calls.length - answered.length}`);
console.error(` by tool (answered):`, byTool);
console.error(` with a real choice recovered: ${withChoice}/${gates.length}`);
console.error(` session: ${sessionId ?? "(unknown)"}`);
console.error(` question: ${(replay.session.question ?? "(not found)").slice(0, 90)}`);
console.error(`wrote ${outPath.pathname}`);
File diff suppressed because it is too large Load Diff
+480
View File
@@ -0,0 +1,480 @@
// Standalone replay server: serves the built SPA + a tiny SSE/REST shim that
// replays the recorded reviewer gates of a logged session, no VPN/Pi/DWH needed.
//
// Same-origin on :5333. The SPA bundle is built once with
// VITE_BACKEND_URL=http://localhost:5333 (see scripts/replay.sh), so all REST
// + SSE calls land on this server and we control the entire interaction loop.
//
// Run: node tools/replay/server.mjs (after `scripts/replay.sh build`)
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, extname, normalize } from "node:path";
import { homedir } from "node:os";
import { createRequire } from "node:module";
const PORT = Number(process.env.PORT ?? 5333);
const HERE = dirname(fileURLToPath(import.meta.url));
const WEB_DIR = join(HERE, "web");
const REPLAY_PATH = join(HERE, "replay.json");
const require = createRequire(import.meta.url);
const replay = JSON.parse(await readFile(REPLAY_PATH, "utf8"));
const REPLAY_SESSION_ID = replay.session.id;
const GATES = replay.gates;
if (!existsSync(WEB_DIR)) {
console.error(`Missing ${WEB_DIR}. Run: scripts/replay.sh build`);
process.exit(1);
}
// --- replay state (single-user, single-session) --------------------------
// One cursor advances across the gate sequence. The SSE connection streams the
// gate at the cursor; a POST /response advances the cursor and the next gate is
// pushed on the same SSE stream. A fresh SSE connection (browser refresh, or
// clicking the session again after completion) resets the cursor to 0, so the
// replay can be walked any number of times.
let cursor = 0;
let sseClient = null; // the active SSE response, if any
function sendSse(res, eventName, data) {
res.write(`event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`);
}
function currentGate() {
return { index: cursor, gate: GATES[cursor] ?? null };
}
// Replay-completion marker id prefix. The final ui_request carries this id so
// the response handler can recognise "the user wants to restart" and close the
// SSE (browser EventSource reconnects → cursor resets → gate 1 re-emitted).
const RESTART_ID_PREFIX = "replay-restart-";
function emitCurrentGate(res) {
const { index, gate } = currentGate();
if (!gate) {
// End of replay: emit a final ui_request as a one-button select. This
// matters because the reducer sets pendingWidget on ANY ui_request, and
// pendingWidget!=null is exactly what stops the working spinner. Using a
// select (rather than widget=info) gives the user an in-place "Ripeti il
// replay" button: clicking it POSTs a response, which we recognise via the
// RESTART_ID_PREFIX and handle by closing the SSE — the browser EventSource
// then auto-reconnects, the cursor resets to 0, and gate 1 is re-emitted.
// We deliberately do NOT mark the session "finalized": that status makes
// the real backend refuse resume with 409, and we want the replay to be
// re-runnable any number of times.
const id = `${RESTART_ID_PREFIX}${Date.now()}`;
sendSse(res, "ui_request", {
type: "ui_request",
ui_request: {
id,
widget: "select",
title: `✓ Replay completato — ${GATES.length}/${GATES.length} gate riprodotti`,
intro: `Hai attraversato tutti i gate reviewer della sessione. Scegli cosa fare:`,
// Two real options so SelectWidget renders them as equal-weight buttons
// (a reserved "exit" would render as a small muted link instead). The
// response handler tells them apart by ui_response.choices[0].
options: [
{ id: "restart", label: "↻ Ripeti il replay", recommended: true },
{ id: "exit", label: "■ Esci" },
],
},
});
console.error(`[done] replay completato (${GATES.length} gate)`);
return;
}
sendSse(res, "ui_request", {
type: "ui_request",
ui_request: gate.descriptor,
});
console.error(
`[${index + 1}/${GATES.length}] -> ${gate.toolName}: ${gate.descriptor.title}`,
);
}
// --- helpers --------------------------------------------------------------
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".ico": "image/x-icon",
".woff2": "font/woff2",
".woff": "font/woff",
".map": "application/json; charset=utf-8",
};
async function serveStatic(req, res, urlPath) {
let p = normalize(join(WEB_DIR, urlPath));
if (!p.startsWith(WEB_DIR)) {
res.writeHead(403);
res.end("forbidden");
return;
}
// Directory requests (incl. "/") and missing files fall back to index.html
// (SPA: client-side routing handles all paths under /).
let isDir = false;
try { isDir = (await stat(p)).isDirectory(); } catch { /* missing */ }
if (isDir || !existsSync(p)) {
p = join(WEB_DIR, "index.html");
}
try {
const data = await readFile(p);
res.writeHead(200, { "Content-Type": MIME[extname(p)] ?? "application/octet-stream" });
res.end(data);
} catch {
res.writeHead(404);
res.end("not found");
}
}
function sendJson(res, code, obj) {
const body = JSON.stringify(obj);
res.writeHead(code, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
}
function sendNoContent(res) {
res.writeHead(204);
res.end();
}
// Describe what the reviewer actually picked on 3 Jul, for the info badge.
function describeRealChoice(gate) {
const r = gate.real;
if (r.failed) return { level: "warning", text: `⚠ Gate fallito nella sessione reale: ${r.note}` };
if (r.note && /Fase memoria vuota|avanzamento automatico/i.test(r.note)) {
return { level: "info", text: `ℹ Nella sessione reale questo gate fu saltato (memorie vuote, avanzamento automatico).` };
}
const labels = r.labels ?? [];
const types = r.decisionTypes ?? [];
if (labels.length === 0 && types.length === 0) {
return { level: "info", text: `ℹ Scelta reale non campionata dal log: ${r.note ?? "—"}` };
}
const parts = [];
if (labels.length) parts.push(labels.join(", "));
if (types.length) parts.push(`decisioni: ${types.join(", ")}`);
return { level: "warning", text: `📌 SCELTA REALE (3 lug 2026): ${parts.join(" — ")}` };
}
// Compare the user's click to the real choice. Approve/Reject and select options
// use labels; decide uses decisionTypes (loose match — see compareChoices).
function compareUserChoice(gate, uiResponse) {
const r = gate.real;
const realLabels = new Set((r.labels ?? []).map((s) => s.toLowerCase()));
const realTypes = new Set((r.decisionTypes ?? []));
const choiceIds = new Set(uiResponse.choices ?? []);
const opts = gate.descriptor.options ?? [];
// select: choice id -> option label
if (gate.descriptor.widget === "select") {
const picked = opts.find((o) => choiceIds.has(o.id));
if (!picked || realLabels.size === 0) return null;
const match = [...realLabels].some((rl) => picked.label.toLowerCase().includes(rl.split(" (")[0].toLowerCase()));
return match ? "✓ Hai scelto come il 3 luglio." : null;
}
// artifact-gate: approve/reject
if (gate.descriptor.widget === "artifact-gate") {
if (choiceIds.has("approve") && realLabels.has("salva e procedi")) {
return "✓ Hai scelto come il 3 luglio (Salva e procedi).";
}
if (choiceIds.has("reject") && realLabels.has("rifiuta")) {
return "✓ Hai scelto come il 3 luglio (Rifiuta).";
}
return null;
}
// multiselect: count of picked options vs number of real decision types
if (gate.descriptor.widget === "multiselect") {
if (realTypes.size === 0) return null;
const pickedCount = opts.filter((o) => choiceIds.has(o.id)).length;
return pickedCount === realTypes.size
? `✓ Stesso numero di scelte del 3 luglio (${pickedCount}).`
: null;
}
return null;
}
// --- HTTP routing ---------------------------------------------------------
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const path = url.pathname;
const method = req.method;
// CORS preflight (not needed same-origin, but harmless).
res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
if (method === "OPTIONS") {
res.writeHead(204);
return res.end();
}
// --- SSE stream (the heart of the replay) -------------------------------
if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}/events`) {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
sseClient = res;
// A fresh SSE connection restarts the replay from the beginning: this is
// what makes the session re-runnable after completion, and what makes a
// browser refresh re-walk the gates. (Single-user, single-session.)
cursor = 0;
req.on("close", () => {
if (sseClient === res) sseClient = null;
});
emitCurrentGate(res);
return;
}
// --- reviewer response: advance the cursor + push next gate on SSE ------
if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/response`) {
const body = await readBody(req);
const uiResponse = (body?.ui_response ?? {});
const isRestartId = typeof uiResponse.id === "string" && uiResponse.id.startsWith(RESTART_ID_PREFIX);
if (isRestartId) {
// The final "completed" select carries a RESTART_ID_PREFIX id with two
// options: "restart" (re-run the loop) and "exit" (park the session).
// Restart closes the SSE so the browser EventSource reconnects → cursor
// resets → gate 1. Exit keeps the SSE open and emits a parked info widget
// (no spinner, no replay); the user resumes via the sidebar menu.
const choice = (uiResponse.choices ?? [])[0];
if (choice === "exit") {
// Emit a system_event the frontend interprets as "leave the live session
// view and return to the landing". The AppShell has an effect that calls
// stopSession() on system_event { event: "session_exit" }, which resets
// the active session and shows the landing again. The session stays in
// the sidebar list with status "open", so the user can click it to open
// the documents panel and press Resume there to restart the replay.
console.error("[exit] replay fermato, ritorno alla landing");
if (sseClient) {
sendSse(sseClient, "system_event", {
type: "system_event",
event: "session_exit",
});
}
return sendNoContent(res);
}
// Restart (default): close SSE to reconnect.
if (sseClient) {
try { sseClient.end(); } catch { /* already closed */ }
sseClient = null;
}
console.error("[restart] replay richiesto di nuovo dall'utente");
return sendNoContent(res);
}
const { index, gate } = currentGate();
const isLast = index === GATES.length - 1;
// Ack the POST first. Then push info badges + the next gate. The delay lets
// the browser clear its `stepMessages` (reset by WidgetHost's setLastUserEntry
// on POST completion) BEFORE our info badges arrive, so the "scelta reale"
// badge survives. On the LAST gate we skip the delay: the final "completed"
// ui_request (widget=select) must land ASAP to set pendingWidget and stop
// the spinner; setLastUserEntry does NOT reset pendingWidget, so order is safe.
const delay = isLast ? 0 : 150;
setTimeout(() => {
if (!gate) return;
logChoice(gate, uiResponse);
if (sseClient) {
const realDesc = describeRealChoice(gate);
sendSse(sseClient, "info", { type: "info", level: realDesc.level, text: realDesc.text });
const match = compareUserChoice(gate, uiResponse);
if (match) sendSse(sseClient, "info", { type: "info", level: "info", text: match });
}
cursor += 1;
if (sseClient) emitCurrentGate(sseClient);
}, delay);
return sendNoContent(res);
}
// --- REST stubs the SPA needs to boot -----------------------------------
if (method === "GET" && path === "/health") {
return sendJson(res, 200, { ok: true, replay: true, gates: GATES.length });
}
if (method === "GET" && path === "/workspaces") {
return sendJson(res, 200, [{ name: "psd (replay)", file: "psd.yaml" }]);
}
if (method === "GET" && path === "/models") {
return sendJson(res, 200, { models: [{ id: "glm-5.2", provider: "zai", label: "GLM 5.2 (replay)" }] });
}
if (method === "GET" && path === "/settings") {
return sendJson(res, 200, {
workspace: "psd",
provider: "zai",
model: "glm-5.2",
thinking: "medium",
});
}
if (method === "GET" && path === "/sessions") {
// Status stays "open" forever: the replay must remain re-runnable, and the
// real backend refuses resume with 409 when status is "finalized".
return sendJson(res, 200, [
{
id: REPLAY_SESSION_ID,
status: "open",
question: replay.session.question ?? "(replay)",
summary: "Replay 3 lug — 20 gate reviewer",
created_at: replay.session.created_at ?? "2026-07-03T17:07:32Z",
updated_at: null,
author: "replay",
name: "Replay 170732",
group: null,
archived: false,
},
]);
}
if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}`) {
return sendJson(res, 200, {
id: REPLAY_SESSION_ID,
status: "open",
phase: 1,
question: replay.session.question ?? "",
});
}
if (method === "GET" && path === `/sessions/${REPLAY_SESSION_ID}/documents`) {
return sendJson(res, 200, buildSessionDocuments(REPLAY_SESSION_ID));
}
// Resume: the frontend's doResume reuses the SAME SSE connection when the
// session is already active (the useSessionStream effect only re-runs on
// sessionId change), so simply resetting the cursor wouldn't reach the
// client. We forcibly CLOSE the current SSE connection: the browser's
// EventSource auto-reconnects, opening a fresh stream that resets the cursor
// to 0 and re-emits gate 1. This makes "resume immediately after the last
// gate" work without a hard refresh.
if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/resume`) {
if (sseClient) {
try { sseClient.end(); } catch { /* already closed */ }
sseClient = null;
}
return sendNoContent(res);
}
if (method === "POST" && /^\/sessions\/[^/]+\/(close|steer|rename|group|archive|unarchive)$/.test(path)) {
return sendNoContent(res);
}
if (method === "POST" && path === "/sessions") {
// New-session form: redirect into the replay instead.
return sendJson(res, 200, { id: REPLAY_SESSION_ID });
}
if (method === "DELETE" && path === `/sessions/${REPLAY_SESSION_ID}`) {
return sendNoContent(res);
}
if (method === "PUT" && path === "/settings") {
return sendNoContent(res);
}
// --- static SPA fallback ------------------------------------------------
if (method === "GET") {
return serveStatic(req, res, path);
}
sendNoContent(res);
});
function logChoice(gate, uiResponse) {
const opts = gate.descriptor.options ?? [];
const choiceIds = uiResponse.choices ?? [];
const picked = opts.filter((o) => choiceIds.includes(o.id)).map((o) => o.label);
const real = gate.real;
const realDesc = real.labels?.length
? real.labels.join(", ")
: real.decisionTypes?.length
? `${real.decisionTypes.length} decisioni: ${real.decisionTypes.join(", ")}`
: real.failed
? "FALLITO"
: "—";
console.error(
` <- user picked: ${picked.join(" | ") || uiResponse.control || "(empty)"} | reale: ${realDesc}`,
);
}
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
resolve({});
}
});
});
}
// --- session documents ----------------------------------------------------
// Mirror of harness/tht/session/store.py build_documents, so the frontend's
// GateArtifactBody can resolve file-reference artifacts (e.g. {file:"question.md"})
// to their full content via GET /sessions/:id/documents.
const DOC_SPEC = [
["question.md", "F3", "revised_question", "Revised question", "markdown"],
["schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"],
["sql_final.sql", "F7", "sql", "Final SQL", "sql"],
["validation_report.md", "finalize", "validation_report", "Validation report", "markdown"],
["review_decisions.jsonl", "—", "decisions", "Decisions", "decisions"],
];
function buildSessionDocuments(sid) {
const dir = findSessionDir(sid);
const docs = [{
phase: "—",
key: "question",
title: "Original question",
format: "text",
content: replay.session.question ?? "",
}];
if (!dir) return docs;
for (const [filename, phase, key, title, fmt] of DOC_SPEC) {
const p = join(dir, filename);
if (existsSync(p)) {
docs.push({ phase, key, title, format: fmt, content: readFileSync(p, "utf8") });
}
}
return docs;
}
// Resolve a session id to its on-disk directory: check every workspace yaml in
// harness/workspaces/ for paths.sessions (absolute or relative), then the legacy
// PSD location. Same logic as extract.mjs findSessionDir.
function findSessionDir(sid) {
const repoRoot = join(HERE, "..", "..");
const wsDir = join(repoRoot, "harness/workspaces");
const candidates = [];
if (existsSync(wsDir)) {
for (const f of readdirSync(wsDir)) {
if (!/\.(ya?ml)$/i.test(f)) continue;
const ypath = join(wsDir, f);
let y;
try { y = readFileSync(ypath, "utf8"); } catch { continue; }
const m = y.match(/^paths:\s*\n(?:[ \t]+.*\n)*?[ \t]+sessions:\s*(\S+)/m);
if (!m) continue;
let p = m[1].replace(/^['"]|['"]$/g, "").replace(/\s+#.*$/, "");
if (!p || p.includes("${")) continue;
candidates.push(p);
}
}
candidates.push(join(homedir(), "projects", "tht-workspace-psd", "sessions"));
for (const base of candidates) {
const abs = join(base, sid);
if (existsSync(abs)) return abs;
}
return null;
}
server.listen(PORT, "127.0.0.1", () => {
console.error(`\n▶ ThothII replay: http://localhost:${PORT}`);
console.error(` sessione: ${REPLAY_SESSION_ID}`);
console.error(` domanda: ${(replay.session.question ?? "").slice(0, 100)}…`);
console.error(` ${GATES.length} gate reviewer da attraversare.`);
console.error(` Ctrl-C per uscire.\n`);
});