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.
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.
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.
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.
| Table | Holds |
|---|---|
| runs | One row per run, upserted as events arrive via record_run_event. |
| events | id, run_id, ts (RFC 3339), kind name, and the full payload_json. Indexed on (run_id, ts). |
| goal_ledger | The ledger as JSON columns, upserted on run_id — plan, acceptance and evidence each stored whole. |
| tool_traces | Per-call tool I/O: input_json, output_json, duration_ms, status. |
| memories | Title, body, source, promoted flag, updated_at. |
| memories_fts | FTS5 virtual table backing memory search. |
| procedures | Promoted 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.
$ 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" }
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.
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.
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.
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",…
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.