From cd7dd21deeb7b1a6ee0fedd07d1d04146dbaa5b3 Mon Sep 17 00:00:00 2001 From: marquesds Date: Fri, 19 Jun 2026 15:05:11 -0300 Subject: [PATCH] fix(web): show truthful session details Users could not trust the local dashboard because Codex sessions appeared as Claude, shell calls hid their commands, prompts were absent, and incomplete sessions used unexplained internal terminology. Infer identity and model from captured evidence, backfill historical rows, capture prompts through global hooks, and render bounded prompt and command summaries in the data-first Web UI. --- docs/usage-setup.md | 4 +- docs/web.md | 15 ++- src/shell/ingest.rs | 18 ++- src/shell/ingest/identity.rs | 59 ++++++++ src/shell/ingest/tests.rs | 29 ++++ src/shell/init.rs | 16 ++- src/store/sqlite/mod.rs | 2 + src/store/sqlite/session_identity.rs | 54 ++++++++ src/store/sqlite/sessions.rs | 15 +++ src/store/sqlite/tests/sessions.rs | 28 +++- src/visualization/build.rs | 1 + src/visualization/types.rs | 2 + src/web/assets.rs | 9 +- src/web/assets/index.html | 10 +- src/web/assets/kaizen-detail.js | 16 ++- src/web/assets/kaizen-format.js | 40 ++++++ src/web/assets/kaizen-render.js | 18 ++- src/web/assets/kaizen.css | 9 +- src/web/event_display.rs | 193 +++++++++++++++++++++++++++ src/web/mod.rs | 2 + src/web/prompt_cache.rs | 99 ++++++++++++++ src/web/snapshot.rs | 5 +- src/web/snapshot/tests.rs | 22 +++ tests/init_prompt_hook.rs | 34 +++++ 24 files changed, 664 insertions(+), 36 deletions(-) create mode 100644 src/shell/ingest/identity.rs create mode 100644 src/store/sqlite/session_identity.rs create mode 100644 src/web/event_display.rs create mode 100644 src/web/prompt_cache.rs create mode 100644 tests/init_prompt_hook.rs diff --git a/docs/usage-setup.md b/docs/usage-setup.md index 129de6c..bf1917f 100644 --- a/docs/usage-setup.md +++ b/docs/usage-setup.md @@ -36,8 +36,8 @@ Idempotent workspace setup: | Artifact | Action | |---|---| | `~/.kaizen/projects//config.toml` | Created if missing. | -| `~/.cursor/hooks.json` | Patched for Cursor lifecycle hooks. | -| `~/.claude/settings.json` | Patched for Claude Code lifecycle hooks. | +| `~/.cursor/hooks.json` | Patched for Cursor lifecycle, prompt, and tool hooks. | +| `~/.claude/settings.json` | Patched for Claude Code lifecycle, prompt, and tool hooks. | | `~/.openclaw/hooks/kaizen-events/handler.ts` | Written for OpenClaw events. | | `~/.cursor/skills/kaizen-retro/SKILL.md` | Written unless already replaced. | | `~/.cursor/skills/kaizen-eval/SKILL.md` | Written unless already replaced. | diff --git a/docs/web.md b/docs/web.md index c74cb3a..cedc487 100644 --- a/docs/web.md +++ b/docs/web.md @@ -26,10 +26,11 @@ The dashboard provides: - automatic selection of the most recently active valid project, plus a manual local-path fallback; - session, active-session, error, and cost totals; -- project-level tool, attention, and telemetry-coverage insights; +- project-level tool, attention, and telemetry-coverage insights; Tool Pattern + lists the selected session's three most frequent recent shell commands; - the latest 30 sessions for the selected project; -- selected-session facts, recent events, nested tool spans, touched files, and - top tools; +- selected-session prompt, facts, recent events with bounded command details, + nested tool spans, touched files, and top tools; - the exact bounded report under **Developer details**. Selected-session detail is capped at 40 events, 40 spans, and 40 files. Those @@ -37,6 +38,10 @@ limits keep refresh latency and memory use predictable. The server watches the selected project's SQLite database and WAL; a committed change requests a new snapshot within one second. **Refresh now** remains available for manual checks. +`No completion` means Kaizen received activity but no final session event for +at least 30 minutes. It does not mean the work failed. Models and prompts remain +`Unknown` or unavailable when no captured source provides them. + Web is an Observe-only surface. It cannot mutate experiments, guidance, sync, configuration, or local data. Use the CLI or MCP for those workflows. @@ -46,6 +51,10 @@ The server binds to loopback. Data calls use an authenticated WebSocket, and the token is included in the URL printed by Kaizen. Treat that URL as a local secret: do not paste it into issues, chat, or logs. +Prompts and command summaries can contain source code or secrets. They stay in +the authenticated loopback response and are never added to static assets. Raw +event payloads remain omitted. + Kaizen stores the restart-stable token in `$KAIZEN_HOME/web_token.hex` (normally `~/.kaizen/web_token.hex`) with mode `0600` on Unix. Static assets contain no session data. See [daemon.md](daemon.md) diff --git a/src/shell/ingest.rs b/src/shell/ingest.rs index ec6f77a..f7cec95 100644 --- a/src/shell/ingest.rs +++ b/src/shell/ingest.rs @@ -8,6 +8,7 @@ use anyhow::Result; use serde_json::Value; use std::path::PathBuf; +mod identity; mod prompt_change; mod sidecars; #[cfg(test)] @@ -94,6 +95,7 @@ pub(crate) fn ingest_hook_with_store( }; let mut event = event; event.ts_ms = ts; + let identity = identity::from_payload(source, &event.payload); let seq = store.next_event_seq(&event.session_id)?; let ev = collect::hooks::normalize::hook_to_event(&event, seq); if let Some(status) = collect::hooks::normalize::hook_to_status(&event.kind) { @@ -107,13 +109,13 @@ pub(crate) fn ingest_hook_with_store( let env = session_env_fields(&event.payload); let record = SessionRecord { id: event.session_id.clone(), - agent: source.agent().to_string(), - model, + agent: identity.agent.clone(), + model: identity.model.clone().or(model), workspace: ws.to_string_lossy().to_string(), started_at_ms: event.ts_ms, ended_at_ms: None, status: status.clone(), - trace_path: String::new(), + trace_path: identity.trace_path.clone().unwrap_or_default(), start_commit: None, end_commit: None, branch: None, @@ -132,7 +134,7 @@ pub(crate) fn ingest_hook_with_store( } else { store.ensure_session_stub( &event.session_id, - source.agent(), + &identity.agent, &ws.to_string_lossy(), event.ts_ms, )?; @@ -151,11 +153,17 @@ pub(crate) fn ingest_hook_with_store( } else { store.ensure_session_stub( &event.session_id, - source.agent(), + &identity.agent, &ws.to_string_lossy(), event.ts_ms, )?; } + store.enrich_session_identity( + &event.session_id, + identity.agent_update.as_deref(), + identity.model.as_deref(), + identity.trace_path.as_deref(), + )?; store.append_event_with_sync(&ev, sync_ctx.as_ref())?; if matches!(event.kind, collect::hooks::EventKind::Stop) { store.flush_search()?; diff --git a/src/shell/ingest/identity.rs b/src/shell/ingest/identity.rs new file mode 100644 index 0000000..d2fbc6a --- /dev/null +++ b/src/shell/ingest/identity.rs @@ -0,0 +1,59 @@ +use super::IngestSource; +use serde_json::Value; + +pub(super) struct HookIdentity { + pub(super) agent: String, + pub(super) agent_update: Option, + pub(super) model: Option, + pub(super) trace_path: Option, +} + +pub(super) fn from_payload(source: IngestSource, payload: &Value) -> HookIdentity { + let model = crate::collect::model_from_json::from_value(payload); + let trace_path = text(payload, "transcript_path"); + let agent_update = inferred_agent(source, payload, model.as_deref(), trace_path.as_deref()); + let agent = agent_update + .clone() + .unwrap_or_else(|| source.agent().into()); + HookIdentity { + agent, + agent_update, + model, + trace_path, + } +} + +fn inferred_agent( + source: IngestSource, + payload: &Value, + model: Option<&str>, + path: Option<&str>, +) -> Option { + match source { + IngestSource::Claude if codex_evidence(payload, model, path) => Some("codex".into()), + IngestSource::Claude => None, + _ => Some(source.agent().into()), + } +} + +fn codex_evidence(payload: &Value, model: Option<&str>, path: Option<&str>) -> bool { + payload.get("turn_id").is_some() + || path.is_some_and(codex_path) + || model.is_some_and(codex_model) +} + +fn codex_path(path: &str) -> bool { + let path = path.to_ascii_lowercase(); + path.contains("/.codex/") || path.contains("\\.codex\\") +} + +fn codex_model(model: &str) -> bool { + let model = model.to_ascii_lowercase(); + ["gpt-", "o1", "o3", "o4", "codex", "kindle-", "nova-"] + .iter() + .any(|prefix| model.starts_with(prefix) || model.contains(prefix)) +} + +fn text(payload: &Value, key: &str) -> Option { + payload.get(key)?.as_str().map(ToOwned::to_owned) +} diff --git a/src/shell/ingest/tests.rs b/src/shell/ingest/tests.rs index 3fd2458..ae55413 100644 --- a/src/shell/ingest/tests.rs +++ b/src/shell/ingest/tests.rs @@ -70,3 +70,32 @@ fn post_tool_use_without_session_start_auto_provisions_stub() { assert_eq!(rows[0].agent, "cursor"); assert_eq!(rows[0].id, "s-stub"); } + +#[test] +fn claude_hook_with_codex_evidence_records_codex_identity() { + let _guard = test_lock::global().lock().unwrap(); + let (_home, workspace) = setup_ws(); + let payload = r#"{"hook_event_name":"SessionStart","session_id":"s-codex","turn_id":"t1","model":"gpt-5.4","transcript_path":"/tmp/.codex/sessions/s.jsonl"}"#; + ingest_hook_text(IngestSource::Claude, payload, Some(workspace.path().into())).unwrap(); + let row = sessions(&workspace).remove(0); + unsafe { std::env::remove_var("KAIZEN_HOME") }; + assert_eq!(row.agent, "codex"); + assert_eq!(row.model.as_deref(), Some("gpt-5.4")); + assert_eq!(row.trace_path, "/tmp/.codex/sessions/s.jsonl"); +} + +#[test] +fn later_hook_enriches_missing_model() { + let _guard = test_lock::global().lock().unwrap(); + let (_home, workspace) = setup_ws(); + let start = r#"{"hook_event_name":"SessionStart","session_id":"s-model"}"#; + ingest_hook_text(IngestSource::Claude, start, Some(workspace.path().into())).unwrap(); + let call = r#"{"hook_event_name":"PreToolUse","session_id":"s-model","turn_id":"t1","model":"kindle-alpha","tool_name":"Bash"}"#; + ingest_hook_text(IngestSource::Claude, call, Some(workspace.path().into())).unwrap(); + let result = r#"{"hook_event_name":"PostToolUse","session_id":"s-model","tool_name":"Bash"}"#; + ingest_hook_text(IngestSource::Claude, result, Some(workspace.path().into())).unwrap(); + let row = sessions(&workspace).remove(0); + unsafe { std::env::remove_var("KAIZEN_HOME") }; + assert_eq!(row.agent, "codex"); + assert_eq!(row.model.as_deref(), Some("kindle-alpha")); +} diff --git a/src/shell/init.rs b/src/shell/init.rs index 118ef4a..1ca267c 100644 --- a/src/shell/init.rs +++ b/src/shell/init.rs @@ -22,8 +22,20 @@ const CONFIG_TOML: &str = r#"[kaizen] const KAIZEN_RETRO_SKILL: &str = include_str!("../../assets/kaizen-retro-SKILL.md"); const KAIZEN_EVAL_SKILL: &str = include_str!("../../assets/kaizen-eval-SKILL.md"); -const CURSOR_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"]; -const CLAUDE_HOOK_EVENTS: &[&str] = &["SessionStart", "PreToolUse", "PostToolUse", "Stop"]; +const CURSOR_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", +]; +const CLAUDE_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Stop", +]; #[derive(Clone, Copy, Debug, Default)] pub struct InitOptions { diff --git a/src/store/sqlite/mod.rs b/src/store/sqlite/mod.rs index de43e3a..b72c267 100644 --- a/src/store/sqlite/mod.rs +++ b/src/store/sqlite/mod.rs @@ -76,6 +76,7 @@ mod reports; mod rows; mod samples; mod schema; +mod session_identity; mod session_read; mod session_window; mod sessions; @@ -134,6 +135,7 @@ impl Store { conn.execute_batch(sql)?; } schema::ensure_schema_columns(&conn)?; + session_identity::backfill(&conn)?; outbox_migration::migrate(&conn, path.parent().unwrap_or_else(|| Path::new(".")))?; } let root = path diff --git a/src/store/sqlite/session_identity.rs b/src/store/sqlite/session_identity.rs new file mode 100644 index 0000000..f8f6c10 --- /dev/null +++ b/src/store/sqlite/session_identity.rs @@ -0,0 +1,54 @@ +use anyhow::Result; +use rusqlite::Connection; + +const MARKER: &str = "session_identity_backfill_v1"; +const REPAIR: &str = r#" +UPDATE sessions AS s SET + agent = CASE WHEN lower(s.agent) = 'claude' AND ( + EXISTS(SELECT 1 FROM events e WHERE e.session_id = s.id AND + (json_type(e.payload, '$.turn_id') IS NOT NULL OR + lower(COALESCE(json_extract(e.payload, '$.transcript_path'), '')) LIKE '%/.codex/%')) + OR lower(COALESCE(s.model, '')) GLOB 'gpt-*' + OR lower(COALESCE(s.model, '')) GLOB '*codex*' + OR lower(COALESCE(s.model, '')) GLOB 'kindle-*' + OR lower(COALESCE(s.model, '')) GLOB 'nova-*' + ) THEN 'codex' ELSE s.agent END, + model = COALESCE(s.model, (SELECT json_extract(e.payload, '$.model') FROM events e + WHERE e.session_id = s.id AND json_type(e.payload, '$.model') = 'text' + ORDER BY e.seq DESC LIMIT 1)), + trace_path = CASE WHEN s.trace_path = '' THEN COALESCE((SELECT + json_extract(e.payload, '$.transcript_path') FROM events e WHERE e.session_id = s.id + AND json_type(e.payload, '$.transcript_path') = 'text' ORDER BY e.seq DESC LIMIT 1), '') + ELSE s.trace_path END +WHERE EXISTS(SELECT 1 FROM events e WHERE e.session_id = s.id AND + (json_type(e.payload, '$.turn_id') IS NOT NULL OR json_type(e.payload, '$.model') = 'text' + OR json_type(e.payload, '$.transcript_path') = 'text')) +"#; + +pub(super) fn backfill(conn: &Connection) -> Result<()> { + if empty(conn)? || complete(conn)? { + return Ok(()); + } + conn.execute(REPAIR, [])?; + conn.execute( + "INSERT OR REPLACE INTO sync_state(k, v) VALUES (?1, 'done')", + [MARKER], + )?; + Ok(()) +} + +fn empty(conn: &Connection) -> Result { + Ok( + conn.query_row("SELECT NOT EXISTS(SELECT 1 FROM sessions)", [], |row| { + row.get(0) + })?, + ) +} + +fn complete(conn: &Connection) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sync_state WHERE k = ?1)", + [MARKER], + |row| row.get(0), + )?) +} diff --git a/src/store/sqlite/sessions.rs b/src/store/sqlite/sessions.rs index 79a2c42..4b0628e 100644 --- a/src/store/sqlite/sessions.rs +++ b/src/store/sqlite/sessions.rs @@ -92,6 +92,21 @@ impl Store { Ok(()) } + pub fn enrich_session_identity( + &self, + id: &str, + agent: Option<&str>, + model: Option<&str>, + trace_path: Option<&str>, + ) -> Result<()> { + self.conn.execute( + "UPDATE sessions SET agent = COALESCE(?2, agent), model = COALESCE(?3, model), + trace_path = CASE WHEN COALESCE(?4, '') = '' THEN trace_path ELSE ?4 END WHERE id = ?1", + params![id, agent, model, trace_path], + )?; + Ok(()) + } + /// Update only status for existing session. pub fn update_session_status(&self, id: &str, status: SessionStatus) -> Result<()> { self.conn.execute( diff --git a/src/store/sqlite/tests/sessions.rs b/src/store/sqlite/tests/sessions.rs index eab0368..e8ed963 100644 --- a/src/store/sqlite/tests/sessions.rs +++ b/src/store/sqlite/tests/sessions.rs @@ -1,5 +1,6 @@ use super::super::{SessionFilter, Store}; -use super::{SessionStatus, make_session}; +use super::{SessionStatus, make_event, make_session}; +use serde_json::json; use tempfile::TempDir; #[test] @@ -119,3 +120,28 @@ fn update_session_status_changes_status() { let got = store.get_session("s6").unwrap().unwrap(); assert_eq!(got.status, SessionStatus::Running); } + +#[test] +fn reopen_repairs_historical_codex_identity_and_model() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("kaizen.db"); + let store = Store::open(&path).unwrap(); + let mut session = make_session("legacy-codex"); + session.agent = "claude".into(); + session.model = None; + session.trace_path.clear(); + store.upsert_session(&session).unwrap(); + let mut event = make_event("legacy-codex", 0); + event.payload = json!({"turn_id":"t1","model":"gpt-5.4","transcript_path":"/home/u/.codex/sessions/s.jsonl"}); + store.append_event(&event).unwrap(); + drop(store); + + let repaired = Store::open(&path) + .unwrap() + .get_session("legacy-codex") + .unwrap() + .unwrap(); + assert_eq!(repaired.agent, "codex"); + assert_eq!(repaired.model.as_deref(), Some("gpt-5.4")); + assert_eq!(repaired.trace_path, "/home/u/.codex/sessions/s.jsonl"); +} diff --git a/src/visualization/build.rs b/src/visualization/build.rs index ff7bf9e..0058f74 100644 --- a/src/visualization/build.rs +++ b/src/visualization/build.rs @@ -101,6 +101,7 @@ fn selected_detail( let id = session.id.clone(); Ok(Some(TraceDetail { session, + prompt: None, events: store.list_latest_events_for_session(&id, query.limits.selected_events)?, spans: store.limited_session_span_tree(&id, query.limits.selected_spans)?, files: store.limited_files_for_session(&id, query.limits.selected_files)?, diff --git a/src/visualization/types.rs b/src/visualization/types.rs index 1d55b71..94ca6e0 100644 --- a/src/visualization/types.rs +++ b/src/visualization/types.rs @@ -89,6 +89,8 @@ pub struct TraceSummary { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TraceDetail { pub session: SessionRecord, + #[serde(default)] + pub prompt: Option, pub events: Vec, pub spans: Vec, pub files: Vec, diff --git a/src/web/assets.rs b/src/web/assets.rs index b4c9543..726243b 100644 --- a/src/web/assets.rs +++ b/src/web/assets.rs @@ -61,7 +61,7 @@ fn content(kind: &'static str, body: &'static str) -> impl IntoResponse { #[cfg(test)] mod tests { - use super::{CSS, INDEX, JS, RAW_JS, RENDER_JS, TOKENS}; + use super::{CSS, DETAIL_JS, FORMAT_JS, INDEX, JS, RAW_JS, RENDER_JS, TOKENS}; #[test] fn web_assets_do_not_seed_fixture_values() { @@ -118,6 +118,7 @@ mod tests { "id=\"detail-spans\"", "id=\"detail-files\"", "id=\"detail-tools\"", + "id=\"detail-prompt\"", "id=\"project-insights\"", "id=\"insight-tools\"", "id=\"insight-attention\"", @@ -139,7 +140,13 @@ mod tests { assert!(JS.contains(needle), "js missing {needle}"); } assert!(!JS.contains("setInterval(")); + assert!(!INDEX.contains("See what your coding agents are doing.")); + assert!(!INDEX.contains("page-heading")); assert!(RENDER_JS.contains("renderInsights")); + assert!(RENDER_JS.contains("topCommands(report?.selected?.events")); + assert!(RENDER_JS.contains("top commands in selected session")); + assert!(FORMAT_JS.contains("No completion event received")); + assert!(DETAIL_JS.contains("event.payload?.summary")); } #[test] diff --git a/src/web/assets/index.html b/src/web/assets/index.html index b5cf579..f475396 100644 --- a/src/web/assets/index.html +++ b/src/web/assets/index.html @@ -23,11 +23,7 @@
-
-

Local agent field notes

-

See what your coding agents are doing.

-

Review recent work, cost, errors, tools, and touched files without reading raw traces.

-
+

Kaizen Observe

@@ -141,6 +137,10 @@

Session detail

Waiting for a session
+
+

Prompt

+

No prompt captured.

+

Recent events

    diff --git a/src/web/assets/kaizen-detail.js b/src/web/assets/kaizen-detail.js index 4edd810..9f440e9 100644 --- a/src/web/assets/kaizen-detail.js +++ b/src/web/assets/kaizen-detail.js @@ -1,4 +1,4 @@ -import { clock, count, dateTime, duration, label, money, shortId } from "./kaizen-format.js"; +import { clock, count, dateTime, duration, label, money, shortId, statusExplanation, statusLabel } from "./kaizen-format.js"; const $ = selector => document.querySelector(selector); const MAX_ITEMS = 40; @@ -9,6 +9,7 @@ export function renderDetail(report) { if (!detail) return renderEmpty(); $("#selected-session").textContent = shortId(detail.session.id); $("#detail-facts").replaceChildren(...facts(detail.session, summary)); + renderPrompt(detail.prompt); renderEvents(detail.events || []); renderSpans(detail.spans || []); renderSimple("#detail-files", detail.files || [], "No files recorded."); @@ -18,6 +19,7 @@ export function renderDetail(report) { function renderEmpty() { $("#selected-session").textContent = "No session selected"; $("#detail-facts").replaceChildren(...fact("Status", "Waiting for data")); + renderPrompt(null); renderSimple("#detail-events", [], "No events available."); renderSimple("#detail-spans", [], "No spans available."); renderSimple("#detail-files", [], "No files recorded."); @@ -25,17 +27,23 @@ function renderEmpty() { } function facts(session, summary) { + const status = summary?.status || String(session.status).toLowerCase(); return [ ...fact("Agent", label(session.agent)), ...fact("Model", session.model || "Unknown"), ...fact("Started", dateTime(session.started_at_ms)), ...fact("Duration", duration(session.started_at_ms, session.ended_at_ms)), - ...fact("Status", label(summary?.status || session.status)), + ...fact("Status", statusLabel(status)), + ...(statusExplanation(status) ? fact("Status note", statusExplanation(status)) : []), ...fact("Cost", money(summary?.cost_usd_e6)), ...fact("Errors", count(summary?.error_count)), ]; } +function renderPrompt(prompt) { + $("#detail-prompt").textContent = prompt || "Prompt unavailable for this session."; +} + function fact(name, value) { return [node("dt", name), node("dd", value)]; } @@ -49,6 +57,7 @@ function eventRow(event) { const row = node("li"); row.append(node("time", clock(event.ts_ms)), node("strong", label(event.kind))); row.append(node("span", event.tool || label(event.source))); + if (event.payload?.summary) row.append(node("code", event.payload.summary)); return row; } @@ -63,7 +72,8 @@ function spanRow(entry) { row.className = "span-row"; row.style.setProperty("--depth", entry.depth); row.append(node("strong", span.tool || "Unknown tool")); - row.append(node("span", `${label(span.status)} | ${span.lead_time_ms || 0} ms`)); + const status = String(span.status || "").toLowerCase() === "orphaned" ? "No result event" : label(span.status); + row.append(node("span", `${status} | ${span.lead_time_ms || 0} ms`)); return row; } diff --git a/src/web/assets/kaizen-format.js b/src/web/assets/kaizen-format.js index f6c0a07..87317a0 100644 --- a/src/web/assets/kaizen-format.js +++ b/src/web/assets/kaizen-format.js @@ -47,3 +47,43 @@ export function statusTone(status) { if (status === "active" || status === "done") return "ready"; return "neutral"; } + +export function statusLabel(status) { + return status === "orphaned" ? "No completion" : label(status); +} + +export function statusExplanation(status) { + if (status === "orphaned") return "No completion event received for 30+ minutes."; + return ""; +} + +const SHELL_TOOLS = new Set(["bash", "shell", "exec_command", "run_terminal_cmd", "terminal"]); +const SHELL_WRAPPERS = new Set(["command", "env", "exec", "nohup", "sudo", "time"]); +const SHELL_NOISE = new Set(["cd", "export", "set", "source"]); +const QUOTED_ARGUMENT = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g; + +export function topCommands(events) { + const counts = (events || []).filter(shellCall).flatMap(commandNames).reduce(addCommand, new Map()); + return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3); +} + +function shellCall(event) { + return SHELL_TOOLS.has(String(event.tool || "").toLowerCase()) && event.payload?.summary; +} + +function commandNames(event) { + const shell = event.payload.summary.replace(QUOTED_ARGUMENT, ""); + return shell.split(/&&|\|\||[;|\n]/).map(commandName).filter(Boolean); +} + +function commandName(segment) { + const words = segment.trim().split(/\s+/); + const word = words.find(item => item && !item.includes("=") && !item.startsWith("-") && !SHELL_WRAPPERS.has(item)); + const command = word?.replace(/^['"]|['"]$/g, "").split("/").pop() || ""; + return SHELL_NOISE.has(command) ? "" : command; +} + +function addCommand(counts, command) { + counts.set(command, (counts.get(command) || 0) + 1); + return counts; +} diff --git a/src/web/assets/kaizen-render.js b/src/web/assets/kaizen-render.js index 19814cc..23acf50 100644 --- a/src/web/assets/kaizen-render.js +++ b/src/web/assets/kaizen-render.js @@ -1,5 +1,5 @@ import { renderDetail } from "./kaizen-detail.js"; -import { count, dateTime, label, money, shortId, statusTone } from "./kaizen-format.js"; +import { count, dateTime, label, money, shortId, statusLabel, statusTone, topCommands } from "./kaizen-format.js"; const $ = selector => document.querySelector(selector); const MAX_SESSION_ROWS = 30; @@ -51,14 +51,22 @@ export function renderReport(report) { function renderInsights(report) { const sessions = report?.sessions || []; - const [tool, calls] = topTool(sessions); const attention = sessions.filter(row => ["errored", "orphaned"].includes(row.status)).length; const quality = report?.quality || {}; - setInsight("tools", tool ? `${label(tool)} leads` : "No tool calls yet", tool ? `${count(calls)} calls in visible sessions` : "Live tool use appears here."); - setInsight("attention", attention ? `${count(attention)} need attention` : "No recent warnings", `${count(sessions.length)} visible sessions checked`); + renderToolInsight(report, sessions); + setInsight("attention", attention ? `${count(attention)} need attention` : "No recent warnings", `${count(sessions.length)} checked; includes errors and missing completion events`); setInsight("coverage", `${percent(quality.token_coverage_pct)} token coverage`, `${percent(quality.cost_coverage_pct)} cost coverage`); } +function renderToolInsight(report, sessions) { + const [tool, calls] = topTool(sessions); + const commands = topCommands(report?.selected?.events || []); + const names = commands.map(([name]) => name).join(" ยท "); + const total = commands.reduce((sum, [, value]) => sum + value, 0); + const note = names ? `${count(total)} calls across top commands in selected session; ${label(tool)} leads visible tools` : `${count(calls)} calls in visible sessions`; + setInsight("tools", names || (tool ? `${label(tool)} leads` : "No tool calls yet"), note); +} + function topTool(sessions) { const counts = sessions.flatMap(row => row.top_tools || []).reduce(addTool, new Map()); return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] || ["", 0]; @@ -141,7 +149,7 @@ function identity(session) { } function status(session) { - const node = element("span", "status-label", label(session.status)); + const node = element("span", "status-label", statusLabel(session.status)); node.dataset.tone = statusTone(session.status); node.title = session.status_reason || ""; return node; diff --git a/src/web/assets/kaizen.css b/src/web/assets/kaizen.css index de7028b..d0ce130 100644 --- a/src/web/assets/kaizen.css +++ b/src/web/assets/kaizen.css @@ -17,6 +17,7 @@ label, .kicker { color: var(--ink-faint); font-size: 0.76rem; font-weight: 800; :focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 3px; } .skip-link { position: fixed; left: var(--space-4); top: -5rem; z-index: 20; padding: var(--space-3); color: white; background: var(--focus-ring); } .skip-link:focus { top: var(--space-4); } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .site-header { display: flex; align-items: center; @@ -44,11 +45,7 @@ label, .kicker { color: var(--ink-faint); font-size: 0.76rem; font-weight: 800; .connection-state::before { content: ""; display: inline-block; width: 0.55rem; height: 0.55rem; margin-right: var(--space-2); border-radius: 50%; background: var(--amber); } .connection-state[data-tone="ready"]::before { background: var(--field-green); } .connection-state[data-tone="danger"]::before { background: var(--rust); } -main { width: min(var(--content-width), 100%); margin: auto; padding: var(--space-6) clamp(var(--space-4), 4vw, 3rem) 4rem; } -.page-heading { max-width: 780px; margin-bottom: var(--space-5); } -.page-heading h1 { max-width: 680px; margin: var(--space-2) 0; font-family: var(--font-display); font-size: clamp(2rem, 5vw, 4.6rem); font-weight: 500; line-height: 0.98; } -.page-heading p { color: var(--ink-soft); font-size: 1.02rem; line-height: 1.6; } -.page-heading .kicker { margin: 0; font-size: 0.76rem; } +main { width: min(var(--content-width), 100%); margin: auto; padding: var(--space-4) clamp(var(--space-4), 4vw, 3rem) 4rem; } .project-controls { display: grid; grid-template-columns: minmax(220px, 1fr) auto minmax(180px, 0.6fr); @@ -124,6 +121,8 @@ td strong { color: var(--ink); } .detail-list li { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); } .detail-list time { color: var(--ink-faint); font-family: var(--font-mono); font-size: 0.72rem; } .detail-list span { grid-column: 2; overflow-wrap: anywhere; } +.detail-list code { grid-column: 1 / -1; overflow-wrap: anywhere; white-space: pre-wrap; } +.prompt-text { margin: 0; color: var(--ink); line-height: 1.5; white-space: pre-wrap; } .span-row { margin-left: calc(var(--depth) * 0.8rem); } .developer-raw { margin-top: var(--space-4); padding: var(--space-4); border: 1px dashed var(--rule-strong); background: var(--paper-raised); } .developer-raw p { color: var(--ink-soft); } diff --git a/src/web/event_display.rs b/src/web/event_display.rs new file mode 100644 index 0000000..2a73e96 --- /dev/null +++ b/src/web/event_display.rs @@ -0,0 +1,193 @@ +use crate::core::event::{Event, EventKind}; +use crate::visualization::TraceDetail; +use serde_json::{Value, json}; + +const MAX_COMMAND_CHARS: usize = 600; +const MAX_PROMPT_CHARS: usize = 8_000; + +pub(super) fn prepare(detail: &mut TraceDetail) { + detail.prompt = prompt_from_events(&detail.events) + .or_else(|| prompt_from_trace(&detail.session.trace_path)); + detail.events.iter_mut().for_each(compact_event); +} + +fn compact_event(event: &mut Event) { + event.payload = event_summary(event) + .map(|summary| json!({"summary": summary})) + .unwrap_or(Value::Null); +} + +fn event_summary(event: &Event) -> Option { + (event.kind == EventKind::ToolCall) + .then(|| payload_summary(&event.payload)) + .flatten() + .map(|value| compact_text(&value, MAX_COMMAND_CHARS)) +} + +fn payload_summary(payload: &Value) -> Option { + pointer_text( + payload, + &["/tool_input/command", "/input/command", "/command", "/cmd"], + ) + .or_else(|| argument_summary(payload)) + .or_else(|| pointer_text(payload, &["/tool_input/file_path", "/input/path", "/path"])) +} + +fn argument_summary(payload: &Value) -> Option { + let raw = pointer_text(payload, &["/arguments", "/function/arguments"])?; + let parsed: Value = serde_json::from_str(&raw).ok()?; + pointer_text(&parsed, &["/cmd", "/command", "/path", "/file_path"]) +} + +fn pointer_text(value: &Value, pointers: &[&str]) -> Option { + pointers + .iter() + .find_map(|pointer| value.pointer(pointer)?.as_str().map(ToOwned::to_owned)) +} + +fn prompt_from_events(events: &[Event]) -> Option { + events + .iter() + .rev() + .find_map(|event| prompt_from_value(&event.payload)) +} + +fn prompt_from_trace(raw: &str) -> Option { + super::prompt_cache::from_trace(raw) +} + +pub(super) fn prompt_from_line(line: &str) -> Option { + let value: Value = serde_json::from_str(line).ok()?; + value + .get("payload") + .and_then(prompt_from_value) + .or_else(|| prompt_from_value(&value)) +} + +fn prompt_from_value(value: &Value) -> Option { + direct_prompt(value) + .or_else(|| user_message(value)) + .map(|value| compact_text(&value, MAX_PROMPT_CHARS)) +} + +fn direct_prompt(value: &Value) -> Option { + pointer_text(value, &["/prompt", "/user_prompt"]) + .as_deref() + .and_then(clean_prompt) +} + +fn user_message(value: &Value) -> Option { + let message = value.get("message").unwrap_or(value); + (message.get("role")?.as_str()? == "user") + .then(|| content_prompt(message.get("content")?)) + .flatten() +} + +fn content_prompt(content: &Value) -> Option { + content.as_str().and_then(clean_prompt).or_else(|| { + content + .as_array()? + .iter() + .rev() + .find_map(|part| part.get("text")?.as_str().and_then(clean_prompt)) + }) +} + +fn clean_prompt(raw: &str) -> Option { + let value = objective(raw).unwrap_or(raw).trim(); + (!value.is_empty() && !ignored_context(value)).then(|| value.to_string()) +} + +fn objective(raw: &str) -> Option<&str> { + tagged(raw, "", "") + .or_else(|| tagged(raw, "", "")) +} + +fn tagged<'a>(raw: &'a str, open: &str, close: &str) -> Option<&'a str> { + raw.split_once(open)?.1.split_once(close).map(|pair| pair.0) +} + +fn ignored_context(value: &str) -> bool { + value.starts_with("") || value.starts_with("# AGENTS.md instructions") +} + +fn compact_text(value: &str, limit: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let truncated = normalized.chars().take(limit).collect::(); + if normalized.chars().count() > limit { + format!("{truncated}...") + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::path::PathBuf; + + #[test] + fn trusted_trace_returns_latest_user_prompt() { + let _guard = crate::core::paths::test_lock::global().lock().unwrap(); + let (temp, path, _home) = trace_fixture(); + let line = json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hidden"},{"type":"input_text","text":"Show commands"}]}}); + let filler = format!( + "{}\n", + json!({"type":"event_msg","payload":{"type":"noise"}}) + ) + .repeat(10_000); + std::fs::write(&path, format!("{line}\n{filler}")).unwrap(); + assert_eq!( + prompt_from_trace(path.to_str().unwrap()).as_deref(), + Some("Show commands") + ); + drop(temp); + } + + #[test] + fn codex_arguments_expose_command_only() { + let payload = json!({"arguments":"{\"cmd\":\"rg parser src\",\"max_output_tokens\":1000}"}); + assert_eq!(payload_summary(&payload).as_deref(), Some("rg parser src")); + } + + #[test] + fn objective_wrapper_returns_user_objective() { + let wrapped = + "Fix identity"; + assert_eq!(clean_prompt(wrapped).as_deref(), Some("Fix identity")); + } + + #[test] + fn goal_update_wrapper_returns_user_objective() { + let wrapped = "Show promptBudget: hidden"; + assert_eq!(clean_prompt(wrapped).as_deref(), Some("Show prompt")); + } + + fn trace_fixture() -> (tempfile::TempDir, PathBuf, HomeGuard) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(".codex/sessions/session.jsonl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let home = HomeGuard::set(temp.path().into()); + (temp, path, home) + } + + struct HomeGuard(Option); + + impl HomeGuard { + fn set(value: OsString) -> Self { + let old = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", value) }; + Self(old) + } + } + + impl Drop for HomeGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 00f52e3..fe362e3 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -2,8 +2,10 @@ //! Local daemon web app: embedded UI plus WebSocket tool calls. mod assets; +mod event_display; pub mod features; mod live; +mod prompt_cache; mod server; mod snapshot; mod token; diff --git a/src/web/prompt_cache.rs b/src/web/prompt_cache.rs new file mode 100644 index 0000000..e6702ad --- /dev/null +++ b/src/web/prompt_cache.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +const MAX_SCAN_BYTES: u64 = 8 * 1024 * 1024; +const MAX_ENTRIES: usize = 128; + +#[derive(Clone)] +struct Entry { + len: u64, + prompt: Option, +} + +pub(super) fn from_trace(raw: &str) -> Option { + let path = trusted_trace(raw)?; + let len = std::fs::metadata(&path).ok()?.len(); + let mut cache = prompt_cache().lock().ok()?; + if let Some(entry) = cache.get_mut(&path) { + return refresh(&path, len, entry); + } + let prompt = read_latest(&path, len.saturating_sub(MAX_SCAN_BYTES)); + insert(&mut cache, path, len, prompt.clone()); + prompt +} + +fn refresh(path: &Path, len: u64, entry: &mut Entry) -> Option { + if len != entry.len { + let start = if len < entry.len { + len.saturating_sub(MAX_SCAN_BYTES) + } else { + entry.len.max(len.saturating_sub(MAX_SCAN_BYTES)) + }; + entry.prompt = read_latest(path, start).or_else(|| entry.prompt.clone()); + entry.len = len; + } + entry.prompt.clone() +} + +fn insert(cache: &mut HashMap, path: PathBuf, len: u64, prompt: Option) { + if cache.len() >= MAX_ENTRIES { + cache.clear(); + } + cache.insert(path, Entry { len, prompt }); +} + +fn read_latest(path: &Path, start: u64) -> Option { + let text = read_from(path, start)?; + text.lines() + .rev() + .find_map(super::event_display::prompt_from_line) +} + +fn read_from(path: &Path, start: u64) -> Option { + let mut file = std::fs::File::open(path).ok()?; + file.seek(SeekFrom::Start(start)).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) +} + +fn trusted_trace(raw: &str) -> Option { + let path = std::fs::canonicalize(raw).ok()?; + let home = std::fs::canonicalize(std::env::var_os("HOME")?).ok()?; + trusted_root(&path, &home).then_some(path) +} + +fn trusted_root(path: &Path, home: &Path) -> bool { + path.extension().is_some_and(|ext| ext == "jsonl") + && [".codex/sessions", ".claude/projects", ".cursor/projects"] + .iter() + .any(|root| path.starts_with(home.join(root))) +} + +fn prompt_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_replaces_prompt_after_trace_truncation() { + let temp = tempfile::NamedTempFile::new().unwrap(); + let line = serde_json::json!({"role":"user","content":"New prompt"}); + std::fs::write(temp.path(), format!("{line}\n")).unwrap(); + let len = std::fs::metadata(temp.path()).unwrap().len(); + let mut entry = Entry { + len: len + 100, + prompt: Some("Old prompt".into()), + }; + assert_eq!( + refresh(temp.path(), len, &mut entry).as_deref(), + Some("New prompt") + ); + } +} diff --git a/src/web/snapshot.rs b/src/web/snapshot.rs index 8417ece..aad804f 100644 --- a/src/web/snapshot.rs +++ b/src/web/snapshot.rs @@ -118,10 +118,7 @@ fn compact_report(report: &mut VisualizationReport) { let Some(detail) = report.selected.as_mut() else { return; }; - detail - .events - .iter_mut() - .for_each(|event| event.payload = serde_json::Value::Null); + super::event_display::prepare(detail); } #[cfg(test)] diff --git a/src/web/snapshot/tests.rs b/src/web/snapshot/tests.rs index 1a40d14..c5edf41 100644 --- a/src/web/snapshot/tests.rs +++ b/src/web/snapshot/tests.rs @@ -20,6 +20,27 @@ fn compact_report_strips_web_payload() { assert_eq!(report.totals.session_count, 31); } +#[test] +fn compact_report_keeps_prompt_and_command_summary() { + let mut report = report(); + let detail = report.selected.as_mut().unwrap(); + detail.events[0].kind = EventKind::Lifecycle; + detail.events[0].payload = json!({"type":"user_prompt_submit","prompt":"Fix the parser"}); + detail.events[1].kind = EventKind::ToolCall; + detail.events[1].tool = Some("Bash".into()); + detail.events[1].payload = json!({"tool_input":{"command":"rg parser src"}}); + compact_report(&mut report); + let value = serde_json::to_value(report).unwrap(); + assert_eq!( + value.pointer("/selected/prompt"), + Some(&json!("Fix the parser")) + ); + assert_eq!( + value.pointer("/selected/events/1/payload/summary"), + Some(&json!("rg parser src")) + ); +} + #[test] fn missing_workspace_does_not_create_project_state() { let _guard = test_lock::global().lock().unwrap(); @@ -61,6 +82,7 @@ fn report() -> VisualizationReport { sessions: (0..31).map(|n| summary(&format!("s{n}"))).collect(), selected: Some(TraceDetail { session, + prompt: None, events: (0..41).map(event).collect(), spans: Vec::new(), files: Vec::new(), diff --git a/tests/init_prompt_hook.rs b/tests/init_prompt_hook.rs new file mode 100644 index 0000000..387a960 --- /dev/null +++ b/tests/init_prompt_hook.rs @@ -0,0 +1,34 @@ +use std::ffi::OsString; + +#[test] +fn init_wires_user_prompt_capture_in_user_config() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let workspace = temp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + let _home = EnvGuard::set("HOME", temp.path().into()); + let _kaizen = EnvGuard::set("KAIZEN_HOME", temp.path().join("kaizen").into()); + kaizen::shell::init::init_text(Some(&workspace))?; + let raw = std::fs::read_to_string(temp.path().join(".claude/settings.json"))?; + let value: serde_json::Value = serde_json::from_str(&raw)?; + assert!(value.pointer("/hooks/UserPromptSubmit").is_some()); + Ok(()) +} + +struct EnvGuard(&'static str, Option); + +impl EnvGuard { + fn set(key: &'static str, value: OsString) -> Self { + let old = std::env::var_os(key); + unsafe { std::env::set_var(key, value) }; + Self(key, old) + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match self.1.take() { + Some(value) => unsafe { std::env::set_var(self.0, value) }, + None => unsafe { std::env::remove_var(self.0) }, + } + } +}