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) }, + } + } +}