From b01a300c3176a1ba2f6a05cd2a4e9a055886da28 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:04:18 -0700 Subject: [PATCH] feat(ecc2): persist tool log params and trigger context --- ecc2/src/observability/mod.rs | 10 +++ ecc2/src/session/store.rs | 78 +++++++++++++++++--- ecc2/src/tui/dashboard.rs | 53 ++++++++++++- scripts/hooks/session-activity-tracker.js | 45 +++++++++++ tests/hooks/session-activity-tracker.test.js | 4 + 5 files changed, 175 insertions(+), 15 deletions(-) diff --git a/ecc2/src/observability/mod.rs b/ecc2/src/observability/mod.rs index 586c4431..fae8ddd0 100644 --- a/ecc2/src/observability/mod.rs +++ b/ecc2/src/observability/mod.rs @@ -9,7 +9,9 @@ pub struct ToolCallEvent { pub session_id: String, pub tool_name: String, pub input_summary: String, + pub input_params_json: String, pub output_summary: String, + pub trigger_summary: String, pub duration_ms: u64, pub risk_score: f64, } @@ -47,7 +49,9 @@ impl ToolCallEvent { .score, tool_name, input_summary, + input_params_json: "{}".to_string(), output_summary: output_summary.into(), + trigger_summary: String::new(), duration_ms, } } @@ -238,7 +242,9 @@ pub struct ToolLogEntry { pub session_id: String, pub tool_name: String, pub input_summary: String, + pub input_params_json: String, pub output_summary: String, + pub trigger_summary: String, pub duration_ms: u64, pub risk_score: f64, pub timestamp: String, @@ -268,7 +274,9 @@ impl<'a> ToolLogger<'a> { &event.session_id, &event.tool_name, &event.input_summary, + &event.input_params_json, &event.output_summary, + &event.trigger_summary, event.duration_ms, event.risk_score, ×tamp, @@ -398,6 +406,8 @@ mod tests { assert_eq!(first_page.entries.len(), 2); assert_eq!(first_page.entries[0].tool_name, "Bash"); assert_eq!(first_page.entries[1].tool_name, "Write"); + assert_eq!(first_page.entries[0].input_params_json, "{}"); + assert_eq!(first_page.entries[0].trigger_summary, ""); let second_page = logger.query("sess-1", 2, 2)?; assert_eq!(second_page.total, 3); diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 1fa31298..18d6610c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -155,7 +155,9 @@ impl StateStore { session_id TEXT NOT NULL REFERENCES sessions(id), tool_name TEXT NOT NULL, input_summary TEXT, + input_params_json TEXT NOT NULL DEFAULT '{}', output_summary TEXT, + trigger_summary TEXT NOT NULL DEFAULT '', duration_ms INTEGER, risk_score REAL DEFAULT 0.0, timestamp TEXT NOT NULL, @@ -293,6 +295,24 @@ impl StateStore { .context("Failed to add file_events_json column to tool_log table")?; } + if !self.has_column("tool_log", "input_params_json")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN input_params_json TEXT NOT NULL DEFAULT '{}'", + [], + ) + .context("Failed to add input_params_json column to tool_log table")?; + } + + if !self.has_column("tool_log", "trigger_summary")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN trigger_summary TEXT NOT NULL DEFAULT ''", + [], + ) + .context("Failed to add trigger_summary column to tool_log table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -754,6 +774,8 @@ impl StateStore { tool_name: String, #[serde(default)] input_summary: String, + #[serde(default = "default_input_params_json")] + input_params_json: String, #[serde(default)] output_summary: String, #[serde(default)] @@ -781,6 +803,11 @@ impl StateStore { let reader = BufReader::new(file); let mut aggregates: HashMap = HashMap::new(); let mut seen_event_ids = HashSet::new(); + let session_tasks = self + .list_sessions()? + .into_iter() + .map(|session| (session.id, session.task)) + .collect::>(); for line in reader.lines() { let line = line?; @@ -853,6 +880,7 @@ impl StateStore { ) .score; let session_id = row.session_id.clone(); + let trigger_summary = session_tasks.get(&session_id).cloned().unwrap_or_default(); self.conn.execute( "INSERT OR IGNORE INTO tool_log ( @@ -860,20 +888,24 @@ impl StateStore { session_id, tool_name, input_summary, + input_params_json, output_summary, + trigger_summary, duration_ms, risk_score, timestamp, file_paths_json, file_events_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ row.id, row.session_id, row.tool_name, row.input_summary, + row.input_params_json, row.output_summary, + trigger_summary, row.duration_ms, risk_score, timestamp, @@ -1472,19 +1504,23 @@ impl StateStore { session_id: &str, tool_name: &str, input_summary: &str, + input_params_json: &str, output_summary: &str, + trigger_summary: &str, duration_ms: u64, risk_score: f64, timestamp: &str, ) -> Result { self.conn.execute( - "INSERT INTO tool_log (session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO tool_log (session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ session_id, tool_name, input_summary, + input_params_json, output_summary, + trigger_summary, duration_ms, risk_score, timestamp, @@ -1496,7 +1532,9 @@ impl StateStore { session_id: session_id.to_string(), tool_name: tool_name.to_string(), input_summary: input_summary.to_string(), + input_params_json: input_params_json.to_string(), output_summary: output_summary.to_string(), + trigger_summary: trigger_summary.to_string(), duration_ms, risk_score, timestamp: timestamp.to_string(), @@ -1519,7 +1557,7 @@ impl StateStore { )?; let mut stmt = self.conn.prepare( - "SELECT id, session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp + "SELECT id, session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp FROM tool_log WHERE session_id = ?1 ORDER BY timestamp DESC, id DESC @@ -1533,10 +1571,14 @@ impl StateStore { session_id: row.get(1)?, tool_name: row.get(2)?, input_summary: row.get::<_, Option>(3)?.unwrap_or_default(), - output_summary: row.get::<_, Option>(4)?.unwrap_or_default(), - duration_ms: row.get::<_, Option>(5)?.unwrap_or_default(), - risk_score: row.get::<_, Option>(6)?.unwrap_or_default(), - timestamp: row.get(7)?, + input_params_json: row + .get::<_, Option>(4)? + .unwrap_or_else(|| "{}".to_string()), + output_summary: row.get::<_, Option>(5)?.unwrap_or_default(), + trigger_summary: row.get::<_, Option>(6)?.unwrap_or_default(), + duration_ms: row.get::<_, Option>(7)?.unwrap_or_default(), + risk_score: row.get::<_, Option>(8)?.unwrap_or_default(), + timestamp: row.get(9)?, }) })? .collect::, _>>()?; @@ -1757,6 +1799,10 @@ fn normalize_optional_string(value: Option) -> Option { }) } +fn default_input_params_json() -> String { + "{}".to_string() +} + fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { let tool_name = tool_name.trim().to_ascii_lowercase(); if tool_name.contains("read") { @@ -1991,9 +2037,9 @@ mod tests { fs::write( &metrics_path, concat!( - "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\",\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"input_params_json\":\"{\\\"file_path\\\":\\\"src/lib.rs\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"input_params_json\":\"{\\\"file_path\\\":\\\"src/lib.rs\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"input_params_json\":\"{\\\"file_path\\\":\\\"README.md\\\",\\\"content\\\":\\\"hello\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\",\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; @@ -2015,6 +2061,16 @@ mod tests { assert_eq!(logs.total, 2); assert_eq!(logs.entries[0].tool_name, "Write"); assert_eq!(logs.entries[1].tool_name, "Read"); + assert_eq!( + logs.entries[0].input_params_json, + "{\"file_path\":\"README.md\",\"content\":\"hello\"}" + ); + assert_eq!(logs.entries[0].trigger_summary, "sync tools"); + assert_eq!( + logs.entries[1].input_params_json, + "{\"file_path\":\"src/lib.rs\"}" + ); + assert_eq!(logs.entries[1].trigger_summary, "sync tools"); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 53d8c23a..df8b60fd 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -883,15 +883,31 @@ impl Dashboard { self.logs .iter() .map(|entry| { - format!( - "[{}] {} | {}ms | risk {:.0}%\ninput: {}\noutput: {}", + let mut block = format!( + "[{}] {} | {}ms | risk {:.0}%", self.short_timestamp(&entry.timestamp), entry.tool_name, entry.duration_ms, entry.risk_score * 100.0, + ); + if !entry.trigger_summary.trim().is_empty() { + block.push_str(&format!( + "\nwhy: {}", + self.log_field(&entry.trigger_summary) + )); + } + if entry.input_params_json.trim() != "{}" { + block.push_str(&format!( + "\nparams: {}", + self.log_field(&entry.input_params_json) + )); + } + block.push_str(&format!( + "\ninput: {}\noutput: {}", self.log_field(&entry.input_summary), self.log_field(&entry.output_summary) - ) + )); + block }) .collect::>() .join("\n\n") @@ -3559,7 +3575,7 @@ impl Dashboard { entry.duration_ms, truncate_for_dashboard(&entry.input_summary, 56) ), - detail_lines: Vec::new(), + detail_lines: tool_log_detail_lines(&entry), }) })); events @@ -5475,6 +5491,23 @@ fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String ) } +fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec { + let mut lines = Vec::new(); + if !entry.trigger_summary.trim().is_empty() { + lines.push(format!( + "why {}", + truncate_for_dashboard(&entry.trigger_summary, 72) + )); + } + if entry.input_params_json.trim() != "{}" { + lines.push(format!( + "params {}", + truncate_for_dashboard(&entry.input_params_json, 72) + )); + } + lines +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6050,7 +6083,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{\"command\":\"cargo test -q\"}", "ok", + "stabilize planner session", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6069,6 +6104,8 @@ mod tests { assert!(rendered.contains("created session as planner")); assert!(rendered.contains("received query lead-123")); assert!(rendered.contains("tool bash")); + assert!(rendered.contains("why stabilize planner session")); + assert!(rendered.contains("params {\"command\":\"cargo test -q\"}")); assert!(rendered.contains("files touched 3")); } @@ -6104,7 +6141,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6254,7 +6293,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6313,7 +6354,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(4)).to_rfc3339(), @@ -6325,7 +6368,9 @@ mod tests { "review-87654321", "git", "git status --short", + "{}", "ok", + "", 120, 0.1, &(now - chrono::Duration::minutes(2)).to_rfc3339(), diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index e802d5d0..9d4716a1 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -50,6 +50,50 @@ function truncateSummary(value, maxLength = 220) { return `${normalized.slice(0, maxLength - 3)}...`; } +function sanitizeParamValue(value, depth = 0) { + if (depth >= 4) { + return '[Truncated]'; + } + + if (value == null) { + return value; + } + + if (typeof value === 'string') { + return truncateSummary(value, 160); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1)); + } + + if (typeof value === 'object') { + const output = {}; + for (const [key, nested] of Object.entries(value).slice(0, 20)) { + output[key] = sanitizeParamValue(nested, depth + 1); + } + return output; + } + + return truncateSummary(String(value), 160); +} + +function sanitizeInputParams(toolInput) { + if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) { + return '{}'; + } + + try { + return JSON.stringify(sanitizeParamValue(toolInput)); + } catch { + return '{}'; + } +} + function pushPathCandidate(paths, value) { const candidate = String(value || '').trim(); if (!candidate) { @@ -514,6 +558,7 @@ function buildActivityRow(input, env = process.env) { session_id: sessionId, tool_name: toolName, input_summary: summarizeInput(toolName, toolInput, filePaths), + input_params_json: sanitizeInputParams(toolInput), output_summary: summarizeOutput(input?.tool_output), duration_ms: 0, file_paths: filePaths, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index da56c677..99eb7c6f 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -95,6 +95,7 @@ function runTests() { const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); assert.strictEqual(row.session_id, 'ecc-session-1234'); assert.strictEqual(row.tool_name, 'Write'); + assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}'); assert.deepStrictEqual(row.file_paths, ['src/app.rs']); assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]); assert.ok(row.id, 'Expected stable event id'); @@ -331,6 +332,9 @@ function runTests() { assert.ok(row.input_summary.includes('')); assert.ok(!row.input_summary.includes('abc123')); assert.ok(!row.input_summary.includes('topsecret')); + assert.ok(row.input_params_json.includes('')); + assert.ok(!row.input_params_json.includes('abc123')); + assert.ok(!row.input_params_json.includes('topsecret')); fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++);