From 823a1bc7e218c516d136ce6a375440a80694a2e7 Mon Sep 17 00:00:00 2001 From: arko33 <221298156+arko33@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:38:54 +0700 Subject: [PATCH] feat: Ghost Studio (localhost:5417) + real-data 3D playground (?live=1) --- README.md | 12 ++ app/api/ghost/snapshot/route.ts | 31 +++++ app/playground/page.tsx | 16 ++- packages/ghost/ghost.mjs | 129 ++++++++++++++++- src/autopsy/engine.ts | 87 ++++++++++-- src/components/playground/LiveDashboard.tsx | 85 ++++++++++++ src/components/playground/city.ts | 67 +++++++-- src/components/site/LandingPage.tsx | 4 +- src/ghost/adapter.ts | 146 ++++++++++++++++++++ 9 files changed, 543 insertions(+), 34 deletions(-) create mode 100644 app/api/ghost/snapshot/route.ts create mode 100644 src/components/playground/LiveDashboard.tsx create mode 100644 src/ghost/adapter.ts diff --git a/README.md b/README.md index 9d8711c..f6e0248 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,18 @@ ghost sessions # list sessions stored in .ghost ghost setup # optional: pair the device with Ghost Cloud (metadata-only sync; source code never leaves the machine) ``` +While `ghost watch` runs it also opens **Ghost Studio** in your browser — +`http://127.0.0.1:5417` (the recorder's own zero-dependency page): the live +session shows the repo, the real turns (prompts), the files being changed with +per-file churn, and the runtime samples from your app's `/api/health`. Data +never leaves the machine. + +For the full 3D playground (the same interface as the published demo, fed by +YOUR recording instead of the fixture): run this repo's dashboard locally +(`npm run dev`) and open [`/playground?live=1`](http://localhost:3000/playground?live=1). +It streams the live recording through a loopback bridge — the cloud playground +sees sessions only after explicit pairing (`ghost setup`), never raw disks. + **Using OpenCode?** (Mode A — real turn ids, richest context) copy the one-file adapter into your project, then start `ghost watch` and use OpenCode as usual: diff --git a/app/api/ghost/snapshot/route.ts b/app/api/ghost/snapshot/route.ts new file mode 100644 index 0000000..98ce174 --- /dev/null +++ b/app/api/ghost/snapshot/route.ts @@ -0,0 +1,31 @@ +// Loopback bridge to the local Ghost recorder (packages/ghost ghost watch). +// Works only while the dashboard runs on the same machine (`npm run dev`): +// the recorder serves the live session JSON on 127.0.0.1:/session. +// On a hosted deployment this responds as "recorder: not reachable". +import { NextResponse } from "next/server"; + +const RECORDER = + process.env.GHOST_RECORDER_URL ?? "http://127.0.0.1:5417"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const res = await fetch(`${RECORDER}/session`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(1500), + cache: "no-store", + }); + if (!res.ok) throw new Error(`recorder ${res.status}`); + const session = await res.json(); + return NextResponse.json( + { recorder: true, session }, + { headers: { "cache-control": "no-store" } }, + ); + } catch { + return NextResponse.json( + { recorder: false, reason: "no local ghost watch is running on this machine" }, + { headers: { "cache-control": "no-store" } }, + ); + } +} diff --git a/app/playground/page.tsx b/app/playground/page.tsx index b8a7b10..a1c0d75 100644 --- a/app/playground/page.tsx +++ b/app/playground/page.tsx @@ -1,7 +1,21 @@ import { demoSession } from "@/src/data/fixture"; import Dashboard from "@/src/components/Dashboard"; +import LiveDashboard from "@/src/components/playground/LiveDashboard"; -export default function Page() { +export default function Page({ + searchParams, +}: { + searchParams?: { live?: string }; +}) { + if (searchParams?.live) { + // ?live=1 — stream the REAL recording (`ghost watch`) instead of the + // committed demo session. Same Dashboard, same 3D city, real data. + return ( +
+ +
+ ); + } return (
diff --git a/packages/ghost/ghost.mjs b/packages/ghost/ghost.mjs index 0fb5728..24e34db 100644 --- a/packages/ghost/ghost.mjs +++ b/packages/ghost/ghost.mjs @@ -17,7 +17,91 @@ const ROOT = process.cwd(); const GHOST_DIR = path.join(ROOT, ".ghost"); const DEFAULT_RECORDER_PORT = 5417; +// ── Ghost Studio (served by the recorder on 127.0.0.1:) ──────────────── +// Zero-dependency live view of the session being recorded: repo, turns, +// checkpoints (files + diff refs), runtime samples. Data source: GET /session. +function studioHtml(base) { + return ` + +Ghost Studio — live recording +
+

Ghost Studio — recording live

+same host as your recorder · loopback only · data stays in this machine +
+
repo…
+
session…
+
git head…
+
app…
+
+

Turns

    +

    Checkpoints — shadow commits

    +

    Runtime samples

    +
    Recorder keeps saving to .ghost/sessions/… · Ctrl+C stops & saves ·
    +locals: /session (JSON) · events target for the OpenCode adapter: ${base}/events
    +
    `; +} + // ── helpers ────────────────────────────────────────────────────────────────── +// Parse a unified diff into per-file churn (added+deleted lines) used as the +// 3D city "height" — so towers stay a fact of the recording, never invented. +function parseDiffChurn(diffText) { + const churn = {}; + let current = null; + for (const line of String(diffText).split(/\r?\n/)) { + const m = /^\+\+\+ b\/(.+)$/.exec(line); + if (m) { current = m[1]; if (!churn[current]) churn[current] = 0; continue; } + if (line.startsWith("diff --git ")) { current = null; continue; } + if (current && (line.startsWith("+") || line.startsWith("-")) && !line.startsWith("+++") && !line.startsWith("---")) churn[current] += 1; + } + return churn; +} const readConfig = () => { try { return JSON.parse(fs.readFileSync(path.join(GHOST_DIR, "config.json"), "utf8")); } catch { return { cloudUrl: "https://ghostinthecodebase.vercel.app", sync: "off", recorderPort: DEFAULT_RECORDER_PORT }; } @@ -122,6 +206,33 @@ async function cmdWatch() { const port = Number(arg("--recorder-port", config.recorderPort || DEFAULT_RECORDER_PORT)); const eventsBase = `http://127.0.0.1:${port}`; const emitter = http.createServer((req, res) => { + if (req.method === "GET" && (req.url === "/" || req.url === "/studio")) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(studioHtml(eventsBase)); + return; + } + if (req.method === "GET" && req.url === "/session") { + res.writeHead(200, { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + }); + res.end(JSON.stringify(session)); + return; + } + if (req.method === "GET" && req.url.startsWith("/diff/")) { + const name = decodeURIComponent(req.url.slice(6).split("?")[0]); + const dir = store.sessionDir(ROOT, session); + try { + const file = fs.readdirSync(path.join(dir, "diffs")).find((f) => f.startsWith(name)); + if (!file) throw new Error("not found"); + res.writeHead(200, { + "content-type": "text/plain; charset=utf-8", + "access-control-allow-origin": "*", + }); + res.end(fs.readFileSync(path.join(dir, "diffs", file), "utf8")); + } catch { res.writeHead(404).end(); return; } + return; + } if (req.method !== "POST" || req.url !== "/events") { res.writeHead(404).end(); return; } let body = ""; req.on("data", (c) => (body += c)); @@ -144,16 +255,30 @@ async function cmdWatch() { }); await new Promise((r) => emitter.listen(port, "127.0.0.1", r)); + // open Ghost Studio in the default browser (best effort; --no-open to skip) + if (!arg("--no-open", false)) { + try { + if (process.platform === "win32") spawn("cmd", ["/c", "start", "", `${eventsBase}/studio`], { detached: true, stdio: "ignore" }).unref(); + else spawn(process.platform === "darwin" ? "open" : "xdg-open", [`${eventsBase}/studio`], { detached: true, stdio: "ignore" }).unref(); + } catch { /* headless machine — the banner still shows the URL */ } + } + // Mode B — filesystem watcher (works with ANY agent, no events needed) let burst = null; const checkpoint = (label) => { try { const snap = shadow.commitSnapshot(repo, `ghost: ${label}`); if (!snap.diff) return; + // recorder internals (.ghost/*) are bookkeeping, not app files — they + // must never become towers in the city. + const isNoise = (p) => p.startsWith(".ghost/") || p === ".ghost"; + const changedFiles = snap.changedFiles.filter((p) => !isNoise(p)); + const churnEntries = Object.entries(parseDiffChurn(snap.diff)).filter(([p]) => !isNoise(p)); + if (!changedFiles.length || !churnEntries.length) return; const dir = store.sessionDir(ROOT, session); fs.mkdirSync(path.join(dir, "diffs"), { recursive: true }); fs.writeFileSync(path.join(dir, "diffs", `${snap.sha.slice(0, 12)}.patch`), snap.diff); - session.checkpoints.push({ ts: new Date().toISOString(), turnNumber: currentTurn(), sha: snap.sha, files: snap.changedFiles, diffRef: `${session.id}/diffs/${snap.sha.slice(0, 12)}.patch` }); + session.checkpoints.push({ ts: new Date().toISOString(), turnNumber: currentTurn(), sha: snap.sha, files: changedFiles, diffRef: `${session.id}/diffs/${snap.sha.slice(0, 12)}.patch`, churn: Object.fromEntries(churnEntries) }); const t = activeTurn(); // attribute checkpoint against the open turn } catch (e) { console.error(" ! checkpoint failed:", e.message); } }; @@ -177,6 +302,8 @@ async function cmdWatch() { console.log(` repo ${repo}`); if (!session.head) console.log(` git none — file recording only (run git init to enable shadow-git checkpoints)`); console.log(` app ${appUrl}`); + console.log(` studio ${eventsBase} ← open in browser: live turns, checkpoints, samples`); + console.log(` replay local dashboard: /playground?live=1 (identical to the published demo)`); console.log(` adapter ${eventsBase}/events (OpenCode plugin target)`); console.log(` sync ${sync}${sync === "off" ? " (local-only) — run: ghost setup to enable" : ""}`); console.log(` press Ctrl+C to stop & save\n`); diff --git a/src/autopsy/engine.ts b/src/autopsy/engine.ts index 1e68254..2e9fb90 100644 --- a/src/autopsy/engine.ts +++ b/src/autopsy/engine.ts @@ -30,19 +30,62 @@ const THRESHOLDS = { }; export function detectAnomalies(metrics: MetricSample[]): Anomaly[] { - // matcha:todo real detection (trend + threshold). Placeholder: flag any - // sample breaching a hard threshold and attribute to its turn. + // First-breach detection over the hard thresholds, deduped per kind: the + // goal is the ONSET turn of a real breach, not an alert storm. The + // trend-slope detector is the next layer (PRD_CONCEPT.md); for live + // recordings the onset turn is already an actionable, honest verdict. + // Convention: metric samples carry their recorder turn number verbatim + // (1-based) and turn ids are "t" — the adapter re-resolves. const out: Anomaly[] = []; + const seen = new Set(); + const now = () => new Date().toISOString(); for (const m of metrics) { - if (m.mem > THRESHOLDS.memMb) { + if (m.mem > THRESHOLDS.memMb && !seen.has("leak")) { + seen.add("leak"); out.push({ - id: `anom-mem-${m.turn}`, + id: `anom-mem-t${m.turn}`, kind: "leak", - turnId: `t${m.turn + 1}`, + turnId: `t${m.turn}`, severity: "high", metric: "mem", - detectedAt: new Date().toISOString(), - note: `Memory exceeded ${THRESHOLDS.memMb} MB.`, + detectedAt: now(), + note: `Memory breached ${THRESHOLDS.memMb} MB (first sample: ${m.mem} MB).`, + }); + } + if (m.cpu > THRESHOLDS.cpuPct && !seen.has("cpu-spin")) { + seen.add("cpu-spin"); + out.push({ + id: `anom-cpu-t${m.turn}`, + kind: "cpu-spin", + turnId: `t${m.turn}`, + severity: "med", + metric: "cpu", + detectedAt: now(), + note: `CPU breached ${THRESHOLDS.cpuPct}% (${m.cpu}%).`, + }); + } + if (m.errors > THRESHOLDS.errors && !seen.has("error-spike")) { + seen.add("error-spike"); + out.push({ + id: `anom-err-t${m.turn}`, + kind: "error-spike", + turnId: `t${m.turn}`, + severity: "high", + metric: "errors", + detectedAt: now(), + note: `${m.errors} errors in one sample window.`, + }); + } + if (m.latency > THRESHOLDS.latencyMs && !seen.has("hang")) { + seen.add("hang"); + out.push({ + id: `anom-lat-t${m.turn}`, + kind: "hang", + turnId: `t${m.turn}`, + severity: "med", + metric: "latency", + detectedAt: now(), + note: `Latency breached ${THRESHOLDS.latencyMs} ms p95 (${m.latency} ms).`, }); } } @@ -50,16 +93,30 @@ export function detectAnomalies(metrics: MetricSample[]): Anomaly[] { } export function buildAutopsy( - _anomaly: Anomaly, - _turns: Turn[], + anomaly: Anomaly, + turns: Turn[], ): Autopsy { - // matcha:todo attribution bisect -> blamedTurnId + generated fixPrompt. + // Attribution, honest edition: global turn ids may differ from the recording + // (plugin ids / estimated turns), so attach the real turn — if present — and + // ground the verdict in what the recording actually shows: the onset sample + // plus the files that turn touched. Never claim more causality than that. + const blamedTurn = + turns.find((t) => t.id === anomaly.turnId) ?? + turns.find((t) => `t${t.index}` === anomaly.turnId) ?? + null; + const files = blamedTurn?.changedFiles ?? []; + const rootCause = files.length + ? `${anomaly.note} Recorded while this turn was in flight (it touched: ${files.join(", ")}). The recording, not a causal claim.` + : anomaly.note; + const fixPrompt = files.length + ? `Roll back the files touched by this turn (${files.join(", ")}) to their pre-turn state, re-run ghost watch, and confirm the curve flattens.` + : `Re-run ghost watch around a checkpoint and compare the curve across the burst.`; return { - id: "aut-stub", - anomalyId: _anomaly.id, - blamedTurnId: _anomaly.turnId, - rootCause: "Stub autopsy — wire attribution in the full build.", - fixPrompt: "Stub fix prompt.", + id: `aut-${anomaly.id}`, + anomalyId: anomaly.id, + blamedTurnId: anomaly.turnId, + rootCause, + fixPrompt, healed: false, }; } diff --git a/src/components/playground/LiveDashboard.tsx b/src/components/playground/LiveDashboard.tsx new file mode 100644 index 0000000..a54a451 --- /dev/null +++ b/src/components/playground/LiveDashboard.tsx @@ -0,0 +1,85 @@ +"use client"; + +// LiveDashboard — the playground fed by the real local recording. +// Polls the loopback bridge (/api/ghost/snapshot → ghost watch on 127.0.0.1), +// adapts contract-v1 recordings into the dashboard Session, and renders the +// SAME Dashboard the demo uses: 3D code city, tape rail, autopsy panel. +import { useCallback, useEffect, useRef, useState } from "react"; +import Dashboard from "@/src/components/Dashboard"; +import type { Session } from "@/src/types"; +import { toDashboardSession } from "@/src/ghost/adapter"; + +const POLL_MS = 2500; + +export default function LiveDashboard() { + const [session, setSession] = useState(null); + const [reachable, setReachable] = useState(true); + const latest = useRef(null); + + const poll = useCallback(async () => { + try { + const res = await fetch("/api/ghost/snapshot", { cache: "no-store" }); + const body = await res.json(); + if (!body.recorder) { + setReachable(false); + return; + } + setReachable(true); + setSession(toDashboardSession(body.session)); + } catch { + setReachable(false); + } + }, []); + + useEffect(() => { + void poll(); + latest.current = setInterval(() => void poll(), POLL_MS); + return () => { + if (latest.current) clearInterval(latest.current); + }; + }, [poll]); + + if (!session || !session.turns.length) { + return ( +
    +
    + {!reachable ? ( + <> +
    +

    + No local ghost watch is running on this machine. + Start the recorder in your project, then reload — no clouds + involved, this page only talks to 127.0.0.1. +

    +

    + npx ghost watch … or see the README quickstart. +

    +
    + + ) : ( + <> +
    +

    + Ghost is recording — waiting for turn 1. Make an edit (or let + your agent work) and the city will build itself from the real + files it touches. +

    +

    polling…

    +
    + + )} +
    +
    + ); + } + + return ; +} diff --git a/src/components/playground/city.ts b/src/components/playground/city.ts index 2210a9b..d7c6a19 100644 --- a/src/components/playground/city.ts +++ b/src/components/playground/city.ts @@ -81,10 +81,44 @@ export function buildPlaygroundCity( }); const snapshotFiles = new Map(snapshot.files.map((file) => [file.path, file])); - const blamedTurn = activeTurn >= 3 && activeTurn < 6; - const healed = activeTurn >= 5; + const spacing = 3.05; + const cols = 10; + const rows = 8; + + // Demo fixture keeps its curated layout. Live recordings build the city from + // the files the recording actually touched — deterministically placed per + // path hash so the skyline is stable between live refreshes. + const isLive = session.agent.startsWith("ghost-live"); + const blamedTurnId = session.autopsies[0]?.blamedTurnId ?? session.anomalies[0]?.turnId ?? null; + const blamedTurn = session.turns.find((turn) => turn.id === blamedTurnId) ?? null; + const hotFiles = new Set(blamedTurn?.changedFiles ?? []); + const healed = + !isLive && activeTurn < 5 ? false : isLive ? !!session.autopsies[0]?.healed : true; + + const realFiles = isLive + ? [...snapshotFiles.values()].filter((file) => file.path) + : []; - const files = KNOWN_FILES.map((path) => { + const determinist = (path: string): [number, number] => { + const col = Math.floor(hash(path) * cols); + const row = Math.floor(hash(`${path}·z`) * rows); + return [ + (col - (cols - 1) / 2) * spacing, + (row - (rows - 1) / 2) * spacing, + ]; + }; + + const files: Array<{ path: string; label: string; loc: number; owner: "ai" | "human"; unreviewed: boolean; turn: number; seed: number }> = realFiles.length + ? realFiles.map((file) => ({ + path: file.path, + label: file.path, + loc: file.loc, + owner: file.owner, + unreviewed: !!file.unreviewed, + turn: firstSeen.get(file.path) ?? 1, + seed: hash(file.path), + })) + : (KNOWN_FILES.map((path) => { const snap = snapshotFiles.get(path); const seed = hash(path); return { @@ -104,7 +138,7 @@ export function buildPlaygroundCity( unreviewed: boolean; turn: number; seed: number; - }>; + }>); const forced: Record = { "src/store/invoices.ts": [2.2, 0.4], @@ -122,9 +156,6 @@ export function buildPlaygroundCity( const towers: PlaygroundTower[] = []; const occupied: Array<[number, number]> = []; - const spacing = 3.05; - const cols = 10; - const rows = 8; const place = ( path: string | null, @@ -139,6 +170,7 @@ export function buildPlaygroundCity( flag = 0, ) => { const height = Math.max(1.6, Math.min(12, loc / 7 + 2.2)); + const hot = path !== null && hotFiles.has(path); towers.push({ path, label, @@ -146,14 +178,17 @@ export function buildPlaygroundCity( owner, unreviewed, turn, - health: - path === "src/store/invoices.ts" + health: hot + ? isLive ? healed ? 0.95 - : blamedTurn - ? 0.06 - : 0.86 - : 0.62 + seed * 0.38, + : 0.06 + : activeTurn >= 3 && activeTurn < 6 + ? healed + ? 0.95 + : 0.06 + : 0.86 + : 0.62 + seed * 0.38, flag, seed, x, @@ -168,8 +203,10 @@ export function buildPlaygroundCity( const spot = forced[file.path]; let x: number; let z: number; - if (spot) { + if (spot && !isLive) { [x, z] = spot; + } else if (isLive) { + [x, z] = determinist(file.path); } else { let guard = 0; do { @@ -187,7 +224,7 @@ export function buildPlaygroundCity( } occupied.push([x, z]); const flag = - file.path === "src/store/invoices.ts" && blamedTurn && !healed ? 1 : 0; + hotFiles.has(file.path) && !healed && (!isLive || !!blamedTurn) ? 1 : 0; place( file.path, file.path, diff --git a/src/components/site/LandingPage.tsx b/src/components/site/LandingPage.tsx index 216d23f..2f322a4 100644 --- a/src/components/site/LandingPage.tsx +++ b/src/components/site/LandingPage.tsx @@ -980,8 +980,8 @@ export default function LandingPage() { }, { n: "03", - t: "Record any session", - d: "Run ghost watch in your project, and run your app yourself (npm run dev etc.) - the recorder probes its /api/health. With the plugin, turns stream live; with any other agent (Cursor, Claude Code, a human) the filesystem watcher + runtime probes record everything from the outside - turns marked estimated, never invented.", + t: "Watch yourself record", + d: "Run ghost watch (and run your app separately - the recorder probes its /api/health). Ghost Studio opens instantly at localhost:5417: real turns, files + churn, runtime samples. With the 3D playground too: npm run dev in the dashboard repo and open /playground?live=1 - your recording builds the code city live.", }, { n: "04", diff --git a/src/ghost/adapter.ts b/src/ghost/adapter.ts new file mode 100644 index 0000000..814e61e --- /dev/null +++ b/src/ghost/adapter.ts @@ -0,0 +1,146 @@ +// Ghost recorder (contract v1) → dashboard Session adapter. +// Bridges the REAL recording (packages/ghost ghost watch) into the exact +// Session type the playground renders. Every field is derived from the +// recording itself — nothing invented: +// turns ← recorder turns (prompt = user text; assistant turns keep +// their plugin id) + meta-attested file lists +// checkpoints ← cumulative snapshot towers; loc = diff churn +// ("height is churn" — the recording fact, not fiction) +// samples ← runtime probes (metrics), unit-normalized to MB +// anomalies/autopsies ← src/autopsy/engine run on the real curves +import type { MetricSample, Session, Snapshot, Turn } from "@/src/types"; +import { runAutopsy } from "@/src/autopsy/engine"; + +/** raw session shape served by the recorder at GET 127.0.0.1:/session */ +interface RecorderTurn { + index: number; + id?: string | null; + estimated?: boolean; + agent?: string | null; + prompt?: string; + startedAt: string; + endedAt?: string | null; + files?: string[]; +} +interface RecorderCheckpoint { + ts: string; + turnNumber: number; + sha: string; + files: string[]; + diffRef?: string; + churn?: Record; +} +interface RecorderSample { + ts: string; + turnNumber: number; + metrics: { + cpu?: number; + rss?: number; + heap?: number; + latencyMs?: number | null; + errors?: number; + httpStatus?: number; + }; + probeFailed?: boolean; +} +export interface GhostRecordingSession { + id: string; + repo: string; + appUrl?: string; + head?: string | null; + synced?: string | null; + turns: RecorderTurn[]; + samples: RecorderSample[]; + checkpoints: RecorderCheckpoint[]; +} + +/** recorder rss unit is whatever the probed app reports — normalize to MB */ +function toMb(rss: number | undefined): number { + if (!rss) return 0; + return rss > 100_000 ? Math.round((rss / 1_048_576) * 10) / 10 : Math.round(rss * 10) / 10; +} + +export function toDashboardSession(raw: GhostRecordingSession): Session { + const turns: Turn[] = []; + + // checkpoints grouped per turn → changed files + patch refs + const filesByTurn = new Map(); + const refsByTurn = new Map(); + orderCheckpoints(raw.checkpoints ?? []).forEach((c) => { + if (!c.turnNumber) return; + filesByTurn.set(c.turnNumber, [...new Set([...(filesByTurn.get(c.turnNumber) ?? []), ...(c.files ?? [])])]); + refsByTurn.set(c.turnNumber, [...(refsByTurn.get(c.turnNumber) ?? []), c.diffRef ?? ""]); + }); + + for (const t of raw.turns ?? []) { + const files = [...new Set([...(t.files ?? []), ...(filesByTurn.get(t.index) ?? [])])]; + turns.push({ + id: t.id ?? `est-turn-${t.index}`, + index: t.index, + role: t.prompt ? "user" : "assistant", + text: + t.prompt ?? + (t.id + ? `assistant turn (id ${t.id}) — events only` + : "turn inferred from file activity (mode B)"), + timestamp: t.startedAt, + changedFiles: files, + diff: [], + snapshotId: refsByTurn.get(t.index)?.[0] ? `sha:${refsByTurn.get(t.index)![0].split("/").pop()}` : undefined, + }); + } + + // cumulative snapshots: each checkpoint = city state after that burst + const cumulative = new Map(); + const snapshots: Snapshot[] = (raw.checkpoints ?? []).map((c) => { + for (const [file, churn] of Object.entries(c.churn ?? {})) { + cumulative.set(file, (cumulative.get(file) ?? 0) + churn); + } + const turn = turns.find((x) => x.index === c.turnNumber); + return { + id: c.sha.slice(0, 12), + turnId: turn?.id ?? `est-turn-${c.turnNumber}`, + files: [...cumulative.entries()].map(([path, loc]) => ({ + path, + loc: Math.max(1, loc), + owner: "ai" as const, + unreviewed: true, + })), + imports: [], + }; + }); + + const metrics: MetricSample[] = (raw.samples ?? []) + .filter((s) => !s.probeFailed) + .map((s) => ({ + turn: s.turnNumber, + cpu: s.metrics.cpu ?? 0, + mem: toMb(s.metrics.rss), + errors: s.metrics.errors ?? 0, + latency: s.metrics.latencyMs ?? 0, + })); + + const session: Session = { + id: raw.id, + agent: "ghost-live (agent-agnostic)", + repo: raw.repo, + startedAt: raw.turns?.[0]?.startedAt ?? raw.checkpoints?.[0]?.ts ?? new Date().toISOString(), + turns, + metrics, + snapshots, + anomalies: [], + autopsies: [], + }; + + const { anomalies, autopsies } = runAutopsy(session); + // engine ids are sample-turn-based ("t"); re-resolve to the real turn id + const realId = (id: string) => + /^t\d+$/.test(id) ? turns.find((t) => t.index === Number(id.slice(1)))?.id ?? id : id; + session.anomalies = anomalies.map((a) => ({ ...a, turnId: realId(a.turnId) })); + session.autopsies = autopsies.map((a) => ({ ...a, blamedTurnId: realId(a.blamedTurnId) })); + return session; +} + +function orderCheckpoints(checkpoints: RecorderCheckpoint[]): RecorderCheckpoint[] { + return [...checkpoints].sort((a, b) => (a.turnNumber - b.turnNumber) || (a.ts < b.ts ? -1 : 1)); +}