localnpcrust harness · v0.1.0

Sections

module src/events.rs · src/runtime/replay.rs kinds 31 sinks SQLite + JSONL path .localnpc/runs/<run-id>/events.jsonl

The trace is the run, not a log of it.

A LocalNPC run emits one Event per meaningful thing that happens — the prompt as sent, the response length, every tool input and structured output, every gate rejection, every verification command and its result. Those events are the same values the terminal console renders, the daemon streams over SSE, the replay command reconstructs, and the eval harness re-scores.

01 · ShapeOne struct, thirty-one kinds

Event is four fields: a fresh Uuid, the run’s Uuid, a UTC timestamp, and an EventKind. The kind enum carries the payload, tagged for serde with #[serde(tag = "kind", content = "payload")], so a serialised event is a flat object with a discriminator string and a nested payload — readable with jq, filterable by kind without deserialising the payload.

Structure of a trace record and the thirty-one event kinds On the left, the Event struct with its id, run_id, ts and kind fields, and beneath it the serialised JSON line shape showing the kind discriminator and payload object. In the centre, the EventKind variants grouped into six families: run lifecycle, model, tools, workers, state and verification, totalling twenty core kinds, plus a separate group of eleven Ultimate-mode kinds appended at the end of the enum to preserve serde ordering. On the right, the LiveReplayRecorder showing a bounded channel feeding a dedicated writer thread that appends to both an EventStore SQLite database and a per-run JSONL file, flushing after every line, and below it the four consumers of the stream: the terminal console, the daemon SSE endpoint, the replay command and the eval scorer. src/events.rs · struct Event id: Uuid run_id: Uuid ts: DateTime<Utc> kind: EventKind Debug · Clone · Serialize · Deserialize ONE JSONL LINE {"id": "9f2c…", "run_id": "b1f4…", "ts": "2026-…T14:02:19Z", "kind": "ToolFinished", "payload": { "name": "fs.read", "status": "Passed", "duration_ms": 4 }} CONSUMERS OF THE SAME STREAM tui/render.rs · live frame apply_event() folds into TuiLiveState GET /runs/:id/events · SSE event_sink closure on the loop npclocal replay <run-id> read_jsonl → render_timeline eval run --replay <run-id> re-score without re-running the model enum EventKind · serde(tag="kind", content="payload") RUN LIFECYCLE RunStarted { goal } RunFinished { status } MODEL ModelRequest { model } ModelPrompt { prompt } ModelResponse { chars } TranscriptAppended { line } ActionParseFailed { error, response_preview } TOOLS ToolStarted { name } ToolInput { name, arguments } ToolFinished { name, status, duration_ms } ToolOutput { name, summary, structured } ToolApprovalRequested { name, command, reason } WORKERS WorkerStarted { id, kind, prompt, claimed_paths, command } WorkerFinished { id, status, duration_ms, output_preview } WorkerRejected { reason } STATE GoalUpdated ProfileChanged { from, to } ContextCompacted { before_chars, after_chars } VERIFICATION VerificationStarted { strategy } VerificationFinished { strategy, passed, evidence } 20 core kinds ULTIMATE MODE · APPENDED AT THE END StrategySeeded · BranchExecuted · BranchCritiqued PoolGenerated · MemoryDistilled · PqfDecision BranchReplaced · HypothesisHeartbeat · FinalJudgeSelected FinalJudgeFallback · UltimateBudgetExceeded LiveReplayRecorder std::sync::mpsc::channel record(&Event) clones and sends std::thread::spawn(replay_worker) writer runs off the async runtime Arc<Mutex<LiveReplayState>> replay_path · run_id · error finish() joins and surfaces errors a failed writer is reported, not swallowed SINK A · EventStore (SQLite) runs events + idx_events_run_ts goal_ledger upsert on run_id tool_traces input · output · ms memories memories_fts FTS5 virtual table procedures rusqlite 0.32, bundled — no system SQLite SINK B · JSONL .localnpc/runs/<run-id>/ events.jsonl created lazily on the first event opened append + create writeln! then flush() per event a killed process leaves a readable trace
Trace record structure. The serde tagging is what makes the JSONL useful outside Rust: jq 'select(.kind=="ToolFinished")' works without a schema. The eleven Ultimate-mode variants are appended at the end of the enum with a comment saying so — enum ordering is treated as part of the wire contract.

Event kinds

31

20 core + 11 ultimate

SQLite tables

7

incl. one FTS5 virtual

Sinks

2

per event, both

Replay tests

6

tests/replay_tests.rs

02 · DurabilityA writer thread, not an async task

LiveReplayRecorder::start opens a channel and spawns a plain OS thread. Not a tokio::spawn, not a blocking pool task — std::thread::spawn. Recording an event is a clone and a channel send from whatever context the loop is in; the SQLite insert and the file append happen on the writer thread and cannot block the async runtime or be starved by it.

src/runtime/replay.rs118–142fn replay_worker — the write path
ReplayMessage::Event(event) => {
    store.append_event(&event)?;
    if jsonl_path.is_none() {
        let dir = runs_dir.join(event.run_id.to_string());
        std::fs::create_dir_all(&dir)?;
        let path = dir.join("events.jsonl");
        let file = std::fs::OpenOptions::new()
            .create(true).append(true).open(&path)?;
        jsonl_path = Some(path.clone());
        jsonl_file = Some(file);
        // publish the path so callers can print it before the run ends
        if let Ok(mut state) = state.lock() { state.replay_path = Some(path); }
    }
    if let Some(file) = jsonl_file.as_mut() {
        writeln!(file, "{}", serde_json::to_string(&event)?)?;
        file.flush()?;               // every line, no buffering
    }
}

Three decisions in that fragment are worth naming. The directory and file are created lazily on the first event, so the run id — which the recorder learns from RunStarted — determines the path rather than being guessed up front. The path is published into shared state as soon as it exists, so the CLI can print replay=… while the run is still going. And flush() runs after every line: a run killed with Ctrl-C leaves a complete, parseable trace of everything up to the interruption.

Errors are surfaced, not swallowed

If replay_worker returns an error, it is stored in LiveReplayState.error. finish() joins the thread and then bail!s with "live replay recorder failed: {error}". A run whose trace failed to write does not quietly succeed. There is also record_terminal_status, which the CLI calls when a run errors out, so an aborted run still gets a terminal RunFinished row.

The SQLite side

EventStore::open creates parent directories, opens the connection, and runs migrate(). The schema is CREATE TABLE IF NOT EXISTS throughout, with one index — idx_events_run_ts ON events(run_id, ts) — which is exactly the access pattern replay and the SSE stream need.

src/memory/store.rs — migrate()
TableHolds
runsOne row per run, upserted as events arrive via record_run_event.
eventsid, run_id, ts (RFC 3339), kind name, and the full payload_json. Indexed on (run_id, ts).
goal_ledgerThe ledger as JSON columns, upserted on run_id — plan, acceptance and evidence each stored whole.
tool_tracesPer-call tool I/O: input_json, output_json, duration_ms, status.
memoriesTitle, body, source, promoted flag, updated_at.
memories_ftsFTS5 virtual table backing memory search.
proceduresPromoted procedural memory.

The database path comes from config.memory.path, defaulting to ~/.local/share/localnpc/localnpc.sqlite3 and expanded through expand_home_path. The JSONL lives next to the project, under .localnpc/runs/. That split is deliberate: the database accumulates across projects and is what memory search queries; the JSONL is per-project, per-run, and is what you attach to a bug report.

03 · Reading it backreplay, and re-scoring without a model

The replay command is deliberately unglamorous. It joins .localnpc/runs/<run-id>/events.jsonl, reads it with read_jsonl — which skips blank lines and deserialises each remaining one — and prints render_timeline, which is an RFC 3339 timestamp followed by the Debug formatting of the event kind.

npclocal replay
$ cargo run -- replay b1f4a9c2-7d31-4e08-9a55-0c2e6f1b83d4
2026-07-28T14:02:11.204831+00:00 RunStarted { goal: "Verify this Rust project" }
2026-07-28T14:02:11.205117+00:00 ModelRequest { model: "action-model" }
2026-07-28T14:02:11.205402+00:00 ModelPrompt { prompt: "You are LocalNPC. Challenge completion claims and require au
2026-07-28T14:02:14.881260+00:00 ModelResponse { chars: 71 }
2026-07-28T14:02:14.881744+00:00 TranscriptAppended { line: "{\"name\":\"capability_load\",\"arguments\":{\"name\":\"shell\"}}" }
2026-07-28T14:02:14.882011+00:00 TranscriptAppended { line: "capability_load_ack=shell" }
2026-07-28T14:02:17.402993+00:00 ModelResponse { chars: 58 }
2026-07-28T14:02:17.403480+00:00 VerificationStarted { strategy: "RustTests" }
2026-07-28T14:02:35.845112+00:00 VerificationFinished { strategy: "RustTests", passed: true, evidence: "rust-checks passed in 18442 ms" }
2026-07-28T14:02:35.845690+00:00 RunFinished { status: "complete" }
Output format is render_timeline in src/runtime/replay.rs: format!("{} {:?}", event.ts.to_rfc3339(), event.kind). The run id is the directory name under .localnpc/runs/. Timestamps and payloads shown are illustrative; the format is exact.

Because the events are complete enough to reconstruct the run, they are also complete enough to score it. score_replay_inputs reads the same file and derives three things without touching a model: the step count, taken as the number of ModelResponse events; the elapsed seconds, taken as the difference between the first and last timestamps; and the evidence list.

src/app.rs1232–1262fn verified_eval_evidence — the honesty filter
let mut tool_status = std::collections::BTreeMap::<String, bool>::new();
for event in events {
    match &event.kind {
        EventKind::ToolFinished { name, status, .. } => {
            tool_status.insert(name.clone(), matches!(status, ToolStatus::Passed));
        }
        // Only a tool whose LAST ToolFinished said Passed contributes evidence.
        EventKind::ToolOutput { name, summary, structured, .. }
            if tool_status.get(name).copied().unwrap_or(false) => {
            evidence.push(summary.clone());
            if let Some(structured) = structured { evidence.push(structured.to_string()); }
        }
        EventKind::VerificationFinished { passed: true, evidence: item, .. } => evidence.push(item.clone()),
        _ => {}
    }
}

The guard clause is the point. A tool’s output only counts as evidence if that tool’s most recent ToolFinished reported Passed, and a verification only counts if it actually passed. Output from a failed shell.run — which might well contain the substring an eval is looking for — is excluded. Evidence is filtered by outcome, not by presence.

A trace that can be re-scored is a trace that can be argued with. If the score is wrong, the disagreement is about the scoring rule, not about what happened.

04 · The consoleWhat ratatui actually draws

npclocal opens a terminal console built on ratatui 0.30. Normal input runs the agent loop — “build this” or “open it in the browser” goes through tools and verification rather than plain chat. /ask forces a conversational reply; /run forces tools.

The layout is worth reconstructing precisely, because it is a good example of the codebase’s general habit of encoding decisions as constants you can read.

Reconstruction of the LocalNPC ratatui console layout A terminal frame divided vertically into three regions by ratatui constraints: a three-row header showing the LocalNPC name, model, host and status; a body region with a minimum of ten rows; and an input region whose height is computed from the wrapped input. The body splits horizontally when the terminal is at least 104 columns wide, giving a transcript pane with a minimum of 58 columns and a fixed 38-column sidebar. The sidebar stacks a fixed eleven-row Session panel, a fixed five-row Context gauge, and a Commands panel taking the remaining space with a minimum of four rows. Annotations on the right list the exact Constraint values, the 104-column breakpoint below which the sidebar is dropped entirely, and the input height formula. TERMINAL FRAME · frame.area() LocalNPC qwen3-coder-30b host workstation Borders::ALL · BorderType::Rounded · Alignment::Center status ready Transcript YOU fix the failing test and verify it LOCALNPC 🔧 fs.read → mod tests { … } 🔧 fs.patch → applied 1 hunk 🔧 shell.run → test result: ok. 41 passed SYSTEM rust-checks passed in 18442 ms Paragraph · Wrap { trim: false } · scroll((scroll, 0)) visible_lines = area.height - 2 max_scroll = lines.len() - visible_lines Roles render as coloured caps: YOUCyan LOCALNPCGreen SYSTEMYellow Session Modelqwen3-coder-30b APIQwen3-Coder-30B… Hostworkstation Queue0 busy Workers2 Modechat or tools CWD/Users/…/repo Context 10412 / 262144 (4.0%) Commands /commandsshow controls /runexplicit task /askplain chat /modelrouting /statusqueue/cwd Input > fix the failing test and verify it Type naturally. Enter sends. /run forces tools. Alt-Enter inserts a newline. Enter: send Alt-Enter: newline Up/Down: history PgUp/PgDn: scroll /ask: chat Esc/Ctrl-C: exit border_style Cyan — the only cyan-bordered block on screen Constraint::Length(3) header, fixed three rows Constraint::Min(10) body region if area.width >= 104 Horizontal split: Constraint::Min(58) transcript Constraint::Length(38) sidebar else: transcript only the sidebar is dropped, not squeezed sidebar Vertical Length(11)Session Length(5)Context gauge Min(4)Commands gauge ratio = est_tokens / context_window, clamped to 0..1; colour from context_percent() Constraint::Length(input_height) lines = wrap_input_lines(input, width - 4) visible = min(lines, MAX_INPUT_ROWS) wanted = visible + 3 max = max(total_height - 6, 4) height = min(wanted, max).max(4) Cursor is placed only when its line is inside the visible window, and only if it lands inside the block's own area. RENDERED HEADLESS BY render_chat_ratatui_snapshot() TestBackend, width.max(80), height.max(20)
Console layout, reconstructed from src/tui/chat.rs. Every constraint value shown is literal. The 104-column breakpoint is the interesting one: below it the sidebar is removed rather than compressed, because a 38-column panel plus a 58-column transcript does not fit and a squeezed transcript is worse than no sidebar.

The console is testable without a terminal. render_chat_ratatui_snapshot(state, width, height) draws into a ratatui TestBackend and returns the buffer as a string, which is why tests/browser_tui_tests.rs can assert on seventeen rendering behaviours in a normal cargo test run.

There is a second, simpler renderer alongside it. src/tui/render.rs produces plain strings — render_live_frame for a running task and render_run_view for a finished one — showing goal, model, host, queue and worker slots, context chars, status, current tool, acceptance summary, worker events, memory hits, the last ten events and the last ten transcript lines. It has no ratatui dependency at all, which makes it the natural thing to print in non-interactive contexts.

render_live_frame — plain-string renderer
LocalNPC Live
goal          Fix the failing test and verify it
model         qwen3-coder-30b on workstation
queue_busy    0
worker_slots  2
context_chars 120000
status        running
current_tool  shell.run
acceptance    RustTests passed=true evidence=rust-checks passed in 18442 ms
workers       none
memory_hits   none

Events
  14:02:14 ToolStarted capability_call
  14:02:14 ToolInput shell.run
  14:02:35 ToolFinished shell.run Passed
  14:02:35 ToolOutput shell.run
  14:02:35 VerificationFinished

Transcript
  capability_load_ack=shell
  {"name":"capability_call","arguments":{"name":"shell.run",…
Field labels, column padding and the ten-item event/transcript windows are taken from render_live_frame in src/tui/render.rs. Transcript lines have newlines escaped to \n by the renderer so each entry stays on one row.