diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 086a7d53..188324ed 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -131,8 +131,19 @@ pub struct SessionMessage { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileActivityEntry { pub session_id: String, - pub tool_name: String, + pub action: FileActivityAction, pub path: String, pub summary: String, pub timestamp: DateTime, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FileActivityAction { + Read, + Create, + Modify, + Move, + Delete, + Touch, +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 061c96d6..a237aa87 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -11,7 +11,9 @@ use crate::config::Config; use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; -use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState}; +use super::{ + FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, +}; pub struct StateStore { conn: Connection, @@ -146,7 +148,8 @@ impl StateStore { duration_ms INTEGER, risk_score REAL DEFAULT 0.0, timestamp TEXT NOT NULL, - file_paths_json TEXT NOT NULL DEFAULT '[]' + file_paths_json TEXT NOT NULL DEFAULT '[]', + file_events_json TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS messages ( @@ -270,6 +273,15 @@ impl StateStore { .context("Failed to add file_paths_json column to tool_log table")?; } + if !self.has_column("tool_log", "file_events_json")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN file_events_json TEXT NOT NULL DEFAULT '[]'", + [], + ) + .context("Failed to add file_events_json column to tool_log table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -738,9 +750,17 @@ impl StateStore { #[serde(default)] file_paths: Vec, #[serde(default)] + file_events: Vec, + #[serde(default)] timestamp: String, } + #[derive(serde::Deserialize)] + struct ToolActivityFileEvent { + path: String, + action: String, + } + let file = File::open(metrics_path) .with_context(|| format!("Failed to open {}", metrics_path.display()))?; let reader = BufReader::new(file); @@ -773,8 +793,35 @@ impl StateStore { .map(|path| path.trim().to_string()) .filter(|path| !path.is_empty()) .collect(); + let file_events: Vec = if row.file_events.is_empty() { + file_paths + .iter() + .cloned() + .map(|path| PersistedFileEvent { + path, + action: infer_file_activity_action(&row.tool_name), + }) + .collect() + } else { + row.file_events + .into_iter() + .filter_map(|event| { + let path = event.path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: parse_file_activity_action(&event.action) + .unwrap_or_else(|| infer_file_activity_action(&row.tool_name)), + }) + }) + .collect() + }; let file_paths_json = serde_json::to_string(&file_paths).unwrap_or_else(|_| "[]".to_string()); + let file_events_json = + serde_json::to_string(&file_events).unwrap_or_else(|_| "[]".to_string()); let timestamp = if row.timestamp.trim().is_empty() { chrono::Utc::now().to_rfc3339() } else { @@ -798,9 +845,10 @@ impl StateStore { duration_ms, risk_score, timestamp, - file_paths_json + file_paths_json, + file_events_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", rusqlite::params![ row.id, row.session_id, @@ -811,6 +859,7 @@ impl StateStore { risk_score, timestamp, file_paths_json, + file_events_json, ], )?; @@ -1487,11 +1536,13 @@ impl StateStore { limit: usize, ) -> Result> { let mut stmt = self.conn.prepare( - "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json + "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_events_json, file_paths_json FROM tool_log WHERE session_id = ?1 - AND file_paths_json IS NOT NULL - AND file_paths_json != '[]' + AND ( + (file_events_json IS NOT NULL AND file_events_json != '[]') + OR (file_paths_json IS NOT NULL AND file_paths_json != '[]') + ) ORDER BY timestamp DESC, id DESC", )?; @@ -1505,17 +1556,23 @@ impl StateStore { row.get::<_, String>(4)?, row.get::<_, Option>(5)? .unwrap_or_else(|| "[]".to_string()), + row.get::<_, Option>(6)? + .unwrap_or_else(|| "[]".to_string()), )) })? .collect::, _>>()?; let mut events = Vec::new(); - for (session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json) in - rows + for ( + session_id, + tool_name, + input_summary, + output_summary, + timestamp, + file_events_json, + file_paths_json, + ) in rows { - let Ok(paths) = serde_json::from_str::>(&file_paths_json) else { - continue; - }; let occurred_at = chrono::DateTime::parse_from_rfc3339(×tamp) .unwrap_or_default() .with_timezone(&chrono::Utc); @@ -1525,16 +1582,28 @@ impl StateStore { output_summary }; - for path in paths { - let path = path.trim().to_string(); - if path.is_empty() { - continue; - } + let persisted = parse_persisted_file_events(&file_events_json).unwrap_or_else(|| { + serde_json::from_str::>(&file_paths_json) + .unwrap_or_default() + .into_iter() + .filter_map(|path| { + let path = path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: infer_file_activity_action(&tool_name), + }) + }) + .collect() + }); + for event in persisted { events.push(FileActivityEntry { session_id: session_id.clone(), - tool_name: tool_name.clone(), - path, + action: event.action, + path: event.path, summary: summary.clone(), timestamp: occurred_at, }); @@ -1548,6 +1617,62 @@ impl StateStore { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedFileEvent { + path: String, + action: FileActivityAction, +} + +fn parse_persisted_file_events(value: &str) -> Option> { + let events = serde_json::from_str::>(value).ok()?; + let events: Vec = events + .into_iter() + .filter_map(|event| { + let path = event.path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: event.action, + }) + }) + .collect(); + if events.is_empty() { + return None; + } + Some(events) +} + +fn parse_file_activity_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "read" => Some(FileActivityAction::Read), + "create" => Some(FileActivityAction::Create), + "modify" | "edit" | "write" => Some(FileActivityAction::Modify), + "move" | "rename" => Some(FileActivityAction::Move), + "delete" | "remove" => Some(FileActivityAction::Delete), + "touch" => Some(FileActivityAction::Touch), + _ => None, + } +} + +fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { + let tool_name = tool_name.trim().to_ascii_lowercase(); + if tool_name.contains("read") { + FileActivityAction::Read + } else if tool_name.contains("write") { + FileActivityAction::Create + } else if tool_name.contains("edit") { + FileActivityAction::Modify + } else if tool_name.contains("delete") || tool_name.contains("remove") { + FileActivityAction::Delete + } else if tool_name.contains("move") || tool_name.contains("rename") { + FileActivityAction::Move + } else { + FileActivityAction::Touch + } +} + #[cfg(test)] mod tests { use super::*; @@ -1803,10 +1928,11 @@ mod tests { let activity = db.list_file_activity("session-1", 10)?; assert_eq!(activity.len(), 3); - assert_eq!(activity[0].tool_name, "Write"); + assert_eq!(activity[0].action, FileActivityAction::Create); assert_eq!(activity[0].path, "README.md"); + assert_eq!(activity[1].action, FileActivityAction::Create); assert_eq!(activity[1].path, "src/lib.rs"); - assert_eq!(activity[2].tool_name, "Read"); + assert_eq!(activity[2].action, FileActivityAction::Read); assert_eq!(activity[2].path, "src/lib.rs"); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9bf315b9..a708a8a0 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -5400,25 +5400,19 @@ fn session_state_color(state: &SessionState) -> Color { fn file_activity_summary(entry: &FileActivityEntry) -> String { format!( "{} {}", - file_activity_verb(&entry.tool_name), + file_activity_verb(entry.action.clone()), truncate_for_dashboard(&entry.path, 72) ) } -fn file_activity_verb(tool_name: &str) -> &'static str { - let tool_name = tool_name.trim().to_ascii_lowercase(); - if tool_name.contains("read") { - "read" - } else if tool_name.contains("write") { - "write" - } else if tool_name.contains("edit") { - "edit" - } else if tool_name.contains("delete") || tool_name.contains("remove") { - "delete" - } else if tool_name.contains("move") || tool_name.contains("rename") { - "move" - } else { - "touch" +fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { + match action { + crate::session::FileActivityAction::Read => "read", + crate::session::FileActivityAction::Create => "create", + crate::session::FileActivityAction::Modify => "modify", + crate::session::FileActivityAction::Move => "move", + crate::session::FileActivityAction::Delete => "delete", + crate::session::FileActivityAction::Touch => "touch", } } @@ -6100,12 +6094,12 @@ mod tests { dashboard.toggle_timeline_mode(); let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("read src/lib.rs")); - assert!(rendered.contains("write README.md")); + assert!(rendered.contains("create README.md")); assert!(!rendered.contains("files touched 2")); let metrics_text = dashboard.selected_session_metrics_text(); assert!(metrics_text.contains("Recent file activity")); - assert!(metrics_text.contains("write README.md")); + assert!(metrics_text.contains("create README.md")); assert!(metrics_text.contains("read src/lib.rs")); let _ = fs::remove_dir_all(root); diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index b3b75728..8627de00 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -62,6 +62,49 @@ function pushPathCandidate(paths, value) { } } +function pushFileEvent(events, value, action) { + const candidate = String(value || '').trim(); + if (!candidate) { + return; + } + if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { + return; + } + if (!events.some(event => event.path === candidate && event.action === action)) { + events.push({ path: candidate, action }); + } +} + +function inferDefaultFileAction(toolName) { + const normalized = String(toolName || '').trim().toLowerCase(); + if (normalized.includes('read')) { + return 'read'; + } + if (normalized.includes('write')) { + return 'create'; + } + if (normalized.includes('edit')) { + return 'modify'; + } + if (normalized.includes('delete') || normalized.includes('remove')) { + return 'delete'; + } + if (normalized.includes('move') || normalized.includes('rename')) { + return 'move'; + } + return 'touch'; +} + +function actionForFileKey(toolName, key) { + if (key === 'source_path' || key === 'old_file_path') { + return 'move'; + } + if (key === 'destination_path' || key === 'new_file_path') { + return 'move'; + } + return inferDefaultFileAction(toolName); +} + function collectFilePaths(value, paths) { if (!value) { return; @@ -99,6 +142,43 @@ function extractFilePaths(toolInput) { return paths; } +function collectFileEvents(toolName, value, events, key = null) { + if (!value) { + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + collectFileEvents(toolName, entry, events, key); + } + return; + } + + if (typeof value === 'string') { + pushFileEvent(events, value, actionForFileKey(toolName, key)); + return; + } + + if (typeof value !== 'object') { + return; + } + + for (const [nestedKey, nested] of Object.entries(value)) { + if (FILE_PATH_KEYS.has(nestedKey)) { + collectFileEvents(toolName, nested, events, nestedKey); + } + } +} + +function extractFileEvents(toolName, toolInput) { + const events = []; + if (!toolInput || typeof toolInput !== 'object') { + return events; + } + collectFileEvents(toolName, toolInput, events); + return events; +} + function summarizeInput(toolName, toolInput, filePaths) { if (toolName === 'Bash') { return truncateSummary(toolInput?.command || 'bash'); @@ -155,6 +235,7 @@ function buildActivityRow(input, env = process.env) { const toolInput = input?.tool_input || {}; const filePaths = extractFilePaths(toolInput); + const fileEvents = extractFileEvents(toolName, toolInput); return { id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, @@ -165,6 +246,7 @@ function buildActivityRow(input, env = process.env) { output_summary: summarizeOutput(input?.tool_output), duration_ms: 0, file_paths: filePaths, + file_events: fileEvents, }; } @@ -205,6 +287,7 @@ if (require.main === module) { module.exports = { buildActivityRow, + extractFileEvents, extractFilePaths, summarizeInput, summarizeOutput, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 7c265f96..c2f01507 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -95,12 +95,41 @@ function runTests() { assert.strictEqual(row.session_id, 'ecc-session-1234'); assert.strictEqual(row.tool_name, 'Write'); 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'); assert.ok(row.timestamp, 'Expected timestamp'); fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures typed move file events from source/destination inputs', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Move', + tool_input: { + source_path: 'src/old.rs', + destination_path: 'src/new.rs', + }, + tool_output: { output: 'moved file' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-5678', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_paths, ['src/old.rs', 'src/new.rs']); + assert.deepStrictEqual(row.file_events, [ + { path: 'src/old.rs', action: 'move' }, + { path: 'src/new.rs', action: 'move' }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = {