Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/usage-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ Idempotent workspace setup:
| Artifact | Action |
|---|---|
| `~/.kaizen/projects/<slug>/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. |
Expand Down
15 changes: 12 additions & 3 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,22 @@ 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
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.

Expand All @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions src/shell/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use anyhow::Result;
use serde_json::Value;
use std::path::PathBuf;

mod identity;
mod prompt_change;
mod sidecars;
#[cfg(test)]
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
)?;
Expand All @@ -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()?;
Expand Down
59 changes: 59 additions & 0 deletions src/shell/ingest/identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use super::IngestSource;
use serde_json::Value;

pub(super) struct HookIdentity {
pub(super) agent: String,
pub(super) agent_update: Option<String>,
pub(super) model: Option<String>,
pub(super) trace_path: Option<String>,
}

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<String> {
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<String> {
payload.get(key)?.as_str().map(ToOwned::to_owned)
}
29 changes: 29 additions & 0 deletions src/shell/ingest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
16 changes: 14 additions & 2 deletions src/shell/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/store/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ mod reports;
mod rows;
mod samples;
mod schema;
mod session_identity;
mod session_read;
mod session_window;
mod sessions;
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/store/sqlite/session_identity.rs
Original file line number Diff line number Diff line change
@@ -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<bool> {
Ok(
conn.query_row("SELECT NOT EXISTS(SELECT 1 FROM sessions)", [], |row| {
row.get(0)
})?,
)
}

fn complete(conn: &Connection) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sync_state WHERE k = ?1)",
[MARKER],
|row| row.get(0),
)?)
}
15 changes: 15 additions & 0 deletions src/store/sqlite/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 27 additions & 1 deletion src/store/sqlite/tests/sessions.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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");
}
1 change: 1 addition & 0 deletions src/visualization/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
Expand Down
2 changes: 2 additions & 0 deletions src/visualization/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ pub struct TraceSummary {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TraceDetail {
pub session: SessionRecord,
#[serde(default)]
pub prompt: Option<String>,
pub events: Vec<Event>,
pub spans: Vec<SpanNode>,
pub files: Vec<String>,
Expand Down
Loading