mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 03:13:29 +08:00
feat(ecc2): classify typed file activity
This commit is contained in:
@@ -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<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileActivityAction {
|
||||
Read,
|
||||
Create,
|
||||
Modify,
|
||||
Move,
|
||||
Delete,
|
||||
Touch,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
file_events: Vec<ToolActivityFileEvent>,
|
||||
#[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<PersistedFileEvent> = 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<Vec<FileActivityEntry>> {
|
||||
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<String>>(5)?
|
||||
.unwrap_or_else(|| "[]".to_string()),
|
||||
row.get::<_, Option<String>>(6)?
|
||||
.unwrap_or_else(|| "[]".to_string()),
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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::<Vec<String>>(&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::<Vec<String>>(&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<Vec<PersistedFileEvent>> {
|
||||
let events = serde_json::from_str::<Vec<PersistedFileEvent>>(value).ok()?;
|
||||
let events: Vec<PersistedFileEvent> = 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<FileActivityAction> {
|
||||
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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user