diff --git a/Cargo.toml b/Cargo.toml index af5b4d8d..6632ecac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ lite = [] medium = ["lang-dart", "lang-pascal", "lang-php", "lang-ruby", "lang-bash", "lang-protobuf", "lang-powershell", "lang-nix", "lang-vbnet"] full = ["medium", "lang-lua", "lang-zig", "lang-objc", "lang-perl", "lang-batch", "lang-fortran", "lang-cobol", "lang-msbasic2", "lang-gwbasic", "lang-qbasic", "lang-dockerfile", "lang-glsl", "lang-wgsl", "lang-hlsl", "lang-metal", "lang-markdown", "lang-r", "lang-sql", "lang-julia", "lang-haskell", "lang-ocaml", "lang-clojure", "lang-erlang", "lang-elixir", "lang-fsharp", "lang-quint", "lang-toml", "lang-lean"] -# Language features (all grammars provided by tokensave-large-treesitters) +# Language features backed by the bundled tree-sitter grammar crate. lang-dart = [] lang-pascal = [] lang-php = [] @@ -106,7 +106,7 @@ axum = "0.8" libsql = "0.9.30" tree-sitter = "0.26" tree-sitter-language = "0.1" -tokensave-large-treesitters = { version = "0.5.0", git = "https://github.com/aovestdipaperino/tokensave-large-treesitters" } +tracedecay-large-treesitters = { package = "tokensave-large-treesitters", version = "0.5.0", git = "https://github.com/aovestdipaperino/tokensave-large-treesitters" } clap = { version = "4.6", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -154,4 +154,3 @@ criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } [[bench]] name = "large_repos" harness = false - diff --git a/cursor-plugin/hooks/hooks.json b/cursor-plugin/hooks/hooks.json index 500b9704..e1b0e22a 100644 --- a/cursor-plugin/hooks/hooks.json +++ b/cursor-plugin/hooks/hooks.json @@ -25,6 +25,12 @@ "timeout": 5 } ], + "preCompact": [ + { + "command": "tracedecay hook-cursor-pre-compact", + "timeout": 120 + } + ], "beforeSubmitPrompt": [ { "command": "tracedecay hook-cursor-before-submit-prompt", diff --git a/src/accounting/classifier.rs b/src/accounting/classifier.rs index 8f13f04b..8f57d42b 100644 --- a/src/accounting/classifier.rs +++ b/src/accounting/classifier.rs @@ -30,40 +30,30 @@ impl fmt::Display for TaskCategory { } impl TaskCategory { - pub fn as_str(&self) -> &'static str { + fn strings(self) -> (&'static str, &'static str) { match self { - Self::Coding => "coding", - Self::Debugging => "debugging", - Self::FeatureDev => "feature_dev", - Self::Refactoring => "refactoring", - Self::Testing => "testing", - Self::Exploration => "exploration", - Self::Planning => "planning", - Self::Delegation => "delegation", - Self::GitOps => "git_ops", - Self::BuildDeploy => "build_deploy", - Self::Brainstorming => "brainstorming", - Self::Conversation => "conversation", - Self::General => "general", + Self::Coding => ("coding", "Coding"), + Self::Debugging => ("debugging", "Debugging"), + Self::FeatureDev => ("feature_dev", "Feature Dev"), + Self::Refactoring => ("refactoring", "Refactoring"), + Self::Testing => ("testing", "Testing"), + Self::Exploration => ("exploration", "Exploration"), + Self::Planning => ("planning", "Planning"), + Self::Delegation => ("delegation", "Delegation"), + Self::GitOps => ("git_ops", "Git Ops"), + Self::BuildDeploy => ("build_deploy", "Build/Deploy"), + Self::Brainstorming => ("brainstorming", "Brainstorming"), + Self::Conversation => ("conversation", "Conversation"), + Self::General => ("general", "General"), } } + pub fn as_str(&self) -> &'static str { + (*self).strings().0 + } + pub fn label(&self) -> &'static str { - match self { - Self::Coding => "Coding", - Self::Debugging => "Debugging", - Self::FeatureDev => "Feature Dev", - Self::Refactoring => "Refactoring", - Self::Testing => "Testing", - Self::Exploration => "Exploration", - Self::Planning => "Planning", - Self::Delegation => "Delegation", - Self::GitOps => "Git Ops", - Self::BuildDeploy => "Build/Deploy", - Self::Brainstorming => "Brainstorming", - Self::Conversation => "Conversation", - Self::General => "General", - } + (*self).strings().1 } } diff --git a/src/accounting/pricing.rs b/src/accounting/pricing.rs index 94270f4a..6e0dbe0e 100644 --- a/src/accounting/pricing.rs +++ b/src/accounting/pricing.rs @@ -2,7 +2,7 @@ //! //! Pricing lifecycle: //! 1. **Cached file** at `pricing.json` in the user data dir -//! (`~/.tracedecay/`, or legacy `~/.tokensave/`) -- checked first. +//! (`~/.tracedecay/`) -- checked first. //! 2. **Embedded fallback** baked into the binary -- used when no cache exists. //! 3. **Remote refresh** from `LiteLLM`'s public pricing JSON -- fetched at most //! once every 24 hours, stored to the cache file. @@ -51,8 +51,7 @@ pub struct ModelPricing { pub cache_read_per_mtok: f64, } -/// Path to the cached pricing file: `pricing.json` in the user data dir -/// (`~/.tracedecay/`, or legacy `~/.tokensave/`). +/// Path to the cached pricing file: `pricing.json` in the user data dir. fn cache_path() -> Option { crate::config::user_data_dir().map(|dir| dir.join("pricing.json")) } diff --git a/src/agents/antigravity.rs b/src/agents/antigravity.rs index 04fe1d21..1dd1527f 100644 --- a/src/agents/antigravity.rs +++ b/src/agents/antigravity.rs @@ -35,10 +35,6 @@ fn cli_plugin_path(home: &Path) -> std::path::PathBuf { home.join(".gemini/antigravity-cli/plugins/tracedecay.json") } -fn legacy_cli_plugin_path(home: &Path) -> std::path::PathBuf { - home.join(".gemini/antigravity-cli/plugins/tokensave.json") -} - impl AgentIntegration for AntigravityIntegration { fn name(&self) -> &'static str { "Antigravity" @@ -109,7 +105,6 @@ impl AgentIntegration for AntigravityIntegration { let mcp_path = mcp_config_path(&ctx.home); uninstall_mcp_server(&mcp_path); uninstall_cli_plugin(&cli_plugin_path(&ctx.home)); - uninstall_cli_plugin(&legacy_cli_plugin_path(&ctx.home)); eprintln!(); eprintln!("Uninstall complete. Tracedecay has been removed from Antigravity."); @@ -137,23 +132,20 @@ impl AgentIntegration for AntigravityIntegration { if mcp_path.exists() { let servers = load_json_file(&mcp_path).get("mcpServers").cloned(); servers.as_ref().and_then(|v| v.get("tracedecay")).is_some() - || servers.as_ref().and_then(|v| v.get("tokensave")).is_some() } else { false } }; let cli_ok = { let plugin_path = cli_plugin_path(home); - let legacy_path = legacy_cli_plugin_path(home); let has_entry = |path: &std::path::Path| { if !path.exists() { return false; } let servers = load_json_file(path).get("mcpServers").cloned(); servers.as_ref().and_then(|v| v.get("tracedecay")).is_some() - || servers.as_ref().and_then(|v| v.get("tokensave")).is_some() }; - has_entry(&plugin_path) || has_entry(&legacy_path) + has_entry(&plugin_path) }; ide_ok || cli_ok } @@ -181,17 +173,16 @@ fn uninstall_mcp_server(mcp_path: &Path) { .and_then(|v| v.as_object_mut()) else { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", mcp_path.display() ); return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", mcp_path.display() ); return; @@ -212,7 +203,7 @@ fn uninstall_mcp_server(mcp_path: &Path) { let pretty = serde_json::to_string_pretty(&settings).unwrap_or_default(); std::fs::write(mcp_path, format!("{pretty}\n")).ok(); eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", mcp_path.display() ); } diff --git a/src/agents/claude.rs b/src/agents/claude.rs index ff475e20..fccaf213 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -160,17 +160,13 @@ fn install_mcp_server(claude_json_path: &Path, tracedecay_bin: &str) -> Result<( /// Remove stale MCP server from old location in settings.json. /// -/// Removes both the new "tracedecay" key and the legacy "tokensave" key, -/// since either can be present depending on which binary version installed. +/// Removes the tracedecay key from the old settings location. fn install_migrate_old_mcp(settings: &mut serde_json::Value, settings_path: &Path) { if let Some(servers) = settings .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { - // Remove both new and legacy keys from the old location. - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if removed_new || removed_legacy { + if servers.remove("tracedecay").is_some() { if servers.is_empty() { settings.as_object_mut().map(|o| o.remove("mcpServers")); } @@ -231,10 +227,9 @@ fn install_single_hook( .cloned() .unwrap_or_default(); - // Detect BOTH "tracedecay" (new) and "tracedecay" (legacy) for idempotency. - let has_hook = hooks_arr.iter().any(|h| { - hook_entry_command(h).is_some_and(|c| c.contains("tracedecay") || c.contains("tokensave")) - }); + let has_hook = hooks_arr + .iter() + .any(|h| hook_entry_command(h).is_some_and(|c| c.contains("tracedecay"))); if !has_hook { let mut new_hooks = hooks_arr; @@ -287,8 +282,8 @@ fn parse_hook_command(cmd_entry: &serde_json::Value) -> Option<(String, String)> Some((bin, sub)) } -/// Find the first tracedecay (or legacy tokensave) hook entry under an event -/// and return `(bin, subcommand, is_legacy_shape)`. `is_legacy_shape` is true +/// Find the first tracedecay hook entry under an event and return +/// `(bin, subcommand, is_legacy_shape)`. `is_legacy_shape` is true /// when the entry uses the broken single-string command shape and needs /// rewriting. fn find_tracedecay_hook( @@ -299,8 +294,7 @@ fn find_tracedecay_hook( arr.iter().find_map(|wrapper| { let cmd_entry = wrapper.get("hooks")?.as_array()?.first()?; let raw_command = cmd_entry.get("command").and_then(|c| c.as_str())?; - // Detect both new "tracedecay" and legacy "tracedecay" binary names. - if !raw_command.contains("tracedecay") && !raw_command.contains("tokensave") { + if !raw_command.contains("tracedecay") { return None; } let (bin, sub) = parse_hook_command(cmd_entry)?; @@ -335,9 +329,8 @@ fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[Str /// Append CLAUDE.md rules (idempotent). fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { let marker = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; - // Legacy markers from older product/binary names — treat as already present. + // Display-case marker from older versions — treat as already present. let display_marker = "## MANDATORY: No Explore Agents When TraceDecay Is Available"; - let legacy_marker = "## MANDATORY: No Explore Agents When Tokensave Is Available"; let existing_md = if claude_md_path.is_file() { std::fs::read_to_string(claude_md_path).map_err(|e| TraceDecayError::Config { message: format!("failed to read {}: {e}", claude_md_path.display()), @@ -347,7 +340,6 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { }; if existing_md.contains(marker) || existing_md.contains(display_marker) - || existing_md.contains(legacy_marker) || existing_md.contains("No Explore Agents When Codegraph Is Available") { eprintln!(" CLAUDE.md already contains tracedecay rules, skipping"); @@ -431,9 +423,7 @@ fn install_clean_local_config() { .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { - // Remove both new and legacy keys. - let removed = servers.remove("tracedecay").is_some() - | servers.remove("tokensave").is_some(); + let removed = servers.remove("tracedecay").is_some(); if removed { if servers.is_empty() { std::fs::remove_file(&mcp_json_path).ok(); @@ -455,13 +445,12 @@ fn install_clean_local_config() { } } -/// Remove tracedecay (and legacy tokensave) entries from a local settings.local.json file. +/// Remove tracedecay entries from a local settings.local.json file. fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { let Ok(contents) = std::fs::read_to_string(local_settings_path) else { return; }; - // Fast-path: file must reference either new or legacy name. - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { return; } let Ok(mut local_val) = serde_json::from_str::(&contents) else { @@ -474,8 +463,7 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { .and_then(|v| v.as_array_mut()) { let before = arr.len(); - // Remove both new and legacy server name entries. - arr.retain(|v| v.as_str() != Some("tracedecay") && v.as_str() != Some("tokensave")); + arr.retain(|v| v.as_str() != Some("tracedecay")); if arr.len() < before { modified = true; } @@ -485,8 +473,7 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { - let removed = - servers.remove("tracedecay").is_some() | servers.remove("tokensave").is_some(); + let removed = servers.remove("tracedecay").is_some(); if removed { modified = true; if servers.is_empty() { @@ -526,9 +513,6 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { // --------------------------------------------------------------------------- /// Remove MCP server from ~/.claude.json. -/// -/// Removes both the new "tracedecay" key and the legacy "tokensave" key so -/// uninstall is complete regardless of which binary version installed them. fn uninstall_mcp_server(claude_json_path: &Path) { if !claude_json_path.exists() { return; @@ -545,10 +529,8 @@ fn uninstall_mcp_server(claude_json_path: &Path) { else { return; }; - // Remove both new and legacy keys; success if either was present. - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!(" No tracedecay MCP server in ~/.claude.json, skipping"); return; } @@ -595,16 +577,12 @@ fn uninstall_settings(settings_path: &Path) { } /// Remove stale MCP server from settings.json. Returns true if modified. -/// -/// Removes both the new "tracedecay" key and the legacy "tokensave" key. fn uninstall_stale_mcp(settings: &mut serde_json::Value) -> bool { if let Some(servers) = settings .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if removed_new || removed_legacy { + if servers.remove("tracedecay").is_some() { if servers.is_empty() { settings.as_object_mut().map(|o| o.remove("mcpServers")); } @@ -615,7 +593,7 @@ fn uninstall_stale_mcp(settings: &mut serde_json::Value) -> bool { false } -/// Remove all tracedecay (and legacy tokensave) hooks. Returns true if modified. +/// Remove all tracedecay hooks. Returns true if modified. fn uninstall_hook(settings: &mut serde_json::Value) -> bool { let mut modified = false; for event in &["PreToolUse", "UserPromptSubmit", "Stop"] { @@ -624,13 +602,12 @@ fn uninstall_hook(settings: &mut serde_json::Value) -> bool { modified } -/// Remove tracedecay (and legacy tokensave) entries from a single hook event. +/// Remove tracedecay entries from a single hook event. /// Returns true if modified. fn uninstall_single_hook(settings: &mut serde_json::Value, event: &str) -> bool { let Some(arr) = settings["hooks"][event].as_array().cloned() else { return false; }; - // Remove entries whose command contains either the new or legacy binary name. let filtered: Vec = arr .into_iter() .filter(|h| { @@ -641,7 +618,7 @@ fn uninstall_single_hook(settings: &mut serde_json::Value, event: &str) -> bool entry .get("command") .and_then(|c| c.as_str()) - .is_some_and(|c| c.contains("tracedecay") || c.contains("tokensave")) + .is_some_and(|c| c.contains("tracedecay")) }) }) }) @@ -667,18 +644,16 @@ fn uninstall_single_hook(settings: &mut serde_json::Value, event: &str) -> bool true } -/// Remove tracedecay (and legacy tokensave) tool permissions. Returns true if modified. +/// Remove tracedecay tool permissions. Returns true if modified. fn uninstall_permissions(settings: &mut serde_json::Value) -> bool { let Some(arr) = settings["permissions"]["allow"].as_array().cloned() else { return false; }; - // Remove both new mcp__tracedecay__* and legacy mcp__tracedecay__* entries. let filtered: Vec = arr .into_iter() .filter(|v| { - !v.as_str().is_some_and(|s| { - s.starts_with("mcp__tracedecay__") || s.starts_with("mcp__tracedecay__") - }) + !v.as_str() + .is_some_and(|s| s.starts_with("mcp__tracedecay__")) }) .collect(); if filtered.len() @@ -705,10 +680,9 @@ fn uninstall_permissions(settings: &mut serde_json::Value) -> bool { true } -/// Remove tracedecay (and legacy tokensave) rules from CLAUDE.md. +/// Remove tracedecay rules from CLAUDE.md. /// -/// Handles the steady marker plus display-case and legacy product names so -/// uninstall works regardless of which version installed the rules. +/// Handles the steady marker plus display-case product name. fn uninstall_claude_md_rules(claude_md_path: &Path) { if !claude_md_path.exists() { return; @@ -716,21 +690,17 @@ fn uninstall_claude_md_rules(claude_md_path: &Path) { let Ok(contents) = std::fs::read_to_string(claude_md_path) else { return; }; - // Check for either new or legacy brand name in the file. - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { eprintln!(" CLAUDE.md does not contain tracedecay rules, skipping"); return; } - // Try steady marker first, then display-case and legacy markers. + // Try steady marker first, then display-case marker. let marker_new = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; let marker_display = "## MANDATORY: No Explore Agents When TraceDecay Is Available"; - let marker_legacy = "## MANDATORY: No Explore Agents When Tokensave Is Available"; let (marker, start) = if let Some(s) = contents.find(marker_new) { (marker_new, s) } else if let Some(s) = contents.find(marker_display) { (marker_display, s) - } else if let Some(s) = contents.find(marker_legacy) { - (marker_legacy, s) } else { return; }; @@ -745,8 +715,7 @@ fn uninstall_claude_md_rules(claude_md_path: &Path) { let abs = search_from + pos; let heading_start = abs + 1; // skip the leading '\n' let heading_line = contents[heading_start..].lines().next().unwrap_or(""); - // Treat headings that contain either new or legacy brand as owned. - if heading_line.contains("tracedecay") || heading_line.contains("tokensave") { + if heading_line.contains("tracedecay") { search_from = heading_start + heading_line.len(); } else { break abs; @@ -1037,10 +1006,7 @@ fn doctor_check_permissions(dc: &mut DoctorCounters, settings: &serde_json::Valu let stale: Vec<&&str> = installed .iter() - .filter(|p| { - (p.starts_with("mcp__tracedecay__") || p.starts_with("mcp__tokensave__")) - && !expected.contains(&p.to_string()) - }) + .filter(|p| p.starts_with("mcp__tracedecay__") && !expected.contains(&p.to_string())) .collect(); if !stale.is_empty() { dc.warn(&format!( @@ -1089,7 +1055,7 @@ fn doctor_check_local_config(dc: &mut DoctorCounters, project_path: &Path) { } } -/// Remove tracedecay (and legacy tokensave) from local .mcp.json. Returns true if cleaned. +/// Remove tracedecay from local .mcp.json. Returns true if cleaned. fn doctor_clean_local_mcp_json(dc: &mut DoctorCounters, mcp_json_path: &Path) -> bool { let Ok(contents) = std::fs::read_to_string(mcp_json_path) else { return false; @@ -1097,10 +1063,7 @@ fn doctor_clean_local_mcp_json(dc: &mut DoctorCounters, mcp_json_path: &Path) -> let Ok(mcp_val) = serde_json::from_str::(&contents) else { return false; }; - // Check for either new or legacy key. - if !mcp_val["mcpServers"]["tracedecay"].is_object() - && !mcp_val["mcpServers"]["tokensave"].is_object() - { + if !mcp_val["mcpServers"]["tracedecay"].is_object() { dc.pass("No tracedecay in .mcp.json"); return false; } @@ -1109,7 +1072,6 @@ fn doctor_clean_local_mcp_json(dc: &mut DoctorCounters, mcp_json_path: &Path) -> return false; }; servers.remove("tracedecay"); - servers.remove("tokensave"); if servers.is_empty() { if std::fs::remove_file(mcp_json_path).is_ok() { dc.warn(&format!( @@ -1126,7 +1088,7 @@ fn doctor_clean_local_mcp_json(dc: &mut DoctorCounters, mcp_json_path: &Path) -> true } -/// Remove tracedecay (and legacy tokensave) from local .claude/settings.local.json. +/// Remove tracedecay from local .claude/settings.local.json. /// Returns true if cleaned. fn doctor_clean_local_settings( dc: &mut DoctorCounters, @@ -1136,7 +1098,7 @@ fn doctor_clean_local_settings( let Ok(contents) = std::fs::read_to_string(local_settings_path) else { return false; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { dc.pass("No tracedecay in .claude/settings.local.json"); return false; } @@ -1147,7 +1109,7 @@ fn doctor_clean_local_settings( if let Some(arr) = local_val["enabledMcpjsonServers"].as_array_mut() { let before = arr.len(); - arr.retain(|v| v.as_str() != Some("tracedecay") && v.as_str() != Some("tokensave")); + arr.retain(|v| v.as_str() != Some("tracedecay")); if arr.len() < before { modified = true; } @@ -1157,8 +1119,7 @@ fn doctor_clean_local_settings( .get_mut("mcpServers") .and_then(|v| v.as_object_mut()) { - let removed = - servers.remove("tracedecay").is_some() | servers.remove("tokensave").is_some(); + let removed = servers.remove("tracedecay").is_some(); if removed { modified = true; if servers.is_empty() { @@ -1273,10 +1234,10 @@ fn warn_missing_permissions(settings: &serde_json::Value) { } } -/// Load `path`, normalize any backslashed tracedecay/tracedecay hook commands, +/// Load `path`, normalize any backslashed tracedecay hook commands, /// backfill missing hook events, and write back if anything changed. Silent on /// any error (missing file, unparseable JSON, write failure). Safe no-op when -/// no tracedecay or legacy tokensave hook is present in the file. +/// no tracedecay hook is present in the file. fn normalize_and_backfill_settings_file(path: &Path) { let Ok(contents) = std::fs::read_to_string(path) else { return; @@ -1284,8 +1245,8 @@ fn normalize_and_backfill_settings_file(path: &Path) { let Ok(mut settings) = serde_json::from_str::(&contents) else { return; }; - // Only touch files that already reference tracedecay or legacy tokensave — - // don't accidentally rewrite unrelated project settings. + // Only touch files that already reference tracedecay so unrelated project + // settings stay untouched. let Some(bin) = extract_tracedecay_bin_from_hooks(&settings) else { return; }; @@ -1298,11 +1259,10 @@ fn normalize_and_backfill_settings_file(path: &Path) { } } -/// Rewrite any tracedecay or legacy tokensave hook command containing a +/// Rewrite any tracedecay hook command containing a /// backslash to use forward slashes. Fixes pre-v4.0.x Windows installs where /// backslashed paths got mangled by `bash -c` (see issue #38). Only touches -/// commands that mention `tracedecay` or `tracedecay` so unrelated hooks are -/// left alone. +/// commands that mention `tracedecay` so unrelated hooks are left alone. fn normalize_hook_command_paths(settings: &mut serde_json::Value) { let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else { return; @@ -1322,10 +1282,7 @@ fn normalize_hook_command_paths(settings: &mut serde_json::Value) { let Some(command) = command_val.as_str() else { continue; }; - // Normalize backslash paths for either new or legacy binary name. - if (command.contains("tracedecay") || command.contains("tokensave")) - && command.contains('\\') - { + if command.contains("tracedecay") && command.contains('\\') { *command_val = serde_json::Value::String(command.replace('\\', "/")); } } @@ -1333,12 +1290,12 @@ fn normalize_hook_command_paths(settings: &mut serde_json::Value) { } } -/// Extracts the tracedecay (or legacy tokensave) binary path from any existing +/// Extracts the tracedecay binary path from any existing /// hook command. /// -/// Scans all hook events for a command containing "tracedecay" or "tracedecay" -/// and returns the binary path. Handles both the modern `{command, args}` shape -/// and the legacy single-string shape. Returns `None` if no managed hook is found. +/// Scans all hook events for a command containing "tracedecay" and returns the +/// binary path. Handles both the modern `{command, args}` shape and the legacy +/// single-string shape. Returns `None` if no managed hook is found. fn extract_tracedecay_bin_from_hooks(settings: &serde_json::Value) -> Option { let hooks = settings.get("hooks")?.as_object()?; for entries in hooks.values() { @@ -1353,8 +1310,7 @@ fn extract_tracedecay_bin_from_hooks(settings: &serde_json::Value) -> Option Result { + let cached_dirs = codex_plugin_cached_install_dirs(&ctx.home); let plugin_dir = codex_plugin_install_dir(&ctx.home); - let legacy_dir = codex_plugin_legacy_install_dir(&ctx.home); + if !cached_dirs.is_empty() { + for target in &cached_dirs { + install_codex_plugin_bundle(target, &ctx.tracedecay_bin, InstallScope::Global)?; + } + cleanup_codex_plugin_bootstrap(&ctx.home)?; + return Ok(UpdatePluginOutcome::Refreshed(cached_dirs)); + } + + if let Some(project_path) = codex_update_project_path(ctx) { + let repo_dir = codex_repo_plugin_install_dir(&project_path); + if repo_dir.join(".codex-plugin/plugin.json").exists() + && codex_plugin_dir_is_tracedecay(&repo_dir) + { + install_codex_plugin_bundle( + &repo_dir, + &ctx.tracedecay_bin, + InstallScope::ProjectLocal, + )?; + return Ok(UpdatePluginOutcome::Refreshed(vec![repo_dir])); + } + } + let target = if codex_plugin_manifest_path(&ctx.home).exists() { - Some(plugin_dir) - } else if codex_plugin_legacy_manifest_path(&ctx.home).exists() { - Some(legacy_dir) + Some(plugin_dir.clone()) } else if Self::has_legacy_config_install(&ctx.home) { return Ok(UpdatePluginOutcome::ConfigOnly); } else { @@ -123,15 +144,22 @@ impl AgentIntegration for CodexIntegration { } fn is_detected(&self, home: &Path) -> bool { - home.join(".codex").is_dir() || codex_plugin_manifest_path(home).exists() + home.join(".codex").is_dir() + || !codex_plugin_cached_install_dirs(home).is_empty() + || codex_plugin_manifest_path(home).exists() } fn primary_config_path(&self, home: &Path) -> Option { - Some(codex_plugin_manifest_path(home)) + Some(codex_plugin_cached_install_dirs(home).pop().map_or_else( + || codex_plugin_manifest_path(home), + |dir| dir.join(".codex-plugin/plugin.json"), + )) } fn has_tracedecay(&self, home: &Path) -> bool { - if codex_plugin_manifest_path(home).exists() { + if !codex_plugin_cached_install_dirs(home).is_empty() + || codex_plugin_manifest_path(home).exists() + { return true; } Self::has_legacy_config_install(home) @@ -278,16 +306,24 @@ fn codex_plugin_install_dir(home: &Path) -> PathBuf { home.join("plugins/tracedecay") } -fn codex_plugin_legacy_install_dir(home: &Path) -> PathBuf { - home.join("plugins/tokensave") +fn codex_plugin_cached_root(home: &Path) -> PathBuf { + home.join(".codex/plugins/cache/personal/tracedecay") } -fn codex_plugin_manifest_path(home: &Path) -> PathBuf { - codex_plugin_install_dir(home).join(".codex-plugin/plugin.json") +fn codex_plugin_cached_install_dirs(home: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(codex_plugin_cached_root(home)) else { + return Vec::new(); + }; + let mut dirs: Vec = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_dir() && codex_plugin_dir_is_tracedecay(path)) + .collect(); + dirs.sort(); + dirs } -fn codex_plugin_legacy_manifest_path(home: &Path) -> PathBuf { - codex_plugin_legacy_install_dir(home).join(".codex-plugin/plugin.json") +fn codex_plugin_manifest_path(home: &Path) -> PathBuf { + codex_plugin_install_dir(home).join(".codex-plugin/plugin.json") } fn codex_personal_marketplace_path(home: &Path) -> PathBuf { @@ -302,14 +338,31 @@ fn codex_repo_marketplace_path(project_path: &Path) -> PathBuf { project_path.join(".agents/plugins/marketplace.json") } +fn codex_update_project_path(ctx: &InstallContext) -> Option { + ctx.project_root + .clone() + .or_else(|| std::env::current_dir().ok()) +} + fn install_codex_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> { + let cached_dirs = codex_plugin_cached_install_dirs(home); + if !cached_dirs.is_empty() { + for install_dir in &cached_dirs { + install_codex_plugin_bundle(install_dir, tracedecay_bin, InstallScope::Global)?; + } + cleanup_codex_plugin_bootstrap(home)?; + eprintln!( + "\x1b[32m✔\x1b[0m Refreshed installed Codex plugin bundle at {}", + cached_dirs + .last() + .map_or_else(|| codex_plugin_cached_root(home), PathBuf::from) + .display() + ); + return Ok(()); + } + let install_dir = codex_plugin_install_dir(home); - install_codex_plugin_bundle( - &install_dir, - Some(&codex_plugin_legacy_install_dir(home)), - tracedecay_bin, - InstallScope::Global, - )?; + install_codex_plugin_bundle(&install_dir, tracedecay_bin, InstallScope::Global)?; install_codex_marketplace_entry( &codex_personal_marketplace_path(home), "personal", @@ -325,12 +378,7 @@ fn install_codex_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> { fn install_codex_repo_plugin(project_path: &Path, tracedecay_bin: &str) -> Result<()> { let install_dir = codex_repo_plugin_install_dir(project_path); - install_codex_plugin_bundle( - &install_dir, - None, - tracedecay_bin, - InstallScope::ProjectLocal, - )?; + install_codex_plugin_bundle(&install_dir, tracedecay_bin, InstallScope::ProjectLocal)?; install_codex_marketplace_entry( &codex_repo_marketplace_path(project_path), "local-repo", @@ -361,7 +409,7 @@ fn uninstall_tracedecay_mcp_if_present(config_path: &Path) { let Ok(contents) = std::fs::read_to_string(config_path) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { return; } if let Err(err) = uninstall_mcp_server(config_path) { @@ -374,7 +422,6 @@ fn uninstall_tracedecay_mcp_if_present(config_path: &Path) { fn install_codex_plugin_bundle( install_dir: &Path, - legacy_dir: Option<&Path>, tracedecay_bin: &str, scope: InstallScope, ) -> Result<()> { @@ -384,9 +431,6 @@ fn install_codex_plugin_bundle( })?; } remove_codex_plugin_install(install_dir)?; - if let Some(legacy_dir) = legacy_dir { - remove_codex_plugin_install(legacy_dir)?; - } write_codex_plugin_files(install_dir, tracedecay_bin, scope) } @@ -466,6 +510,14 @@ fn codex_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result { 60, Some("Bash|apply_patch"), ); + install_codex_hook_event( + &mut hooks, + "PostCompact", + tracedecay_bin, + "hook-codex-post-compact", + 120, + Some("auto|manual"), + ); Ok(format!("{}\n", serde_json::to_string_pretty(&hooks)?)) } @@ -512,7 +564,7 @@ fn install_codex_marketplace_entry( plugins.retain(|entry| { !matches!( entry.get("name").and_then(|value| value.as_str()), - Some("tracedecay" | "tokensave") + Some("tracedecay") ) }); plugins.push(json!({ @@ -536,12 +588,58 @@ fn install_codex_marketplace_entry( } fn uninstall_codex_plugin(home: &Path) -> Result<()> { - remove_codex_plugin_install(&codex_plugin_install_dir(home))?; - remove_codex_plugin_install(&codex_plugin_legacy_install_dir(home))?; + for install_dir in codex_plugin_cached_install_dirs(home) { + remove_codex_plugin_bootstrap_source(&install_dir)?; + } + remove_codex_plugin_bootstrap_source(&codex_plugin_install_dir(home))?; remove_codex_marketplace_entry(home)?; Ok(()) } +fn uninstall_codex_repo_plugin_if_present(ctx: &InstallContext) -> Result<()> { + let Some(project_path) = codex_update_project_path(ctx) else { + return Ok(()); + }; + let install_dir = codex_repo_plugin_install_dir(&project_path); + if install_dir.join(".codex-plugin/plugin.json").exists() + && codex_plugin_dir_is_tracedecay(&install_dir) + { + remove_codex_plugin_install(&install_dir)?; + } + remove_codex_marketplace_entry_at(&codex_repo_marketplace_path(&project_path), "repo")?; + Ok(()) +} + +fn cleanup_codex_plugin_bootstrap(home: &Path) -> Result<()> { + remove_codex_plugin_bootstrap_source(&codex_plugin_install_dir(home))?; + remove_codex_marketplace_entry(home)?; + Ok(()) +} + +fn remove_codex_plugin_bootstrap_source(install_dir: &Path) -> Result<()> { + if install_dir.exists() && codex_plugin_dir_is_tracedecay(install_dir) { + remove_codex_plugin_skills_dir(install_dir)?; + } + remove_codex_plugin_install(install_dir) +} + +fn remove_codex_plugin_skills_dir(install_dir: &Path) -> Result<()> { + let skills_dir = install_dir.join("skills"); + let Ok(metadata) = std::fs::symlink_metadata(&skills_dir) else { + return Ok(()); + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + std::fs::remove_file(&skills_dir).map_err(|e| TraceDecayError::Config { + message: format!("failed to remove {}: {e}", skills_dir.display()), + })?; + } else if metadata.is_dir() { + std::fs::remove_dir_all(&skills_dir).map_err(|e| TraceDecayError::Config { + message: format!("failed to remove {}: {e}", skills_dir.display()), + })?; + } + Ok(()) +} + fn remove_codex_plugin_install(install_dir: &Path) -> Result<()> { let Ok(metadata) = std::fs::symlink_metadata(install_dir) else { return Ok(()); @@ -584,7 +682,7 @@ fn codex_plugin_dir_is_tracedecay(install_dir: &Path) -> bool { let manifest = load_json_file(&install_dir.join(".codex-plugin/plugin.json")); matches!( manifest.get("name").and_then(|value| value.as_str()), - Some("tracedecay" | "tokensave") + Some("tracedecay") ) } @@ -624,10 +722,14 @@ fn collect_regular_files_inner(root: &Path, out: &mut Vec) -> std::io:: fn remove_codex_marketplace_entry(home: &Path) -> Result<()> { let marketplace_path = codex_personal_marketplace_path(home); + remove_codex_marketplace_entry_at(&marketplace_path, "personal") +} + +fn remove_codex_marketplace_entry_at(marketplace_path: &Path, label: &str) -> Result<()> { if !marketplace_path.exists() { return Ok(()); } - let mut marketplace = load_json_file_strict(&marketplace_path)?; + let mut marketplace = load_json_file_strict(marketplace_path)?; let Some(plugins) = marketplace .get_mut("plugins") .and_then(|value| value.as_array_mut()) @@ -638,15 +740,15 @@ fn remove_codex_marketplace_entry(home: &Path) -> Result<()> { plugins.retain(|entry| { !matches!( entry.get("name").and_then(|value| value.as_str()), - Some("tracedecay" | "tokensave") + Some("tracedecay") ) }); if plugins.len() == before { return Ok(()); } - safe_write_json_file(&marketplace_path, &marketplace, None)?; + safe_write_json_file(marketplace_path, &marketplace, None)?; eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay from Codex personal marketplace at {}", + "\x1b[32m✔\x1b[0m Removed tracedecay from Codex {label} marketplace at {}", marketplace_path.display() ); Ok(()) @@ -719,11 +821,12 @@ fn print_hook_trust_guidance() { /// Remove tracedecay-owned hook groups from a Codex `hooks.json`. fn uninstall_hooks(hooks_path: &Path) { - const SUBCOMMANDS: [&str; 5] = [ + const SUBCOMMANDS: [&str; 6] = [ "hook-codex-session-start", "hook-codex-user-prompt-submit", "hook-codex-subagent-start", "hook-codex-post-tool-use", + "hook-codex-post-compact", "hook-codex-pre-tool-use", ]; @@ -774,9 +877,8 @@ fn uninstall_mcp_server(config_path: &Path) -> Result<()> { let Some(servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) else { return Ok(()); }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( " No tracedecay MCP server in {}, skipping", config_path.display() @@ -810,16 +912,13 @@ fn uninstall_prompt_rules(agents_md: &Path) { let Ok(contents) = std::fs::read_to_string(agents_md) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { eprintln!(" AGENTS.md does not contain tracedecay rules, skipping"); return; } let marker_new = "## Prefer tracedecay MCP tools"; - let marker_legacy = "## Prefer tokensave MCP tools"; let (marker, start) = if let Some(start) = contents.find(marker_new) { (marker_new, start) - } else if let Some(start) = contents.find(marker_legacy) { - (marker_legacy, start) } else { return; }; @@ -855,6 +954,14 @@ fn uninstall_prompt_rules(agents_md: &Path) { // --------------------------------------------------------------------------- fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) { + let cached_dirs = codex_plugin_cached_install_dirs(home); + if !cached_dirs.is_empty() { + for plugin_dir in cached_dirs { + doctor_check_plugin_dir(dc, &plugin_dir); + } + return; + } + let plugin_dir = codex_plugin_install_dir(home); let manifest_path = plugin_dir.join(".codex-plugin/plugin.json"); if !manifest_path.exists() { @@ -1077,6 +1184,7 @@ fn doctor_check_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { ("UserPromptSubmit", "hook-codex-user-prompt-submit"), ("SubagentStart", "hook-codex-subagent-start"), ("PostToolUse", "hook-codex-post-tool-use"), + ("PostCompact", "hook-codex-post-compact"), ]; let missing: Vec<&str> = expected .iter() diff --git a/src/agents/copilot.rs b/src/agents/copilot.rs index 6f408e3a..f2059736 100644 --- a/src/agents/copilot.rs +++ b/src/agents/copilot.rs @@ -17,7 +17,6 @@ use super::{ }; const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_RULE_MARKER: &str = "## Prefer tokensave MCP tools"; /// GitHub Copilot agent. pub struct CopilotIntegration; @@ -135,7 +134,6 @@ impl AgentIntegration for CopilotIntegration { let json = load_jsonc_file(&vscode_settings_path); let servers = json.get("mcp").and_then(|v| v.get("servers")); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } else { false }; @@ -144,7 +142,6 @@ impl AgentIntegration for CopilotIntegration { let json = load_jsonc_file(&insiders_settings_path); let servers = json.get("mcp").and_then(|v| v.get("servers")); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } else { false }; @@ -153,7 +150,6 @@ impl AgentIntegration for CopilotIntegration { let json = load_json_file(&cli_settings_path); let servers = json.get("mcpServers"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } else { false }; @@ -267,22 +263,19 @@ fn uninstall_vscode_mcp_server(settings_path: &Path) { let mut settings = load_jsonc_file(settings_path); - // Remove both new and legacy server keys. let removed = if let Some(map) = settings .get_mut("mcp") .and_then(|mcp| mcp.get_mut("servers")) .and_then(|servers| servers.as_object_mut()) { - let removed_new = map.remove("tracedecay").is_some(); - let removed_legacy = map.remove("tokensave").is_some(); - removed_new || removed_legacy + map.remove("tracedecay").is_some() } else { false }; if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", settings_path.display() ); return; @@ -312,7 +305,7 @@ fn uninstall_vscode_mcp_server(settings_path: &Path) { // backup_and_write_json leaves a .bak so any mistake is recoverable (issue #63). if backup_and_write_json(settings_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", settings_path.display() ); } @@ -335,11 +328,10 @@ fn uninstall_cli_mcp_server(settings_path: &Path) { else { return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", settings_path.display() ); return; @@ -356,7 +348,7 @@ fn uninstall_cli_mcp_server(settings_path: &Path) { ); } else if backup_and_write_json(settings_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", settings_path.display() ); } @@ -434,14 +426,10 @@ fn uninstall_prompt_rules(instructions_path: &Path) { let Ok(contents) = std::fs::read_to_string(instructions_path) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { + if !contents.contains("tracedecay") { return; } - let marker = if contents.contains(PROMPT_RULE_MARKER) { - PROMPT_RULE_MARKER - } else { - LEGACY_PROMPT_RULE_MARKER - }; + let marker = PROMPT_RULE_MARKER; let Some(start) = contents.find(marker) else { return; }; diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 1cf34c51..d3052abb 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -75,9 +75,7 @@ impl AgentIntegration for CursorIntegration { // refreshing it is exactly the install path. User config such as // `~/.cursor/mcp.json` is never written by `install_cursor_plugin`, // and unmanaged files inside the plugin dir are preserved. - if !cursor_plugin_manifest_path(&ctx.home).exists() - && !cursor_plugin_legacy_manifest_path(&ctx.home).exists() - { + if !cursor_plugin_manifest_path(&ctx.home).exists() { return Ok(UpdatePluginOutcome::NotInstalled); } install_cursor_plugin(&ctx.home, &ctx.tracedecay_bin)?; @@ -89,7 +87,6 @@ impl AgentIntegration for CursorIntegration { fn uninstall(&self, ctx: &InstallContext) -> Result<()> { remove_cursor_plugin_install(&cursor_plugin_install_dir(&ctx.home))?; - remove_cursor_plugin_install(&cursor_plugin_legacy_install_dir(&ctx.home))?; let mcp_path = ctx.home.join(".cursor/mcp.json"); uninstall_mcp_server(&mcp_path); sweep_legacy_project_artifacts_at_cwd(&ctx.home); @@ -369,18 +366,10 @@ fn cursor_plugin_install_dir(home: &Path) -> PathBuf { home.join(".cursor/plugins/local/tracedecay") } -fn cursor_plugin_legacy_install_dir(home: &Path) -> PathBuf { - home.join(".cursor/plugins/local/tokensave") -} - fn cursor_plugin_manifest_path(home: &Path) -> PathBuf { cursor_plugin_install_dir(home).join(".cursor-plugin/plugin.json") } -fn cursor_plugin_legacy_manifest_path(home: &Path) -> PathBuf { - cursor_plugin_legacy_install_dir(home).join(".cursor-plugin/plugin.json") -} - fn install_cursor_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> { let install_dir = cursor_plugin_install_dir(home); if let Some(parent) = install_dir.parent() { @@ -389,7 +378,6 @@ fn install_cursor_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> { })?; } remove_cursor_plugin_install(&install_dir)?; - remove_cursor_plugin_install(&cursor_plugin_legacy_install_dir(home))?; write_embedded_plugin(&install_dir, tracedecay_bin)?; eprintln!( @@ -451,37 +439,12 @@ fn cursor_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result { /// upgrades don't strand stale surfaces (managed-path removal only covers /// files the *current* bundle ships). `commands/` was migrated to slash /// skills (`disable-model-invocation: true`) when Cursor deprecated the -/// standalone Commands surface. The `skills/tokensave-*` and -/// `skills/tracedecay-*` entries are legacy dispatcher slugs renamed to +/// standalone Commands surface. The `skills/tracedecay-*` entries are +/// legacy dispatcher slugs renamed to /// verb-phrase slugs because Cursor displays the humanized slug as the skill /// title. const LEGACY_PLUGIN_DIRS: &[&str] = &[ "commands", - "skills/tokensave-arch", - "skills/tokensave-audit", - "skills/tokensave-audit-safety", - "skills/tokensave-branch", - "skills/tokensave-check-health", - "skills/tokensave-clean", - "skills/tokensave-clean-dead-code", - "skills/tokensave-commit", - "skills/tokensave-compare-branches", - "skills/tokensave-curate-memory", - "skills/tokensave-diagnose", - "skills/tokensave-draft-commit", - "skills/tokensave-find-impact", - "skills/tokensave-fix-build", - "skills/tokensave-health", - "skills/tokensave-impact", - "skills/tokensave-map-architecture", - "skills/tokensave-port", - "skills/tokensave-port-code", - "skills/tokensave-recall", - "skills/tokensave-recall-memory", - "skills/tokensave-review", - "skills/tokensave-review-diff", - "skills/tokensave-test", - "skills/tokensave-test-changes", "skills/tracedecay-arch", "skills/tracedecay-audit", "skills/tracedecay-branch", @@ -496,11 +459,6 @@ const LEGACY_PLUGIN_DIRS: &[&str] = &[ "skills/tracedecay-test", ]; -/// Individual files shipped by older (pre-rebrand) plugin bundles that the -/// current bundle no longer contains. Swept alongside [`LEGACY_PLUGIN_DIRS`] -/// so upgrades from tokensave-branded installs don't strand them. -const LEGACY_PLUGIN_FILES: &[&str] = &["rules/tokensave.mdc"]; - fn remove_cursor_plugin_install(install_dir: &Path) -> Result<()> { let Ok(metadata) = std::fs::symlink_metadata(install_dir) else { return Ok(()); @@ -536,12 +494,6 @@ fn remove_cursor_plugin_install(install_dir: &Path) -> Result<()> { std::fs::remove_dir_all(&path).ok(); } } - for legacy in LEGACY_PLUGIN_FILES { - let path = install_dir.join(legacy); - if path.is_file() { - std::fs::remove_file(&path).ok(); - } - } if cursor_plugin_dir_has_only_managed_files(install_dir) { std::fs::remove_dir_all(install_dir).map_err(|e| TraceDecayError::Config { message: format!("failed to remove {}: {e}", install_dir.display()), @@ -558,7 +510,7 @@ fn cursor_plugin_dir_is_tracedecay(install_dir: &Path) -> bool { let manifest = load_json_file(&install_dir.join(".cursor-plugin/plugin.json")); matches!( manifest.get("name").and_then(|v| v.as_str()), - Some("tracedecay" | "tokensave") + Some("tracedecay") ) } @@ -599,16 +551,13 @@ fn collect_regular_files_inner(root: &Path, out: &mut Vec) -> std::io:: fn legacy_mcp_has_tracedecay(mcp_path: &Path) -> bool { load_json_file(mcp_path) .get("mcpServers") - .is_some_and(|servers| { - servers.get("tracedecay").is_some() || servers.get("tokensave").is_some() - }) + .is_some_and(|servers| servers.get("tracedecay").is_some()) } fn legacy_project_cursor_has_tracedecay(cursor_dir: &Path) -> bool { legacy_mcp_has_tracedecay(&cursor_dir.join("mcp.json")) || legacy_hooks_have_tracedecay(&cursor_dir.join("hooks.json")) || legacy_rule_has_tracedecay(&cursor_dir.join("rules/tracedecay.mdc")) - || legacy_rule_has_tracedecay(&cursor_dir.join("rules/tokensave.mdc")) } /// Removes legacy PROJECT-local tracedecay artifacts. Pre-plugin versions of @@ -624,10 +573,7 @@ fn sweep_legacy_project_artifacts(project_path: &Path) -> Result<()> { let cursor_dir = project_path.join(".cursor"); let mcp_path = cursor_dir.join("mcp.json"); let hooks_path = cursor_dir.join("hooks.json"); - let rule_paths = [ - cursor_dir.join("rules/tracedecay.mdc"), - cursor_dir.join("rules/tokensave.mdc"), - ]; + let rule_paths = [cursor_dir.join("rules/tracedecay.mdc")]; let legacy_mcp = legacy_mcp_has_tracedecay(&mcp_path); let legacy_hooks = legacy_hooks_have_tracedecay(&hooks_path); let legacy_rule = rule_paths @@ -737,9 +683,8 @@ fn uninstall_mcp_server(mcp_path: &Path) { return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( " No tracedecay MCP server in {}, skipping", mcp_path.display() @@ -802,7 +747,7 @@ fn remove_legacy_project_hooks(hooks_path: &Path) -> Result<()> { ); } else if backup_and_write_json(hooks_path, &hooks) { eprintln!( - "\x1b[32m✔\x1b[0m Removed legacy tokensave hooks from {}", + "\x1b[32m✔\x1b[0m Removed legacy Cursor hooks from {}", hooks_path.display() ); } @@ -816,7 +761,7 @@ fn remove_legacy_project_rule(rule_path: &Path) -> Result<()> { let contents = std::fs::read_to_string(rule_path).map_err(|e| TraceDecayError::Config { message: format!("failed to read {}: {e}", rule_path.display()), })?; - if contents.contains("tracedecay MCP tools") || contents.contains("tokensave MCP tools") { + if contents.contains("tracedecay MCP tools") { std::fs::remove_file(rule_path).map_err(|e| TraceDecayError::Config { message: format!("failed to remove {}: {e}", rule_path.display()), })?; @@ -912,6 +857,7 @@ fn doctor_check_plugin_hooks(dc: &mut DoctorCounters, hooks_path: &Path) { ("sessionEnd", "hook-cursor-session-end"), ("subagentStart", "hook-cursor-subagent-start"), ("postToolUse", "hook-cursor-post-tool-use"), + ("preCompact", "hook-cursor-pre-compact"), ("beforeSubmitPrompt", "hook-cursor-before-submit-prompt"), ("afterFileEdit", "hook-cursor-after-file-edit"), ("afterShellExecution", "hook-cursor-after-shell"), @@ -1379,7 +1325,7 @@ mod tests { let mcp = load_json_file(&cursor_dir.join("mcp.json")); assert!( mcp["mcpServers"].get("tracedecay").is_none(), - "legacy tokensave MCP entry must be removed" + "legacy tracedecay MCP entry must be removed" ); assert!( mcp["mcpServers"].get("other").is_some(), diff --git a/src/agents/gemini.rs b/src/agents/gemini.rs index 9c3eb380..7a95dac7 100644 --- a/src/agents/gemini.rs +++ b/src/agents/gemini.rs @@ -18,7 +18,6 @@ use super::{ }; const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_RULE_MARKER: &str = "## Prefer tokensave MCP tools"; /// Gemini CLI agent. pub struct GeminiIntegration; @@ -97,7 +96,6 @@ impl AgentIntegration for GeminiIntegration { let json = super::load_json_file(&settings); let servers = json.get("mcpServers"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } } @@ -159,16 +157,16 @@ fn install_prompt_rules(gemini_md: &Path) -> Result<()> { They provide instant semantic results from a pre-built knowledge graph and are \ faster than file reads.\n\n\ For project/storage identity questions, use `tracedecay_active_project` \ - or `tracedecay_storage_status` instead of inferring from repo-local marker \ + or `tracedecay_storage_status` instead of inferring from marker \ files or direct DB paths.\n\n\ If a code analysis question cannot be fully answered by tracedecay MCP tools, \ prefer built-in MCP tools first. If the user explicitly needs raw store \ inspection, use the resolved graph DB path reported by `tracedecay_storage_status` \ - rather than a hardcoded repo-local path. Use SQL to answer complex structural \ + rather than a hardcoded repo path. Use SQL to answer complex structural \ queries that go beyond what the built-in tools expose.\n\n\ For durable project/user facts, prefer `tracedecay_fact_store`, \ `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ - Use `tracedecay_message_search` for project-local Cursor transcript recall when \ + Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could be \ @@ -206,11 +204,10 @@ fn uninstall_mcp_server(settings_path: &Path) { else { return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", settings_path.display() ); return; @@ -227,7 +224,7 @@ fn uninstall_mcp_server(settings_path: &Path) { ); } else if backup_and_write_json(settings_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", settings_path.display() ); } @@ -241,15 +238,11 @@ fn uninstall_prompt_rules(gemini_md: &Path) { let Ok(contents) = std::fs::read_to_string(gemini_md) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { - eprintln!(" GEMINI.md does not contain tracedecay/tokensave rules, skipping"); + if !contents.contains("tracedecay") { + eprintln!(" GEMINI.md does not contain tracedecay rules, skipping"); return; } - let marker = if contents.contains(PROMPT_RULE_MARKER) { - PROMPT_RULE_MARKER - } else { - LEGACY_PROMPT_RULE_MARKER - }; + let marker = PROMPT_RULE_MARKER; let Some(start) = contents.find(marker) else { return; }; diff --git a/src/agents/kilo.rs b/src/agents/kilo.rs index f4fde736..149a1099 100644 --- a/src/agents/kilo.rs +++ b/src/agents/kilo.rs @@ -87,7 +87,6 @@ impl AgentIntegration for KiloIntegration { let json = load_jsonc_file(&config_path); let servers = json.get("mcp"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } } @@ -140,11 +139,10 @@ fn uninstall_mcp_server(config_path: &Path) { let Some(servers) = settings.get_mut("mcp").and_then(|v| v.as_object_mut()) else { return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if (removed_new || removed_legacy) && backup_and_write_json(config_path, &settings) { + let removed = servers.remove("tracedecay").is_some(); + if removed && backup_and_write_json(config_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", config_path.display() ); } @@ -153,17 +151,16 @@ fn uninstall_mcp_server(config_path: &Path) { let Some(servers) = settings.get_mut("mcp").and_then(|v| v.as_object_mut()) else { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", config_path.display() ); return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", config_path.display() ); return; @@ -171,7 +168,7 @@ fn uninstall_mcp_server(config_path: &Path) { if backup_and_write_json(config_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", config_path.display() ); } diff --git a/src/agents/kimi.rs b/src/agents/kimi.rs index df06cab8..e0fb1fdf 100644 --- a/src/agents/kimi.rs +++ b/src/agents/kimi.rs @@ -20,7 +20,6 @@ use super::{ }; const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_RULE_MARKER: &str = "## Prefer tokensave MCP tools"; /// Moonshot Kimi CLI agent. pub struct KimiIntegration; @@ -99,7 +98,6 @@ impl AgentIntegration for KimiIntegration { let json = load_json_file(&mcp_path); let servers = json.get("mcpServers"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } } @@ -209,17 +207,16 @@ fn uninstall_mcp_server(mcp_path: &Path) { .and_then(|v| v.as_object_mut()) else { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", mcp_path.display() ); return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", mcp_path.display() ); return; @@ -238,7 +235,7 @@ fn uninstall_mcp_server(mcp_path: &Path) { ); } else if backup_and_write_json(mcp_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", mcp_path.display() ); } @@ -252,15 +249,11 @@ fn uninstall_prompt_rules(agents_md: &Path) { let Ok(contents) = std::fs::read_to_string(agents_md) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { - eprintln!(" AGENTS.md does not contain tracedecay/tokensave rules, skipping"); + if !contents.contains("tracedecay") { + eprintln!(" AGENTS.md does not contain tracedecay rules, skipping"); return; } - let marker = if contents.contains(PROMPT_RULE_MARKER) { - PROMPT_RULE_MARKER - } else { - LEGACY_PROMPT_RULE_MARKER - }; + let marker = PROMPT_RULE_MARKER; let Some(start) = contents.find(marker) else { return; }; diff --git a/src/agents/kiro.rs b/src/agents/kiro.rs index f1e123ea..2f8eefe7 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -28,9 +28,7 @@ use super::{ pub struct KiroIntegration; const PROMPT_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_MARKER: &str = "## Prefer tokensave MCP tools"; const PROMPT_END_MARKER: &str = ""; -const LEGACY_PROMPT_END_MARKER: &str = ""; const KIRO_AGENT_NAME: &str = "tracedecay"; const OWNED_AGENT_DESCRIPTION: &str = "Default Kiro agent with tracedecay MCP tools and code-research guardrails."; @@ -464,15 +462,15 @@ call graph work, symbol lookup, or other code research until tracedecay MCP tool have been tried. Delegation is still appropriate for long-running execution work \ such as builds, tests, generated reports, or independent implementation tasks.\n\n\ For project/storage identity questions, use `tracedecay_active_project` or \ -`tracedecay_storage_status` instead of inferring from repo-local marker files or \ +`tracedecay_storage_status` instead of inferring from marker files or \ direct DB paths.\n\n\ If a code analysis question cannot be fully answered by tracedecay MCP tools, prefer \ built-in MCP tools first. If the user explicitly needs raw store inspection, use the \ resolved graph DB path reported by `tracedecay_storage_status` rather than a hardcoded \ -repo-local path. Use SQL for structural queries that go beyond the MCP tools.\n\n\ +repo path. Use SQL for structural queries that go beyond the MCP tools.\n\n\ For durable project/user facts, prefer `tracedecay_fact_store`, \ `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. Use \ -`tracedecay_message_search` for project-local Cursor transcript recall when prior \ +`tracedecay_message_search` for active-project transcript recall when prior \ conversation context matters. Do not store secrets, credentials, or unnecessary PII \ in persistent facts.\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could answer a \ @@ -500,9 +498,8 @@ fn uninstall_mcp_server(path: &Path) { eprintln!(" No tracedecay MCP server in {}, skipping", path.display()); return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + let removed = servers.remove("tracedecay").is_some(); + if !removed { eprintln!(" No tracedecay MCP server in {}, skipping", path.display()); return; } @@ -528,7 +525,7 @@ fn remove_steering_rules(path: &Path) { let Ok(contents) = std::fs::read_to_string(path) else { return; }; - if !contents.contains(PROMPT_MARKER) && !contents.contains(LEGACY_PROMPT_MARKER) { + if !contents.contains(PROMPT_MARKER) { eprintln!(" Kiro steering does not contain tracedecay rules, skipping"); return; } @@ -639,14 +636,8 @@ fn is_owned_agent_config(config: &serde_json::Value) -> bool { } fn tracedecay_prompt_block_range(contents: &str) -> Option> { - let (start, marker) = if let Some(start) = contents.find(PROMPT_MARKER) { - (start, PROMPT_END_MARKER) - } else { - ( - contents.find(LEGACY_PROMPT_MARKER)?, - LEGACY_PROMPT_END_MARKER, - ) - }; + let start = contents.find(PROMPT_MARKER)?; + let marker = PROMPT_END_MARKER; let end_marker = contents[start..].find(marker)?; let end = start + end_marker + marker.len(); Some(start..end) diff --git a/src/agents/opencode.rs b/src/agents/opencode.rs index 418da8ef..ed7d7417 100644 --- a/src/agents/opencode.rs +++ b/src/agents/opencode.rs @@ -19,7 +19,6 @@ use super::{ }; const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_RULE_MARKER: &str = "## Prefer tokensave MCP tools"; /// `OpenCode` agent. pub struct OpenCodeIntegration; @@ -92,7 +91,6 @@ impl AgentIntegration for OpenCodeIntegration { let json = super::load_json_file(&config_path); let mcp = json.get("mcp"); mcp.and_then(|v| v.get("tracedecay")).is_some() - || mcp.and_then(|v| v.get("tokensave")).is_some() } } @@ -238,11 +236,9 @@ fn uninstall_mcp_server(config_path: &Path) { let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) else { return; }; - let removed_new = mcp.remove("tracedecay").is_some(); - let removed_legacy = mcp.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + if mcp.remove("tracedecay").is_none() { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", config_path.display() ); return; @@ -259,7 +255,7 @@ fn uninstall_mcp_server(config_path: &Path) { ); } else if backup_and_write_json(config_path, &config) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", config_path.display() ); } @@ -273,15 +269,11 @@ fn uninstall_prompt_rules(prompt_path: &Path) { let Ok(contents) = std::fs::read_to_string(prompt_path) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { - eprintln!(" AGENTS.md does not contain tracedecay/tokensave rules, skipping"); + if !contents.contains("tracedecay") { + eprintln!(" AGENTS.md does not contain tracedecay rules, skipping"); return; } - let marker = if contents.contains(PROMPT_RULE_MARKER) { - PROMPT_RULE_MARKER - } else { - LEGACY_PROMPT_RULE_MARKER - }; + let marker = PROMPT_RULE_MARKER; let Some(start) = contents.find(marker) else { return; }; diff --git a/src/agents/roo_code.rs b/src/agents/roo_code.rs index 6d82e8c1..ef6f488e 100644 --- a/src/agents/roo_code.rs +++ b/src/agents/roo_code.rs @@ -81,7 +81,6 @@ impl AgentIntegration for RooCodeIntegration { let json = load_json_file(&settings_path); let servers = json.get("mcpServers"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } } @@ -137,17 +136,15 @@ fn uninstall_mcp_server(settings_path: &Path) { .and_then(|v| v.as_object_mut()) else { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", settings_path.display() ); return; }; - let removed_new = servers.remove("tracedecay").is_some(); - let removed_legacy = servers.remove("tokensave").is_some(); - if !removed_new && !removed_legacy { + if servers.remove("tracedecay").is_none() { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", settings_path.display() ); return; @@ -166,7 +163,7 @@ fn uninstall_mcp_server(settings_path: &Path) { ); } else if backup_and_write_json(settings_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", settings_path.display() ); } diff --git a/src/agents/vibe.rs b/src/agents/vibe.rs index 5fdaf6ce..05b0713e 100644 --- a/src/agents/vibe.rs +++ b/src/agents/vibe.rs @@ -37,9 +37,7 @@ fn vibe_prompt_path(home: &Path) -> std::path::PathBuf { /// The TOML marker that identifies a tracedecay MCP server entry. const TOML_MARKER: &str = "name = \"tracedecay\""; -const LEGACY_TOML_MARKER: &str = "name = \"tokensave\""; const PROMPT_RULE_MARKER: &str = "## Prefer tracedecay MCP tools"; -const LEGACY_PROMPT_RULE_MARKER: &str = "## Prefer tokensave MCP tools"; impl AgentIntegration for VibeIntegration { fn name(&self) -> &'static str { @@ -109,7 +107,7 @@ impl AgentIntegration for VibeIntegration { return false; } let contents = std::fs::read_to_string(&config_path).unwrap_or_default(); - contents.contains(TOML_MARKER) || contents.contains(LEGACY_TOML_MARKER) + contents.contains(TOML_MARKER) } } @@ -230,9 +228,9 @@ fn uninstall_mcp_server(config_path: &Path) { return; }; - if !contents.contains(TOML_MARKER) && !contents.contains(LEGACY_TOML_MARKER) { + if !contents.contains(TOML_MARKER) { eprintln!( - " No tracedecay/tokensave MCP server in {}, skipping", + " No tracedecay MCP server in {}, skipping", config_path.display() ); return; @@ -262,7 +260,7 @@ fn uninstall_mcp_server(config_path: &Path) { } } - if line.contains(TOML_MARKER) || line.contains(LEGACY_TOML_MARKER) { + if line.contains(TOML_MARKER) { // This line is inside the tracedecay block — remove it and // the preceding [[mcp_servers]] header. // Pop the header we already pushed. @@ -305,7 +303,7 @@ fn uninstall_mcp_server(config_path: &Path) { } else { std::fs::write(config_path, &new_contents).ok(); eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave MCP server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", config_path.display() ); } @@ -319,15 +317,11 @@ fn uninstall_prompt_rules(prompt_path: &Path) { let Ok(contents) = std::fs::read_to_string(prompt_path) else { return; }; - if !contents.contains("tracedecay") && !contents.contains("tokensave") { - eprintln!(" Vibe prompt does not contain tracedecay/tokensave rules, skipping"); + if !contents.contains("tracedecay") { + eprintln!(" Vibe prompt does not contain tracedecay rules, skipping"); return; } - let marker = if contents.contains(PROMPT_RULE_MARKER) { - PROMPT_RULE_MARKER - } else { - LEGACY_PROMPT_RULE_MARKER - }; + let marker = PROMPT_RULE_MARKER; let Some(start) = contents.find(marker) else { return; }; diff --git a/src/agents/zed.rs b/src/agents/zed.rs index 4ed38c8d..e80c9c52 100644 --- a/src/agents/zed.rs +++ b/src/agents/zed.rs @@ -92,7 +92,6 @@ impl AgentIntegration for ZedIntegration { let json = load_jsonc_file(&settings_path); let servers = json.get("context_servers"); servers.and_then(|v| v.get("tracedecay")).is_some() - || servers.and_then(|v| v.get("tokensave")).is_some() } } @@ -144,16 +143,14 @@ fn uninstall_context_server(settings_path: &Path) { .get_mut("context_servers") .and_then(|v| v.as_object_mut()) { - let removed_new = map.remove("tracedecay").is_some(); - let removed_legacy = map.remove("tokensave").is_some(); - removed_new || removed_legacy + map.remove("tracedecay").is_some() } else { false }; if !removed { eprintln!( - " No tracedecay/tokensave context server in {}, skipping", + " No tracedecay context server in {}, skipping", settings_path.display() ); return; @@ -174,7 +171,7 @@ fn uninstall_context_server(settings_path: &Path) { // backup_and_write_json leaves a .bak so any mistake is recoverable (issue #63). if backup_and_write_json(settings_path, &settings) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay/tokensave context server from {}", + "\x1b[32m✔\x1b[0m Removed tracedecay context server from {}", settings_path.display() ); } diff --git a/src/branch_meta.rs b/src/branch_meta.rs index ce7d8008..82f47288 100644 --- a/src/branch_meta.rs +++ b/src/branch_meta.rs @@ -1,7 +1,7 @@ //! Branch metadata persistence for multi-branch indexing. //! //! Stores tracking information in `branch-meta.json` inside the project data -//! dir (`.tracedecay/`, or legacy `.tokensave/`). +//! dir. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -13,8 +13,8 @@ const BRANCH_META_FILENAME: &str = "branch-meta.json"; /// Metadata for a single tracked branch. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BranchEntry { - /// Relative path to the DB file (e.g. `tracedecay.db` — `tokensave.db` - /// in legacy data dirs — or `branches/feature_foo.db`). + /// Relative path to the DB file, such as `tracedecay.db` or + /// `branches/feature_foo.db`. pub db_file: String, /// Branch this was copied from (None for the default branch). #[serde(skip_serializing_if = "Option::is_none")] @@ -36,16 +36,13 @@ pub struct BranchMeta { impl BranchMeta { /// Creates a new metadata with a single default branch entry pointing at - /// the standard `tracedecay.db`. Prefer [`BranchMeta::new_for_dir`] when - /// the data dir is at hand so legacy `.tokensave/` projects keep pointing - /// at their existing `tokensave.db`. + /// the standard `tracedecay.db`. pub fn new(default_branch: &str) -> Self { Self::with_db_file(default_branch, crate::config::DB_FILENAME) } /// Creates a new metadata whose default-branch entry references the main - /// DB filename appropriate for `data_dir` (`tracedecay.db`, or - /// `tokensave.db` inside a legacy dir). + /// DB filename appropriate for `data_dir`. pub fn new_for_dir(data_dir: &Path, default_branch: &str) -> Self { Self::with_db_file(default_branch, crate::config::db_filename(data_dir)) } @@ -195,11 +192,9 @@ mod tests { } #[test] - fn new_for_dir_tracks_data_dir_brand() { - let legacy = BranchMeta::new_for_dir(Path::new("/p/.tokensave"), "main"); - assert_eq!(legacy.branches["main"].db_file, "tokensave.db"); - let fresh = BranchMeta::new_for_dir(Path::new("/p/.tracedecay"), "main"); - assert_eq!(fresh.branches["main"].db_file, "tracedecay.db"); + fn new_for_dir_tracks_current_db_file() { + let meta = BranchMeta::new_for_dir(Path::new("/p/.tracedecay"), "main"); + assert_eq!(meta.branches["main"].db_file, "tracedecay.db"); } #[test] diff --git a/src/cli.rs b/src/cli.rs index c6645815..e7e05f10 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -165,6 +165,9 @@ pub enum Commands { /// Cursor beforeSubmitPrompt hook handler (called by Cursor, not by users directly) #[command(name = "hook-cursor-before-submit-prompt", hide = true)] HookCursorBeforeSubmitPrompt, + /// Cursor preCompact hook handler (called by Cursor, not by users directly) + #[command(name = "hook-cursor-pre-compact", hide = true)] + HookCursorPreCompact, /// Cursor afterFileEdit hook handler (called by Cursor, not by users directly) #[command(name = "hook-cursor-after-file-edit", hide = true)] HookCursorAfterFileEdit, @@ -195,6 +198,9 @@ pub enum Commands { /// Codex PostToolUse hook handler for incremental sync (called by Codex) #[command(name = "hook-codex-post-tool-use", hide = true)] HookCodexPostToolUse, + /// Codex PostCompact hook handler for app-server LCM summaries (called by Codex) + #[command(name = "hook-codex-post-compact", hide = true)] + HookCodexPostCompact, /// Serve the local dashboard UI (holographic memory + LCM + code graph explorers) Dashboard { /// Project path (default: current directory, with discovery) diff --git a/src/cloud.rs b/src/cloud.rs index 135c3583..80cf838c 100644 --- a/src/cloud.rs +++ b/src/cloud.rs @@ -1,5 +1,5 @@ -//! HTTP client for the worldwide token counter Cloudflare Worker and -//! GitHub release version checking. +//! HTTP client for the worldwide counter Cloudflare Worker and GitHub release +//! version checking. //! //! All operations are best-effort with timeouts. Failures are silently //! ignored and never block the CLI. @@ -7,7 +7,7 @@ use std::time::Duration; /// The Cloudflare Worker endpoint URL. -const WORKER_URL: &str = "https://tokensave-counter.enzinol.workers.dev"; +const WORKER_URL: &str = "https://tracedecay-counter.enzinol.workers.dev"; /// GitHub API endpoint for the latest stable release. const GITHUB_RELEASES_URL: &str = @@ -137,32 +137,15 @@ pub(crate) fn asset_name(version: &str, is_beta: bool) -> String { platform_asset_name(prefix, version) } -/// Legacy (pre-rebrand) archive name. Releases published while the project -/// was still called "tokensave" carry `tokensave-v*` / `tokensave-beta-v*` -/// assets; upgrades to/from those versions must keep working. -pub(crate) fn legacy_asset_name(version: &str, is_beta: bool) -> String { - let prefix = if is_beta { - "tokensave-beta" - } else { - "tokensave" - }; - platform_asset_name(prefix, version) -} - fn platform_asset_name(prefix: &str, version: &str) -> String { let platform = current_platform(); let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; format!("{prefix}-v{version}-{platform}.{ext}") } -/// Candidate asset names for explicit release installs, newest naming first. -/// The install path probes these in order so both post-rebrand -/// (`tracedecay-v*`) and legacy (`tokensave-v*`) archives remain installable. -pub(crate) fn asset_name_candidates(version: &str, is_beta: bool) -> [String; 2] { - [ - asset_name(version, is_beta), - legacy_asset_name(version, is_beta), - ] +/// Candidate asset names for explicit release installs. +pub(crate) fn asset_name_candidates(version: &str, is_beta: bool) -> [String; 1] { + [asset_name(version, is_beta)] } /// First major version of the pre-reset release line that already shipped @@ -185,12 +168,10 @@ fn release_is_pre_reset_epoch(tag_name: &str) -> bool { } /// True when the release is a valid upgrade candidate for the current -/// platform: it must carry the post-rebrand asset name (`tracedecay-v*` / -/// `tracedecay-beta-v*`) for this platform and not belong to the pre-reset -/// version epoch. Intentionally stricter than explicit install probing -/// (`asset_name_candidates`): legacy-only `tokensave-*` releases (1.x–3.x) -/// and pre-reset `tracedecay-*` releases (4.x–6.x) must never be offered as -/// the "latest" upgrade, even if old releases reappear. +/// platform: it must carry the `tracedecay-v*` / `tracedecay-beta-v*` asset +/// name for this platform and not belong to the pre-reset version epoch. +/// Pre-reset `tracedecay-*` releases (4.x-6.x) must never be offered as the +/// "latest" upgrade, even if old releases reappear. fn release_has_current_platform_asset(release: &GitHubRelease) -> bool { if release_is_pre_reset_epoch(&release.tag_name) { return false; @@ -397,15 +378,6 @@ mod tests { assert!(release_has_current_platform_asset(&r)); } - #[test] - fn skips_release_with_only_legacy_tokensave_asset() { - // Legacy `tokensave-v*` assets remain valid for explicit version - // installs, but latest-version detection must ignore them. - let expected = legacy_asset_name("0.9.9", false); - let r = release("v0.9.9", false, &[&expected]); - assert!(!release_has_current_platform_asset(&r)); - } - #[test] fn accepts_beta_release_with_matching_beta_asset() { let expected = asset_name("0.9.9-beta.1", true); @@ -413,21 +385,13 @@ mod tests { assert!(release_has_current_platform_asset(&r)); } - #[test] - fn skips_beta_release_with_only_legacy_tokensave_beta_asset() { - let expected = legacy_asset_name("0.9.9-beta.1", true); - let r = release("v0.9.9-beta.1", true, &[&expected]); - assert!(!release_has_current_platform_asset(&r)); - } - #[test] fn rejects_stable_named_asset_on_beta_release() { // If someone uploads a stable-named asset to a prerelease, the // filter should still reject — the naming convention says beta // releases carry `*-beta-v...` assets. let stable_name = asset_name("0.9.9-beta.1", false); - let legacy_stable_name = legacy_asset_name("0.9.9-beta.1", false); - let r = release("v0.9.9-beta.1", true, &[&stable_name, &legacy_stable_name]); + let r = release("v0.9.9-beta.1", true, &[&stable_name]); assert!(!release_has_current_platform_asset(&r)); } @@ -465,9 +429,8 @@ mod tests { } #[test] - fn asset_name_candidates_orders_new_name_first() { - let [first, second] = asset_name_candidates("9.9.9", false); + fn asset_name_candidates_use_current_name() { + let [first] = asset_name_candidates("9.9.9", false); assert!(first.starts_with("tracedecay-v9.9.9-")); - assert!(second.starts_with("tokensave-v9.9.9-")); } } diff --git a/src/commands.rs b/src/commands.rs index f3e4c3f9..9d827b88 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1197,14 +1197,19 @@ pub(crate) async fn init_and_index( skip_folders: &[String], verbose: bool, ) -> tracedecay::errors::Result { - debug_assert!( - project_path.is_dir(), - "init_and_index: project_path is not a directory" - ); - debug_assert!( - project_path.is_absolute(), - "init_and_index: project_path must be absolute" - ); + if !project_path.is_dir() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!( + "project path is not a directory: {}", + project_path.display() + ), + }); + } + if !project_path.is_absolute() { + return Err(tracedecay::errors::TraceDecayError::Config { + message: format!("project path must be absolute: {}", project_path.display()), + }); + } let mut cg = if TraceDecay::is_initialized(project_path) { TraceDecay::open(project_path).await? } else { @@ -1292,9 +1297,7 @@ pub async fn handle_gain( let gdb = match tracedecay::global_db::GlobalDb::open().await { Some(db) => db, None => { - eprintln!( - "Could not open the global database (~/.tracedecay/global.db, or legacy ~/.tokensave/global.db)." - ); + eprintln!("Could not open the global database (~/.tracedecay/global.db)."); return Ok(()); } }; diff --git a/src/config.rs b/src/config.rs index 00945614..5b0a9253 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,16 +16,9 @@ pub const TRACEDECAY_DIR: &str = ".tracedecay"; /// Environment variable that pins the user-level `TraceDecay` data directory. pub const USER_DATA_DIR_ENV: &str = "TRACEDECAY_DATA_DIR"; -/// Legacy (pre-rebrand) data directory name. Projects that already have a -/// `.tokensave/` dir keep using it as-is — read and write, no auto-migration. -pub const LEGACY_TOKENSAVE_DIR: &str = ".tokensave"; - /// Project graph database filename inside a `.tracedecay/` data dir. pub const DB_FILENAME: &str = "tracedecay.db"; -/// Project graph database filename inside a legacy `.tokensave/` data dir. -pub const LEGACY_DB_FILENAME: &str = "tokensave.db"; - /// Configuration for a `TraceDecay` project. /// /// Controls which files are indexed, size limits, and feature toggles. @@ -81,99 +74,53 @@ impl Default for TraceDecayConfig { } } -/// Returns the data directory for the given project root. +/// Returns the project marker directory for the given project root. /// -/// This is the single point of brand-dir resolution: prefer `.tracedecay/`; -/// when it does not exist but a legacy `.tokensave/` dir does, use the legacy -/// dir as-is (read and write — existing user data is never migrated or -/// renamed). New installs (neither dir present) get `.tracedecay/`. +/// New runtime storage lives in the user-level profile shard. The project root +/// only carries lightweight marker/config files under `.tracedecay/`. pub fn get_tracedecay_dir(project_root: &Path) -> PathBuf { - let primary = project_root.join(TRACEDECAY_DIR); - if primary.is_dir() { - return primary; - } - let legacy = project_root.join(LEGACY_TOKENSAVE_DIR); - if legacy.is_dir() { - return legacy; - } - primary + project_root.join(TRACEDECAY_DIR) } -/// Name of the active data directory for this project root: `.tracedecay`, -/// or `.tokensave` when the project still uses a legacy dir. +/// Name of the project marker directory for this project root. pub fn active_data_dir_name(project_root: &Path) -> &'static str { - if get_tracedecay_dir(project_root) - .file_name() - .is_some_and(|n| n == LEGACY_TOKENSAVE_DIR) - { - LEGACY_TOKENSAVE_DIR - } else { - TRACEDECAY_DIR - } + let _ = project_root; + TRACEDECAY_DIR } -/// Database filename appropriate for the given data directory: legacy -/// `.tokensave/` dirs keep `tokensave.db`, everything else uses -/// `tracedecay.db`. +/// Database filename appropriate for the given data directory. pub fn db_filename(data_dir: &Path) -> &'static str { - if data_dir - .file_name() - .is_some_and(|n| n == LEGACY_TOKENSAVE_DIR) - { - LEGACY_DB_FILENAME - } else { - DB_FILENAME - } + let _ = data_dir; + DB_FILENAME } -/// Full path to the project graph database, brand-fallback aware: -/// `.tracedecay/tracedecay.db`, or `.tokensave/tokensave.db` for legacy -/// projects. +/// Full path to the repo-local graph database marker path. +/// +/// Normal runtime graph storage resolves through `crate::storage::StoreLayout` +/// into the user profile shard; this helper is only for explicit marker checks +/// and migration cleanup. pub fn get_project_db_path(project_root: &Path) -> PathBuf { - let dir = get_tracedecay_dir(project_root); - let file = db_filename(&dir); - dir.join(file) + get_tracedecay_dir(project_root).join(DB_FILENAME) } -/// Returns true when either supported project DB filename exists at this root. -/// -/// This intentionally checks the legacy path independently of -/// [`get_tracedecay_dir`], because a profile-storage enrollment marker creates -/// `.tracedecay/` even when the graph DB still lives in a legacy `.tokensave/` -/// directory. +/// Returns true when the old repo-local `TraceDecay` graph DB exists at this root. pub fn has_project_database(project_root: &Path) -> bool { project_root.join(TRACEDECAY_DIR).join(DB_FILENAME).exists() - || project_root - .join(LEGACY_TOKENSAVE_DIR) - .join(LEGACY_DB_FILENAME) - .exists() } -/// User-level data directory: `~/.tracedecay`, falling back to an existing -/// legacy `~/.tokensave` (used as-is), defaulting to `~/.tracedecay` when -/// neither exists yet. +/// User-level data directory. Runtime storage is always rooted at +/// `~/.tracedecay` unless `TRACEDECAY_DATA_DIR` explicitly overrides it. pub fn user_data_dir() -> Option { if let Some(path) = std::env::var_os(USER_DATA_DIR_ENV).filter(|path| !path.is_empty()) { return Some(PathBuf::from(path)); } let home = dirs::home_dir()?; - let primary = home.join(TRACEDECAY_DIR); - if primary.is_dir() { - return Some(primary); - } - let legacy = home.join(LEGACY_TOKENSAVE_DIR); - if legacy.is_dir() { - return Some(legacy); - } - Some(primary) + Some(home.join(TRACEDECAY_DIR)) } -/// Reads the `TRACEDECAY_` environment variable, falling back to the -/// legacy `TOKENSAVE_` name so pre-rebrand setups keep working. +/// Reads the `TRACEDECAY_` environment variable. pub fn brand_env(suffix: &str) -> Option { - std::env::var(format!("TRACEDECAY_{suffix}")) - .or_else(|_| std::env::var(format!("TOKENSAVE_{suffix}"))) - .ok() + std::env::var(format!("TRACEDECAY_{suffix}")).ok() } /// Returns the path to the configuration file (`config.json`) within the @@ -272,8 +219,8 @@ pub fn save_config(project_root: &Path, config: &TraceDecayConfig) -> Result<()> Ok(()) } -/// Returns `true` if the active data dir (`.tracedecay`, or legacy -/// `.tokensave`) is ignored by Git for this project. +/// Returns `true` if the project marker dir (`.tracedecay`) is ignored by Git +/// for this project. /// /// This respects the repository `.gitignore`, `.git/info/exclude`, and the /// user's global excludes file via `git check-ignore`. If Git cannot answer @@ -326,10 +273,9 @@ fn is_in_local_gitignore(project_path: &Path) -> bool { } } -/// Appends the active data-dir name (`.tracedecay`, or legacy `.tokensave`) -/// to the project's `.gitignore`, creating the file if needed. Ensures the -/// entry starts on its own line (adds a trailing newline to existing content -/// if missing). +/// Appends the project marker dir name (`.tracedecay`) to the project's +/// `.gitignore`, creating the file if needed. Ensures the entry starts on its +/// own line (adds a trailing newline to existing content if missing). pub fn add_to_gitignore(project_path: &Path) { let dir_name = active_data_dir_name(project_path); let gitignore = project_path.join(".gitignore"); @@ -349,15 +295,26 @@ pub fn add_to_gitignore(project_path: &Path) { /// If `path` is `Some`, uses that value; otherwise falls back to the current /// working directory. pub fn resolve_path(path: Option) -> PathBuf { - match path { + let path = match path { Some(p) => PathBuf::from(p), None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + }; + absolutize_path(path) +} + +fn absolutize_path(path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) } } -/// Walks from `start` upward looking for an initialised project database -/// (`.tracedecay/tracedecay.db`, or legacy `.tokensave/tokensave.db`) or a -/// profile-storage enrollment marker (`.tracedecay/enrollment.json`). +/// Walks from `start` upward looking for an initialised repo marker +/// (`.tracedecay/tracedecay.db`) or a profile-storage enrollment marker +/// (`.tracedecay/enrollment.json`). /// /// Returns the first ancestor directory (inclusive) that contains an /// initialised `TraceDecay` project, or `None` if the filesystem root is @@ -386,7 +343,13 @@ pub fn resolve_path(path: Option) -> PathBuf { pub fn discover_project_root(start: &Path) -> Option { let mut dir = start.to_path_buf(); loop { - if has_project_database(&dir) || crate::storage::has_enrollment_marker(&dir) { + if has_project_database(&dir) + || crate::storage::has_enrollment_marker(&dir) + || crate::storage::resolve_layout_for_current_profile(&dir).is_ok_and(|layout| { + layout.storage_mode == crate::storage::StorageMode::ProfileSharded + && layout.graph_db_path.exists() + }) + { return Some(dir); } if !dir.pop() { @@ -504,23 +467,8 @@ mod tests { } #[test] - fn test_data_dir_falls_back_to_existing_legacy_dir() { - let root = TempDir::new().unwrap(); - fs::create_dir(root.path().join(".tokensave")).unwrap(); - assert_eq!( - get_tracedecay_dir(root.path()), - root.path().join(".tokensave") - ); - assert_eq!( - get_project_db_path(root.path()), - root.path().join(".tokensave/tokensave.db") - ); - } - - #[test] - fn test_data_dir_prefers_tracedecay_when_both_exist() { + fn test_data_dir_uses_tracedecay_when_present() { let root = TempDir::new().unwrap(); - fs::create_dir(root.path().join(".tokensave")).unwrap(); fs::create_dir(root.path().join(".tracedecay")).unwrap(); assert_eq!( get_tracedecay_dir(root.path()), @@ -534,10 +482,6 @@ mod tests { db_filename(std::path::Path::new("/p/.tracedecay")), "tracedecay.db" ); - assert_eq!( - db_filename(std::path::Path::new("/p/.tokensave")), - "tokensave.db" - ); } #[test] diff --git a/src/db/analytics.rs b/src/db/analytics.rs index 349084c8..63fbfb50 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -154,7 +154,7 @@ impl Database { } if let Some(prefix) = path_prefix { conditions.push(format!("n.file_path LIKE ?{param_idx}")); - param_values.push(libsql::Value::Text(format!("{prefix}%"))); + param_values.push(path_prefix_like_value(prefix)); param_idx += 1; } @@ -221,7 +221,7 @@ impl Database { } if let Some(prefix) = path_prefix { conditions.push(format!("file_path LIKE ?{param_idx}")); - param_values.push(libsql::Value::Text(format!("{prefix}%"))); + param_values.push(path_prefix_like_value(prefix)); param_idx += 1; } @@ -451,7 +451,7 @@ impl Database { WHERE file_path LIKE ?1 GROUP BY file_path, kind ORDER BY file_path, cnt DESC", - vec![libsql::Value::Text(format!("{prefix}%"))], + vec![path_prefix_like_value(prefix)], ), None => ( "SELECT file_path, kind, COUNT(*) AS cnt @@ -510,7 +510,7 @@ impl Database { JOIN nodes n ON e.source = n.id WHERE e.kind = 'calls' AND n.file_path LIKE ?1" .to_string(), - vec![libsql::Value::Text(format!("{prefix}%"))], + vec![path_prefix_like_value(prefix)], ), None => ( "SELECT source, target FROM edges WHERE kind = 'calls'".to_string(), @@ -563,7 +563,7 @@ impl Database { JOIN nodes n ON e.source = n.id WHERE e.kind = 'calls' AND n.file_path LIKE ?1" .to_string(), - vec![libsql::Value::Text(format!("{prefix}%"))], + vec![path_prefix_like_value(prefix)], ), None => ( "SELECT source, target, line FROM edges WHERE kind = 'calls'".to_string(), @@ -631,7 +631,7 @@ impl Database { } if let Some(prefix) = path_prefix { conditions.push(format!("n.file_path LIKE ?{param_idx}")); - param_values.push(libsql::Value::Text(format!("{prefix}%"))); + param_values.push(path_prefix_like_value(prefix)); param_idx += 1; } @@ -727,7 +727,7 @@ impl Database { LIMIT ?2" ), vec![ - libsql::Value::Text(format!("{prefix}%")), + path_prefix_like_value(prefix), libsql::Value::Integer(limit as i64), ], ), diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 44af255d..26aa1b17 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -15,7 +15,7 @@ pub mod python; pub mod rust; pub mod typescript; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Serialize; @@ -94,3 +94,15 @@ pub async fn run_all(project_root: &Path, scope: &Scope) -> Result String { + let abs = if Path::new(file_name).is_absolute() { + PathBuf::from(file_name) + } else { + project_root.join(file_name) + }; + if let Ok(rel) = abs.strip_prefix(project_root) { + return rel.to_string_lossy().to_string(); + } + file_name.to_string() +} diff --git a/src/diagnostics/python.rs b/src/diagnostics/python.rs index 0df4c1a3..bce4afa7 100644 --- a/src/diagnostics/python.rs +++ b/src/diagnostics/python.rs @@ -20,7 +20,7 @@ use std::process::Stdio; use serde::Deserialize; -use crate::diagnostics::{Diagnostic, Driver, Scope}; +use crate::diagnostics::{canonicalise_file, Diagnostic, Driver, Scope}; use crate::errors::Result; pub struct PyrightDriver; @@ -98,18 +98,6 @@ fn matches_severity(severity: &str) -> bool { matches!(severity, "error" | "warning") } -fn canonicalise_file(file_name: &str, project_root: &Path) -> String { - let abs = if Path::new(file_name).is_absolute() { - std::path::PathBuf::from(file_name) - } else { - project_root.join(file_name) - }; - if let Ok(rel) = abs.strip_prefix(project_root) { - return rel.to_string_lossy().to_string(); - } - file_name.to_string() -} - #[derive(Debug, Deserialize)] struct PyrightReport { #[serde(rename = "generalDiagnostics", default)] diff --git a/src/diagnostics/rust.rs b/src/diagnostics/rust.rs index 82bd17e5..01707b5e 100644 --- a/src/diagnostics/rust.rs +++ b/src/diagnostics/rust.rs @@ -5,10 +5,9 @@ //! `Diagnostic` rows: zero when the message has no spans (rare; usually a //! cross-cutting note), one per `spans[]` entry otherwise. //! -//! The cargo target dir is forced to `.tracedecay/target/` so concurrent -//! IDE / user `cargo check` runs don't fight us for `target/`'s lockfile. -//! That doubles disk usage on the project but is the only safe option -//! without coordination. +//! The cargo target dir is forced outside the project tree so concurrent +//! IDE / user `cargo check` runs don't fight us for `target/`'s lockfile +//! and diagnostics do not create repo-local `TraceDecay` folders. //! //! Per-package and per-file scopes drop to `cargo check -p `; cargo //! has no native single-file mode, so the `File` scope falls back to @@ -21,7 +20,7 @@ use std::process::Stdio; use serde::Deserialize; -use crate::diagnostics::{Diagnostic, Driver, Scope}; +use crate::diagnostics::{canonicalise_file, Diagnostic, Driver, Scope}; use crate::errors::{Result, TraceDecayError}; /// Driver for Rust projects. Probes for `Cargo.toml` at the project root. @@ -112,27 +111,14 @@ impl Driver for CargoDriver { } } -/// `.tracedecay/target/` is our private cargo target dir — set so we don't -/// race with the user's IDE or interactive `cargo check`. Created lazily -/// by cargo on first run. +/// Private cargo target dir for diagnostics. Created lazily by cargo on first +/// run and kept outside the project tree so diagnostics never create +/// project-local `.tracedecay` folders. fn target_dir_for(project_root: &Path) -> PathBuf { - crate::config::get_tracedecay_dir(project_root).join("target") -} - -/// Convert cargo's reported `file_name` (project-relative or absolute) into -/// the project-relative form the rest of tracedecay uses. Cargo emits paths -/// relative to the manifest dir; we strip a leading `project_root` prefix -/// when present in case the path is absolute. -fn canonicalise_file(file_name: &str, project_root: &Path) -> String { - let abs = if Path::new(file_name).is_absolute() { - PathBuf::from(file_name) - } else { - project_root.join(file_name) - }; - if let Ok(rel) = abs.strip_prefix(project_root) { - return rel.to_string_lossy().to_string(); - } - file_name.to_string() + std::env::temp_dir() + .join("tracedecay-target") + .join(crate::storage::default_profile_project_id(project_root)) + .join("diagnostics") } /// Cargo emits messages of many levels — "warning" and "error" produce @@ -205,8 +191,16 @@ mod tests { } #[test] - fn target_dir_under_dot_tracedecay() { + fn target_dir_is_outside_project_tree() { let p = target_dir_for(Path::new("/tmp/proj")); - assert_eq!(p, Path::new("/tmp/proj/.tracedecay/target")); + assert_eq!( + p, + std::env::temp_dir() + .join("tracedecay-target") + .join(crate::storage::default_profile_project_id(Path::new( + "/tmp/proj" + ))) + .join("diagnostics") + ); } } diff --git a/src/extraction/markdown_extractor.rs b/src/extraction/markdown_extractor.rs index e1415ab2..9a28a250 100644 --- a/src/extraction/markdown_extractor.rs +++ b/src/extraction/markdown_extractor.rs @@ -67,7 +67,8 @@ impl ExtractionState { fn parse_inline(&mut self, inline_node: TsNode<'_>) -> Option { let parser = self.inline_parser.get_or_insert_with(|| { let mut p = Parser::new(); - let _ = p.set_language(&tokensave_large_treesitters::markdown::inline::LANGUAGE.into()); + let _ = + p.set_language(&tracedecay_large_treesitters::markdown::inline::LANGUAGE.into()); p }); let range = Range { @@ -136,7 +137,7 @@ impl MarkdownExtractor { fn parse(source: &str) -> Result { let mut parser = Parser::new(); parser - .set_language(&tokensave_large_treesitters::markdown::LANGUAGE.into()) + .set_language(&tracedecay_large_treesitters::markdown::LANGUAGE.into()) .map_err(|e| format!("failed to load markdown grammar: {e}"))?; parser .parse(source, None) diff --git a/src/extraction/ts_provider.rs b/src/extraction/ts_provider.rs index cc5a8427..1683ab7c 100644 --- a/src/extraction/ts_provider.rs +++ b/src/extraction/ts_provider.rs @@ -1,7 +1,7 @@ //! Tree-sitter grammar provider. //! -//! All grammars are served from the `tokensave-large-treesitters` bundled -//! crate via a lazily-initialised lookup table. +//! All grammars are served from the bundled tree-sitter crate via a +//! lazily-initialised lookup table. use std::collections::HashMap; use std::sync::LazyLock; @@ -24,7 +24,7 @@ mod wgsl_grammar { /// Cached map of language key -> `Language` built once from the bundled crate. static LANGUAGES: LazyLock> = LazyLock::new(|| { #[allow(unused_mut)] - let mut map: HashMap<&'static str, Language> = tokensave_large_treesitters::all_languages() + let mut map: HashMap<&'static str, Language> = tracedecay_large_treesitters::all_languages() .into_iter() .map(|(name, lang_fn)| (name, lang_fn.into())) .collect(); diff --git a/src/global.rs b/src/global.rs index 3a55a355..c15f7302 100644 --- a/src/global.rs +++ b/src/global.rs @@ -204,7 +204,7 @@ fn elapsed_since(now: i64, recorded_at: i64) -> i64 { } /// Best-effort: register this project in the user-level global DB and -/// accumulate the token-saved delta into the pending upload counter. +/// accumulate the token savings delta into the pending upload counter. pub(crate) async fn update_global_db(cg: &TraceDecay) { if !tracedecay::user_config::UserConfig::exists() { return; @@ -358,8 +358,8 @@ pub(crate) async fn gather_target_projects( } } -/// Returns project roots whose data dir (`.tracedecay`, or legacy -/// `.tokensave`) lives in cwd, an ancestor, or a descendant. +/// Returns project roots whose `.tracedecay` data dir lives in cwd, an +/// ancestor, or a descendant. pub(crate) fn gather_local_projects( home_tracedecay: &Option, ) -> Vec { @@ -404,16 +404,9 @@ pub(crate) fn gather_local_projects_from( let mut cursor: Option<&Path> = Some(cwd); while let Some(dir) = cursor { - // Both brand dirs count: a root can hold a `.tracedecay/` or a - // legacy `.tokensave/` index. - for dir_name in [ - tracedecay::config::TRACEDECAY_DIR, - tracedecay::config::LEGACY_TOKENSAVE_DIR, - ] { - let ts = dir.join(dir_name); - if is_project_dir(dir, &ts) && seen.insert(dir.to_path_buf()) { - out.push(dir.to_path_buf()); - } + let ts = dir.join(tracedecay::config::TRACEDECAY_DIR); + if is_project_dir(dir, &ts) && seen.insert(dir.to_path_buf()) { + out.push(dir.to_path_buf()); } cursor = dir.parent(); } @@ -423,8 +416,8 @@ pub(crate) fn gather_local_projects_from( out } -/// Iteratively walks `start` looking for project data dirs -/// (`.tracedecay/tracedecay.db`, or legacy `.tokensave/tokensave.db`). +/// Iteratively walks `start` looking for `.tracedecay/tracedecay.db` project +/// data dirs. /// /// Skips common heavy directories (node_modules, target, .git, etc.) and never /// descends into a data dir once found. Tracks canonicalized directories @@ -465,9 +458,7 @@ pub(crate) fn find_descendant_tracedecay( let path = entry.path(); let name = entry.file_name(); let name_str = name.to_string_lossy(); - if name_str == tracedecay::config::TRACEDECAY_DIR - || name_str == tracedecay::config::LEGACY_TOKENSAVE_DIR - { + if name_str == tracedecay::config::TRACEDECAY_DIR { // Only canonicalize when the entry could match the home skip; // doing it for every dir entry would mean one syscall per // entry on tree walks of arbitrary size. @@ -616,31 +607,6 @@ mod gather_tests { .unwrap(); } - /// Plant a legacy `.tokensave/tokensave.db` marker — pre-rebrand projects - /// must keep being detected. - fn make_legacy_project(root: &Path) { - let ts = root.join(".tokensave"); - fs::create_dir_all(&ts).unwrap(); - fs::write(ts.join("tokensave.db"), b"").unwrap(); - } - - #[test] - fn finds_legacy_project_at_cwd_and_descendant() { - let dir = tempfile::tempdir().unwrap(); - let cwd = dir.path().canonicalize().unwrap(); - make_legacy_project(&cwd); - let child = cwd.join("sub").join("legacy"); - fs::create_dir_all(&child).unwrap(); - make_legacy_project(&child); - - let out = gather_local_projects_from(&cwd, &None); - assert!(out.contains(&cwd), "legacy cwd project missing: {out:?}"); - assert!( - out.contains(&child), - "legacy descendant project missing: {out:?}" - ); - } - #[test] fn finds_project_at_cwd() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/global_db.rs b/src/global_db.rs index 9877363a..d6cc43a3 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -1,8 +1,7 @@ //! User-level database that tracks all `TraceDecay` projects and their saved tokens. //! -//! Stored at `~/.tracedecay/global.db` (or a legacy `~/.tokensave/global.db` -//! when that dir already exists — see [`crate::config::user_data_dir`]), this -//! DB holds one row per project with the project's DB path and its cumulative +//! Stored at `~/.tracedecay/global.db`, this DB holds one row per project with +//! the project's DB path and its cumulative //! tokens-saved count. All operations are best-effort: failures are silently //! ignored so they never block the main MCP server loop. @@ -10,8 +9,13 @@ use std::fmt::Write as _; use std::path::{Path, PathBuf}; use libsql::{params, Builder, Connection, Database as LibsqlDatabase, OpenFlags, Value}; +use serde_json::Value as JsonValue; use crate::sessions::{ + lcm::{ + LcmSourceRef, LcmSummaryNode, LcmSummaryNodeDraft, LcmSummaryRequest, + LcmSummarySourceMessage, LcmSummarySourceRange, + }, SessionMessageRecord, SessionMessageSearchResult, SessionRecord, SessionSearchScope, }; @@ -41,6 +45,12 @@ pub struct TokenCountUpsert { pub token_count: i64, } +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingCodexCompactionSummary { + pub node_id: String, + pub request: LcmSummaryRequest, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct CodeProjectRecord { pub project_id: String, @@ -202,19 +212,35 @@ pub struct GlobalDb { _db: LibsqlDatabase, } +struct TranscriptSummarySources { + refs: Vec, + source_token_count: i64, + source_time_start: Option, + source_time_end: Option, + excerpts: Vec, +} + +struct TranscriptSummaryExcerpt { + role: String, + text: String, +} + +const CODEX_COMPACTION_SUMMARY_PROMPT: &str = concat!( + "Summarize the visible transcript messages that Codex compacted. ", + "Preserve durable user intent, implementation decisions, file/module names, ", + "unresolved tasks, and verification status. Return only the summary text." +); + const GLOBAL_DB_PATH_ENV: &str = "TRACEDECAY_GLOBAL_DB"; -/// Legacy env-var spelling, still honored as a fallback. -const LEGACY_GLOBAL_DB_PATH_ENV: &str = "TOKENSAVE_GLOBAL_DB"; fn global_db_path_override() -> Option { - [GLOBAL_DB_PATH_ENV, LEGACY_GLOBAL_DB_PATH_ENV] - .iter() - .find_map(|name| std::env::var_os(name).filter(|path| !path.is_empty())) + std::env::var_os(GLOBAL_DB_PATH_ENV) + .filter(|path| !path.is_empty()) .map(PathBuf::from) } /// Returns the path to the global database: `global.db` inside the user-level -/// data dir (`~/.tracedecay/`, or a legacy `~/.tokensave/` when present). +/// data dir (`~/.tracedecay/` by default). pub fn global_db_path() -> Option { if let Some(path) = global_db_path_override() { return Some(path); @@ -222,10 +248,9 @@ pub fn global_db_path() -> Option { crate::config::user_data_dir().map(|dir| dir.join("global.db")) } -/// True when `TRACEDECAY_GLOBAL_DB` (or the legacy `TOKENSAVE_GLOBAL_DB`) -/// pins the global DB to an explicit path. Consumers (the dashboard LCM -/// store selection) treat the override as an operator decision that wins -/// over project-local store discovery. +/// True when `TRACEDECAY_GLOBAL_DB` pins the global DB to an explicit path. +/// Consumers treat the override as an operator decision that wins over project +/// store discovery. pub fn global_db_path_is_overridden() -> bool { global_db_path_override().is_some() } @@ -236,12 +261,10 @@ pub fn global_db_path_is_overridden() -> bool { pub enum AccountingMode { /// No env override — global accounting is on by default. Default, - /// `TRACEDECAY_ENABLE_GLOBAL_DB` (or legacy `TOKENSAVE_ENABLE_GLOBAL_DB`) - /// explicitly enabled it. + /// `TRACEDECAY_ENABLE_GLOBAL_DB` explicitly enabled it. EnabledByEnv, /// `TRACEDECAY_ENABLE_GLOBAL_DB` (falsy value) or - /// `TRACEDECAY_DISABLE_GLOBAL_DB` (or their legacy `TOKENSAVE_*` - /// spellings) explicitly disabled it. + /// `TRACEDECAY_DISABLE_GLOBAL_DB` explicitly disabled it. DisabledByEnv, } @@ -283,12 +306,8 @@ pub fn env_flag(name: &str) -> bool { /// Savings dashboard reads the ledger — an opt-in gate here silently left /// the ledger empty while lifetime counters kept growing. Precedence: /// -/// 1. `TRACEDECAY_ENABLE_GLOBAL_DB` set (or legacy -/// `TOKENSAVE_ENABLE_GLOBAL_DB`) → its truthiness decides (the spelling -/// existing agent installers already write). -/// 2. `TRACEDECAY_DISABLE_GLOBAL_DB` (or legacy spelling) truthy → disabled -/// (opt-out; set by `.cargo/config.toml` so `cargo test` runs stay -/// hermetic). +/// 1. `TRACEDECAY_ENABLE_GLOBAL_DB` set → its truthiness decides. +/// 2. `TRACEDECAY_DISABLE_GLOBAL_DB` truthy → disabled. /// 3. Otherwise → enabled. pub fn global_accounting_mode() -> AccountingMode { if let Some(value) = crate::config::brand_env("ENABLE_GLOBAL_DB") { @@ -317,6 +336,80 @@ fn opt_i64(value: Option) -> Value { value.map_or(Value::Null, Value::Integer) } +fn estimated_tokens_from_chars(char_count: i64) -> i64 { + ((char_count.max(0) + 3) / 4).max(1) +} + +fn estimate_summary_tokens(text: &str) -> i64 { + i64::from(crate::context::read_modes::estimate_tokens(text)) +} + +fn transcript_summary_text( + message: &SessionMessageRecord, + metadata: &JsonValue, + sources: &TranscriptSummarySources, +) -> String { + if metadata.get("summary_body").and_then(JsonValue::as_str) == Some("plaintext") { + return message.text.clone(); + } + let Some(source_summary) = extractive_transcript_summary(&sources.excerpts) else { + return message.text.clone(); + }; + let codex_body = metadata + .get("summary_body") + .and_then(JsonValue::as_str) + .unwrap_or("unavailable"); + format!( + "TraceDecay-generated Codex compaction summary from visible transcript messages. Codex's own compaction body is {codex_body} in the rollout.\n\n{source_summary}" + ) +} + +fn extractive_transcript_summary(excerpts: &[TranscriptSummaryExcerpt]) -> Option { + let meaningful = excerpts + .iter() + .filter_map(|excerpt| { + let text = normalize_summary_excerpt(&excerpt.text); + if text.is_empty() { + None + } else { + Some((&excerpt.role, text)) + } + }) + .collect::>(); + if meaningful.is_empty() { + return None; + } + + let mut selected = Vec::new(); + if meaningful.len() <= 12 { + selected.extend(meaningful.iter()); + } else { + selected.extend(meaningful.iter().take(4)); + selected.extend(meaningful.iter().skip(meaningful.len().saturating_sub(8))); + } + + let mut summary = String::from("Visible source highlights:"); + for (role, text) in selected { + let role = role.trim(); + let role = if role.is_empty() { "unknown" } else { role }; + let line = truncate_summary_excerpt(text, 320); + let _ = write!(summary, "\n- {role}: {line}"); + } + Some(summary) +} + +fn normalize_summary_excerpt(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn truncate_summary_excerpt(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + let keep = max_chars.saturating_sub(3); + format!("{}...", text.chars().take(keep).collect::()) +} + fn row_to_session(row: &libsql::Row) -> Option { Some(SessionRecord { provider: row.get(0).ok()?, @@ -1358,7 +1451,8 @@ impl GlobalDb { .conn .execute( "INSERT INTO projects (path, tokens_saved) VALUES (?1, ?2) - ON CONFLICT(path) DO UPDATE SET tokens_saved = ?2", + ON CONFLICT(path) DO UPDATE SET + tokens_saved = MAX(tokens_saved, excluded.tokens_saved)", params![path_str, tokens_saved as i64], ) .await; @@ -1696,17 +1790,162 @@ impl GlobalDb { .await; match raw_result { Ok(raw) => { - self.upsert_session_message_projection( - message, - &raw.projection_text, - raw.projection_metadata_json.as_deref(), - ) - .await + if !self + .upsert_session_message_projection( + message, + &raw.projection_text, + raw.projection_metadata_json.as_deref(), + ) + .await + { + return false; + } + self.upsert_lcm_summary_for_transcript_summary(message) + .await } Err(_) => false, } } + async fn upsert_lcm_summary_for_transcript_summary( + &self, + message: &SessionMessageRecord, + ) -> bool { + if message.kind.as_deref() != Some("summary") { + return true; + } + let Some(metadata_json) = message.metadata_json.as_deref() else { + return true; + }; + let Ok(metadata) = serde_json::from_str::(metadata_json) else { + return true; + }; + if metadata.get("source").and_then(JsonValue::as_str) != Some("codex_context_compacted") { + return true; + } + let Ok(sources) = self.transcript_summary_sources(message).await else { + return false; + }; + if sources.refs.is_empty() { + return true; + } + let depth = metadata + .get("codex_compaction_depth") + .and_then(JsonValue::as_i64) + .unwrap_or(1) + .max(1); + let summary_text = transcript_summary_text(message, &metadata, &sources); + let mut summary_metadata = metadata.as_object().cloned().unwrap_or_default(); + if summary_metadata + .get("summary_body") + .and_then(JsonValue::as_str) + == Some("encrypted") + && !sources.excerpts.is_empty() + { + summary_metadata.insert( + "tracedecay_summary_source".to_string(), + JsonValue::String("visible_transcript_source_messages".to_string()), + ); + summary_metadata.insert( + "codex_summary_body".to_string(), + JsonValue::String("encrypted".to_string()), + ); + } + let summary_metadata_json = + serde_json::to_string(&JsonValue::Object(summary_metadata)).ok(); + let draft = LcmSummaryNodeDraft { + provider: message.provider.clone(), + conversation_id: message.session_id.clone(), + session_id: message.session_id.clone(), + depth, + summary_text: summary_text.clone(), + source_refs: sources.refs, + summary_token_count: estimate_summary_tokens(&summary_text), + source_token_count: sources.source_token_count, + source_time_start: sources.source_time_start, + source_time_end: sources.source_time_end.or(message.timestamp), + expand_hint: Some("Codex context compaction boundary".to_string()), + metadata_json: summary_metadata_json.or_else(|| Some(metadata_json.to_string())), + }; + crate::sessions::lcm::dag::insert_summary_node_in_transaction(&self.conn, draft) + .await + .is_ok() + } + + async fn transcript_summary_sources( + &self, + message: &SessionMessageRecord, + ) -> Result { + let mut rows = self + .conn + .query( + "SELECT r.store_id, r.timestamp, + length(COALESCE(r.content, r.snippet_text, '')), + r.role, + substr(COALESCE(r.content, r.snippet_text, ''), 1, 4000) + FROM lcm_raw_messages r + JOIN session_messages m + ON m.provider = r.provider + AND m.message_id = r.message_id + WHERE r.provider = ?1 + AND r.session_id = ?2 + AND r.ordinal < ?3 + AND r.ordinal > COALESCE(( + SELECT MAX(prev.ordinal) + FROM session_messages prev + WHERE prev.provider = ?1 + AND prev.session_id = ?2 + AND prev.ordinal < ?3 + AND COALESCE(prev.kind, 'message') = 'summary' + ), -9223372036854775808) + AND COALESCE(m.kind, 'message') <> 'summary' + ORDER BY r.store_id", + params![ + message.provider.as_str(), + message.session_id.as_str(), + message.ordinal, + ], + ) + .await?; + + let mut refs = Vec::new(); + let mut source_token_count = 0_i64; + let mut source_time_start = None; + let mut source_time_end = None; + let mut excerpts = Vec::new(); + while let Some(row) = rows.next().await? { + let store_id: i64 = row.get(0)?; + let timestamp: Option = row.get(1)?; + let char_count: i64 = row.get(2)?; + let role: String = row.get(3)?; + let excerpt_text: String = row.get(4)?; + refs.push(LcmSourceRef::RawMessage { store_id }); + source_token_count = + source_token_count.saturating_add(estimated_tokens_from_chars(char_count)); + if !excerpt_text.trim().is_empty() { + excerpts.push(TranscriptSummaryExcerpt { + role, + text: excerpt_text, + }); + } + if let Some(timestamp) = timestamp { + source_time_start = Some( + source_time_start.map_or(timestamp, |start| std::cmp::min(start, timestamp)), + ); + source_time_end = + Some(source_time_end.map_or(timestamp, |end| std::cmp::max(end, timestamp))); + } + } + + Ok(TranscriptSummarySources { + refs, + source_token_count, + source_time_start, + source_time_end, + excerpts, + }) + } + /// Atomically upserts one transcript session + all parsed messages and then /// advances the parse cursor. Any failure rolls back the entire batch so a /// follow-up ingest can safely replay from the previous offset. @@ -1930,6 +2169,233 @@ impl GlobalDb { crate::sessions::lcm::query::describe(&self.conn, request).await } + /// Returns Codex compaction summary nodes that still need an auxiliary + /// Codex app-server summary. + pub async fn pending_codex_compaction_summary_requests( + &self, + session_id: Option<&str>, + limit: usize, + ) -> Result, crate::sessions::lcm::LcmError> { + let limit = limit.clamp(1, 100) as i64; + let mut sql = String::from( + "SELECT node_id, session_id + FROM lcm_summary_nodes + WHERE provider = 'codex' + AND json_extract(metadata_json, '$.source') = 'codex_context_compacted' + AND COALESCE( + json_extract(metadata_json, '$.tracedecay_summary_source'), + '' + ) <> 'codex_app_server'", + ); + let mut query_params = vec![Value::Integer(limit)]; + if let Some(session_id) = session_id { + sql.push_str(" AND session_id = ?2 ORDER BY depth DESC, created_at DESC LIMIT ?1"); + query_params.push(Value::Text(session_id.to_string())); + } else { + sql.push_str(" ORDER BY created_at DESC, depth DESC LIMIT ?1"); + } + + let mut rows = self.conn.query(&sql, query_params).await?; + let mut pending = Vec::new(); + while let Some(row) = rows.next().await? { + let node_id: String = row.get(0)?; + let row_session_id: String = row.get(1)?; + if let Some(request) = self + .codex_compaction_summary_request_for_node(&node_id, &row_session_id) + .await? + { + pending.push(PendingCodexCompactionSummary { node_id, request }); + } + } + Ok(pending) + } + + async fn codex_compaction_summary_request_for_node( + &self, + node_id: &str, + session_id: &str, + ) -> Result, crate::sessions::lcm::LcmError> { + let mut rows = self + .conn + .query( + "SELECT r.store_id, r.role, COALESCE(r.content, r.snippet_text, '') + FROM lcm_summary_sources s + JOIN lcm_raw_messages r + ON s.source_kind = 'raw_message' + AND CAST(s.source_id AS INTEGER) = r.store_id + WHERE s.node_id = ?1 + AND r.provider = 'codex' + AND r.session_id = ?2 + ORDER BY s.ordinal", + params![node_id, session_id], + ) + .await?; + let mut source_messages = Vec::new(); + while let Some(row) = rows.next().await? { + let store_id: i64 = row.get(0)?; + let role: String = row.get(1)?; + let content: String = row.get(2)?; + source_messages.push(LcmSummarySourceMessage { + store_id, + role, + content, + }); + } + let (Some(first), Some(last)) = (source_messages.first(), source_messages.last()) else { + return Ok(None); + }; + Ok(Some(LcmSummaryRequest { + provider: "codex".to_string(), + session_id: session_id.to_string(), + focus_topic: Some("Codex context compaction".to_string()), + prompt: CODEX_COMPACTION_SUMMARY_PROMPT.to_string(), + source_range: LcmSummarySourceRange { + from_store_id: first.store_id, + to_store_id: last.store_id, + }, + source_messages, + extraction_request: None, + })) + } + + /// Replaces a deterministic Codex compaction placeholder summary with an + /// auxiliary summary while preserving the exact source lineage. + pub async fn replace_codex_compaction_summary( + &self, + node_id: &str, + summary_text: &str, + route: &str, + model: Option<&str>, + ) -> Result { + let mut draft = self.codex_compaction_summary_draft(node_id).await?; + if draft.provider != "codex" { + return Err(crate::sessions::lcm::LcmError::SummaryNodeNotFound); + } + let mut metadata: serde_json::Map = draft + .metadata_json + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + if metadata.get("source").and_then(JsonValue::as_str) != Some("codex_context_compacted") { + return Err(crate::sessions::lcm::LcmError::SummaryNodeNotFound); + } + draft.summary_text = summary_text.trim().to_string(); + draft.summary_token_count = estimate_summary_tokens(&draft.summary_text); + metadata.insert( + "tracedecay_summary_source".to_string(), + JsonValue::String(route.to_string()), + ); + if let Some(model) = model.filter(|model| !model.trim().is_empty()) { + metadata.insert( + "codex_auxiliary_model".to_string(), + JsonValue::String(model.trim().to_string()), + ); + } + draft.metadata_json = Some(JsonValue::Object(metadata).to_string()); + + self.conn.execute("BEGIN IMMEDIATE", ()).await?; + let result = async { + self.conn + .execute( + "DELETE FROM lcm_summary_sources WHERE node_id = ?1", + params![node_id], + ) + .await?; + self.conn + .execute( + "DELETE FROM lcm_summary_nodes WHERE node_id = ?1", + params![node_id], + ) + .await?; + crate::sessions::lcm::dag::insert_summary_node_in_transaction(&self.conn, draft).await + } + .await; + match result { + Ok(node) => { + self.conn.execute("COMMIT", ()).await?; + Ok(node) + } + Err(err) => { + let _ = self.conn.execute("ROLLBACK", ()).await; + Err(err) + } + } + } + + async fn codex_compaction_summary_draft( + &self, + node_id: &str, + ) -> Result { + let mut rows = self + .conn + .query( + "SELECT provider, conversation_id, session_id, depth, summary_text, + summary_token_count, source_token_count, source_time_start, + source_time_end, expand_hint, metadata_json + FROM lcm_summary_nodes + WHERE node_id = ?1", + params![node_id], + ) + .await?; + let row = rows + .next() + .await? + .ok_or(crate::sessions::lcm::LcmError::SummaryNodeNotFound)?; + let source_refs = self.summary_source_refs(node_id).await?; + Ok(LcmSummaryNodeDraft { + provider: row.get(0)?, + conversation_id: row.get(1)?, + session_id: row.get(2)?, + depth: row.get(3)?, + summary_text: row.get(4)?, + summary_token_count: row.get(5)?, + source_token_count: row.get(6)?, + source_time_start: row.get(7)?, + source_time_end: row.get(8)?, + expand_hint: row.get(9)?, + metadata_json: row.get(10)?, + source_refs, + }) + } + + async fn summary_source_refs( + &self, + node_id: &str, + ) -> Result, crate::sessions::lcm::LcmError> { + let mut rows = self + .conn + .query( + "SELECT source_kind, source_id + FROM lcm_summary_sources + WHERE node_id = ?1 + ORDER BY ordinal", + params![node_id], + ) + .await?; + let mut refs = Vec::new(); + while let Some(row) = rows.next().await? { + let source_kind: String = row.get(0)?; + let source_id: String = row.get(1)?; + match source_kind.as_str() { + "raw_message" => refs.push(LcmSourceRef::RawMessage { + store_id: source_id.parse().map_err(|err| { + crate::sessions::lcm::LcmError::Db(format!( + "invalid raw message source id '{source_id}': {err}" + )) + })?, + }), + "summary_node" => refs.push(LcmSourceRef::SummaryNode { node_id: source_id }), + _ => { + return Err(crate::sessions::lcm::LcmError::Db(format!( + "invalid summary source kind '{source_kind}'" + ))); + } + } + } + Ok(refs) + } + /// Reports LCM schema, storage, payload, and currently implemented maintenance counts. pub async fn lcm_status( &self, diff --git a/src/hooks.rs b/src/hooks.rs index 40c8fa7a..57404fd2 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -236,6 +236,172 @@ pub async fn hook_cursor_stop() -> i32 { 0 } +/// Cursor `preCompact` hook handler. +/// +/// Cursor's compaction event exposes pressure metadata but not Cursor's own +/// generated summary text. At the boundary, `TraceDecay` ingests the current +/// transcript tail, asks LCM for the compactable raw-message backlog, generates +/// a summary through `cursor-agent -p`, and stores that summary as a normal LCM +/// summary node. The hook is fail-open and emits Cursor's empty object shape. +pub async fn hook_cursor_pre_compact() -> i32 { + let event = read_hook_event!(); + if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_err() { + let mut config = crate::sessions::cursor_agent::CursorAgentSummaryConfig::from_env(); + config.timeout = config.timeout.min(CURSOR_PRE_COMPACT_SUMMARY_BUDGET); + let outcome = cursor_pre_compact_for_event_with_config(&event, &config).await; + if outcome.status == "error" { + eprintln!( + "tracedecay Cursor preCompact summary failed: {}", + outcome.reason + ); + } + } + println!("{}", serde_json::json!({})); + 0 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CursorPreCompactOutcome { + pub status: String, + pub reason: String, + pub summary_nodes_created: usize, + pub summary_node_ids: Vec, +} + +impl CursorPreCompactOutcome { + fn skipped(reason: impl Into) -> Self { + Self { + status: "skipped".to_string(), + reason: reason.into(), + summary_nodes_created: 0, + summary_node_ids: Vec::new(), + } + } + + fn error(reason: impl Into) -> Self { + Self { + status: "error".to_string(), + reason: reason.into(), + summary_nodes_created: 0, + summary_node_ids: Vec::new(), + } + } +} + +pub async fn cursor_pre_compact_for_event_with_config( + event_json: &str, + config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, +) -> CursorPreCompactOutcome { + match tokio::time::timeout( + CURSOR_PRE_COMPACT_BUDGET, + cursor_pre_compact_for_event_inner(event_json, config), + ) + .await + { + Ok(outcome) => outcome, + Err(_) => CursorPreCompactOutcome::error("timed out"), + } +} + +async fn cursor_pre_compact_for_event_inner( + event_json: &str, + config: &crate::sessions::cursor_agent::CursorAgentSummaryConfig, +) -> CursorPreCompactOutcome { + if std::env::var(crate::sessions::cursor_agent::CURSOR_SUMMARY_CHILD_ENV).is_ok() { + return CursorPreCompactOutcome::skipped("cursor summary child"); + } + let parsed = match serde_json::from_str::(event_json) { + Ok(parsed) => parsed, + Err(err) => return CursorPreCompactOutcome::error(format!("invalid event JSON: {err}")), + }; + let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { + return CursorPreCompactOutcome::skipped("no project root"); + }; + if !cursor_event_transcript_path_exists(&parsed) { + return CursorPreCompactOutcome::skipped("no transcript path"); + } + + let caught_up = + ingest_cursor_transcript_for_event(event_json, None, CURSOR_PRE_COMPACT_INGEST_BUDGET) + .await; + if !caught_up { + return CursorPreCompactOutcome::skipped("transcript ingest did not complete"); + } + + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return CursorPreCompactOutcome::skipped("session database unavailable"); + }; + let Some(session_id) = event_session_id(&parsed) else { + return CursorPreCompactOutcome::skipped("no session id"); + }; + + let messages_to_compact = event_usize(&parsed, &["messages_to_compact", "compact_count"]); + if messages_to_compact == Some(0) { + return CursorPreCompactOutcome::skipped("no messages to compact"); + } + let fresh_tail_count = cursor_pre_compact_fresh_tail_count(&parsed, messages_to_compact); + let current_tokens = event_i64(&parsed, &["context_tokens", "current_tokens", "tokens"]); + let context_length = event_i64(&parsed, &["context_window_size", "context_length"]); + + let first = match db + .lcm_compress(cursor_pre_compact_lcm_request( + &session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::HermesAuxiliary, + None, + )) + .await + { + Ok(response) => response, + Err(err) => return CursorPreCompactOutcome::error(format!("LCM prepare failed: {err}")), + }; + let Some(summary_request) = first.summary_request else { + return CursorPreCompactOutcome::skipped(first.reason); + }; + + let summary = match crate::sessions::cursor_agent::summarize_with_cursor_agent( + &summary_request, + config, + ) { + Ok(summary) => summary, + Err(err) => { + return CursorPreCompactOutcome::error(format!("cursor-agent summary failed: {err}")) + } + }; + + let second = match db + .lcm_compress(cursor_pre_compact_lcm_request( + &session_id, + current_tokens, + context_length, + messages_to_compact, + fresh_tail_count, + crate::sessions::lcm::LcmSummarizerMode::Provided { + summary_text: summary, + route: Some("cursor_agent".to_string()), + }, + first.frontier.current_frontier_store_id.or(Some(0)), + )) + .await + { + Ok(response) => response, + Err(err) => return CursorPreCompactOutcome::error(format!("LCM persist failed: {err}")), + }; + CursorPreCompactOutcome { + status: second.status, + reason: second.reason, + summary_nodes_created: second.summary_nodes_created, + summary_node_ids: second + .summary_nodes + .iter() + .map(|node| node.node_id.clone()) + .collect(), + } +} + /// Cursor `afterFileEdit` hook handler. /// /// Keeps the graph fresh after Cursor Agent writes files. This uses a @@ -1280,6 +1446,21 @@ pub async fn hook_codex_post_tool_use() -> i32 { 0 } +/// Codex `PostCompact` hook handler. +/// +/// Codex stores compacted context bodies encrypted in the transcript. This hook +/// uses the visible source messages already ingested into the LCM store, asks a +/// child Codex app-server turn to summarize them, and replaces the temporary +/// deterministic summary node. Fail-open: compaction must never block Codex. +pub async fn hook_codex_post_compact() -> i32 { + let event = read_hook_event!(); + if std::env::var_os(crate::sessions::codex_app_server::CODEX_SUMMARY_CHILD_ENV).is_none() { + codex_post_compact(&event).await; + } + println!("{}", serde_json::json!({})); + 0 +} + /// Builds a Codex hook stdout payload that injects model-visible context via /// `hookSpecificOutput.additionalContext`. Used by `SessionStart`, /// `UserPromptSubmit`, and `SubagentStart`. @@ -1445,6 +1626,60 @@ async fn codex_post_tool_use(event_json: &str) { } } +const CODEX_POST_COMPACT_BUDGET: Duration = Duration::from_secs(115); + +async fn codex_post_compact(event_json: &str) { + let work = async { + let Some(project_root) = codex_project_root_from_event(event_json) else { + return; + }; + if !crate::tracedecay::TraceDecay::is_initialized(&project_root) { + return; + } + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { + return; + }; + if let Some(source) = crate::sessions::codex::CodexSource::new() { + let _ = crate::sessions::source::ingest_source(&db, &source, &project_root, None).await; + } + let session_id = serde_json::from_str::(event_json) + .ok() + .and_then(|parsed| event_session_id(&parsed)); + let Ok(mut pending) = db + .pending_codex_compaction_summary_requests(session_id.as_deref(), 1) + .await + else { + return; + }; + let Some(pending) = pending.pop() else { + return; + }; + let config = crate::sessions::codex_app_server::CodexAppServerSummaryConfig::from_env(); + let summary = match crate::sessions::codex_app_server::summarize_with_codex_app_server( + &pending.request, + &config, + ) { + Ok(summary) => summary, + Err(err) => { + eprintln!("tracedecay Codex PostCompact summary failed: {err}"); + return; + } + }; + if let Err(err) = db + .replace_codex_compaction_summary( + &pending.node_id, + &summary.text, + "codex_app_server", + summary.model.as_deref().or(config.model.as_deref()), + ) + .await + { + eprintln!("tracedecay Codex PostCompact summary replacement failed: {err}"); + } + }; + let _ = tokio::time::timeout(CODEX_POST_COMPACT_BUDGET, work).await; +} + async fn reset_counter_for_codex_event(event_json: &str) { let Some(project_root) = codex_project_root_from_event(event_json) else { return; @@ -1644,33 +1879,106 @@ const CURSOR_HOT_INGEST_BUDGET: Duration = Duration::from_millis(1_500); const CURSOR_SESSION_INGEST_BUDGET: Duration = Duration::from_secs(4); /// Budget for the end-of-turn `stop` catch-up ingest (registered with a 30s timeout). const CURSOR_STOP_INGEST_BUDGET: Duration = Duration::from_secs(25); +/// Budget for the transcript catch-up portion of the `preCompact` hook. +const CURSOR_PRE_COMPACT_INGEST_BUDGET: Duration = Duration::from_secs(20); +/// Budget for the auxiliary `cursor-agent` summary call inside the hook. Kept +/// below the registered Cursor hook timeout so the child can be killed/reaped +/// by `TraceDecay` rather than by Cursor killing the hook process. +const CURSOR_PRE_COMPACT_SUMMARY_BUDGET: Duration = Duration::from_secs(85); +/// Overall budget for the `preCompact` hook (registered with a 120s timeout). +const CURSOR_PRE_COMPACT_BUDGET: Duration = Duration::from_secs(115); + +fn cursor_pre_compact_lcm_request( + session_id: &str, + current_tokens: Option, + context_length: Option, + max_source_messages: Option, + fresh_tail_count: Option, + summarizer: crate::sessions::lcm::LcmSummarizerMode, + expected_current_frontier_store_id: Option, +) -> crate::sessions::lcm::LcmCompressionRequest { + crate::sessions::lcm::LcmCompressionRequest { + provider: "cursor".to_string(), + session_id: session_id.to_string(), + messages: Vec::new(), + current_tokens, + focus_topic: Some("Cursor context compaction".to_string()), + ignore_session_patterns: Vec::new(), + stateless_session_patterns: Vec::new(), + ignore_message_patterns: Vec::new(), + expected_current_frontier_store_id, + threshold_tokens: None, + max_assembly_tokens: None, + leaf_chunk_tokens: None, + max_source_messages, + summary_fan_in: None, + incremental_max_depth: None, + fresh_tail_count, + dynamic_leaf_chunk_enabled: None, + dynamic_leaf_chunk_max: None, + context_length, + reserve_tokens_floor: None, + summarizer, + } +} + +fn cursor_pre_compact_fresh_tail_count( + parsed: &Value, + messages_to_compact: Option, +) -> Option { + let message_count = event_usize(parsed, &["message_count", "messages_count"])?; + let messages_to_compact = messages_to_compact?; + Some(message_count.saturating_sub(messages_to_compact)) +} + +fn event_i64(parsed: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = parsed.get(*key)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) + .or_else(|| value.as_str()?.parse::().ok()) + }) +} + +fn event_usize(parsed: &Value, keys: &[&str]) -> Option { + event_i64(parsed, keys).and_then(|value| usize::try_from(value).ok()) +} + +fn cursor_event_transcript_path_exists(parsed: &Value) -> bool { + parsed + .get("transcript_path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + .is_some_and(|path| Path::new(path).exists()) +} /// Incrementally ingests the Cursor transcript referenced by `event_json` into -/// the project-local session DB, bounded by `max_new_bytes` (the hot-path cap) +/// the resolved project session DB, bounded by `max_new_bytes` (the hot-path cap) /// and an overall `budget`. Always fails open: a timeout, missing transcript, or /// any error is swallowed so the calling hook never blocks the agent. async fn ingest_cursor_transcript_for_event( event_json: &str, max_new_bytes: Option, budget: Duration, -) { +) -> bool { let work = async { let Ok(parsed) = serde_json::from_str::(event_json) else { - return; + return false; }; let Some(project_root) = cursor_project_root_from_parsed_event(&parsed) else { - return; + return false; }; if let Some(cwd_root) = cursor_event_cwd(&parsed) .as_deref() .and_then(crate::config::discover_project_root) { if !paths_same(&cwd_root, &project_root) { - return; + return false; } } let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; + return false; }; let _ = crate::sessions::cursor::ingest_cursor_transcript_event_capped( event_json, @@ -1678,10 +1986,11 @@ async fn ingest_cursor_transcript_for_event( max_new_bytes, ) .await; + true }; // Short-lived CLI hook processes exit immediately, so the ingest must run // inline (not on a detached task); the timeout keeps it inside budget. - let _ = tokio::time::timeout(budget, work).await; + tokio::time::timeout(budget, work).await.unwrap_or(false) } async fn sync_for_kiro_event(event_json: &str) -> crate::errors::Result<()> { @@ -1864,16 +2173,61 @@ pub async fn hook_stop() { #[allow(clippy::unwrap_used)] mod tests { use super::*; + use crate::config::USER_DATA_DIR_ENV; + use std::sync::{Mutex, OnceLock}; + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set_path(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + fn env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } #[test] fn codex_prompt_hints_dedupe_by_session_and_category() { + let _lock = env_lock().lock().unwrap(); let project = tempfile::tempdir().unwrap(); - let data_dir = project.path().join(".tracedecay"); - std::fs::create_dir(&data_dir).unwrap(); - std::fs::write(data_dir.join("tracedecay.db"), "").unwrap(); + let profile = tempfile::tempdir().unwrap(); + let project_root = project.path().canonicalize().unwrap(); + let profile_root = profile.path().canonicalize().unwrap(); + let _profile_env = EnvGuard::set_path(USER_DATA_DIR_ENV, &profile_root); + crate::storage::write_enrollment_marker( + &project_root, + &crate::storage::EnrollmentMarker { + project_id: "proj_hook_codex_prompt".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .unwrap(); + let layout = crate::storage::resolve_layout_for_current_profile(&project_root).unwrap(); + std::fs::create_dir_all(&layout.data_root).unwrap(); let event = serde_json::json!({ "session_id": "codex-session-1", - "cwd": project.path(), + "cwd": project_root, "prompt": "Please explain the impact of changing parse_user" }) .to_string(); diff --git a/src/main.rs b/src/main.rs index ca20fe05..4818ffe8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,7 +295,7 @@ fn run_startup_preamble(command: &Commands) { if is_first_run && !skip_startup_maintenance { eprintln!( - "note: tracedecay uploads anonymous token-saved counts to a worldwide counter.\n\ + "note: tracedecay uploads anonymous token savings counts to a worldwide counter.\n\ \x20 Run `tracedecay disable-upload-counter` to opt out." ); } @@ -1012,6 +1012,12 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { process::exit(code); } } + Commands::HookCursorPreCompact => { + let code = tracedecay::hooks::hook_cursor_pre_compact().await; + if code != 0 { + process::exit(code); + } + } Commands::HookCursorAfterFileEdit => { let code = tracedecay::hooks::hook_cursor_after_file_edit().await; if code != 0 { @@ -1072,6 +1078,12 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { process::exit(code); } } + Commands::HookCodexPostCompact => { + let code = tracedecay::hooks::hook_codex_post_compact().await; + if code != 0 { + process::exit(code); + } + } Commands::Dashboard { path, host, @@ -1083,12 +1095,10 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { tracedecay::dashboard::run(&cg, &host, port, open).await?; } Commands::Serve { path, timings } => { - if matches!(std::env::var("DISABLE_TRACEDECAY").as_deref(), Ok("true")) - || matches!(std::env::var("DISABLE_TOKENSAVE").as_deref(), Ok("true")) - { + if matches!(std::env::var("DISABLE_TRACEDECAY").as_deref(), Ok("true")) { // Allow users to opt out per-project by setting - // DISABLE_TRACEDECAY=true (legacy DISABLE_TOKENSAVE still supported). - // The process exits cleanly so the host does not retry. + // DISABLE_TRACEDECAY=true. The process exits cleanly so the + // host does not retry. return Ok(()); } let original_cwd = std::env::current_dir().ok(); @@ -1534,6 +1544,7 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::HookCursorSubagentStart | Commands::HookCursorPostToolUse | Commands::HookCursorBeforeSubmitPrompt + | Commands::HookCursorPreCompact | Commands::HookCursorAfterFileEdit | Commands::HookCursorSessionStart | Commands::HookCursorSessionEnd @@ -1544,6 +1555,7 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::HookCodexUserPromptSubmit | Commands::HookCodexSubagentStart | Commands::HookCodexPostToolUse + | Commands::HookCodexPostCompact // `Serve` is the hot path used by MCP clients (Claude Code, // Codex, etc.). Clients impose a 30 s `initialize` timeout, so // every pre-serve startup task — `try_flush` network round-trip, diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4a91eb1a..78257e4b 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -52,10 +52,11 @@ const VERSION_CHECK_INTERVAL: Duration = Duration::from_mins(15); /// Mirrors `src/db/migrations.rs::create_schema`. Update both together. const SCHEMA_MARKDOWN: &str = r"# tracedecay SQLite schema -The on-disk database lives at `.tracedecay/tracedecay.db` (legacy projects -fall back to `.tokensave/tokensave.db`; per-branch variants under multi-branch -mode). All tables are plain SQLite; safe to query with any -client. WAL mode is used, so readers do not block writers. +The active project database lives in the user-level TraceDecay profile store +(`~/.tracedecay/projects//tracedecay.db` by default), scoped to the +current project. Per-branch variants live beside it under the same store. All +tables are plain SQLite; safe to query with any client. WAL mode is used, so +readers do not block writers. ## Tables @@ -1440,10 +1441,10 @@ impl McpServer { async fn read_resource_branches(&self, id: Value) -> JsonRpcResponse { let cg = self.cg_snapshot().await; - let tracedecay_dir = crate::config::get_tracedecay_dir(cg.project_root()); + let tracedecay_dir = &cg.store_layout().data_root; let current = cg.active_branch(); - let branches: Vec = match crate::branch_meta::load_branch_meta(&tracedecay_dir) { + let branches: Vec = match crate::branch_meta::load_branch_meta(tracedecay_dir) { Some(meta) => meta .branches .iter() diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index a5d7a363..75750ee4 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -258,7 +258,7 @@ fn def_retrieve() -> ToolDefinition { def( "tracedecay_retrieve", "Retrieve Truncated Response", - "Use `tracedecay_retrieve` with required argument `handle` to retrieve the exact cached original text for a local response handle emitted by a truncated MCP response. This does not re-run the source tool or read a file/session/node again; handles are project-local, expire automatically, and never reference remote storage. Only call it when the missing details are needed to answer the user's request.", + "Use `tracedecay_retrieve` with required argument `handle` to retrieve the exact cached original text for a local response handle emitted by a truncated MCP response. This does not re-run the source tool or read a file/session/node again; handles are scoped to the active project store, expire automatically, and never reference remote storage. Only call it when the missing details are needed to answer the user's request.", json!({ "type": "object", "properties": { @@ -1899,7 +1899,7 @@ fn def_message_search() -> ToolDefinition { def( "tracedecay_message_search", "Message Search", - "Search ingested Cursor/Codex/agent transcript messages stored in tracedecay's project-local session-message FTS index.", + "Search ingested Cursor/Codex/agent transcript messages stored in tracedecay's active project session-message FTS index.", json!({ "type": "object", "properties": { @@ -1980,7 +1980,7 @@ fn def_lcm_status() -> ToolDefinition { def( "tracedecay_lcm_status", "LCM Status", - "Return LCM schema, raw-message, summary, payload, and maintenance counts plus store token estimates, summary-DAG depth distribution with compression ratio, payload byte totals, and payload GC status from project-local or Hermes profile sessions.db storage.", + "Return LCM schema, raw-message, summary, payload, and maintenance counts plus store token estimates, summary-DAG depth distribution with compression ratio, payload byte totals, and payload GC status from the active project or Hermes profile session store.", json!({ "type": "object", "properties": { @@ -2066,7 +2066,7 @@ fn def_lcm_load_session() -> ToolDefinition { def( "tracedecay_lcm_load_session", "LCM Load Session", - "Load ordered lossless raw session messages with stable pagination and bounded content slices from project-local or Hermes profile LCM storage.", + "Load ordered lossless raw session messages with stable pagination and bounded content slices from the active project or Hermes profile LCM store.", json!({ "type": "object", "properties": { @@ -2132,7 +2132,7 @@ fn def_lcm_grep() -> ToolDefinition { def( "tracedecay_lcm_grep", "LCM Grep", - "Search bounded LCM raw-message snippets and optional summary text in project-local or Hermes profile sessions.db storage.", + "Search bounded LCM raw-message snippets and optional summary text in the active project or Hermes profile session store.", json!({ "type": "object", "properties": { @@ -2204,7 +2204,7 @@ fn def_lcm_describe() -> ToolDefinition { def( "tracedecay_lcm_describe", "LCM Describe", - "Describe one session's LCM raw-message and summary-DAG shape from project-local or Hermes profile storage without exposing full payload bodies.", + "Describe one session's LCM raw-message and summary-DAG shape from the active project or Hermes profile store without exposing full payload bodies.", json!({ "type": "object", "properties": { @@ -2247,7 +2247,7 @@ fn def_lcm_expand() -> ToolDefinition { def( "tracedecay_lcm_expand", "LCM Expand", - "Expand one raw message, summary node, or external payload through the bounded LCM query API from project-local or Hermes profile storage.", + "Expand one raw message, summary node, or external payload through the bounded LCM query API from the active project or Hermes profile store.", json!({ "type": "object", "properties": { @@ -2317,7 +2317,7 @@ fn def_lcm_expand_query() -> ToolDefinition { def( "tracedecay_lcm_expand_query", "LCM Expand Query", - "Assemble bounded LCM retrieval context for a prompt from project-local or Hermes profile storage; host integrations synthesize the final answer when needs_synthesis is true.", + "Assemble bounded LCM retrieval context for a prompt from the active project or Hermes profile store; host integrations synthesize the final answer when needs_synthesis is true.", json!({ "type": "object", "properties": { @@ -2378,7 +2378,7 @@ fn def_lcm_preflight() -> ToolDefinition { def_rw( "tracedecay_lcm_preflight", "LCM Preflight", - "Run compression preflight checks against project-local or Hermes profile LCM storage.", + "Run compression preflight checks against the active project or Hermes profile LCM store.", json!({ "type": "object", "properties": { @@ -2468,7 +2468,7 @@ fn def_lcm_compress() -> ToolDefinition { def_rw( "tracedecay_lcm_compress", "LCM Compress", - "Advance the LCM compression lifecycle in project-local or Hermes profile storage without invoking an auxiliary LLM.", + "Advance the LCM compression lifecycle in the active project or Hermes profile store without invoking an auxiliary LLM.", json!({ "type": "object", "properties": { @@ -2873,7 +2873,7 @@ fn def_diagnostics() -> ToolDefinition { message, driver, and the enclosing graph node when one can be \ resolved. Replaces the recurring 'run cargo → parse text → read \ file' loop with a single structured response. \ - \n\nNote: the cargo target dir is forced to .tracedecay/target/ so \ + \n\nNote: the cargo target dir is forced to /tmp/tracedecay-target//diagnostics so \ we don't race with the user's interactive cargo runs. The first \ call against a fresh tree builds dependencies from scratch, which \ can take several minutes on large workspaces; subsequent calls \ diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 6690e0ff..7ccf0cf9 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -100,7 +100,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result Result /// (signature differs). pub(super) async fn handle_branch_diff(cg: &TraceDecay, args: Value) -> Result { let project_root = cg.project_root(); - let tracedecay_dir = crate::config::get_tracedecay_dir(project_root); + let tracedecay_dir = &cg.store_layout().data_root; // Resolve base and head branches - let meta = crate::branch_meta::load_branch_meta(&tracedecay_dir).ok_or_else(|| { + let meta = crate::branch_meta::load_branch_meta(tracedecay_dir).ok_or_else(|| { TraceDecayError::Config { message: "no branch tracking configured — run `tracedecay branch add` first" .to_string(), diff --git a/src/mcp/tools/handlers/health.rs b/src/mcp/tools/handlers/health.rs index 195c49db..cda956b8 100644 --- a/src/mcp/tools/handlers/health.rs +++ b/src/mcp/tools/handlers/health.rs @@ -1107,11 +1107,11 @@ pub(super) async fn handle_session_start( "timestamp": crate::tracedecay::current_timestamp(), }); - // Write baseline to .tracedecay/session_baseline.json - let tracedecay_dir = crate::config::get_tracedecay_dir(cg.project_root()); - std::fs::create_dir_all(&tracedecay_dir).map_err(|e| { + // Write baseline to the active project store. + let tracedecay_dir = &cg.store_layout().data_root; + std::fs::create_dir_all(tracedecay_dir).map_err(|e| { crate::errors::TraceDecayError::Config { - message: format!("failed to create .tracedecay dir: {e}"), + message: format!("failed to create active store data root: {e}"), } })?; let baseline_path = tracedecay_dir.join("session_baseline.json"); @@ -1143,7 +1143,7 @@ pub(super) async fn handle_session_end( args: Value, scope_prefix: Option<&str>, ) -> Result { - let tracedecay_dir = crate::config::get_tracedecay_dir(cg.project_root()); + let tracedecay_dir = &cg.store_layout().data_root; let baseline_path = tracedecay_dir.join("session_baseline.json"); // Check if baseline exists diff --git a/src/mcp/tools/handlers/memory.rs b/src/mcp/tools/handlers/memory.rs index 9e22d30e..f734cca1 100644 --- a/src/mcp/tools/handlers/memory.rs +++ b/src/mcp/tools/handlers/memory.rs @@ -5,6 +5,9 @@ use std::path::Path; use serde_json::{json, Value}; use crate::errors::{Result, TraceDecayError}; +use crate::memory::retrieval::FactRetriever; +use crate::memory::store::MemoryStore; +use crate::memory::trust::DEFAULT_TRUST; use crate::memory::types::{ AddFactRequest, FeedbackAction, FeedbackRequest, MemoryCategory, SearchFactsRequest, UpdateFactRequest, @@ -14,6 +17,9 @@ use crate::tracedecay::TraceDecay; use super::super::ToolResult; use super::truncated_json_envelope_with_handle; +const DEFAULT_FACT_LIMIT: usize = 20; +const MAX_FACT_LIMIT: usize = 200; + fn tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { let formatted = serde_json::to_string_pretty(value).unwrap_or_default(); ToolResult { @@ -45,7 +51,9 @@ fn optional_category(args: &Value) -> Result> { fn limit(args: &Value) -> usize { args.get("limit") .and_then(Value::as_u64) - .map_or(20, |n| (n as usize).clamp(1, 200)) + .map_or(DEFAULT_FACT_LIMIT, |n| { + (n as usize).clamp(1, MAX_FACT_LIMIT) + }) } fn optional_f64(args: &Value, key: &str) -> Option { @@ -134,6 +142,14 @@ fn results_envelope(action: &str, results: &Value, count: usize) -> Value { }) } +fn fact_result_ids(results: &[crate::memory::types::FactSearchResult]) -> Vec { + results.iter().map(|result| result.fact.fact_id).collect() +} + +fn fact_ids(facts: &[crate::memory::types::FactRecord]) -> Vec { + facts.iter().map(|fact| fact.fact_id).collect() +} + fn update_rejected_secret_like(err: &TraceDecayError) -> Option { match err { TraceDecayError::Database { message, operation } @@ -145,14 +161,14 @@ fn update_rejected_secret_like(err: &TraceDecayError) -> Option { } } -async fn update_trust(args: &Value, cg: &TraceDecay, fact_id: i64) -> Result> { +async fn update_trust(args: &Value, store: &MemoryStore<'_>, fact_id: i64) -> Result> { if let Some(trust) = optional_f64(args, "trust") { return Ok(Some(trust)); } let Some(delta) = optional_f64(args, "trust_delta") else { return Ok(None); }; - let existing = cg + let existing = store .get_fact(fact_id) .await? .ok_or_else(|| config_error(format!("fact {fact_id} not found")))?; @@ -161,21 +177,27 @@ async fn update_trust(args: &Value, cg: &TraceDecay, fact_id: i64) -> Result Result { let action = required_str(&args, "action")?; + let db = cg.open_project_store_db().await?; + let conn = db.conn(); + let store = MemoryStore::new(conn); let out = match action { "add" => { - let outcome = cg - .add_fact(AddFactRequest { - content: required_str(&args, "content")?.to_string(), - category: optional_category(&args)?.unwrap_or(MemoryCategory::General), - source: args - .get("source") - .and_then(Value::as_str) - .map(ToOwned::to_owned), - tags: string_array(&args, "tags"), - entities: request_entities(&args), - trust: optional_f64(&args, "trust"), - metadata: metadata_with_tags(&args), - }) + let outcome = store + .add_fact( + AddFactRequest { + content: required_str(&args, "content")?.to_string(), + category: optional_category(&args)?.unwrap_or(MemoryCategory::General), + source: args + .get("source") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + tags: string_array(&args, "tags"), + entities: request_entities(&args), + trust: optional_f64(&args, "trust"), + metadata: metadata_with_tags(&args), + }, + DEFAULT_TRUST, + ) .await?; // Additive write-time diff report fields, so writers SEE // near-duplicates, possible conflicts, and secret rejections. @@ -191,73 +213,123 @@ pub(super) async fn handle_fact_store(cg: &TraceDecay, args: Value) -> Result { - let facts = cg - .search_facts(SearchFactsRequest { - query: required_str(&args, "query")?.to_string(), - category: optional_category(&args)?, - limit: Some(limit(&args)), - min_trust: optional_f64(&args, "min_trust"), - include_why: true, - }) + let request = SearchFactsRequest { + query: required_str(&args, "query")?.to_string(), + category: optional_category(&args)?, + limit: Some(limit(&args)), + min_trust: optional_f64(&args, "min_trust"), + include_why: true, + }; + let facts = FactRetriever::new(conn) + .search( + &request.query, + request.category, + request.min_trust, + request.limit.unwrap_or(DEFAULT_FACT_LIMIT), + ) .await?; + let ids = fact_result_ids(&facts); + store.increment_retrieval_counts(&ids).await?; let count = facts.len(); results_envelope(action, &json!(facts), count) } "probe" => { - let facts = cg - .probe_entity( + let facts = FactRetriever::new(conn) + .probe( required_str(&args, "entity")?, optional_category(&args)?, optional_f64(&args, "min_trust"), limit(&args), ) .await?; + let ids = fact_result_ids(&facts); + store.increment_retrieval_counts(&ids).await?; let count = facts.len(); results_envelope(action, &json!(facts), count) } "related" => { - let facts = cg - .related_facts( - required_str(&args, "entity")?, - optional_category(&args)?, - optional_f64(&args, "min_trust"), - limit(&args), - ) + let limit = limit(&args); + let retriever = FactRetriever::new(conn); + let related_entities = retriever + .related(required_str(&args, "entity")?, limit) .await?; + let mut seen = std::collections::HashSet::new(); + let mut facts = Vec::new(); + for related in related_entities { + for result in retriever + .probe( + &related.name, + optional_category(&args)?, + optional_f64(&args, "min_trust"), + limit.saturating_mul(2), + ) + .await? + { + if seen.insert(result.fact.fact_id) { + facts.push(result); + if facts.len() >= limit.clamp(1, MAX_FACT_LIMIT) { + break; + } + } + } + if facts.len() >= limit.clamp(1, MAX_FACT_LIMIT) { + break; + } + } + let ids = fact_result_ids(&facts); + store.increment_retrieval_counts(&ids).await?; let count = facts.len(); results_envelope(action, &json!(facts), count) } "reason" => { let entities = request_entities(&args); - let facts = cg - .reason_facts( + let facts = FactRetriever::new(conn) + .reason( &entities, optional_category(&args)?, optional_f64(&args, "min_trust"), limit(&args), ) .await?; + let ids = fact_result_ids(&facts); + store.increment_retrieval_counts(&ids).await?; let count = facts.len(); results_envelope(action, &json!(facts), count) } "contradict" => { - let facts = cg - .contradict_facts( - optional_category(&args)?, - optional_f64(&args, "threshold").unwrap_or(0.3), - limit(&args), - ) - .await?; + let threshold = optional_f64(&args, "threshold").unwrap_or(0.3); + let limit = limit(&args); + let retriever = FactRetriever::new(conn); + let facts = if let Some(category) = optional_category(&args)? { + retriever.contradict(category, threshold, limit).await? + } else { + let mut out = Vec::new(); + for category in [ + MemoryCategory::General, + MemoryCategory::UserPref, + MemoryCategory::Project, + MemoryCategory::Tool, + MemoryCategory::Decision, + MemoryCategory::CodeArea, + ] { + out.extend(retriever.contradict(category, threshold, limit).await?); + if out.len() >= limit.clamp(1, MAX_FACT_LIMIT) { + out.truncate(limit.clamp(1, MAX_FACT_LIMIT)); + break; + } + } + out + }; let count = facts.len(); results_envelope(action, &json!(facts), count) } "get" => { let id = fact_id(&args)?; - let fact = cg + let fact = store .get_fact(id) .await? .ok_or_else(|| config_error(format!("fact {id} not found")))?; - let trust_history = cg.fact_trust_history(id).await?; + let trust_history = store.fact_trust_history(id).await?; json!({ "action": action, "fact": fact, @@ -276,14 +348,14 @@ pub(super) async fn handle_fact_store(cg: &TraceDecay, args: Value) -> Result json!({ "action": action, "fact": fact, "count": 1 }), Err(err) => { if let Some(reason) = update_rejected_secret_like(&err) { @@ -302,17 +374,19 @@ pub(super) async fn handle_fact_store(cg: &TraceDecay, args: Value) -> Result { - let removed = cg.remove_fact(fact_id(&args)?).await?; + let removed = store.remove_fact(fact_id(&args)?).await?; json!({ "action": action, "removed": removed, "count": usize::from(removed) }) } "list" => { - let facts = cg + let facts = store .list_facts( optional_category(&args)?, optional_f64(&args, "min_trust"), limit(&args), ) .await?; + let ids = fact_ids(&facts); + store.increment_retrieval_counts(&ids).await?; let count = facts.len(); results_envelope(action, &json!(facts), count) } @@ -327,8 +401,9 @@ pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result .or_else(|| args.get("reason")) .and_then(Value::as_str) .map(ToOwned::to_owned); - let result = cg - .record_fact_feedback(FeedbackRequest { + let db = cg.open_project_store_db().await?; + let result = MemoryStore::new(db.conn()) + .record_feedback_event(FeedbackRequest { fact_id: fact_id(&args)?, action: feedback_action(&args)?, source: args @@ -345,7 +420,7 @@ pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result } pub(super) async fn handle_memory_status(cg: &TraceDecay) -> Result { - let status = cg.memory_status().await?; + let status = cg.project_memory_status().await?; Ok(tool_json( Some(cg.project_root()), &json!({ "status": "ok", "memory": status }), diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 869cd33d..c827a0c4 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -874,7 +874,12 @@ mod tests { #[test] fn test_truncated_json_envelope_reports_store_failure() { let dir = tempfile::TempDir::new().unwrap(); - std::fs::write(dir.path().join(".tracedecay"), "not-a-directory").unwrap(); + std::fs::create_dir_all(dir.path().join(".tracedecay")).unwrap(); + std::fs::write( + dir.path().join(".tracedecay/enrollment.json"), + r#"{"project_id":"../invalid","storage_mode":"profile_sharded"}"#, + ) + .unwrap(); let long = format!( "{{\"items\":[{}]}}", (0..3_000) diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 7b5312d1..d1bee91f 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -114,19 +114,6 @@ fn compact_lcm_preflight_payload( Value::Object(object) } -fn lcm_compress_tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { - let formatted = serde_json::to_string_pretty(value).unwrap_or_default(); - let text = if formatted.len() <= MAX_RESPONSE_CHARS { - formatted - } else { - truncated_json_envelope_with_handle(project_root, &formatted) - }; - ToolResult { - value: json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: Vec::new(), - } -} - fn compact_messages_for_mcp( value: Option<&Value>, limit: usize, @@ -910,7 +897,7 @@ fn lcm_unavailable() -> ToolResult { None, &json!({ "status": "unavailable", - "message": "could not open project-local tracedecay session database", + "message": "could not open active project tracedecay session database", }), ) } @@ -947,7 +934,7 @@ fn lcm_storage_scope_unavailable(storage_scope: &str) -> ToolResult { lcm_scoped_unavailable( storage_scope, format!( - "{storage_scope} LCM status storage is not available from the project-local handler" + "{storage_scope} LCM status storage is not available from the active project handler" ), ) } @@ -1099,7 +1086,11 @@ async fn open_lcm_storage( let Some(project_root) = project_root else { return LcmStorageResolution::Unavailable(project_local_storage_without_project()); }; - let db_path = crate::sessions::cursor::project_session_db_path(project_root); + let Some(db_path) = + crate::sessions::cursor::resolved_project_session_db_path(project_root).await + else { + return LcmStorageResolution::Unavailable(project_local_storage_without_project()); + }; let db = match mode { LcmOpenMode::Writable => open_session_db_with_cached_ensure(&db_path).await, LcmOpenMode::ReadOnlyExisting => GlobalDb::open_read_only_at(&db_path).await, @@ -1328,13 +1319,25 @@ pub(super) async fn handle_message_search(cg: &TraceDecay, args: Value) -> Resul .unwrap_or(10) .clamp(1, 50) as usize; - let db_path = crate::sessions::cursor::project_session_db_path(cg.project_root()); + let Some(db_path) = + crate::sessions::cursor::resolved_project_session_db_path(cg.project_root()).await + else { + return Ok(tool_json( + Some(cg.project_root()), + &json!({ + "status": "unavailable", + "message": "could not resolve active project tracedecay session database", + "results": [], + "count": 0 + }), + )); + }; let Some(db) = open_session_db_with_cached_ensure(&db_path).await else { return Ok(tool_json( Some(cg.project_root()), &json!({ "status": "unavailable", - "message": "could not open project-local tracedecay session database", + "message": "could not open active project tracedecay session database", "results": [], "count": 0 }), @@ -1807,7 +1810,7 @@ pub(super) async fn handle_lcm_compress( }) .await .map_err(lcm_error)?; - Ok(lcm_compress_tool_json( + Ok(tool_json( response_handle_root.as_deref(), &json!({ "status": response.status, diff --git a/src/memory/entities.rs b/src/memory/entities.rs index 1186ec76..9ec03c4c 100644 --- a/src/memory/entities.rs +++ b/src/memory/entities.rs @@ -437,8 +437,7 @@ fn clean_code_token(token: &str) -> String { .to_string(); let normalized_tool = cleaned.replace('-', "_").to_ascii_lowercase(); - // Accept both tracedecay_ (new) and tokensave_ (legacy) tool name prefixes. - if normalized_tool.starts_with("tracedecay_") || normalized_tool.starts_with("tokensave_") { + if normalized_tool.starts_with("tracedecay_") { normalized_tool.trim_end_matches('.').to_string() } else { cleaned.trim_end_matches('.').to_string() @@ -462,13 +461,11 @@ fn is_rust_symbol(token: &str) -> bool { .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | ':')) } -/// Returns true for both `tracedecay_*` (new) and `tokensave_*` (legacy) -/// MCP tool names found in stored session messages. -/// -/// LEGACY-COMPAT: tokensave_ prefix accepted alongside tracedecay_. +/// Returns true for `tracedecay_*` MCP tool names found in stored session +/// messages. fn is_tracedecay_tool(token: &str) -> bool { let normalized = token.replace('-', "_").to_ascii_lowercase(); - (normalized.starts_with("tracedecay_") || normalized.starts_with("tokensave_")) + normalized.starts_with("tracedecay_") && token .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') diff --git a/src/memory/hygiene.rs b/src/memory/hygiene.rs index 24df5a91..b8c0b218 100644 --- a/src/memory/hygiene.rs +++ b/src/memory/hygiene.rs @@ -11,10 +11,19 @@ use std::sync::OnceLock; use regex::Regex; +fn compile_patterns(patterns: &[(&'static str, &'static str)]) -> Vec<(Regex, &'static str)> { + patterns + .iter() + // Patterns are compile-time literals; a failed compile would only + // drop that rule (and is covered by the unit tests). + .filter_map(|(pattern, reason)| Regex::new(pattern).ok().map(|regex| (regex, *reason))) + .collect() +} + fn regex_set() -> &'static Vec<(Regex, &'static str)> { static PATTERNS: OnceLock> = OnceLock::new(); PATTERNS.get_or_init(|| { - [ + compile_patterns(&[ ( // PEM-encoded private key blocks. r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----", @@ -39,12 +48,7 @@ fn regex_set() -> &'static Vec<(Regex, &'static str)> { r#"(?i)\b(api[_-]?key|secret|token|passwd|password|credential|private[_-]?key|access[_-]?key)\b\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}"#, "credential-like key=value assignment", ), - ] - .into_iter() - // Patterns are compile-time literals; a failed compile would only - // drop that rule (and is covered by the unit tests). - .filter_map(|(pattern, reason)| Regex::new(pattern).ok().map(|regex| (regex, reason))) - .collect() + ]) }) } @@ -115,7 +119,7 @@ pub fn detect_secret_like(content: &str) -> Option { fn transient_regexes() -> &'static Vec<(Regex, &'static str)> { static PATTERNS: OnceLock> = OnceLock::new(); PATTERNS.get_or_init(|| { - [ + compile_patterns(&[ ( r"(?i)\b(localhost|127\.0\.0\.1|0\.0\.0\.0):\d{2,5}\b", "ephemeral local port", @@ -126,12 +130,7 @@ fn transient_regexes() -> &'static Vec<(Regex, &'static str)> { r"(?i)\b(listening on|started in \d+\s*ms|exit code \d+|finished in \d+(\.\d+)?s)\b", "run-log output", ), - ] - .into_iter() - // Patterns are compile-time literals; a failed compile would only - // drop that rule (and is covered by the unit tests). - .filter_map(|(pattern, reason)| Regex::new(pattern).ok().map(|regex| (regex, reason))) - .collect() + ]) }) } diff --git a/src/migrate/inventory.rs b/src/migrate/inventory.rs index a2b272bd..0d54776e 100644 --- a/src/migrate/inventory.rs +++ b/src/migrate/inventory.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use libsql::{Builder, OpenFlags}; use serde::{Deserialize, Serialize}; -use crate::config::{self, db_filename, LEGACY_TOKENSAVE_DIR, TRACEDECAY_DIR}; +use crate::config::{self, db_filename, TRACEDECAY_DIR}; use crate::errors::Result; use crate::global_db; use crate::storage::{BRANCH_META_FILENAME, SESSIONS_DB_FILENAME, STORE_MANIFEST_FILENAME}; @@ -28,7 +28,6 @@ pub struct MigrationInventory { #[serde(rename_all = "snake_case")] pub enum StoreBrand { TraceDecay, - LegacyTokensave, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -158,16 +157,6 @@ pub async fn build_inventory(options: MigrationInventoryOptions) -> Result GlobalDbInvent exists, path_overridden, accounting_mode: global_db::global_accounting_mode().as_str().to_string(), - legacy_home_fallback: config::user_data_dir() - .and_then(|dir| dir.file_name().map(|name| name == LEGACY_TOKENSAVE_DIR)) - .unwrap_or(false), + legacy_home_fallback: false, project_count, session_count, lcm_raw_message_count, @@ -790,9 +743,6 @@ fn read_hermes_project_pin(config_path: &Path) -> Option { let lines = config.lines().collect::>(); let (plugins_start, plugins_end) = find_top_level_section(&lines, "plugins")?; read_project_pin_from_plugin_block(&lines, plugins_start, plugins_end, "tracedecay") - .or_else(|| { - read_project_pin_from_plugin_block(&lines, plugins_start, plugins_end, "tokensave") - }) .map(PathBuf::from) } diff --git a/src/monitor.rs b/src/monitor.rs index 2c023e05..82999c13 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,8 +1,8 @@ //! Global memory-mapped ring buffer for live token-savings monitoring. //! //! The mmap lives at `monitor.mmap` inside the user-level data dir -//! (`~/.tracedecay/`, or a legacy `~/.tokensave/` when present) so a single -//! TUI can show activity from every project on the machine. Multiple MCP +//! (`~/.tracedecay/` by default) so a single TUI can show activity from every +//! project on the machine. Multiple MCP //! server instances (one per project) write concurrently using file locking. //! //! Entry format is generic: each entry carries a **prefix** (tool suite @@ -37,8 +37,7 @@ const EOFF_TIMESTAMP: usize = 112; const MMAP_FILENAME: &str = "monitor.mmap"; const LOCK_FILENAME: &str = "monitor.lock"; -/// Resolve the user-level data directory (`~/.tracedecay/`, falling back to -/// a legacy `~/.tokensave/` when present). +/// Resolve the user-level data directory (`~/.tracedecay/` by default). fn global_tracedecay_dir() -> Option { crate::config::user_data_dir() } diff --git a/src/sessions/codex.rs b/src/sessions/codex.rs index c5239aa5..52c44a81 100644 --- a/src/sessions/codex.rs +++ b/src/sessions/codex.rs @@ -16,6 +16,10 @@ //! * `event_msg` with `payload.type == "token_count"` — per-API-call usage; a //! turn's tool loop emits one per call, so a turn's true cost is the *sum* //! (see [`CodexTurnUsage`]). +//! * `compacted` — Codex context-compression boundary. The rollout stores the +//! replacement history and an encrypted compaction body, so `TraceDecay` records +//! the boundary/provenance as a summary record without claiming plaintext +//! access to Codex's private summary. //! * subagent rollouts — separate `rollout-*.jsonl` files whose leading //! `session_meta` has `thread_source == "subagent"` and parent ids in //! `forked_from_id` / `source.subagent.thread_spawn.parent_thread_id`. @@ -118,6 +122,7 @@ impl TranscriptSource for CodexSource { // Real session_meta lines carry no model; track the active model from // `turn_context` lines instead (it can change mid-session). let mut current_model = meta.model.clone(); + let mut compaction_depth = prior_compaction_depth(path, prev.position); for line in &new.lines { if turn_usage.observe(&line.value) { continue; @@ -126,6 +131,19 @@ impl TranscriptSource for CodexSource { current_model = Some(model); continue; } + if let Some(message) = compacted_summary_from_line( + &line.value, + &meta, + current_model.as_deref(), + path, + line.offset, + compaction_depth + 1, + ) { + flush_turn_usage(&mut messages, &mut turn_usage); + compaction_depth += 1; + messages.push(message); + continue; + } if let Some(message) = message_from_line( &line.value, &meta, @@ -245,6 +263,41 @@ fn string_field(payload: &Value, key: &str) -> Option { .map(str::to_string) } +fn prior_compaction_depth(path: &Path, before_offset: u64) -> i64 { + if before_offset == 0 { + return 0; + } + use std::io::BufRead; + let Ok(file) = std::fs::File::open(path) else { + return 0; + }; + let mut reader = std::io::BufReader::new(file); + let mut line = String::new(); + let mut offset = 0_u64; + let mut depth = 0_i64; + loop { + line.clear(); + let Ok(n) = reader.read_line(&mut line) else { + break; + }; + if n == 0 || offset >= before_offset { + break; + } + let line_offset = offset; + offset = offset.saturating_add(n as u64); + if line_offset >= before_offset { + break; + } + let Ok(value) = serde_json::from_str::(line.trim()) else { + continue; + }; + if value.get("type").and_then(Value::as_str) == Some("compacted") { + depth += 1; + } + } + depth +} + fn nested_string_field(payload: &Value, pointer: &str) -> Option { payload .pointer(pointer) @@ -337,6 +390,107 @@ fn message_from_line( }) } +fn compacted_summary_from_line( + record: &Value, + meta: &CodexMeta, + model: Option<&str>, + path: &Path, + offset: i64, + depth: i64, +) -> Option { + if record.get("type").and_then(Value::as_str) != Some("compacted") { + return None; + } + let payload = record.get("payload")?; + let replacement_history_count = payload + .get("replacement_history") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let compaction = payload + .get("replacement_history") + .and_then(Value::as_array) + .and_then(|history| { + history + .iter() + .rev() + .find(|entry| entry.get("type").and_then(Value::as_str) == Some("compaction")) + }); + let plaintext = payload + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()); + let encrypted = compaction + .and_then(|entry| entry.get("encrypted_content")) + .and_then(Value::as_str) + .is_some_and(|content| !content.is_empty()); + let summary_body = if plaintext.is_some() { + "plaintext" + } else if encrypted { + "encrypted" + } else { + "unavailable" + }; + let timestamp_text = record + .get("timestamp") + .and_then(Value::as_str) + .unwrap_or("unknown time"); + let text = plaintext.map_or_else( + || { + format!( + "Codex context compaction at {timestamp_text}. Summary body is {summary_body} in the rollout; replacement history entries: {replacement_history_count}." + ) + }, + str::to_string, + ); + + let timestamp = record + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_timestamp) + .map(|secs| secs as i64); + + let mut metadata = serde_json::Map::new(); + metadata.insert( + "source".to_string(), + Value::String("codex_context_compacted".to_string()), + ); + metadata.insert( + "source_event".to_string(), + Value::String("compacted".to_string()), + ); + metadata.insert( + "summary_body".to_string(), + Value::String(summary_body.to_string()), + ); + metadata.insert( + "replacement_history_count".to_string(), + Value::from(replacement_history_count as i64), + ); + metadata.insert( + "codex_compaction_depth".to_string(), + Value::from(depth.max(1)), + ); + metadata.insert("source_offset".to_string(), Value::from(offset)); + metadata.insert("encrypted".to_string(), Value::from(encrypted)); + + Some(SessionMessageRecord { + provider: PROVIDER.to_string(), + message_id: format!("{}:{offset}", meta.session_id), + session_id: meta.session_id.clone(), + role: "assistant".to_string(), + timestamp, + ordinal: offset, + text, + kind: Some("summary".to_string()), + model: model.map(str::to_string), + tool_names: None, + source_path: Some(path.to_string_lossy().to_string()), + source_offset: Some(offset), + metadata_json: serde_json::to_string(&Value::Object(metadata)).ok(), + }) +} + fn message_metadata(payload: &Value) -> Value { let mut metadata = serde_json::Map::new(); metadata.insert( @@ -367,6 +521,7 @@ pub(crate) struct CodexTurnUsage { input: i64, output: i64, cache_read: i64, + reasoning: i64, total: i64, seen: bool, last_cumulative: Option, @@ -399,30 +554,50 @@ impl CodexTurnUsage { if cumulative.is_some() { self.last_cumulative = cumulative; } - let Some(last) = info.get("last_token_usage") else { - return true; - }; - let (Some(input), Some(output)) = ( - last.get("input_tokens").and_then(Value::as_i64), - last.get("output_tokens").and_then(Value::as_i64), - ) else { + let Some(last) = info + .get("last_token_usage") + .or_else(|| info.get("total_token_usage")) + else { return true; }; + let input = last + .get("input_tokens") + .and_then(Value::as_i64) + .unwrap_or(0); + let output = last + .get("output_tokens") + .or_else(|| last.get("completion_tokens")) + .and_then(Value::as_i64) + .unwrap_or(0); let cached = last .get("cached_input_tokens") + .or_else(|| last.get("cache_read_input_tokens")) + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0); + let reasoning = last + .get("reasoning_output_tokens") + .or_else(|| last.get("reasoning_tokens")) .and_then(Value::as_i64) .unwrap_or(0) .max(0); + let total = last + .get("total_tokens") + .and_then(Value::as_i64) + .or(cumulative) + .unwrap_or_else(|| input.saturating_add(output).saturating_add(reasoning)); + if input == 0 && output == 0 && cached == 0 && reasoning == 0 && total == 0 { + return true; + } self.input = self .input .saturating_add((input.saturating_sub(cached)).max(0)); self.cache_read = self.cache_read.saturating_add(cached); - self.output = self.output.saturating_add(output.max(0)); - self.total = self.total.saturating_add( - last.get("total_tokens") - .and_then(Value::as_i64) - .unwrap_or_else(|| input.saturating_add(output)), - ); + self.reasoning = self.reasoning.saturating_add(reasoning); + self.output = self + .output + .saturating_add(output.max(0).saturating_add(reasoning)); + self.total = self.total.saturating_add(total.max(0)); self.seen = true; true } @@ -442,12 +617,16 @@ impl CodexTurnUsage { Value::from(self.cache_read), ); } + if self.reasoning > 0 { + usage.insert("reasoning_tokens".to_string(), Value::from(self.reasoning)); + } if self.total > 0 { usage.insert("total_tokens".to_string(), Value::from(self.total)); } self.input = 0; self.output = 0; self.cache_read = 0; + self.reasoning = 0; self.total = 0; self.seen = false; Some(Value::Object(usage)) diff --git a/src/sessions/codex_app_server.rs b/src/sessions/codex_app_server.rs new file mode 100644 index 00000000..2cc9e654 --- /dev/null +++ b/src/sessions/codex_app_server.rs @@ -0,0 +1,467 @@ +//! Codex app-server adapter used to generate auxiliary compaction summaries. + +use std::fmt::Write as _; +use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::errors::{Result, TraceDecayError}; +use crate::sessions::lcm::LcmSummaryRequest; + +pub const CODEX_SUMMARY_CHILD_ENV: &str = "TRACEDECAY_CODEX_SUMMARY_CHILD"; + +#[derive(Debug, Clone)] +pub struct CodexAppServerSummaryConfig { + pub codex_bin: String, + pub model: Option, + pub timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppServerSummary { + pub text: String, + pub model: Option, +} + +impl Default for CodexAppServerSummaryConfig { + fn default() -> Self { + Self { + codex_bin: "codex".to_string(), + model: None, + timeout: Duration::from_secs(90), + } + } +} + +impl CodexAppServerSummaryConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Some(bin) = non_empty_env("TRACEDECAY_CODEX_BIN") { + config.codex_bin = bin; + } + if let Some(model) = non_empty_env("TRACEDECAY_CODEX_SUMMARY_MODEL") { + config.model = Some(model); + } + if let Some(secs) = non_empty_env("TRACEDECAY_CODEX_SUMMARY_TIMEOUT_SECS") + .and_then(|secs| secs.parse::().ok()) + { + config.timeout = Duration::from_secs(secs.clamp(5, 300)); + } + config + } +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn configured_model(config: &CodexAppServerSummaryConfig) -> Option<&str> { + config.model.as_deref().filter(|model| !model.is_empty()) +} + +pub fn summarize_with_codex_app_server( + request: &LcmSummaryRequest, + config: &CodexAppServerSummaryConfig, +) -> Result { + let prompt = build_codex_summary_prompt(request); + let model = configured_model(config); + let child = Command::new(&config.codex_bin) + .arg("app-server") + .env(CODEX_SUMMARY_CHILD_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|err| TraceDecayError::Config { + message: format!("failed to start `{}` app-server: {err}", config.codex_bin), + })?; + let mut child = ChildGuard { child }; + + let stdout = child + .child + .stdout + .take() + .ok_or_else(|| TraceDecayError::Config { + message: "codex app-server stdout was not available".to_string(), + })?; + let (line_tx, line_rx) = mpsc::channel::>(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if line_tx.send(line).is_err() { + break; + } + } + }); + + let mut stdin = child + .child + .stdin + .take() + .ok_or_else(|| TraceDecayError::Config { + message: "codex app-server stdin was not available".to_string(), + })?; + let deadline = Instant::now() + config.timeout; + send_json( + &mut stdin, + &json!({ + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "tracedecay_codex_summary", + "title": "TraceDecay Codex Summary", + "version": env!("CARGO_PKG_VERSION") + } + } + }), + )?; + wait_for_response(&line_rx, deadline, 0)?; + send_json(&mut stdin, &json!({"method": "initialized", "params": {}}))?; + + let mut thread_params = json!({}); + if let Some(model) = model { + thread_params["model"] = json!(model); + } + send_json( + &mut stdin, + &json!({"method": "thread/start", "id": 1, "params": thread_params}), + )?; + let thread_response = wait_for_response(&line_rx, deadline, 1)?; + let thread_model = find_model_id(&thread_response); + let thread_id = thread_response + .pointer("/result/thread/id") + .or_else(|| thread_response.pointer("/result/id")) + .and_then(Value::as_str) + .ok_or_else(|| TraceDecayError::Config { + message: format!( + "codex app-server thread/start response lacked a thread id: {thread_response}" + ), + })? + .to_string(); + + let mut turn_params = json!({ + "threadId": thread_id, + "input": [{"type": "text", "text": prompt}], + "cwd": std::env::temp_dir().to_string_lossy(), + "effort": "low", + "summary": "concise" + }); + if let Some(model) = model { + turn_params["model"] = json!(model); + } + send_json( + &mut stdin, + &json!({"method": "turn/start", "id": 2, "params": turn_params}), + )?; + + let mut summary = wait_for_turn_summary(&line_rx, deadline)?; + if summary.model.is_none() { + summary.model = thread_model; + } + let text = strip_reasoning_tags(&summary.text); + let text = text.trim(); + if text.is_empty() { + return Err(TraceDecayError::Config { + message: "codex app-server returned an empty summary".to_string(), + }); + } + summary.text = text.to_string(); + Ok(summary) +} + +struct ChildGuard { + child: Child, +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn send_json(stdin: &mut impl IoWrite, value: &Value) -> Result<()> { + writeln!(stdin, "{value}")?; + stdin.flush()?; + Ok(()) +} + +fn wait_for_response( + line_rx: &mpsc::Receiver>, + deadline: Instant, + id: i64, +) -> Result { + loop { + let line = recv_line(line_rx, deadline)?; + let value: Value = serde_json::from_str(&line)?; + if value.get("id").and_then(Value::as_i64) != Some(id) { + continue; + } + if let Some(error) = value.get("error") { + return Err(TraceDecayError::Config { + message: format!("codex app-server request {id} failed: {error}"), + }); + } + return Ok(value); + } +} + +fn wait_for_turn_summary( + line_rx: &mpsc::Receiver>, + deadline: Instant, +) -> Result { + let mut text = String::new(); + let mut model = None; + loop { + let line = recv_line(line_rx, deadline)?; + let value: Value = serde_json::from_str(&line)?; + if model.is_none() { + model = find_model_id(&value); + } + if let Some(error) = value.get("error") { + return Err(TraceDecayError::Config { + message: format!("codex app-server turn failed: {error}"), + }); + } + match value.get("method").and_then(Value::as_str) { + Some("item/agentMessage/delta") => { + if let Some(delta) = value.pointer("/params/delta").and_then(Value::as_str) { + text.push_str(delta); + } + } + Some("item/completed") if text.trim().is_empty() => { + if let Some(item_text) = collect_item_text(value.get("params")) { + text.push_str(&item_text); + } + } + Some("turn/completed") => { + return Ok(CodexAppServerSummary { text, model }); + } + _ => {} + } + } +} + +fn recv_line( + line_rx: &mpsc::Receiver>, + deadline: Instant, +) -> Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + if remaining.is_zero() { + return Err(TraceDecayError::Config { + message: "timed out waiting for codex app-server".to_string(), + }); + } + match line_rx.recv_timeout(remaining) { + Ok(Ok(line)) => Ok(line), + Ok(Err(err)) => Err(err.into()), + Err(mpsc::RecvTimeoutError::Timeout) => Err(TraceDecayError::Config { + message: "timed out waiting for codex app-server".to_string(), + }), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(TraceDecayError::Config { + message: "codex app-server closed stdout before completing".to_string(), + }), + } +} + +fn collect_item_text(value: Option<&Value>) -> Option { + match value? { + Value::String(text) => Some(text.clone()), + Value::Array(items) => { + let text = items + .iter() + .filter_map(|item| collect_item_text(Some(item))) + .collect::(); + (!text.is_empty()).then_some(text) + } + Value::Object(map) => { + for key in ["text", "message", "item", "content"] { + if let Some(text) = collect_item_text(map.get(key)) { + return Some(text); + } + } + None + } + _ => None, + } +} + +fn find_model_id(value: &Value) -> Option { + const MODEL_KEYS: [&str; 13] = [ + "model", + "model_id", + "modelId", + "model_name", + "modelName", + "model_slug", + "modelSlug", + "model_display_name", + "modelDisplayName", + "display_model", + "displayModel", + "display_model_name", + "displayModelName", + ]; + match value { + Value::Object(map) => { + for key in MODEL_KEYS { + if let Some(model) = map + .get(key) + .and_then(Value::as_str) + .filter(|model| !model.trim().is_empty()) + { + return Some(model.trim().to_string()); + } + } + map.iter() + .filter(|(key, _)| { + !matches!( + key.as_str(), + "provider" | "model_provider" | "modelProvider" | "clientInfo" + ) + }) + .find_map(|(_, child)| find_model_id(child)) + } + Value::Array(items) => items.iter().find_map(find_model_id), + _ => None, + } +} + +pub fn build_codex_summary_prompt(request: &LcmSummaryRequest) -> String { + let mut prompt = String::new(); + prompt.push_str( + "You are generating a durable TraceDecay LCM summary from Codex transcript messages.\n", + ); + prompt.push_str("Return only the summary text. Do not mention that you are summarizing. Do not inspect files or run tools.\n\n"); + prompt.push_str("Summarization goal:\n"); + prompt.push_str(&request.prompt); + prompt.push_str("\n\nSource messages:\n"); + for message in &request.source_messages { + let _ = write!( + prompt, + "\n[{} store_id={}]\n{}\n", + message.role, message.store_id, message.content + ); + } + prompt +} + +pub fn strip_reasoning_tags(text: &str) -> String { + let mut output = String::new(); + let mut rest = text; + loop { + let Some(start) = rest.find("") else { + output.push_str(rest); + break; + }; + output.push_str(&rest[..start]); + let after_start = &rest[start + "".len()..]; + let Some(end) = after_start.find("") else { + break; + }; + rest = &after_start[end + "".len()..]; + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sessions::lcm::{LcmSummaryRequest, LcmSummarySourceMessage, LcmSummarySourceRange}; + use serde_json::json; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + #[test] + fn prompt_contains_source_messages_and_no_tool_instruction() { + let request = LcmSummaryRequest { + provider: "codex".to_string(), + session_id: "s1".to_string(), + focus_topic: None, + prompt: "Summarize durable facts.".to_string(), + source_range: LcmSummarySourceRange { + from_store_id: 1, + to_store_id: 2, + }, + source_messages: vec![ + LcmSummarySourceMessage { + store_id: 1, + role: "user".to_string(), + content: "Need release automation.".to_string(), + }, + LcmSummarySourceMessage { + store_id: 2, + role: "assistant".to_string(), + content: "Added release-plz.".to_string(), + }, + ], + extraction_request: None, + }; + + let prompt = build_codex_summary_prompt(&request); + assert!(prompt.contains("Do not inspect files or run tools")); + assert!(prompt.contains("[user store_id=1]")); + assert!(prompt.contains("Need release automation.")); + assert!(prompt.contains("[assistant store_id=2]")); + assert!(prompt.contains("Added release-plz.")); + } + + #[test] + fn strip_reasoning_tags_removes_internal_text() { + assert_eq!( + strip_reasoning_tags("before hidden after").trim(), + "before after" + ); + } + + #[test] + fn completed_item_text_descends_through_params_item_content() { + let event = json!({ + "params": { + "item": { + "content": [ + {"type": "output_text", "text": "first "}, + {"type": "output_text", "text": "second"} + ] + } + } + }); + + assert_eq!( + collect_item_text(event.get("params")).as_deref(), + Some("first second") + ); + } + + #[test] + fn turn_summary_records_actual_model_from_app_server_events() { + let (tx, rx) = mpsc::channel(); + assert!(tx + .send(Ok(json!({ + "method": "item/completed", + "params": { + "model": "gpt-5.5-codex-actual", + "item": {"content": [{"text": "summary text"}]} + } + }) + .to_string())) + .is_ok()); + assert!(tx + .send(Ok(json!({"method": "turn/completed"}).to_string())) + .is_ok()); + + let summary = match wait_for_turn_summary(&rx, Instant::now() + Duration::from_secs(1)) { + Ok(summary) => summary, + Err(err) => panic!("turn summary should be returned: {err}"), + }; + assert_eq!(summary.text, "summary text"); + assert_eq!(summary.model.as_deref(), Some("gpt-5.5-codex-actual")); + } +} diff --git a/src/sessions/cursor.rs b/src/sessions/cursor.rs index c165634d..7c654394 100644 --- a/src/sessions/cursor.rs +++ b/src/sessions/cursor.rs @@ -12,9 +12,12 @@ use crate::sessions::source::{ TranscriptSource, }; use crate::sessions::SessionMessageRecord; -use crate::storage::{project_local_layout, resolve_layout_for_current_profile, StorageMode}; +use crate::storage::{ + default_profile_project_id, default_profile_root, profile_sharded_data_root, + resolve_layout_for_current_profile, SESSIONS_DB_FILENAME, +}; -const PROJECT_SESSION_DB_FILENAME: &str = "sessions.db"; +const PROJECT_SESSION_DB_FILENAME: &str = SESSIONS_DB_FILENAME; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct CursorTranscriptIngestStats { @@ -23,26 +26,36 @@ pub struct CursorTranscriptIngestStats { } pub fn project_session_db_path(project_root: &Path) -> PathBuf { + if is_hermes_profile_home(project_root) { + return hermes_profile_session_db_path(project_root); + } resolve_layout_for_current_profile(project_root).map_or_else( - |_| project_local_layout(project_root).sessions_db_path, + |_| { + let profile_root = default_profile_root() + .unwrap_or_else(|_| PathBuf::from(crate::config::TRACEDECAY_DIR)); + profile_sharded_data_root(&profile_root, &default_profile_project_id(project_root)) + .join(PROJECT_SESSION_DB_FILENAME) + }, |layout| layout.sessions_db_path, ) } pub async fn open_project_session_db(project_root: &Path) -> Option { - if is_hermes_profile_home(project_root) { - return GlobalDb::open_at(&hermes_profile_session_db_path(project_root)).await; - } - let layout = resolve_layout_for_current_profile(project_root).ok()?; - if layout.storage_mode == StorageMode::ProfileSharded || layout.data_root.is_dir() { - return GlobalDb::open_at(&layout.sessions_db_path).await; - } - let db_path = registry_profile_session_db_path(project_root).await?; + let db_path = resolved_project_session_db_path(project_root).await?; GlobalDb::open_at(&db_path).await } -fn is_hermes_profile_home(project_root: &Path) -> bool { - project_root.join("state.db").is_file() +pub async fn resolved_project_session_db_path(project_root: &Path) -> Option { + if is_hermes_profile_home(project_root) { + return Some(hermes_profile_session_db_path(project_root)); + } + if let Ok(layout) = resolve_layout_for_current_profile(project_root) { + return Some(layout.sessions_db_path); + } + if let Some(db_path) = registry_profile_session_db_path(project_root).await { + return Some(db_path); + } + None } async fn registry_profile_session_db_path(project_root: &Path) -> Option { @@ -59,21 +72,14 @@ async fn registry_profile_session_db_path(project_root: &Path) -> Option bool { + path.join("config.yaml").is_file() || path.join("state.db").is_file() +} + pub fn hermes_profile_session_db_path(hermes_home: &Path) -> PathBuf { - // Prefer .tracedecay; fall back to an existing legacy .tokensave; default - // to .tracedecay for fresh profiles. - let primary = hermes_home.join(".tracedecay"); - let base = if primary.is_dir() { - primary - } else { - let legacy = hermes_home.join(".tokensave"); - if legacy.is_dir() { - legacy - } else { - primary - } - }; - base.join(PROJECT_SESSION_DB_FILENAME) + hermes_home + .join(crate::config::TRACEDECAY_DIR) + .join(PROJECT_SESSION_DB_FILENAME) } pub fn resolve_hermes_profile_session_db_path( @@ -111,31 +117,12 @@ pub fn resolve_hermes_profile_session_db_readonly(hermes_home: &Path) -> HermesP } } -/// Resolves the brand data directory within a Hermes profile home. -/// -/// Prefers `.tracedecay` when it already exists; falls back to the legacy -/// `.tokensave` directory for existing installs (backward-compat dual-accept -/// site — see rebrand notes). New directories are always created as -/// `.tracedecay`. -/// -/// LEGACY-COMPAT: `hermes_home/.tokensave` accepted alongside `.tracedecay`. +/// Resolves the `TraceDecay` data directory within a Hermes profile home. fn resolve_hermes_profile_tracedecay_dir( hermes_home: &Path, create_missing: bool, ) -> std::result::Result { - let tracedecay_dir = hermes_home.join(".tracedecay"); - let legacy_dir = hermes_home.join(".tokensave"); - - // Pick which directory to use: prefer .tracedecay if it already exists; - // accept legacy .tokensave for existing installs; default to .tracedecay - // for new ones so create_missing writes the new name. - let brand_dir = match ( - std::fs::symlink_metadata(&tracedecay_dir), - std::fs::symlink_metadata(&legacy_dir), - ) { - (Err(e1), Ok(_)) if e1.kind() == std::io::ErrorKind::NotFound => legacy_dir.clone(), - _ => tracedecay_dir.clone(), - }; + let brand_dir = hermes_home.join(crate::config::TRACEDECAY_DIR); match std::fs::symlink_metadata(&brand_dir) { Ok(metadata) => { @@ -244,30 +231,30 @@ fn parse_cursor_jsonl( || parent_session_id.to_string(), |(session_id, _agent_id)| session_id.clone(), ); + let subagent_model = subagent.as_ref().and_then(|(_, agent_id)| { + parent_dispatch_model_for_subagent(path, parent_session_id, agent_id) + }); let mut carry = TimestampCarry::new(i64::try_from(new.new_cursor.mtime).ok()); let mut messages = Vec::new(); for line in &new.lines { let derived_timestamp = carry.observe(&line.value); + let context = CursorMessageContext { + transcript_path: path, + source_offset: line.offset, + derived_timestamp, + model_fallback: subagent_model.as_deref(), + }; // The byte offset doubles as the message ordinal and source_offset, // matching the original Cursor ingestion. - if let Some(message) = event_message( - &line.value, - event, - &session_id, - path, - line.offset, - line.offset, - derived_timestamp, - ) { + if let Some(message) = event_message(&line.value, event, &session_id, line.offset, context) + { messages.push(message); } messages.extend(event_dispatch_messages( &line.value, event, &session_id, - path, - line.offset, - derived_timestamp, + context, )); } @@ -314,7 +301,7 @@ fn parse_cursor_jsonl( /// Ingest the Cursor transcript referenced by a hook payload into the /// provider-neutral session/message tables for the provided database. Project -/// hooks should pass the project-local DB from [`open_project_session_db`]. +/// hooks should pass the resolved project DB from [`open_project_session_db`]. /// /// Ingestion is **incremental**: it resumes from the byte offset recorded in the /// DB's `parse_offsets` table (via the shared [`crate::sessions::source`] @@ -410,11 +397,6 @@ impl TranscriptSource for CursorSweepSource { } fn transcript_paths(&self, project_root: &Path) -> Vec { - // Only indexed projects keep a project-local session store; roots - // without a tracedecay data dir are skipped outright. - if !crate::config::get_tracedecay_dir(project_root).is_dir() { - return Vec::new(); - } let Some(slug) = cursor_project_slug(project_root) else { return Vec::new(); }; @@ -489,8 +471,7 @@ impl TranscriptSource for CursorSweepSource { /// Compute the `~/.cursor/projects` directory slug Cursor derives from a /// workspace path: every normal path component joined with `-`, case -/// preserved (verified against real `~/.cursor/projects` entries, e.g. -/// `/home/zack/projects/tokensave` → `home-zack-projects-tokensave`). +/// preserved (verified against real `~/.cursor/projects` entries). /// Returns `None` for non-UTF-8, relative, or traversal-containing paths. pub fn cursor_project_slug(project_root: &Path) -> Option { let mut parts = Vec::new(); @@ -624,6 +605,70 @@ fn cursor_subagent_identity(path: &Path, parent_session_id: &str) -> Option<(Str Some((session_id.clone(), session_id)) } +fn parent_dispatch_model_for_subagent( + path: &Path, + parent_session_id: &str, + agent_id: &str, +) -> Option { + let parent_dir = path.parent()?.parent()?; + let candidates = [ + parent_dir.join(format!("{parent_session_id}.jsonl")), + parent_dir.with_extension("jsonl"), + ]; + for candidate in candidates { + if let Some(model) = dispatch_model_for_agent(&candidate, agent_id) { + return Some(model); + } + } + None +} + +fn dispatch_model_for_agent(path: &Path, agent_id: &str) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + for line in contents.lines() { + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + let message = record.get("message").unwrap_or(&record); + let content = message.get("content").unwrap_or(message); + let Some(items) = content.as_array() else { + continue; + }; + for item in items { + let Some(name) = item.get("name").and_then(Value::as_str) else { + continue; + }; + if is_subagent_dispatch_tool(name) && dispatch_targets_agent(item, agent_id) { + if let Some(model) = cursor_dispatch_model(item) { + return Some(model); + } + } + } + } + None +} + +fn dispatch_targets_agent(item: &Value, agent_id: &str) -> bool { + let input = item.get("input").unwrap_or(item); + [ + "agent_id", + "agentId", + "subagent_id", + "subagentId", + "session_id", + "sessionId", + "id", + ] + .into_iter() + .any(|key| { + input + .get(key) + .or_else(|| item.get(key)) + .and_then(Value::as_str) + == Some(agent_id) + }) +} + /// Per-line timestamp derivation for Cursor transcripts, which carry no /// structured per-message timestamps. The injected `` /// tag in user prompts is parsed and carried forward across subsequent lines @@ -674,14 +719,20 @@ fn timestamp_tag_from_text(text: &str) -> Option { crate::timeutil::parse_cursor_human_timestamp(text[start..end].trim()) } +#[derive(Clone, Copy)] +struct CursorMessageContext<'a> { + transcript_path: &'a Path, + source_offset: i64, + derived_timestamp: Option, + model_fallback: Option<&'a str>, +} + fn event_message( record: &Value, event: &Value, session_id: &str, - transcript_path: &Path, ordinal: i64, - source_offset: i64, - derived_timestamp: Option, + context: CursorMessageContext<'_>, ) -> Option { let role = record .get("role") @@ -711,12 +762,9 @@ fn event_message( || format!("{session_id}:{ordinal}"), std::string::ToString::to_string, ); - let model = record - .get("model") - .or_else(|| message.get("model")) - .or_else(|| event.get("model")) - .and_then(Value::as_str) - .map(str::to_string); + let model = cursor_record_message_model(record, message) + .or_else(|| context.model_fallback.map(str::to_string)) + .or_else(|| cursor_model_string(event)); Some(SessionMessageRecord { provider: "cursor".to_string(), @@ -725,14 +773,14 @@ fn event_message( role: role.to_string(), timestamp: record_timestamp(record) .or_else(|| record_timestamp(event)) - .or(derived_timestamp), + .or(context.derived_timestamp), ordinal, text, kind: content_kind(content).map(str::to_string), model, tool_names: (!tool_names.is_empty()).then(|| tool_names.join(",")), - source_path: Some(transcript_path.to_string_lossy().to_string()), - source_offset: Some(source_offset), + source_path: Some(context.transcript_path.to_string_lossy().to_string()), + source_offset: Some(context.source_offset), metadata_json: serde_json::to_string(&message_metadata(record, message)).ok(), }) } @@ -741,9 +789,7 @@ fn event_dispatch_messages( record: &Value, event: &Value, session_id: &str, - transcript_path: &Path, - source_offset: i64, - derived_timestamp: Option, + context: CursorMessageContext<'_>, ) -> Vec { let Some(role) = record .get("role") @@ -774,7 +820,12 @@ fn event_dispatch_messages( .and_then(Value::as_str) .filter(|id| !id.is_empty()); let message_id = tool_use_id.map_or_else( - || format!("{session_id}:tool_dispatch:{source_offset}:{index}"), + || { + format!( + "{}:tool_dispatch:{}:{index}", + session_id, context.source_offset + ) + }, |id| format!("{session_id}:tool_dispatch:{id}"), ); out.push(SessionMessageRecord { @@ -784,19 +835,17 @@ fn event_dispatch_messages( role: role.to_string(), timestamp: record_timestamp(record) .or_else(|| record_timestamp(event)) - .or(derived_timestamp), - ordinal: source_offset.saturating_add(index as i64), + .or(context.derived_timestamp), + ordinal: context.source_offset.saturating_add(index as i64), text, kind: Some("tool_dispatch".to_string()), - model: record - .get("model") - .or_else(|| message.get("model")) - .or_else(|| event.get("model")) - .and_then(Value::as_str) - .map(str::to_string), + model: cursor_dispatch_model(item) + .or_else(|| cursor_record_message_model(record, message)) + .or_else(|| context.model_fallback.map(str::to_string)) + .or_else(|| cursor_model_string(event)), tool_names: Some(name.to_string()), - source_path: Some(transcript_path.to_string_lossy().to_string()), - source_offset: Some(source_offset), + source_path: Some(context.transcript_path.to_string_lossy().to_string()), + source_offset: Some(context.source_offset), metadata_json: serde_json::to_string(&serde_json::json!({ "source": "cursor_transcript", "raw_type": record.get("type").cloned(), @@ -808,6 +857,42 @@ fn event_dispatch_messages( out } +fn cursor_model_string(value: &Value) -> Option { + [ + "model", + "model_id", + "modelId", + "model_name", + "modelName", + "model_slug", + "modelSlug", + "model_display_name", + "modelDisplayName", + "display_model", + "displayModel", + "display_model_name", + "displayModelName", + ] + .into_iter() + .find_map(|key| { + value + .get(key) + .and_then(Value::as_str) + .filter(|model| !model.trim().is_empty()) + .map(str::to_string) + }) +} + +fn cursor_record_message_model(record: &Value, message: &Value) -> Option { + cursor_model_string(record).or_else(|| cursor_model_string(message)) +} + +fn cursor_dispatch_model(item: &Value) -> Option { + item.get("input") + .and_then(cursor_model_string) + .or_else(|| cursor_model_string(item)) +} + fn is_subagent_dispatch_tool(name: &str) -> bool { matches!(name.to_ascii_lowercase().as_str(), "task" | "subagent") } diff --git a/src/sessions/cursor_agent.rs b/src/sessions/cursor_agent.rs new file mode 100644 index 00000000..f0262af8 --- /dev/null +++ b/src/sessions/cursor_agent.rs @@ -0,0 +1,182 @@ +//! Cursor CLI adapter used to generate auxiliary compaction summaries. + +use std::fmt::Write as _; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime}; + +use crate::errors::{Result, TraceDecayError}; +use crate::sessions::codex_app_server::strip_reasoning_tags; +use crate::sessions::lcm::LcmSummaryRequest; + +pub const CURSOR_SUMMARY_CHILD_ENV: &str = "TRACEDECAY_CURSOR_SUMMARY_CHILD"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CursorAgentSummaryConfig { + pub cursor_agent_bin: String, + pub model: Option, + pub timeout: Duration, + pub workspace: Option, +} + +impl Default for CursorAgentSummaryConfig { + fn default() -> Self { + Self { + cursor_agent_bin: "cursor-agent".to_string(), + model: None, + timeout: Duration::from_secs(90), + workspace: None, + } + } +} + +impl CursorAgentSummaryConfig { + pub fn from_env() -> Self { + let mut config = Self::default(); + if let Some(bin) = non_empty_env("TRACEDECAY_CURSOR_AGENT_BIN") { + config.cursor_agent_bin = bin; + } + if let Some(model) = non_empty_env("TRACEDECAY_CURSOR_SUMMARY_MODEL") { + config.model = Some(model); + } + if let Some(secs) = non_empty_env("TRACEDECAY_CURSOR_SUMMARY_TIMEOUT_SECS") + .and_then(|secs| secs.parse::().ok()) + { + config.timeout = Duration::from_secs(secs.clamp(5, 300)); + } + if let Some(workspace) = non_empty_env("TRACEDECAY_CURSOR_SUMMARY_WORKSPACE") { + config.workspace = Some(PathBuf::from(workspace)); + } + config + } +} + +fn non_empty_env(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +pub fn summarize_with_cursor_agent( + request: &LcmSummaryRequest, + config: &CursorAgentSummaryConfig, +) -> Result { + let prompt = build_cursor_summary_prompt(request); + let workspace = config.workspace.clone().unwrap_or_else(std::env::temp_dir); + std::fs::create_dir_all(&workspace)?; + let prompt_path = workspace.join(cursor_summary_prompt_filename()); + std::fs::write(&prompt_path, prompt)?; + let _prompt_cleanup = FileCleanupGuard(prompt_path.clone()); + let driver_prompt = format!( + "Read the TraceDecay summary input file at {} and produce the requested durable summary. Return only the summary text. Do not inspect any other files.", + prompt_path.display() + ); + + let mut command = Command::new(&config.cursor_agent_bin); + command + .arg("-p") + .arg("--output-format") + .arg("text") + .arg("--mode") + .arg("ask") + .arg("--trust") + .arg("--sandbox") + .arg("enabled") + .arg("--workspace") + .arg(&workspace); + if let Some(model) = config.model.as_deref().filter(|model| !model.is_empty()) { + command.arg("--model").arg(model); + } + command + .arg(driver_prompt) + .env(CURSOR_SUMMARY_CHILD_ENV, "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|err| TraceDecayError::Config { + message: format!("failed to start `{}`: {err}", config.cursor_agent_bin), + })?; + let deadline = Instant::now() + config.timeout; + loop { + if child.try_wait()?.is_some() { + break; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(TraceDecayError::Config { + message: format!("timed out waiting for `{}`", config.cursor_agent_bin), + }); + } + std::thread::sleep(Duration::from_millis(50)); + } + + let output = child.wait_with_output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + return Err(TraceDecayError::Config { + message: if stderr.is_empty() { + format!( + "`{}` exited with status {}", + config.cursor_agent_bin, output.status + ) + } else { + format!( + "`{}` exited with status {}: {}", + config.cursor_agent_bin, + output.status, + stderr.chars().take(2000).collect::() + ) + }, + }); + } + + let text = String::from_utf8_lossy(&output.stdout); + let text = strip_reasoning_tags(&text); + let text = text.trim(); + if text.is_empty() { + return Err(TraceDecayError::Config { + message: "cursor-agent returned an empty summary".to_string(), + }); + } + Ok(text.to_string()) +} + +struct FileCleanupGuard(PathBuf); + +impl Drop for FileCleanupGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn cursor_summary_prompt_filename() -> String { + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!( + "tracedecay-cursor-summary-{}-{nanos}.txt", + std::process::id() + ) +} + +pub fn build_cursor_summary_prompt(request: &LcmSummaryRequest) -> String { + let mut prompt = String::new(); + prompt.push_str( + "You are generating a durable TraceDecay LCM summary from Cursor transcript messages.\n", + ); + prompt.push_str("Return only the summary text. Do not mention that you are summarizing. Do not inspect project files or run shell commands.\n\n"); + prompt.push_str("Summarization goal:\n"); + prompt.push_str(&request.prompt); + prompt.push_str("\n\nSource messages:\n"); + for message in &request.source_messages { + let _ = write!( + prompt, + "\n[{} store_id={}]\n{}\n", + message.role, message.store_id, message.content + ); + } + prompt +} diff --git a/src/sessions/hermes.rs b/src/sessions/hermes.rs index ba7ca6b2..cf924c40 100644 --- a/src/sessions/hermes.rs +++ b/src/sessions/hermes.rs @@ -6,8 +6,8 @@ //! `~/.hermes/profiles/` for named profiles. A profile maps to exactly //! one ingest target: the `plugins.tracedecay.project_root` pin in its //! `config.yaml` when set (the same pin the generated Hermes plugin resolves -//! at runtime), or — for unpinned profiles — the profile home itself, whose -//! `.tracedecay/sessions.db` is the profile-scoped store the plugin serves. +//! at runtime), or — for unpinned profiles — the profile home itself as a +//! project identity in the unified user-level `TraceDecay` store. //! //! Unlike the file-based adapters this source holds *many* sessions in one //! store, so it does not implement [`TranscriptSource`]; it drives the shared @@ -82,12 +82,9 @@ pub async fn ingest_homes( /// Locates the `state.db` of every profile that maps to `project_root`. /// /// A profile maps to a project either through its `plugins.tracedecay` -/// `project_root` pin, or — for unpinned profiles (the default since the -/// installer stopped writing storage-home pins) — through its own profile -/// home: sweeping with `project_root == ` ingests that -/// profile's history into the profile-scoped store at -/// `/.tracedecay/sessions.db`, which is exactly the store the -/// generated plugin's `hermes_profile` storage scope serves. +/// `project_root` pin, or — for unpinned profiles — through its own profile +/// home as a project identity. In both cases the active `GlobalDb` is the +/// unified user-level `TraceDecay` store resolved for that project root. /// /// Returns `(state_db_path, profile_name)`; the default profile (the home /// directory itself) has no profile name. @@ -120,10 +117,8 @@ fn pinned_state_dbs( // An explicit pin (including the legacy home-equal pin) // maps the profile to that project. Some(pin) => paths_equal(Path::new(&pin), project_root), - // Unpinned profiles map to their own home, so sweeping - // `` as the project ingests their history - // into the profile-scoped store the generated plugin's - // `hermes_profile` storage serves. + // Unpinned profiles map to their own home as the project + // identity in the unified user-level store. None => paths_equal(&profile_dir, project_root), }; if !matches { diff --git a/src/sessions/lcm/schema.rs b/src/sessions/lcm/schema.rs index 291eb518..e0822bcd 100644 --- a/src/sessions/lcm/schema.rs +++ b/src/sessions/lcm/schema.rs @@ -7,8 +7,7 @@ use super::util; pub const LCM_SCHEMA_VERSION: i64 = 5; const MIGRATION_NAME: &str = "lcm"; -const LEGACY_TRUNCATION_MARKERS: &[&str] = - &["\n[truncated by tracedecay]", "\n[truncated by tokensave]"]; +const TRUNCATION_MARKER: &str = "\n[truncated by tracedecay]"; /// Raw-message FTS structure (schema v3): index only `index_text`, matching /// hermes-lcm `build_message_fts_spec` (store.py:173-204), which indexes @@ -405,9 +404,7 @@ async fn carry_forward_legacy_messages_in_transaction(conn: &Connection) -> Opti let ordinal: i64 = row.get(5).ok()?; let content: String = row.get(6).ok()?; let metadata_json: Option = row.get(7).ok()?; - let legacy_truncated = LEGACY_TRUNCATION_MARKERS - .iter() - .any(|marker| content.contains(marker)); + let legacy_truncated = content.contains(TRUNCATION_MARKER); let content_hash = raw::sha256_hex(&content); let snippet_text = raw::derived_text_for_snippet(&content); let index_text = raw::derived_text_for_index(&content); diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index a346ff84..e28d6bc2 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -9,7 +9,9 @@ use crate::sessions::source::{ingest_source, TranscriptSource}; pub mod claude; pub mod cline_like; pub mod codex; +pub mod codex_app_server; pub mod cursor; +pub mod cursor_agent; pub mod hermes; pub mod kiro; pub mod lcm; @@ -19,7 +21,7 @@ pub(crate) mod transcript_backfill; pub mod vibe; /// Ingest transcripts from every path-discoverable agent whose sessions -/// belong to `project_root`, into the project-local `sessions.db` (`db`). +/// belong to `project_root`, into the active project session store (`db`). /// Hookless agents (Claude, Codex, ...) are reconciled exclusively by this /// startup catch-up sweep; Cursor additionally has live end-of-turn hooks, /// and its sweep entry shares the hooks' parse offsets so neither path ever diff --git a/src/sessions/shared.rs b/src/sessions/shared.rs index e510fd69..6d581d7c 100644 --- a/src/sessions/shared.rs +++ b/src/sessions/shared.rs @@ -174,9 +174,9 @@ pub(crate) fn append_tool_calls_metadata( /// Token-usage counter keys recognized by the savings dashboard /// (`dashboard/savings_api.rs` `MESSAGE_TOKENS_CTE`): both the Anthropic /// (`input_tokens`/`output_tokens`/`cache_*`) and `OpenAI` -/// (`prompt_tokens`/`completion_tokens`) shapes, plus `total_tokens` for -/// reference. -const USAGE_COUNTER_KEYS: [&str; 7] = [ +/// (`prompt_tokens`/`completion_tokens`) shapes, plus total/reasoning counters +/// for reference. +const USAGE_COUNTER_KEYS: [&str; 9] = [ "input_tokens", "output_tokens", "prompt_tokens", @@ -184,6 +184,8 @@ const USAGE_COUNTER_KEYS: [&str; 7] = [ "cache_creation_input_tokens", "cache_read_input_tokens", "total_tokens", + "reasoning_tokens", + "reasoning_output_tokens", ]; /// Extracts a `usage` counters object from a transcript record/message, @@ -198,6 +200,20 @@ pub(crate) fn usage_counters_from(value: &Value) -> Option { counters.insert(key.to_string(), Value::from(count)); } } + if !counters.contains_key("cache_read_input_tokens") { + if let Some(count) = usage.get("cached_input_tokens").and_then(Value::as_i64) { + counters.insert("cache_read_input_tokens".to_string(), Value::from(count)); + } + } + if !counters.is_empty() + && !counters.contains_key("input_tokens") + && !counters.contains_key("prompt_tokens") + && !counters.contains_key("output_tokens") + && !counters.contains_key("completion_tokens") + { + counters.insert("input_tokens".to_string(), Value::from(0)); + counters.insert("output_tokens".to_string(), Value::from(0)); + } (!counters.is_empty()).then_some(Value::Object(counters)) } @@ -290,3 +306,44 @@ pub(crate) fn title_from_messages(messages: &[SessionMessageRecord]) -> Option Option { let mut hasher = Sha256::new(); - hasher.update(b"tokensave-jsonl-file-id-v1"); + hasher.update(b"tracedecay-jsonl-file-id-v1"); #[cfg(unix)] { use std::os::unix::fs::MetadataExt; @@ -555,7 +555,7 @@ fn jsonl_head_fingerprint(path: &Path) -> Option { buf.truncate(JSONL_HEAD_FINGERPRINT_BYTES); } let mut hasher = Sha256::new(); - hasher.update(b"tokensave-jsonl-head-v1"); + hasher.update(b"tracedecay-jsonl-head-v1"); hasher.update(&buf); let digest = hasher.finalize(); let mut bytes = [0_u8; 8]; diff --git a/src/storage.rs b/src/storage.rs index fd46e8a9..fbfe7e68 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -3,6 +3,7 @@ use std::io; use std::path::{Component, Path, PathBuf}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::config::{self, TRACEDECAY_DIR}; use crate::errors::{Result, TraceDecayError}; @@ -174,26 +175,31 @@ pub fn remove_enrollment_marker(project_root: &Path, project_id: &str) -> Result Ok(true) } -pub fn project_local_layout(project_root: &Path) -> StoreLayout { - let data_root = config::get_tracedecay_dir(project_root); - StoreLayout::new( - ProjectIdentity { - project_id: None, - display_root: project_root.to_path_buf(), - primary_alias: project_root.to_path_buf(), - }, - StoreKind::CodeProject, - StorageMode::ProjectLocal, - project_root.to_path_buf(), - data_root, - None, - ) -} - pub fn profile_sharded_data_root(profile_root: &Path, project_id: &str) -> PathBuf { profile_root.join("projects").join(project_id) } +pub fn default_profile_project_id(project_root: &Path) -> String { + let canonical = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let mut hasher = Sha256::new(); + hasher.update(canonical.to_string_lossy().as_bytes()); + let digest = hex::encode(hasher.finalize()); + format!("proj_{}", &digest[..16]) +} + +pub fn default_profile_sharded_layout( + project_root: &Path, + profile_root: &Path, +) -> Result { + let marker = EnrollmentMarker { + project_id: default_profile_project_id(project_root), + storage_mode: StorageMode::ProfileSharded, + }; + profile_sharded_layout(project_root, profile_root, &marker) +} + pub fn profile_sharded_layout( project_root: &Path, profile_root: &Path, @@ -234,7 +240,15 @@ pub fn resolve_layout(project_root: &Path, profile_root: &Path) -> Result { profile_sharded_layout(project_root, profile_root, &marker) } - Some(_) | None => Ok(project_local_layout(project_root)), + Some(marker) => Err(TraceDecayError::Config { + message: format!( + "unsupported storage_mode={:?} in enrollment marker for '{}'; \ + run TraceDecay migration to move this project into the user profile store", + marker.storage_mode, + project_root.display() + ), + }), + None => default_profile_sharded_layout(project_root, profile_root), } } @@ -250,7 +264,18 @@ pub fn resolve_layout_for_current_profile(project_root: &Path) -> Result Ok(project_local_layout(project_root)), + Some(marker) => Err(TraceDecayError::Config { + message: format!( + "unsupported storage_mode={:?} in enrollment marker for '{}'; \ + run TraceDecay migration to move this project into the user profile store", + marker.storage_mode, + project_root.display() + ), + }), + None => { + let profile_root = default_profile_root()?; + default_profile_sharded_layout(project_root, &profile_root) + } } } diff --git a/src/tracedecay.rs b/src/tracedecay.rs index 253eceb3..ed509ca6 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -17,6 +17,7 @@ use crate::context::ContextBuilder; use crate::db::Database; use crate::errors::{Result, TraceDecayError}; use crate::extraction::LanguageRegistry; +use crate::global_db::{GlobalDb, GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; use crate::graph::{GraphQueryManager, GraphTraverser}; use crate::memory::encoding::HolographicEncoder; use crate::memory::retrieval::FactRetriever; @@ -320,8 +321,8 @@ pub fn current_timestamp() -> i64 { impl TraceDecay { /// Initializes a new `TraceDecay` project at the given root. /// - /// Creates the `.tracedecay` directory, writes a default configuration, - /// and initializes a fresh `SQLite` database. + /// Writes a default configuration to the resolved project store and + /// initializes a fresh `SQLite` database. pub async fn init(project_root: &Path) -> Result { let store_layout = storage::resolve_layout_for_current_profile(project_root)?; let config = TraceDecayConfig { @@ -344,7 +345,7 @@ impl TraceDecay { let _ = branch_meta::save_branch_meta(&store_layout.data_root, &meta); } - Ok(Self { + let ts = Self { db, config, project_root: project_root.to_path_buf(), @@ -353,7 +354,9 @@ impl TraceDecay { active_branch, serving_branch: None, fallback_warning: None, - }) + }; + ts.register_project_store_in_global_registry().await; + Ok(ts) } /// Returns a reference to the underlying database. @@ -424,6 +427,7 @@ impl TraceDecay { }) .await?; eprintln!("[tracedecay] re-index complete."); + ts.register_project_store_in_global_registry().await; return Ok(ts); } Err(e) => return Err(e), @@ -454,6 +458,7 @@ impl TraceDecay { }) .await?; eprintln!("[tracedecay] re-index complete."); + ts.register_project_store_in_global_registry().await; return Ok(ts); } // DB is fine — clean up the stale sentinel. @@ -480,6 +485,7 @@ impl TraceDecay { eprintln!("[tracedecay] re-index complete."); } + ts.register_project_store_in_global_registry().await; Ok(ts) } @@ -643,10 +649,143 @@ impl TraceDecay { Some(meta.branches.keys().cloned().collect()) } + async fn register_project_store_in_global_registry(&self) { + if self.store_layout.storage_mode != storage::StorageMode::ProfileSharded { + return; + } + + let Some(project_id) = self.store_layout.identity.project_id.as_deref() else { + return; + }; + let Some(profile_root) = profile_root_for_layout(&self.store_layout) else { + return; + }; + let Some(store_relpath) = profile_relative(&profile_root, &self.store_layout.data_root) + else { + return; + }; + let Some(global_db) = GlobalDb::open().await else { + return; + }; + + let meta = branch_meta::load_branch_meta(&self.store_layout.data_root); + let default_branch = meta.as_ref().map(|meta| meta.default_branch.as_str()); + let git_common_dir = git_common_dir(&self.project_root); + let git_remote_url = git_remote_url(&self.project_root); + let Some(project) = global_db + .upsert_code_project( + project_id, + &self.project_root, + git_common_dir.as_deref(), + git_remote_url.as_deref(), + default_branch, + ) + .await + else { + return; + }; + + let store_id = profile_store_id(&project.project_id); + let manifest_relpath = self + .store_layout + .manifest_path + .as_ref() + .and_then(|path| profile_relative(&profile_root, path)); + let now = current_timestamp(); + let Some(store) = global_db + .upsert_store_instance(StoreInstanceUpsert { + store_id, + project_id: project.project_id, + store_kind: "code_project".to_string(), + storage_mode: "profile_sharded".to_string(), + store_relpath, + manifest_relpath, + last_verified_at: Some(now), + last_write_at: Some(now), + }) + .await + else { + return; + }; + + if let Some(meta) = meta { + for (branch_name, entry) in meta.branches { + let db_path = self.store_layout.data_root.join(&entry.db_file); + let Some(db_relpath) = profile_relative(&profile_root, &db_path) else { + continue; + }; + let _ = global_db + .upsert_graph_scope(GraphScopeUpsert { + graph_scope_id: profile_graph_scope_id(&store.store_id, &branch_name), + project_id: store.project_id.clone(), + store_id: store.store_id.clone(), + branch_name: branch_name.clone(), + db_relpath, + parent_scope_id: entry + .parent + .as_deref() + .map(|parent| profile_graph_scope_id(&store.store_id, parent)), + last_synced_at: entry.last_synced_at.parse::().ok(), + writable: true, + }) + .await; + } + } + + let mut artifacts = Vec::new(); + push_existing_store_artifact( + &mut artifacts, + &store.store_id, + "graph_db", + &profile_root, + &self.store_layout.graph_db_path, + None, + now, + ); + push_existing_store_artifact( + &mut artifacts, + &store.store_id, + "sessions_db", + &profile_root, + &self.store_layout.sessions_db_path, + None, + now, + ); + push_existing_store_artifact( + &mut artifacts, + &store.store_id, + "branch_meta", + &profile_root, + &self.store_layout.branch_meta_path, + None, + now, + ); + if let Some(manifest_path) = &self.store_layout.manifest_path { + push_existing_store_artifact( + &mut artifacts, + &store.store_id, + "store_manifest", + &profile_root, + manifest_path, + Some(storage::STORE_MANIFEST_SCHEMA_VERSION.to_string()), + now, + ); + } + for artifact in artifacts { + let _ = global_db.upsert_store_artifact(artifact).await; + } + } + /// Returns `true` if a `TraceDecay` project has been initialized at the given root. pub fn is_initialized(project_root: &Path) -> bool { crate::config::has_project_database(project_root) || crate::storage::has_enrollment_marker(project_root) + || crate::storage::resolve_layout_for_current_profile(project_root).is_ok_and( + |layout| { + layout.storage_mode == crate::storage::StorageMode::ProfileSharded + && layout.graph_db_path.exists() + }, + ) } } @@ -683,6 +822,78 @@ fn has_dirty_sentinel_at(path: &Path) -> bool { path.exists() } +fn profile_relative(profile_root: &Path, path: &Path) -> Option { + path.strip_prefix(profile_root) + .ok() + .map(|rel| rel.to_string_lossy().to_string()) +} + +fn profile_root_for_layout(layout: &StoreLayout) -> Option { + layout.data_root.parent()?.parent().map(Path::to_path_buf) +} + +fn profile_store_id(project_id: &str) -> String { + format!("store:{project_id}:profile_sharded") +} + +fn git_common_dir(project_root: &Path) -> Option { + git_output(project_root, &["rev-parse", "--git-common-dir"]).map(|path| { + let common_dir = PathBuf::from(path); + if common_dir.is_absolute() { + common_dir + } else { + project_root.join(common_dir) + } + }) +} + +fn git_remote_url(project_root: &Path) -> Option { + git_output(project_root, &["config", "--get", "remote.origin.url"]) +} + +fn git_output(project_root: &Path, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .args(args) + .current_dir(project_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8(output.stdout).ok()?; + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +fn profile_graph_scope_id(store_id: &str, branch_name: &str) -> String { + format!("{store_id}:branch:{branch_name}") +} + +fn push_existing_store_artifact( + artifacts: &mut Vec, + store_id: &str, + artifact_kind: &str, + profile_root: &Path, + path: &Path, + schema_version: Option, + updated_at: i64, +) { + let Some(relpath) = profile_relative(profile_root, path) else { + return; + }; + let Ok(metadata) = std::fs::metadata(path) else { + return; + }; + artifacts.push(StoreArtifactUpsert { + store_id: store_id.to_string(), + artifact_kind: artifact_kind.to_string(), + relpath, + size_bytes: i64::try_from(metadata.len()).ok(), + schema_version, + updated_at: Some(updated_at), + }); +} + /// Deletes the database and its WAL/SHM sidecars. fn delete_db_files(db_path: &std::path::Path) { let _ = std::fs::remove_file(db_path); @@ -3090,6 +3301,11 @@ impl TraceDecay { &self.store_layout } + pub async fn open_project_store_db(&self) -> Result { + let (db, _) = Database::open(&self.store_layout.graph_db_path).await?; + Ok(db) + } + fn build_branch_diagnostics( project_root: &Path, data_root: &Path, @@ -3280,7 +3496,25 @@ impl TraceDecay { pub fn project_branch_diagnostics(project_root: &Path) -> BranchDiagnostics { let store_layout = storage::resolve_layout_for_current_profile(project_root) - .unwrap_or_else(|_| storage::project_local_layout(project_root)); + .unwrap_or_else(|_| { + let profile_root = storage::default_profile_root() + .unwrap_or_else(|_| std::path::PathBuf::from(crate::config::TRACEDECAY_DIR)); + storage::default_profile_sharded_layout(project_root, &profile_root).unwrap_or_else( + |_| { + storage::profile_sharded_layout( + project_root, + &profile_root, + &storage::EnrollmentMarker { + project_id: storage::default_profile_project_id(project_root), + storage_mode: storage::StorageMode::ProfileSharded, + }, + ) + .unwrap_or_else(|err| { + panic!("default profile project id must be valid: {err}") + }) + }, + ) + }); let current_branch = branch::current_branch(project_root); let (serving_db_path, serving_branch, fallback_warning) = Self::resolve_db_for_branch( project_root, @@ -3707,8 +3941,10 @@ impl TraceDecay { .await } - async fn repair_derived_memory(&self) -> Result { - let store = MemoryStore::new(self.db.conn()); + async fn repair_derived_memory_for_conn( + conn: &libsql::Connection, + ) -> Result { + let store = MemoryStore::new(conn); let mut missing_vectors_repaired = 0; loop { let repaired = store.compute_missing_vectors(500).await?; @@ -3726,10 +3962,9 @@ impl TraceDecay { }) } - pub async fn memory_status(&self) -> Result { + async fn memory_status_for_conn(conn: &libsql::Connection) -> Result { let operation = "memory_status"; - let conn = self.db.conn(); - let repair = self.repair_derived_memory().await?; + let repair = Self::repair_derived_memory_for_conn(conn).await?; let hrr_dim = HolographicEncoder::DIMENSIONS; let mut fact_rows = conn .query("SELECT trust_score FROM memory_facts", ()) @@ -3832,6 +4067,15 @@ impl TraceDecay { repair, }) } + + pub async fn memory_status(&self) -> Result { + Self::memory_status_for_conn(self.db.conn()).await + } + + pub async fn project_memory_status(&self) -> Result { + let db = self.open_project_store_db().await?; + Self::memory_status_for_conn(db.conn()).await + } } // --------------------------------------------------------------------------- diff --git a/src/upgrade.rs b/src/upgrade.rs index f7b420f0..43177a5e 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -18,7 +18,7 @@ const GITHUB_REPO: &str = "ScriptedAlchemy/tracedecay"; // whose CI hasn't finished uploading the current platform's binary yet. use crate::cloud::asset_name_candidates; #[cfg(test)] -use crate::cloud::{asset_name, current_platform, legacy_asset_name}; +use crate::cloud::{asset_name, current_platform}; /// The GitHub release tag for a given version. fn release_tag(version: &str) -> String { @@ -32,8 +32,7 @@ fn io_err(msg: &str) -> impl Fn(std::io::Error) -> TraceDecayError + '_ { } /// Fetches the `browser_download_url` for the first matching asset name in a -/// GitHub release. Candidates are probed in order, so the post-rebrand -/// `tracedecay-v*` name wins over the legacy `tokensave-v*` fallback. +/// GitHub release. fn fetch_asset_url(tag: &str, candidates: &[String]) -> Result { #[derive(serde::Deserialize)] struct Asset { @@ -525,7 +524,7 @@ fn replace_for_scoop(new_exe: &Path, _new_version: &str) -> Result<()> { fn preflight_asset_check(version: &str, is_beta: bool) -> Result { let tag = release_tag(version); let candidates = asset_name_candidates(version, is_beta); - let [primary_candidate, _legacy_candidate] = &candidates; + let [primary_candidate] = &candidates; eprintln!(" Asset: {primary_candidate}"); fetch_asset_url(&tag, &candidates) } @@ -551,12 +550,10 @@ fn record_previous_version() { } fn perform_upgrade(version: &str, asset_url: &str, method: &InstallMethod) -> Result<()> { - // Post-rebrand archives ship a `tracedecay` binary; legacy archives a - // `tokensave` one. Probe new name first. let bin_names: &[&str] = if cfg!(windows) { - &["tracedecay.exe", "tokensave.exe"] + &["tracedecay.exe"] } else { - &["tracedecay", "tokensave"] + &["tracedecay"] }; let tmp = download_and_extract(asset_url, bin_names)?; @@ -741,18 +738,9 @@ mod tests { } #[test] - fn test_legacy_asset_name_keeps_tokensave_prefix() { - let stable = legacy_asset_name("3.3.3", false); - assert!(stable.starts_with("tokensave-v3.3.3-")); - let beta = legacy_asset_name("4.0.2-beta.1", true); - assert!(beta.starts_with("tokensave-beta-v4.0.2-beta.1-")); - } - - #[test] - fn test_asset_name_candidates_probe_new_then_legacy() { + fn test_asset_name_candidates_use_current_name() { let candidates = asset_name_candidates("3.3.3", false); assert!(candidates[0].starts_with("tracedecay-v3.3.3-")); - assert!(candidates[1].starts_with("tokensave-v3.3.3-")); } #[test] diff --git a/src/worktree.rs b/src/worktree.rs index f8a4d8b9..417eb796 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -1,9 +1,7 @@ //! Borrowed-index detection for git worktrees. //! -//! A tracedecay index lives in a `.tracedecay/` (or legacy `.tokensave/`) -//! directory and is resolved by -//! walking up parent directories to the nearest one (see -//! [`config::discover_project_root`](crate::config::discover_project_root)). +//! A tracedecay index resolves through the active project root or user profile +//! store (see [`config::discover_project_root`](crate::config::discover_project_root)). //! That walk is unaware of git worktrees: when a worktree is created *inside* //! the main checkout (e.g. agent tooling that puts worktrees under //! `.claude/worktrees//` or `.worktrees//`), a command run from diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 3c62d12d..8c1f72c1 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -3,13 +3,16 @@ mod common; use std::path::Path; use std::process::Command; -use common::pyyaml_shim_pythonpath; +use common::{pyyaml_shim_pythonpath, EnvVarGuard}; use tempfile::TempDir; use tracedecay::agents::*; use tracedecay::branch_meta; -use tracedecay::config::get_tracedecay_dir; +use tracedecay::config::USER_DATA_DIR_ENV; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; +static AGENT_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + // --------------------------------------------------------------------------- // 1. Registry tests // --------------------------------------------------------------------------- @@ -142,6 +145,7 @@ fn tracedecay_command(project: &Path, home: &Path) -> Command { .env("HOME", home) .env("USERPROFILE", home) .env("XDG_CONFIG_HOME", home.join(".config")) + .env(USER_DATA_DIR_ENV, home.join(".tracedecay")) .env("KIRO_HOME", home.join(".kiro")) .env("VIBE_HOME", home.join(".vibe")); command @@ -203,6 +207,10 @@ fn codex_plugin_install_dir(home: &Path) -> std::path::PathBuf { home.join("plugins/tracedecay") } +fn codex_cached_plugin_install_dir(home: &Path) -> std::path::PathBuf { + home.join(".codex/plugins/cache/personal/tracedecay/0.0.4") +} + fn codex_personal_marketplace_path(home: &Path) -> std::path::PathBuf { home.join(".agents/plugins/marketplace.json") } @@ -283,6 +291,19 @@ fn assert_codex_marketplace_entry( assert_eq!(entry["category"], "Productivity"); } +fn assert_codex_marketplace_has_no_tracedecay(marketplace_path: &Path) { + if !marketplace_path.exists() { + return; + } + let marketplace = read_json(marketplace_path); + assert!( + marketplace["plugins"] + .as_array() + .is_none_or(|plugins| plugins.iter().all(|entry| entry["name"] != "tracedecay")), + "Codex marketplace should not keep a tracedecay source entry after cache install" + ); +} + fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str) { let manifest = read_json(&plugin_dir.join(".cursor-plugin/plugin.json")); assert_eq!(manifest["name"], "tracedecay"); @@ -338,6 +359,7 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str) { ("sessionEnd", "hook-cursor-session-end"), ("subagentStart", "hook-cursor-subagent-start"), ("postToolUse", "hook-cursor-post-tool-use"), + ("preCompact", "hook-cursor-pre-compact"), ("beforeSubmitPrompt", "hook-cursor-before-submit-prompt"), ("afterFileEdit", "hook-cursor-after-file-edit"), ("afterShellExecution", "hook-cursor-after-shell"), @@ -579,13 +601,23 @@ fn test_local_install_cursor_installs_plugin_without_project_config() { #[tokio::test] async fn test_local_install_cursor_tracks_current_branch_when_initialized() { + let _env_lock = AGENT_ENV_LOCK.lock().await; let home = TempDir::new().unwrap(); + let home_root = home + .path() + .canonicalize() + .unwrap_or_else(|_| home.path().to_path_buf()); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, home_root.join(".tracedecay")); let project = TempDir::new().unwrap(); + let project_root = project + .path() + .canonicalize() + .unwrap_or_else(|_| project.path().to_path_buf()); let git_init = Command::new("git") .arg("init") .arg("-b") .arg("main") - .current_dir(project.path()) + .current_dir(&project_root) .output() .expect("git init should run"); assert!( @@ -594,14 +626,43 @@ async fn test_local_install_cursor_tracks_current_branch_when_initialized() { String::from_utf8_lossy(&git_init.stdout), String::from_utf8_lossy(&git_init.stderr) ); - std::fs::create_dir_all(project.path().join("src")).unwrap(); - std::fs::write(project.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap(); - TraceDecay::init(project.path()).await.unwrap(); + std::fs::create_dir_all(project_root.join("src")).unwrap(); + std::fs::write(project_root.join("src/lib.rs"), "pub fn hello() {}\n").unwrap(); + let git_add = Command::new("git") + .arg("add") + .arg("src/lib.rs") + .current_dir(&project_root) + .output() + .expect("git add should run"); + assert!( + git_add.status.success(), + "git add should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&git_add.stdout), + String::from_utf8_lossy(&git_add.stderr) + ); + let git_commit = Command::new("git") + .arg("-c") + .arg("user.name=TraceDecay Test") + .arg("-c") + .arg("user.email=tracedecay@example.invalid") + .arg("commit") + .arg("-m") + .arg("initial") + .current_dir(&project_root) + .output() + .expect("git commit should run"); + assert!( + git_commit.status.success(), + "git commit should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&git_commit.stdout), + String::from_utf8_lossy(&git_commit.stderr) + ); + TraceDecay::init(&project_root).await.unwrap(); let checkout = Command::new("git") .arg("checkout") .arg("-b") .arg("feature/install") - .current_dir(project.path()) + .current_dir(&project_root) .output() .expect("git checkout should run"); assert!( @@ -611,9 +672,12 @@ async fn test_local_install_cursor_tracks_current_branch_when_initialized() { String::from_utf8_lossy(&checkout.stderr) ); - assert_local_install_success("cursor", project.path(), home.path()); + assert_local_install_success("cursor", &project_root, &home_root); - let meta = branch_meta::load_branch_meta(&get_tracedecay_dir(project.path())) + let data_dir = resolve_layout_for_current_profile(&project_root) + .unwrap_or_else(|err| panic!("failed to resolve project store layout: {err}")) + .data_root; + let meta = branch_meta::load_branch_meta(&data_dir) .expect("Cursor install should bootstrap branch tracking metadata"); assert!(meta.is_tracked("main")); assert!(meta.is_tracked("feature/install")); @@ -1106,11 +1170,13 @@ assert provider.is_available() is False plugin.tools.TRACEDECAY_BIN = original_bin provider.initialize("session-123", hermes_home="/tmp/hermes-profile") assert provider.hermes_home == "/tmp/hermes-profile" +assert not hasattr(provider, "project_root") assert provider.session_id == "session-123" # Without an explicit hermes_home the provider resolves the active profile # home itself (sync_turn/prefetch need a storage anchor). provider.initialize("session-only") assert provider.hermes_home == os.environ["HERMES_HOME"] +assert not hasattr(provider, "project_root") assert provider.session_id == "session-only" # Collapsed schema surface: fact_store(action=...) covers the nine legacy @@ -2782,6 +2848,54 @@ fn test_codex_install_creates_plugin_bundle_and_marketplace() { ); } +#[test] +fn test_codex_install_refreshes_existing_cache_and_removes_bootstrap_source() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let ctx = make_install_ctx(home); + let cached_plugin_dir = codex_cached_plugin_install_dir(home); + std::fs::create_dir_all(cached_plugin_dir.join(".codex-plugin")).unwrap(); + std::fs::write( + cached_plugin_dir.join(".codex-plugin/plugin.json"), + r#"{"name":"tracedecay","version":"0.0.0"}"#, + ) + .unwrap(); + + let bootstrap_dir = codex_plugin_install_dir(home); + std::fs::create_dir_all(bootstrap_dir.join(".codex-plugin")).unwrap(); + std::fs::write( + bootstrap_dir.join(".codex-plugin/plugin.json"), + r#"{"name":"tracedecay","version":"0.0.0"}"#, + ) + .unwrap(); + std::fs::create_dir_all(bootstrap_dir.join("skills/stale-skill")).unwrap(); + std::fs::write( + bootstrap_dir.join("skills/stale-skill/SKILL.md"), + "---\nname: tracedecay:stale-skill\n---\n", + ) + .unwrap(); + std::fs::create_dir_all(home.join(".agents/plugins")).unwrap(); + std::fs::write( + codex_personal_marketplace_path(home), + r#"{"interface":{"displayName":"Personal"},"name":"personal","plugins":[{"name":"tracedecay","source":{"source":"local","path":"./plugins/tracedecay"}}]}"#, + ) + .unwrap(); + + CodexIntegration.install(&ctx).unwrap(); + + assert_codex_plugin_bundle( + &cached_plugin_dir, + &ctx.tracedecay_bin, + serde_json::json!(["serve"]), + true, + ); + assert!( + !bootstrap_dir.exists(), + "global Codex install should remove the loose marketplace source once a cache install exists" + ); + assert_codex_marketplace_has_no_tracedecay(&codex_personal_marketplace_path(home)); +} + #[test] fn test_codex_install_sweeps_legacy_global_config() { let dir = TempDir::new().unwrap(); @@ -2874,7 +2988,7 @@ fn test_codex_local_install_sweeps_legacy_project_config() { .unwrap(); std::fs::write( codex_dir.join("hooks.json"), - r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/old/tokensave hook-codex-pre-tool-use","timeout":5}]}],"PostToolUse":[{"matcher":"Bash|apply_patch","hooks":[{"type":"command","command":"/old/tracedecay hook-codex-post-tool-use","timeout":60}]}]}}"#, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/old/tracedecay hook-codex-pre-tool-use","timeout":5}]}],"PostToolUse":[{"matcher":"Bash|apply_patch","hooks":[{"type":"command","command":"/old/tracedecay hook-codex-post-tool-use","timeout":60}]}]}}"#, ) .unwrap(); @@ -2886,7 +3000,7 @@ fn test_codex_local_install_sweeps_legacy_project_config() { ); assert!( !codex_dir.join("hooks.json").exists(), - "legacy project Codex hooks should be removed when they only contained tracedecay/tokensave" + "legacy project Codex hooks should be removed when they only contained tracedecay" ); assert_codex_plugin_bundle( &codex_project_plugin_install_dir(project.path()), @@ -2952,12 +3066,23 @@ fn assert_codex_hooks_registered(hooks: &serde_json::Value) { codex_event_has_handler(hooks, "PostToolUse", "hook-codex-post-tool-use"), "Codex PostToolUse hook should keep the index fresh: {hooks}" ); + assert!( + codex_event_has_handler(hooks, "PostCompact", "hook-codex-post-compact"), + "Codex PostCompact hook should generate app-server LCM summaries: {hooks}" + ); let matcher = codex_matcher_for_handler(hooks, "PostToolUse", "hook-codex-post-tool-use") .expect("PostToolUse handler should exist"); assert!( matcher.contains("Bash") && matcher.contains("apply_patch"), "PostToolUse matcher should target Bash and apply_patch, got {matcher:?}" ); + let compact_matcher = + codex_matcher_for_handler(hooks, "PostCompact", "hook-codex-post-compact") + .expect("PostCompact handler should exist"); + assert!( + compact_matcher.contains("auto") && compact_matcher.contains("manual"), + "PostCompact matcher should target auto and manual compactions, got {compact_matcher:?}" + ); } #[test] @@ -3169,40 +3294,6 @@ fn test_cursor_install_creates_plugin() { ); } -#[test] -fn test_cursor_install_sweeps_legacy_tokensave_plugin_dir() { - let dir = TempDir::new().unwrap(); - let home = dir.path(); - - // Simulate a pre-rebrand plugin install: tokensave-branded manifest plus - // the legacy rule file and dispatcher skill dirs the current bundle no - // longer ships. Earlier sweeps missed these and stranded the directory. - let legacy_dir = home.join(".cursor/plugins/local/tokensave"); - std::fs::create_dir_all(legacy_dir.join(".cursor-plugin")).unwrap(); - std::fs::write( - legacy_dir.join(".cursor-plugin/plugin.json"), - r#"{"name": "tokensave", "version": "5.0.0"}"#, - ) - .unwrap(); - std::fs::create_dir_all(legacy_dir.join("rules")).unwrap(); - std::fs::write(legacy_dir.join("rules/tokensave.mdc"), "legacy rule\n").unwrap(); - for skill in ["tokensave-audit-safety", "tokensave-map-architecture"] { - let skill_dir = legacy_dir.join("skills").join(skill); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write(skill_dir.join("SKILL.md"), "legacy dispatcher\n").unwrap(); - } - - let ctx = make_install_ctx(home); - CursorIntegration.install(&ctx).unwrap(); - - assert!( - !legacy_dir.exists(), - "legacy tokensave plugin dir should be fully removed once the \ - tokensave.mdc rule and tokensave- dispatcher skills are swept" - ); - assert_cursor_plugin_bundle(&cursor_plugin_install_dir(home), &ctx.tracedecay_bin); -} - #[test] fn test_opencode_install_creates_config() { let dir = TempDir::new().unwrap(); diff --git a/tests/branch_db_safety_test.rs b/tests/branch_db_safety_test.rs index f00eb255..7666a855 100644 --- a/tests/branch_db_safety_test.rs +++ b/tests/branch_db_safety_test.rs @@ -6,6 +6,7 @@ use std::process::Command; use tempfile::TempDir; use tracedecay::branch::{self, BranchAddOutcome}; use tracedecay::branch_meta::load_branch_meta; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; fn git(project: &Path, args: &[&str]) { @@ -39,6 +40,12 @@ fn commit_all(project: &Path, message: &str) { ); } +fn project_data_dir(project: &Path) -> PathBuf { + resolve_layout_for_current_profile(project) + .unwrap_or_else(|err| panic!("failed to resolve test project storage layout: {err}")) + .data_root +} + async fn open_untracked_project() -> (TempDir, PathBuf, TraceDecay) { let dir = TempDir::new().unwrap(); let project = dir.path().to_path_buf(); @@ -133,7 +140,7 @@ async fn open_auto_tracks_untracked_branch_and_syncs_its_db() { "auto-tracked branch should contain the branch-only symbol" ); - let meta = load_branch_meta(&project.join(".tracedecay")).unwrap(); + let meta = load_branch_meta(&project_data_dir(&project)).unwrap(); let feature_entry = meta .branches .get("feature/untracked") @@ -271,7 +278,7 @@ async fn add_branch_tracking_copies_from_nearest_tracked_ancestor() { .unwrap(); assert_eq!(topic_outcome, BranchAddOutcome::Added); - let meta = load_branch_meta(&project.join(".tracedecay")).unwrap(); + let meta = load_branch_meta(&project_data_dir(project)).unwrap(); let topic_entry = meta .branches .get("topic/child") diff --git a/tests/branch_drift_test.rs b/tests/branch_drift_test.rs index 3f2d3941..1cf868a2 100644 --- a/tests/branch_drift_test.rs +++ b/tests/branch_drift_test.rs @@ -5,12 +5,14 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; use tracedecay::branch_meta::{save_branch_meta, BranchMeta}; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; -fn git(project: &std::path::Path, args: &[&str]) { +fn git(project: &Path, args: &[&str]) { let status = Command::new("git") .args(["-c", "core.hooksPath=.git/no-hooks"]) .args(args) @@ -25,7 +27,7 @@ fn git(project: &std::path::Path, args: &[&str]) { } /// Initialize a git repo on branch `main` with one committed source file. -fn init_repo_on_main(project: &std::path::Path) { +fn init_repo_on_main(project: &Path) { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn f() -> u32 { 1 }\n").unwrap(); git(project, &["init"]); @@ -37,6 +39,12 @@ fn init_repo_on_main(project: &std::path::Path) { git(project, &["branch", "-M", "main"]); } +fn project_data_dir(project: &Path) -> PathBuf { + resolve_layout_for_current_profile(project) + .unwrap_or_else(|err| panic!("failed to resolve test project storage layout: {err}")) + .data_root +} + #[tokio::test] async fn sync_refuses_to_write_after_mid_session_branch_checkout() { let dir = TempDir::new().unwrap(); @@ -48,7 +56,7 @@ async fn sync_refuses_to_write_after_mid_session_branch_checkout() { // Track `main` so the project is in branch-aware mode (serving_branch=Some). let meta = BranchMeta::new("main"); - save_branch_meta(&project.join(".tracedecay"), &meta).unwrap(); + save_branch_meta(&project_data_dir(project), &meta).unwrap(); drop(cg); // Reopen so the instance resolves and pins `main`. @@ -139,7 +147,7 @@ async fn branch_diagnostics_reports_stale_open_and_serving_state_after_checkout( cg.index_all().await.unwrap(); let meta = BranchMeta::new("main"); - save_branch_meta(&project.join(".tracedecay"), &meta).unwrap(); + save_branch_meta(&project_data_dir(project), &meta).unwrap(); drop(cg); let cg = TraceDecay::open(project).await.unwrap(); @@ -172,7 +180,7 @@ async fn branch_diagnostics_reports_auto_tracked_live_branch() { cg.index_all().await.unwrap(); let meta = BranchMeta::new("main"); - save_branch_meta(&project.join(".tracedecay"), &meta).unwrap(); + save_branch_meta(&project_data_dir(project), &meta).unwrap(); drop(cg); git(project, &["checkout", "-b", "feature/untracked"]); @@ -206,7 +214,7 @@ async fn open_repairs_missing_tracked_branch_db_before_diagnostics() { cg.index_all().await.unwrap(); let meta = BranchMeta::new("main"); - save_branch_meta(&project.join(".tracedecay"), &meta).unwrap(); + save_branch_meta(&project_data_dir(project), &meta).unwrap(); drop(cg); git(project, &["checkout", "-b", "feature/tracked"]); @@ -223,7 +231,7 @@ async fn open_repairs_missing_tracked_branch_db_before_diagnostics() { .await .unwrap(); - let tracedecay_dir = project.join(".tracedecay"); + let tracedecay_dir = project_data_dir(project); let meta = tracedecay::branch_meta::load_branch_meta(&tracedecay_dir).unwrap(); let feature_db = tracedecay::branch::resolve_branch_db_path(&tracedecay_dir, "feature/tracked", &meta) diff --git a/tests/cli_non_interactive_test.rs b/tests/cli_non_interactive_test.rs index c71a0371..2826dae1 100644 --- a/tests/cli_non_interactive_test.rs +++ b/tests/cli_non_interactive_test.rs @@ -63,6 +63,29 @@ fn tracedecay_command_with_stdin(home: &std::path::Path, project: &std::path::Pa command } +#[test] +fn init_accepts_relative_current_directory() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let project_root = canonical_temp_path(project.path()); + std::fs::write(project_root.join("lib.rs"), "pub fn indexed() {}\n").unwrap(); + + let mut command = tracedecay_command(home.path(), &project_root); + command.args(["init", "."]); + let output = run_with_timeout(command, Duration::from_secs(30)); + + assert!( + output.status.success(), + "init . should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !project_root.join(".tracedecay/tracedecay.db").exists(), + "default init must use the profile-sharded store, not a repo-local graph DB" + ); +} + fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Path) { let project = canonical_temp_path(project); let shard_root = profile_shard_root(home); @@ -222,8 +245,10 @@ fn init_skips_gitignore_prompt_when_stdin_not_a_terminal() { String::from_utf8_lossy(&output.stderr) ); assert!( - project.path().join(".tracedecay").is_dir(), - "init should still create the index" + std::fs::read_dir(profile_root(home.path()).join("projects")) + .unwrap() + .any(|entry| entry.unwrap().path().join("tracedecay.db").is_file()), + "init should still create the project index in the profile store" ); let gitignore = project.path().join(".gitignore"); assert!( @@ -299,7 +324,16 @@ fn status_skips_create_prompt_when_stdin_not_a_terminal() { async fn status_json_reads_readonly_project_database() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let db_path = project.path().join(".tracedecay/tracedecay.db"); + let project_root = canonical_temp_path(project.path()); + write_enrollment_marker( + &project_root, + &EnrollmentMarker { + project_id: "proj_cli".to_string(), + storage_mode: StorageMode::ProfileSharded, + }, + ) + .unwrap(); + let db_path = profile_shard_root(home.path()).join("tracedecay.db"); let (db, _) = Database::initialize(&db_path).await.unwrap(); db.insert_node(&sample_node("node-1", "process_data", "src/lib.rs")) .await diff --git a/tests/codex_transcript_ingest_test.rs b/tests/codex_transcript_ingest_test.rs index 78fc2a7a..95e33f4b 100644 --- a/tests/codex_transcript_ingest_test.rs +++ b/tests/codex_transcript_ingest_test.rs @@ -3,6 +3,9 @@ use std::io::Write; use tempfile::TempDir; use tracedecay::sessions::codex::CodexSource; use tracedecay::sessions::cursor::open_project_session_db; +use tracedecay::sessions::lcm::{ + LcmContentSlice, LcmDescribeRequest, LcmDescribeTarget, LcmExpandRequest, LcmExpandTarget, +}; use tracedecay::sessions::source::ingest_source; fn setup(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) { @@ -121,6 +124,58 @@ fn write_codex_subagent_rollout( path } +fn write_codex_rollout_with_compaction( + home: &std::path::Path, + project: &std::path::Path, + session: &str, +) -> std::path::PathBuf { + let dir = home.join(".codex/sessions/2026/01/01"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("rollout-2026-01-01T00-00-20-{session}.jsonl")); + let contents = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n", + serde_json::json!({ + "timestamp": "2026-01-01T00:00:20.000Z", + "type": "session_meta", + "payload": {"id": session, "cwd": project.to_string_lossy(), "model": "gpt-5.5"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:21.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Map the release automation state"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:22.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "Release automation is mapped."} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:23.000Z", + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Map the release automation state"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Release automation is mapped."}]}, + {"type": "compaction", "encrypted_content": "encrypted-codex-summary"} + ] + } + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:23.010Z", + "type": "event_msg", + "payload": {"type": "context_compacted"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:24.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Continue after compaction"} + }), + ); + std::fs::write(&path, contents).unwrap(); + path +} + #[tokio::test] async fn codex_rollout_populates_user_and_agent_messages_only() { let tmp = TempDir::new().unwrap(); @@ -186,6 +241,399 @@ async fn codex_rollout_populates_user_and_agent_messages_only() { assert!(user_metadata.get("usage").is_none()); } +#[tokio::test] +async fn codex_context_compaction_creates_lcm_summary_node() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + write_codex_rollout_with_compaction(&home, &project, "codex-compact"); + + let db = open_project_session_db(&project).await.unwrap(); + let source = CodexSource::with_home(&home); + + let stats = ingest_source(&db, &source, &project, None).await; + assert_eq!(stats.messages_upserted, 4); + + let status = db.lcm_status("codex", Some("codex-compact")).await.unwrap(); + assert_eq!(status.raw_message_count, 4); + assert_eq!(status.summary_node_count, 1); + assert!(status.dag.depths.values().any(|depth| depth.count == 1)); + + let description = db + .lcm_describe(LcmDescribeRequest { + provider: "codex".to_string(), + session_id: "codex-compact".to_string(), + target: LcmDescribeTarget::Session, + }) + .await + .unwrap(); + assert_eq!(description.summary_nodes.len(), 1); + assert_eq!(description.summary_nodes[0].depth, 1); + assert_eq!(description.summary_nodes[0].source_count, 2); + + let node_id = description.summary_nodes[0].node_id.clone(); + let expanded = db + .lcm_describe(LcmDescribeRequest { + provider: "codex".to_string(), + session_id: "codex-compact".to_string(), + target: LcmDescribeTarget::SummaryNode { node_id }, + }) + .await + .unwrap(); + let summary = expanded.summary_node.expect("summary node should expand"); + assert_eq!(summary.source_count, 2); + + let expansion = db + .lcm_expand(LcmExpandRequest { + provider: "codex".to_string(), + session_id: "codex-compact".to_string(), + target: LcmExpandTarget::SummaryNode { + node_id: summary.node_id.clone(), + }, + content_slice: Some(LcmContentSlice { + offset: 0, + limit: 1024, + }), + source_offset: 0, + source_limit: Some(10), + }) + .await + .unwrap(); + assert!(expansion + .content + .contains("Map the release automation state")); + assert!(expansion.content.contains("Release automation is mapped")); + assert!(!expansion.content.contains("Summary body is encrypted")); + assert_eq!(expansion.summary_sources.len(), 2); +} + +#[tokio::test] +async fn repeated_codex_compactions_only_source_messages_since_previous_boundary() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let dir = home.join(".codex/sessions/2026/01/01"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rollout-2026-01-01T00-00-30-codex-repeat.jsonl"); + let cwd = project.to_string_lossy(); + let compact = |at: &str| { + serde_json::json!({ + "timestamp": at, + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [ + {"type": "compaction", "encrypted_content": "encrypted"} + ] + } + }) + }; + let lines = [ + serde_json::json!({ + "timestamp": "2026-01-01T00:00:30.000Z", + "type": "session_meta", + "payload": {"id": "codex-repeat", "cwd": cwd, "model": "gpt-5.5"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:31.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "First compacted prompt"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:32.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "First compacted reply"} + }), + compact("2026-01-01T00:00:33.000Z"), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:34.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Second compacted prompt"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:35.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "Second compacted reply"} + }), + compact("2026-01-01T00:00:36.000Z"), + ]; + std::fs::write( + &path, + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let source = CodexSource::with_home(&home); + let stats = ingest_source(&db, &source, &project, None).await; + assert_eq!(stats.messages_upserted, 6); + + let description = db + .lcm_describe(LcmDescribeRequest { + provider: "codex".to_string(), + session_id: "codex-repeat".to_string(), + target: LcmDescribeTarget::Session, + }) + .await + .unwrap(); + assert_eq!(description.summary_nodes.len(), 2); + let source_counts = description + .summary_nodes + .iter() + .map(|node| (node.depth, node.source_count)) + .collect::>(); + assert_eq!(source_counts.get(&1), Some(&2)); + assert_eq!(source_counts.get(&2), Some(&2)); +} + +#[tokio::test] +async fn incremental_codex_compaction_depth_continues_from_prior_history() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let dir = home.join(".codex/sessions/2026/01/01"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rollout-2026-01-01T00-00-40-codex-incremental.jsonl"); + let cwd = project.to_string_lossy(); + let compact = |at: &str| { + serde_json::json!({ + "timestamp": at, + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [ + {"type": "compaction", "encrypted_content": "encrypted"} + ] + } + }) + }; + let first = [ + serde_json::json!({ + "timestamp": "2026-01-01T00:00:40.000Z", + "type": "session_meta", + "payload": {"id": "codex-incremental", "cwd": cwd, "model": "gpt-5.5"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:41.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "First incremental prompt"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:42.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "First incremental reply"} + }), + compact("2026-01-01T00:00:43.000Z"), + ]; + std::fs::write( + &path, + first + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let source = CodexSource::with_home(&home); + let stats = ingest_source(&db, &source, &project, None).await; + assert_eq!(stats.messages_upserted, 3); + + let second = [ + serde_json::json!({ + "timestamp": "2026-01-01T00:00:44.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Second incremental prompt"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:45.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "Second incremental reply"} + }), + compact("2026-01-01T00:00:46.000Z"), + ]; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + for line in second { + writeln!(file, "{line}").unwrap(); + } + + let stats = ingest_source(&db, &source, &project, None).await; + assert_eq!(stats.messages_upserted, 3); + + let description = db + .lcm_describe(LcmDescribeRequest { + provider: "codex".to_string(), + session_id: "codex-incremental".to_string(), + target: LcmDescribeTarget::Session, + }) + .await + .unwrap(); + let depths = description + .summary_nodes + .iter() + .map(|node| node.depth) + .collect::>(); + assert_eq!(depths, [1, 2].into_iter().collect()); +} + +#[tokio::test] +async fn codex_compaction_summary_can_be_replaced_with_auxiliary_summary() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + write_codex_rollout_with_compaction(&home, &project, "codex-compact"); + + let db = open_project_session_db(&project).await.unwrap(); + let source = CodexSource::with_home(&home); + ingest_source(&db, &source, &project, None).await; + + let pending = db + .pending_codex_compaction_summary_requests(Some("codex-compact"), 10) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].request.provider, "codex"); + assert_eq!(pending[0].request.session_id, "codex-compact"); + assert_eq!( + pending[0] + .request + .source_messages + .iter() + .map(|message| message.content.as_str()) + .collect::>(), + vec![ + "Map the release automation state", + "Release automation is mapped." + ] + ); + + let replacement = db + .replace_codex_compaction_summary( + &pending[0].node_id, + "Auxiliary Codex app-server summary", + "codex_app_server", + Some("gpt-5.4"), + ) + .await + .unwrap(); + assert_eq!( + replacement.summary_text, + "Auxiliary Codex app-server summary" + ); + assert_ne!(replacement.node_id, pending[0].node_id); + assert_eq!(replacement.source_refs.len(), 2); + + let pending_after = db + .pending_codex_compaction_summary_requests(Some("codex-compact"), 10) + .await + .unwrap(); + assert!(pending_after.is_empty()); + + let status = db.lcm_status("codex", Some("codex-compact")).await.unwrap(); + assert_eq!(status.summary_node_count, 1); +} + +#[tokio::test] +async fn codex_usage_preserves_cache_only_total_only_and_reasoning_counters() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let dir = home.join(".codex/sessions/2026/01/01"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rollout-2026-01-01T00-00-40-usage-edge.jsonl"); + let cwd = project.to_string_lossy(); + let lines = [ + serde_json::json!({ + "timestamp": "2026-01-01T00:00:40.000Z", + "type": "session_meta", + "payload": {"id": "usage-edge", "cwd": cwd} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:41.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Usage edge prompt one"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:42.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "Usage edge reply one"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:43.000Z", + "type": "event_msg", + "payload": {"type": "token_count", "info": { + "last_token_usage": {"cache_read_input_tokens": 123, "total_tokens": 123} + }} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:44.000Z", + "type": "event_msg", + "payload": {"type": "user_message", "message": "Usage edge prompt two"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:45.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "Usage edge reply two"} + }), + serde_json::json!({ + "timestamp": "2026-01-01T00:00:46.000Z", + "type": "event_msg", + "payload": {"type": "token_count", "info": { + "last_token_usage": { + "input_tokens": 10, + "output_tokens": 5, + "reasoning_output_tokens": 7, + "total_tokens": 22 + } + }} + }), + ]; + std::fs::write( + &path, + lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let source = CodexSource::with_home(&home); + ingest_source(&db, &source, &project, None).await; + + let hits = db + .search_session_messages("codex", None, "Usage edge reply", 10) + .await; + let usage_of = |needle: &str| { + let hit = hits + .iter() + .find(|hit| hit.message.text.contains(needle)) + .unwrap_or_else(|| panic!("message containing {needle:?} should exist")); + let metadata: serde_json::Value = + serde_json::from_str(hit.message.metadata_json.as_deref().unwrap()).unwrap(); + metadata["usage"].clone() + }; + + let cache_only = usage_of("reply one"); + assert_eq!(cache_only["input_tokens"], 0); + assert_eq!(cache_only["output_tokens"], 0); + assert_eq!(cache_only["cache_read_input_tokens"], 123); + assert_eq!(cache_only["total_tokens"], 123); + + let reasoning = usage_of("reply two"); + assert_eq!(reasoning["input_tokens"], 10); + assert_eq!(reasoning["output_tokens"], 12); + assert_eq!(reasoning["reasoning_tokens"], 7); + assert_eq!(reasoning["total_tokens"], 22); +} + #[tokio::test] async fn codex_rollout_ingest_is_incremental() { let tmp = TempDir::new().unwrap(); diff --git a/tests/cursor_transcript_ingest_test.rs b/tests/cursor_transcript_ingest_test.rs index 2097fd70..7f7c1bea 100644 --- a/tests/cursor_transcript_ingest_test.rs +++ b/tests/cursor_transcript_ingest_test.rs @@ -1,11 +1,17 @@ use std::io::Write; +mod common; + +use common::{EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK}; use tempfile::TempDir; use tracedecay::global_db::GlobalDb; +use tracedecay::hooks::cursor_pre_compact_for_event_with_config; use tracedecay::sessions::cursor::{ cursor_project_slug, ingest_cursor_transcript_event, ingest_cursor_transcript_event_capped, open_project_session_db, project_session_db_path, CursorSweepSource, }; +use tracedecay::sessions::cursor_agent::CursorAgentSummaryConfig; +use tracedecay::sessions::lcm::{LcmDescribeRequest, LcmDescribeTarget}; use tracedecay::sessions::source::ingest_source; use tracedecay::sessions::SessionSearchScope; @@ -51,8 +57,121 @@ fn write_cursor_parent_with_subagent(tmp: &TempDir) -> (std::path::PathBuf, std: } #[tokio::test] +// Intentional: this test pins process-wide HOME/TRACEDECAY_GLOBAL_DB while the +// hook resolves its storage paths. +#[allow(clippy::await_holding_lock)] +async fn cursor_pre_compact_uses_cursor_agent_summary_for_lcm() { + let tmp = TempDir::new().unwrap(); + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let _env_guards = [ + EnvVarGuard::set(GLOBAL_DB_ENV, tmp.path().join("global.db")), + EnvVarGuard::set("HOME", tmp.path().join("home")), + EnvVarGuard::set("USERPROFILE", tmp.path().join("home")), + ]; + let project = init_project(&tmp); + + let transcript = tmp.path().join("cursor-session.jsonl"); + std::fs::write( + &transcript, + r#"{"role":"user","message":{"content":[{"type":"text","text":"First durable decision: keep Cursor compaction summaries in LCM."}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Implementation plan: use cursor-agent as an auxiliary summarizer when Cursor exposes no summary."}]}} +{"role":"user","message":{"content":[{"type":"text","text":"Fresh tail should remain replayable."}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Acknowledged fresh tail."}]}} +"#, + ) + .unwrap(); + + let fake_bin = tmp.path().join(if cfg!(windows) { + "cursor-agent-fake.cmd" + } else { + "cursor-agent-fake" + }); + let fake_body = if cfg!(windows) { + "@echo off\r\necho Cursor auxiliary summary: keep compaction summaries in LCM.\r\n" + } else { + "#!/bin/sh\nprintf '%s\\n' 'Cursor auxiliary summary: keep compaction summaries in LCM.'\n" + }; + std::fs::write(&fake_bin, fake_body).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fake_bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let event = serde_json::json!({ + "hook_event_name": "preCompact", + "session_id": "cursor-session", + "conversation_id": "conversation-1", + "transcript_path": transcript, + "workspace_roots": [project], + "message_count": 4, + "messages_to_compact": 2, + "context_tokens": 124000, + "context_window_size": 128000 + }); + let config = CursorAgentSummaryConfig { + cursor_agent_bin: fake_bin.to_string_lossy().to_string(), + model: Some("fake-cursor-model".to_string()), + timeout: std::time::Duration::from_secs(5), + workspace: Some(tmp.path().join("summary-workspace")), + }; + + let outcome = cursor_pre_compact_for_event_with_config(&event.to_string(), &config).await; + assert_eq!(outcome.status, "ok", "{}", outcome.reason); + assert_eq!(outcome.summary_nodes_created, 1); + + let db = open_project_session_db(&project).await.unwrap(); + let node_id = outcome + .summary_node_ids + .first() + .expect("summary node id should be returned"); + let expanded = db + .lcm_expand_summary_node("cursor", "cursor-session", node_id) + .await + .expect("summary node should expand"); + assert!(expanded + .summary + .summary_text + .contains("Cursor auxiliary summary: keep compaction summaries in LCM.")); + let described = db + .lcm_describe(LcmDescribeRequest { + provider: "cursor".to_string(), + session_id: "cursor-session".to_string(), + target: LcmDescribeTarget::SummaryNode { + node_id: node_id.clone(), + }, + }) + .await + .expect("summary node should describe"); + let summary = described + .summary_node + .expect("summary node details should exist"); + assert_eq!(summary.source_count, 2); + assert!(summary + .metadata_json + .as_deref() + .unwrap_or_default() + .contains("cursor_agent")); +} + +#[tokio::test] +// Intentional: this test asserts the resolved profile session DB path, so it +// pins process-wide profile env while opening and checking that path. +#[allow(clippy::await_holding_lock)] async fn cursor_transcript_ingest_populates_searchable_messages() { let tmp = TempDir::new().unwrap(); + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let profile = tmp.path().join("profile"); + let _env_guards = [ + EnvVarGuard::set("TRACEDECAY_DATA_DIR", &profile), + EnvVarGuard::set(GLOBAL_DB_ENV, profile.join("global.db")), + EnvVarGuard::set("HOME", tmp.path().join("home")), + EnvVarGuard::set("USERPROFILE", tmp.path().join("home")), + ]; let project = tmp.path().join("project"); std::fs::create_dir_all(&project).unwrap(); std::fs::create_dir(project.join(".tracedecay")).unwrap(); @@ -100,6 +219,86 @@ async fn cursor_transcript_ingest_populates_searchable_messages() { .any(|hit| hit.message.tool_names.as_deref() == Some("tracedecay_context"))); } +#[tokio::test] +async fn cursor_transcript_ingest_reads_nested_dispatch_tool_input_model() { + let tmp = TempDir::new().unwrap(); + let project = init_project(&tmp); + + let transcript = tmp.path().join("cursor-session.jsonl"); + std::fs::write( + &transcript, + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"Launching model-specific reviewers."},{"type":"tool_use","id":"call-a","name":"Subagent","input":{"model":"gpt-5.5-high","prompt":"Review the storage routing."}},{"type":"tool_use","id":"call-b","name":"Subagent","input":{"model":"claude-opus-4-8-thinking-max","prompt":"Review the memory routing."}}]}} +"#, + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let event = serde_json::json!({ + "session_id": "cursor-session", + "transcript_path": transcript, + "workspace_roots": [project] + }); + + let stats = ingest_cursor_transcript_event(&event.to_string(), &db).await; + assert_eq!(stats.messages_upserted, 3); + + let results = db + .search_session_messages("cursor", None, "routing", 10) + .await; + let dispatch_models: std::collections::BTreeMap<_, _> = results + .iter() + .filter(|hit| hit.message.kind.as_deref() == Some("tool_dispatch")) + .map(|hit| { + ( + hit.message.message_id.clone(), + hit.message.model.clone().unwrap_or_default(), + ) + }) + .collect(); + assert_eq!(dispatch_models.len(), 2); + assert_eq!( + dispatch_models.get("cursor-session:tool_dispatch:call-a"), + Some(&"gpt-5.5-high".to_string()) + ); + assert_eq!( + dispatch_models.get("cursor-session:tool_dispatch:call-b"), + Some(&"claude-opus-4-8-thinking-max".to_string()) + ); +} + +#[tokio::test] +async fn cursor_transcript_ingest_reads_display_model_fields() { + let tmp = TempDir::new().unwrap(); + let project = init_project(&tmp); + + let transcript = tmp.path().join("cursor-session.jsonl"); + std::fs::write( + &transcript, + r#"{"role":"assistant","message":{"modelDisplayName":"gpt-5.5-cursor-display","content":[{"type":"text","text":"Display model field should price correctly."}]}} +"#, + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let event = serde_json::json!({ + "session_id": "cursor-session", + "transcript_path": transcript, + "workspace_roots": [project] + }); + + let stats = ingest_cursor_transcript_event(&event.to_string(), &db).await; + assert_eq!(stats.messages_upserted, 1); + + let results = db + .search_session_messages("cursor", None, "price correctly", 10) + .await; + assert_eq!(results.len(), 1); + assert_eq!( + results[0].message.model.as_deref(), + Some("gpt-5.5-cursor-display") + ); +} + #[tokio::test] async fn cursor_transcript_ingest_preserves_structured_content_in_raw_lcm() { let tmp = TempDir::new().unwrap(); @@ -417,6 +616,48 @@ async fn cursor_subagent_transcript_ingests_as_child_session() { })); } +#[tokio::test] +async fn cursor_subagent_child_messages_inherit_parent_dispatch_model() { + let tmp = TempDir::new().unwrap(); + let project = init_project(&tmp); + let transcripts_dir = tmp.path().join("agent-transcripts"); + std::fs::create_dir_all(&transcripts_dir).unwrap(); + let parent = transcripts_dir.join("parent-session.jsonl"); + std::fs::write( + &parent, + r#"{"role":"assistant","message":{"content":[{"type":"tool_use","id":"toolu-worker-1","name":"Subagent","input":{"agent_id":"worker-1","model":"claude-opus-4-8-thinking-max","prompt":"Review child pricing."}}]}} +"#, + ) + .unwrap(); + let subagent_dir = transcripts_dir.join("parent-session").join("subagents"); + std::fs::create_dir_all(&subagent_dir).unwrap(); + std::fs::write( + subagent_dir.join("worker-1.jsonl"), + r#"{"role":"assistant","message":{"content":[{"type":"text","text":"priced child transcript model evidence"}]}} +"#, + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let event = cursor_event(&project, &parent); + + let stats = ingest_cursor_transcript_event(&event.to_string(), &db).await; + assert_eq!(stats.sessions_upserted, 2); + assert_eq!(stats.messages_upserted, 2); + + let results = db + .search_session_messages("cursor", None, "priced child transcript", 10) + .await; + let child_hit = results + .iter() + .find(|hit| hit.session.session_id == "worker-1") + .expect("expected child transcript hit"); + assert_eq!( + child_hit.message.model.as_deref(), + Some("claude-opus-4-8-thinking-max") + ); +} + #[tokio::test] async fn cursor_capped_ingest_discovers_subagents() { let tmp = TempDir::new().unwrap(); @@ -862,26 +1103,32 @@ async fn cursor_sweep_skips_ambiguous_project_slug() { } #[tokio::test] -async fn cursor_sweep_skips_projects_without_tracedecay() { +// Intentional: this test pins process-wide profile storage while the Cursor +// sweep resolves its project session DB. +#[allow(clippy::await_holding_lock)] +async fn cursor_sweep_ingests_profile_stored_project_without_local_marker() { let tmp = TempDir::new().unwrap(); - let scratch = init_project(&tmp); + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let profile = tmp.path().join("profile"); + let _env_guards = [ + EnvVarGuard::set("TRACEDECAY_DATA_DIR", &profile), + EnvVarGuard::set(GLOBAL_DB_ENV, profile.join("global.db")), + EnvVarGuard::set("HOME", tmp.path().join("home")), + EnvVarGuard::set("USERPROFILE", tmp.path().join("home")), + ]; let home = tmp.path().join("home"); - let unindexed = tmp.path().join("unindexed"); - std::fs::create_dir_all(&unindexed).unwrap(); - write_sweep_fixture(&home, &unindexed); + let project = tmp.path().join("unindexed"); + std::fs::create_dir_all(&project).unwrap(); + write_sweep_fixture(&home, &project); - let db = open_project_session_db(&scratch).await.unwrap(); + let db = open_project_session_db(&project).await.unwrap(); let sweep = CursorSweepSource::with_home(&home); - let skipped = ingest_source(&db, &sweep, &unindexed, None).await; - assert_eq!(skipped.sessions_upserted, 0); - assert_eq!(skipped.messages_upserted, 0); - - // Once the project is indexed, the same sweep picks its transcripts up. - std::fs::create_dir_all(unindexed.join(".tracedecay")).unwrap(); - std::fs::write(unindexed.join(".tracedecay/tracedecay.db"), "").unwrap(); - let indexed = ingest_source(&db, &sweep, &unindexed, None).await; + let indexed = ingest_source(&db, &sweep, &project, None).await; assert_eq!(indexed.sessions_upserted, 2); assert_eq!(indexed.messages_upserted, 2); + assert!(!project.join(".tracedecay").exists()); } #[tokio::test] diff --git a/tests/dashboard_api_test.rs b/tests/dashboard_api_test.rs index 4ea130c8..246e328f 100644 --- a/tests/dashboard_api_test.rs +++ b/tests/dashboard_api_test.rs @@ -11,6 +11,7 @@ use common::{ use serde_json::Value; use tempfile::TempDir; use tracedecay::branch; +use tracedecay::config::USER_DATA_DIR_ENV; use tracedecay::dashboard; use tracedecay::errors::TraceDecayError; use tracedecay::global_db::GlobalDb; @@ -30,7 +31,9 @@ otherwise assume the integration is broken when the store simply has no rows yet struct DashboardFixture { _tmp: TempDir, _env_guard: EnvVarGuard, + _data_dir_guard: EnvVarGuard, base_url: String, + project_root: std::path::PathBuf, project_db_path: std::path::PathBuf, server: tokio::task::JoinHandle<()>, } @@ -359,9 +362,15 @@ fn post_json_body(agent: &ureq::Agent, url: &str, body: &Value) -> (u16, Value) async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); let global_db_path = tmp.path().join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; seed_memory_fixture(&cg).await; @@ -380,7 +389,7 @@ async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let project_db_path = project_root.join(".tracedecay").join("tracedecay.db"); + let project_db_path = cg.store_layout().graph_db_path.clone(); let server = tokio::spawn(async move { let _ = dashboard::run(&cg, "127.0.0.1", port, false).await; }); @@ -391,7 +400,9 @@ async fn start_dashboard_fixture(seed_lcm: bool) -> DashboardFixture { DashboardFixture { _tmp: tmp, _env_guard: env_guard, + _data_dir_guard: data_dir_guard, base_url, + project_root, project_db_path, server, } @@ -631,13 +642,7 @@ fn dashboard_memory_repairs_vectors_and_invalidates_similarity_cache() { clear_fact_vector_without_touching_updated_at(&fixture, 103).await; let port = pick_free_port(); let base_url = format!("http://127.0.0.1:{port}"); - let project_root = fixture - .project_db_path - .parent() - .and_then(Path::parent) - .unwrap_or_else(|| panic!("fixture DB path should be under .tracedecay")) - .to_path_buf(); - let cg = match TraceDecay::open(&project_root).await { + let cg = match TraceDecay::open(&fixture.project_root).await { Ok(cg) => cg, Err(err) => panic!("failed to reopen fixture project: {err}"), }; @@ -706,9 +711,7 @@ fn dashboard_reports_resolved_branch_db_path() { }; let expected = cg.db_path().display().to_string(); assert!( - expected - .replace('\\', "/") - .contains(".tracedecay/branches/"), + expected.replace('\\', "/").contains("/branches/"), "fixture should serve a branch DB path, got {expected}" ); @@ -1960,10 +1963,20 @@ fn lcm_serves_project_session_store_without_global_override() { let runtime = create_runtime(); runtime.block_on(async { let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); let _env_guard = EnvVarGuard::unset(GLOBAL_DB_ENV); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; + let expected_session_db = + tracedecay::sessions::cursor::project_session_db_path(&project_root) + .display() + .to_string(); let session_store = open_project_session_store(&project_root).await; seed_lcm_fixture(&session_store, &project_root).await; drop(session_store); @@ -1984,12 +1997,7 @@ fn lcm_serves_project_session_store_without_global_override() { let lcm_db = capabilities["lcm_db"] .as_str() .unwrap_or_else(|| panic!("expected capabilities.lcm_db string")); - assert!( - lcm_db - .replace('\\', "/") - .ends_with(".tracedecay/sessions.db"), - "capabilities.lcm_db should be the project session store, got {lcm_db}" - ); + assert_eq!(lcm_db, expected_session_db); let (status, overview) = get_json( &agent, @@ -2004,10 +2012,7 @@ fn lcm_serves_project_session_store_without_global_override() { let path = overview["path"] .as_str() .unwrap_or_else(|| panic!("expected overview.path string")); - assert!( - path.replace('\\', "/").ends_with(".tracedecay/sessions.db"), - "overview.path should be the project session store, got {path}" - ); + assert_eq!(path, expected_session_db); let (status, search) = get_json( &agent, @@ -2038,9 +2043,15 @@ fn lcm_global_override_wins_over_project_store() { let runtime = create_runtime(); runtime.block_on(async { let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); - let global_db_path = tmp.path().join("global").join("global.db"); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; // The project store has rows; the overridden global store has none. @@ -2093,9 +2104,15 @@ fn curation_preview_persists_across_dashboard_restarts() { let runtime = create_runtime(); runtime.block_on(async { let tmp = tempdir_or_panic(); - let project_root = tmp.path().join("project"); - let global_db_path = tmp.path().join("global").join("global.db"); + let tmp_root = tmp + .path() + .canonicalize() + .unwrap_or_else(|err| panic!("failed to canonicalize temp root: {err}")); + let project_root = tmp_root.join("project"); + let global_db_path = tmp_root.join("global").join("global.db"); + let profile_root = tmp_root.join("profile").join(".tracedecay"); let _env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + let _data_dir_guard = EnvVarGuard::set(USER_DATA_DIR_ENV, &profile_root); let cg = setup_project(&project_root).await; seed_memory_fixture(&cg).await; diff --git a/tests/global_registry_test.rs b/tests/global_registry_test.rs index b49e7fa9..206ca966 100644 --- a/tests/global_registry_test.rs +++ b/tests/global_registry_test.rs @@ -95,6 +95,23 @@ async fn delete_project_paths_use_same_canonical_key_as_upsert() { assert_eq!(db.global_tokens_saved().await, Some(0)); } +#[tokio::test] +async fn upsert_preserves_highest_known_tokens_saved() { + let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("global.db"); + let project = dir.path().join("repo"); + std::fs::create_dir_all(&project).unwrap(); + let db = GlobalDb::open_at(&db_path).await.unwrap(); + + db.upsert(&project, 12_007_312).await; + db.upsert(&project.join("."), 0).await; + assert_eq!(db.get_project_tokens(&project).await, 12_007_312); + + db.upsert(&project, 12_100_000).await; + assert_eq!(db.get_project_tokens(&project).await, 12_100_000); +} + #[tokio::test] async fn open_at_creates_registry_tables_and_round_trips_registry_records() { let _guard = GLOBAL_REGISTRY_TEST_LOCK.lock().await; diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 7d877f4b..2e8d9735 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -1,3 +1,6 @@ +mod common; + +use common::{EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK}; use tracedecay::hooks::{ build_cursor_session_context, codex_additional_context_json, codex_apply_patch_rel_paths, codex_project_root_from_event, cursor_branch_switch_target, cursor_project_root_from_event, @@ -6,6 +9,9 @@ use tracedecay::hooks::{ evaluate_cursor_subagent_start, evaluate_hook_decision, evaluate_kiro_pre_tool_use, is_git_state_changing_command, CursorShellSyncPlan, }; +use tracedecay::storage::{ + resolve_layout_for_current_profile, write_enrollment_marker, EnrollmentMarker, StorageMode, +}; fn is_blocked(json: &str) -> bool { let v: serde_json::Value = serde_json::from_str(json).unwrap(); @@ -359,9 +365,29 @@ fn test_cursor_post_tool_use_hints_for_single_file_read() { #[test] fn test_cursor_post_tool_use_dedupes_hints_per_session() { let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".tracedecay")).unwrap(); - std::fs::write(dir.path().join(".tracedecay/tracedecay.db"), "").unwrap(); - let root = serde_json::to_string(dir.path().to_str().unwrap()).unwrap(); + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let project_root = dir.path().canonicalize().unwrap(); + let profile_root = project_root.join("profile"); + let _env_guards = [ + EnvVarGuard::set("TRACEDECAY_DATA_DIR", &profile_root), + EnvVarGuard::set(GLOBAL_DB_ENV, profile_root.join("global.db")), + EnvVarGuard::set("HOME", project_root.join("home")), + EnvVarGuard::set("USERPROFILE", project_root.join("home")), + ]; + write_enrollment_marker( + &project_root, + &EnrollmentMarker { + project_id: "proj_hooks_dedupe".to_string(), + storage_mode: StorageMode::ProfileSharded, + }, + ) + .unwrap(); + let layout = resolve_layout_for_current_profile(&project_root).unwrap(); + std::fs::create_dir_all(&layout.data_root).unwrap(); + std::fs::write(&layout.graph_db_path, "").unwrap(); + let root = serde_json::to_string(project_root.to_str().unwrap()).unwrap(); let grep_event = format!( r#"{{ "hook_event_name": "postToolUse", @@ -402,8 +428,8 @@ fn test_cursor_post_tool_use_dedupes_hints_per_session() { ); assert!( - dir.path().join(".tracedecay/tool_hints_seen.json").exists(), - "dedupe state must be persisted under .tracedecay/" + layout.data_root.join("tool_hints_seen.json").exists(), + "dedupe state must be persisted under the profile project shard" ); } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9c5e2db7..375bf89a 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -3,6 +3,7 @@ use std::fs; use std::os::unix::fs::symlink; use tempfile::TempDir; use tracedecay::config::{load_config, save_config}; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; use tracedecay::types::EdgeKind; @@ -905,7 +906,10 @@ async fn test_concurrent_sync_is_rejected() { let cg = TraceDecay::init(project).await.unwrap(); // Simulate an in-progress sync by placing a lockfile with our own PID. - let lock_path = project.join(".tracedecay/sync.lock"); + let lock_path = resolve_layout_for_current_profile(project) + .unwrap() + .data_root + .join("sync.lock"); fs::write(&lock_path, format!("{}", std::process::id())).unwrap(); let err = cg.sync().await.unwrap_err(); diff --git a/tests/mcp_cli_serve_test.rs b/tests/mcp_cli_serve_test.rs index cf9b031a..ebb29a73 100644 --- a/tests/mcp_cli_serve_test.rs +++ b/tests/mcp_cli_serve_test.rs @@ -6,27 +6,42 @@ use std::process::{Command, Stdio}; use serde_json::{json, Value}; use tempfile::TempDir; use tracedecay::global_db::GlobalDb; -use tracedecay::tracedecay::TraceDecay; -async fn init_project_with_file(contents: &str) -> TempDir { +async fn init_project_with_file(home: &Path, contents: &str) -> TempDir { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("src")).unwrap(); std::fs::write(dir.path().join("src/lib.rs"), contents).unwrap(); - let cg = TraceDecay::init(dir.path()).await.unwrap(); - cg.index_all().await.unwrap(); + init_project_with_cli(home, dir.path()); dir } -async fn init_project_under(parent: &Path, name: &str, contents: &str) -> PathBuf { +async fn init_project_under(home: &Path, parent: &Path, name: &str, contents: &str) -> PathBuf { let path = parent.join(name); fs::create_dir_all(path.join("src")).unwrap(); fs::write(path.join("src/lib.rs"), contents).unwrap(); - let cg = TraceDecay::init(&path).await.unwrap(); - cg.index_all().await.unwrap(); + init_project_with_cli(home, &path); path } +fn init_project_with_cli(home: &Path, project: &Path) { + let output = tracedecay_command_with_home(home) + .arg("init") + .current_dir(project) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("tracedecay init should run"); + assert!( + output.status.success(), + "tracedecay init failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + async fn register_global_project(home: &Path, project: &Path) { + let home = canonical_existing_path(home); let db_path = home.join(".tracedecay/global.db"); let db = GlobalDb::open_at(&db_path).await.unwrap(); db.upsert(project, 0).await; @@ -34,15 +49,20 @@ async fn register_global_project(home: &Path, project: &Path) { } fn tracedecay_command_with_home(home: &Path) -> Command { + let home = canonical_existing_path(home); let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); command - .env("HOME", home) - .env("USERPROFILE", home) + .env("HOME", &home) + .env("USERPROFILE", &home) .env("XDG_CONFIG_HOME", home.join(".config")) .env("TRACEDECAY_GLOBAL_DB", home.join(".tracedecay/global.db")); command } +fn canonical_existing_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + fn runtime_project_root(stdout: &[u8], id: i64) -> String { let stdout = String::from_utf8(stdout.to_vec()).unwrap(); let runtime_response: Value = stdout @@ -88,7 +108,7 @@ fn file_uri(path: &Path) -> String { async fn explicit_uninitialized_path_reports_error_instead_of_global_fallback() { let home = TempDir::new().unwrap(); let explicit = TempDir::new().unwrap(); - let active = init_project_with_file("pub fn active_project_marker() {}\n").await; + let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; register_global_project(home.path(), active.path()).await; let output = tracedecay_command_with_home(home.path()) @@ -118,8 +138,8 @@ async fn explicit_uninitialized_path_reports_error_instead_of_global_fallback() async fn no_explicit_path_prefers_initialize_roots_over_global_fallback() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); - let stale = init_project_with_file("pub fn stale_project_marker() {}\n").await; - let active = init_project_with_file("pub fn active_project_marker() {}\n").await; + let stale = init_project_with_file(home.path(), "pub fn stale_project_marker() {}\n").await; + let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; register_global_project(home.path(), stale.path()).await; let mut child = tracedecay_command_with_home(home.path()) @@ -185,9 +205,9 @@ async fn no_explicit_path_prefers_initialize_roots_over_global_fallback() { #[tokio::test] async fn no_explicit_path_prefers_discovered_cwd_over_initialize_roots() { let home = TempDir::new().unwrap(); - let cwd_project = init_project_with_file("pub fn cwd_project_marker() {}\n").await; + let cwd_project = init_project_with_file(home.path(), "pub fn cwd_project_marker() {}\n").await; let nested_cwd = cwd_project.path().join("src"); - let active = init_project_with_file("pub fn active_project_marker() {}\n").await; + let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; let mut child = tracedecay_command_with_home(home.path()) .arg("serve") @@ -252,8 +272,9 @@ async fn no_explicit_path_prefers_discovered_cwd_over_initialize_roots() { #[tokio::test] async fn explicit_initialized_path_ignores_initialize_roots() { let home = TempDir::new().unwrap(); - let explicit = init_project_with_file("pub fn explicit_project_marker() {}\n").await; - let active = init_project_with_file("pub fn active_project_marker() {}\n").await; + let explicit = + init_project_with_file(home.path(), "pub fn explicit_project_marker() {}\n").await; + let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; let mut child = tracedecay_command_with_home(home.path()) .arg("serve") @@ -320,7 +341,7 @@ async fn explicit_initialized_path_ignores_initialize_roots() { async fn no_explicit_path_without_roots_still_uses_global_fallback() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); - let active = init_project_with_file("pub fn active_project_marker() {}\n").await; + let active = init_project_with_file(home.path(), "pub fn active_project_marker() {}\n").await; register_global_project(home.path(), active.path()).await; let output = tracedecay_command_with_home(home.path()) @@ -347,12 +368,14 @@ async fn initialize_roots_decode_file_uri_localhost_and_percent_escapes() { let cwd = TempDir::new().unwrap(); let projects = TempDir::new().unwrap(); let stale = init_project_under( + home.path(), projects.path(), "stale-project", "pub fn stale_project_marker() {}\n", ) .await; let active = init_project_under( + home.path(), projects.path(), "active project", "pub fn active_project_marker() {}\n", @@ -424,8 +447,15 @@ async fn initialize_roots_decode_file_uri_localhost_and_percent_escapes() { async fn same_depth_descendant_global_fallback_is_ambiguous() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); - let alpha = init_project_under(cwd.path(), "alpha", "pub fn alpha_marker() {}\n").await; - let beta = init_project_under(cwd.path(), "beta", "pub fn beta_marker() {}\n").await; + let alpha = init_project_under( + home.path(), + cwd.path(), + "alpha", + "pub fn alpha_marker() {}\n", + ) + .await; + let beta = + init_project_under(home.path(), cwd.path(), "beta", "pub fn beta_marker() {}\n").await; register_global_project(home.path(), &alpha).await; register_global_project(home.path(), &beta).await; diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index b5ed66bb..a2563729 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -7,22 +7,27 @@ use std::ffi::OsString; use std::fs; #[cfg(unix)] use std::os::unix::fs as unix_fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime}; use serde_json::{json, Value}; use tempfile::TempDir; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, MutexGuard}; use tracedecay::db::Database; use tracedecay::errors::TraceDecayError; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::{get_tool_definitions, handle_tool_call}; +use tracedecay::memory::store::MemoryStore; use tracedecay::sessions::cursor::open_project_session_db; use tracedecay::sessions::lcm::{ LcmLifecycleUpdate, LcmMaintenanceDebt, LcmSourceRef, LcmSummaryNodeDraft, }; use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; +use tracedecay::storage::{ + resolve_layout_for_current_profile, resolve_lcm_payload_root, resolve_project_session_db_path, + resolve_response_handle_root, +}; use tracedecay::tracedecay::TraceDecay; static GLOBAL_DB_ENV_LOCK: Mutex<()> = Mutex::const_new(()); @@ -46,6 +51,7 @@ struct GlobalDbEnvGuard { impl GlobalDbEnvGuard { fn set(db_path: &Path) -> Self { let previous = std::env::var_os("TRACEDECAY_GLOBAL_DB"); + let db_path = canonicalize_test_db_path(db_path); std::env::set_var("TRACEDECAY_GLOBAL_DB", db_path); Self { previous } } @@ -71,8 +77,9 @@ impl HomeEnvGuard { let previous_home = std::env::var_os("HOME"); let previous_userprofile = std::env::var_os("USERPROFILE"); let previous_data_dir = std::env::var_os(tracedecay::config::USER_DATA_DIR_ENV); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); + let home = canonicalize_test_dir(home); + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); std::env::set_var( tracedecay::config::USER_DATA_DIR_ENV, home.join(tracedecay::config::TRACEDECAY_DIR), @@ -102,15 +109,65 @@ impl Drop for HomeEnvGuard { } } +fn canonicalize_test_dir(path: &Path) -> PathBuf { + fs::create_dir_all(path).unwrap_or_else(|err| { + panic!( + "failed to create test directory '{}': {err}", + path.display() + ) + }); + path.canonicalize().unwrap_or_else(|err| { + panic!( + "failed to canonicalize test directory '{}': {err}", + path.display() + ) + }) +} + +fn canonicalize_test_db_path(path: &Path) -> PathBuf { + let parent = path + .parent() + .unwrap_or_else(|| panic!("test DB path '{}' has no parent", path.display())); + canonicalize_test_dir(parent).join( + path.file_name() + .unwrap_or_else(|| panic!("test DB path '{}' has no file name", path.display())), + ) +} + // --------------------------------------------------------------------------- // Shared setup // --------------------------------------------------------------------------- +struct TestProject { + dir: TempDir, + _env_lock: MutexGuard<'static, ()>, + _home_guard: HomeEnvGuard, + _global_db_guard: GlobalDbEnvGuard, +} + +impl std::ops::Deref for TestProject { + type Target = TempDir; + + fn deref(&self) -> &Self::Target { + &self.dir + } +} + +struct TestEnv { + _env_lock: MutexGuard<'static, ()>, + _home_guard: HomeEnvGuard, + _global_db_guard: GlobalDbEnvGuard, +} + /// Creates a temporary Rust project with cross-file calls, structs, impls, /// test files, and doc comments, then initialises and indexes a `TraceDecay`. -async fn setup_project() -> (TraceDecay, TempDir) { +async fn setup_project() -> (TraceDecay, TestProject) { + let env_lock = GLOBAL_DB_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path(); + let home = project.join("home"); + let home_guard = HomeEnvGuard::set(&home); + let global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); fs::create_dir_all(project.join("src")).unwrap(); fs::write( @@ -157,15 +214,70 @@ fn test_helper() { assert!(!helper().is_empty()); } let cg = TraceDecay::init(project).await.unwrap(); index_all_retrying_sync_lock(&cg).await; - (cg, dir) + ( + cg, + TestProject { + dir, + _env_lock: env_lock, + _home_guard: home_guard, + _global_db_guard: global_db_guard, + }, + ) +} + +async fn init_test_project(project: &Path) -> (TraceDecay, TestEnv) { + let env_lock = GLOBAL_DB_ENV_LOCK.lock().await; + let home = project.join("home"); + let home_guard = HomeEnvGuard::set(&home); + let global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); + let cg = TraceDecay::init(project).await.unwrap(); + ( + cg, + TestEnv { + _env_lock: env_lock, + _home_guard: home_guard, + _global_db_guard: global_db_guard, + }, + ) +} + +fn project_data_dir(cg: &TraceDecay) -> PathBuf { + resolve_layout_for_current_profile(cg.project_root()) + .unwrap_or_else(|err| panic!("failed to resolve test project storage layout: {err}")) + .data_root +} + +fn project_graph_db(cg: &TraceDecay) -> PathBuf { + resolve_layout_for_current_profile(cg.project_root()) + .unwrap_or_else(|err| panic!("failed to resolve test project storage layout: {err}")) + .graph_db_path +} + +fn response_handle_dir(cg: &TraceDecay) -> PathBuf { + resolve_response_handle_root(cg.project_root()) + .unwrap_or_else(|err| panic!("failed to resolve test response handle root: {err}")) +} + +fn lcm_payload_dir(cg: &TraceDecay) -> PathBuf { + resolve_lcm_payload_root(cg.project_root()) + .unwrap_or_else(|err| panic!("failed to resolve test LCM payload root: {err}")) +} + +fn project_session_db_path(cg: &TraceDecay) -> PathBuf { + resolve_project_session_db_path(cg.project_root()) + .unwrap_or_else(|err| panic!("failed to resolve test project session DB path: {err}")) } /// Creates a small Rust library with an integration-style test that calls a /// public entry point, which then reaches an internal helper. This exercises /// the calibrated depth-3 attribution path in `tracedecay_test_risk`. -async fn setup_integration_test_risk_project() -> (TraceDecay, TempDir) { +async fn setup_integration_test_risk_project() -> (TraceDecay, TestProject) { let dir = TempDir::new().unwrap(); let project = dir.path(); + let env_lock = GLOBAL_DB_ENV_LOCK.lock().await; + let home = project.join("home"); + let home_guard = HomeEnvGuard::set(&home); + let global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); fs::create_dir_all(project.join("src")).unwrap(); fs::create_dir_all(project.join("tests")).unwrap(); @@ -221,14 +333,26 @@ fn integration_public_entry() { let cg = TraceDecay::init(project).await.unwrap(); cg.index_all().await.unwrap(); - (cg, dir) + ( + cg, + TestProject { + dir, + _env_lock: env_lock, + _home_guard: home_guard, + _global_db_guard: global_db_guard, + }, + ) } /// Extends the calibrated integration-risk fixture with a build script so the /// test-risk denominator can prove non-`src/` functions are excluded. -async fn setup_test_risk_non_src_fixture() -> (TraceDecay, TempDir) { +async fn setup_test_risk_non_src_fixture() -> (TraceDecay, TestProject) { let dir = TempDir::new().unwrap(); let project = dir.path(); + let env_lock = GLOBAL_DB_ENV_LOCK.lock().await; + let home = project.join("home"); + let home_guard = HomeEnvGuard::set(&home); + let global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); fs::create_dir_all(project.join("src")).unwrap(); fs::create_dir_all(project.join("tests")).unwrap(); @@ -298,7 +422,15 @@ fn main() { let cg = TraceDecay::init(project).await.unwrap(); cg.index_all().await.unwrap(); - (cg, dir) + ( + cg, + TestProject { + dir, + _env_lock: env_lock, + _home_guard: home_guard, + _global_db_guard: global_db_guard, + }, + ) } /// Extracts the text content from a `ToolResult` value (the standard @@ -402,7 +534,6 @@ async fn seed_project_registry(db_path: &Path, project_root: &Path) { #[tokio::test] async fn project_registry_tools_are_bounded_read_only_and_contextual() { - let _guard = GLOBAL_DB_ENV_LOCK.lock().await; let (cg, _project_dir) = setup_project().await; let registry_dir = TempDir::new().unwrap(); let registry_path = registry_dir.path().join("global.db"); @@ -614,14 +745,14 @@ async fn active_project_tool_reports_resolved_store_metadata() { Some("active_project") ); assert_eq!(payload["storage"]["class"].as_str(), Some("code_project")); - assert_eq!(payload["storage"]["mode"].as_str(), Some("project_local")); + assert_eq!(payload["storage"]["mode"].as_str(), Some("profile_sharded")); assert_eq!( payload["storage"]["graph_db_path"].as_str(), Some(graph_db_path.as_str()) ); assert!(payload["storage"]["data_root"] .as_str() - .is_some_and(|path| path.ends_with(".tracedecay"))); + .is_some_and(|path| path.contains(".tracedecay") && path.contains("projects"))); assert_eq!(payload["branch"]["serving_db_exists"].as_bool(), Some(true)); } @@ -1056,12 +1187,8 @@ async fn retrieve_tool_returns_full_stored_response() { .unwrap(); let stored_payload: Value = serde_json::from_str( - &fs::read_to_string( - cg.project_root() - .join(".tracedecay/response-handles") - .join(format!("{}.json", stored.handle)), - ) - .unwrap(), + &fs::read_to_string(response_handle_dir(&cg).join(format!("{}.json", stored.handle))) + .unwrap(), ) .unwrap(); assert!(stored_payload.get("handle").is_none()); @@ -1276,11 +1403,7 @@ async fn fact_store_large_list_response_reports_store_failure_actionably() { .unwrap(); } - fs::write( - cg.project_root().join(".tracedecay/response-handles"), - "not-a-directory", - ) - .unwrap(); + fs::write(response_handle_dir(&cg), "not-a-directory").unwrap(); let listed = handle_tool_call( &cg, @@ -1506,6 +1629,10 @@ async fn test_branch_list_reports_live_vs_serving_drift_state() { let dir = TempDir::new().unwrap(); let project = dir.path(); + let _env_lock = GLOBAL_DB_ENV_LOCK.lock().await; + let home = project.join("home"); + let _home_guard = HomeEnvGuard::set(&home); + let _global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn f() -> u32 { 1 }\n").unwrap(); git(project, &["init"]); @@ -1517,8 +1644,11 @@ async fn test_branch_list_reports_live_vs_serving_drift_state() { let cg = TraceDecay::init(project).await.unwrap(); cg.index_all().await.unwrap(); + let tracedecay_dir = resolve_layout_for_current_profile(project) + .unwrap() + .data_root; tracedecay::branch_meta::save_branch_meta( - &project.join(".tracedecay"), + &tracedecay_dir, &tracedecay::branch_meta::BranchMeta::new("main"), ) .unwrap(); @@ -2098,7 +2228,7 @@ async fn port_status_does_not_match_methods_of_different_parents() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; index_all_retrying_sync_lock(&cg).await; let result = handle_tool_call( @@ -2156,7 +2286,7 @@ async fn port_status_matches_methods_with_same_parent_type() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -2519,7 +2649,7 @@ async fn test_changelog_with_real_git() { .output() .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; index_all_retrying_sync_lock(&cg).await; let result = handle_tool_call( @@ -2861,7 +2991,7 @@ async fn test_str_replace_success() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -2901,7 +3031,7 @@ async fn path_containment_config_rejects_parent_traversal_before_serving_config( ) .unwrap(); - let cg = TraceDecay::init(&project).await.unwrap(); + let (cg, _env) = init_test_project(&project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -2927,7 +3057,7 @@ async fn path_containment_read_rejects_parent_traversal_before_serving_file() { fs::write(project.join("src/main.rs"), "fn main() {}\n").unwrap(); fs::write(dir.path().join("outside.rs"), "fn leaked() {}\n").unwrap(); - let cg = TraceDecay::init(&project).await.unwrap(); + let (cg, _env) = init_test_project(&project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -2956,7 +3086,7 @@ async fn read_and_outline_preserve_symlink_indexed_file_key() { fs::write(external.join("lib.rs"), "pub fn through_symlink() {}\n").unwrap(); unix_fs::symlink(&external, project.join("src")).unwrap(); - let cg = TraceDecay::init(&project).await.unwrap(); + let (cg, _env) = init_test_project(&project).await; cg.index_all().await.unwrap(); let read = handle_tool_call( @@ -3017,7 +3147,7 @@ async fn path_containment_config_rejects_symlink_escape_before_serving_config() .unwrap(); unix_fs::symlink(&outside_dir, project.join("escape")).unwrap(); - let cg = TraceDecay::init(&project).await.unwrap(); + let (cg, _env) = init_test_project(&project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3063,7 +3193,7 @@ async fn test_str_replace_not_found() { fs::write(project.join("src/main.rs"), "fn hello() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3094,7 +3224,7 @@ async fn test_str_replace_multiple_matches_fails() { fs::write(project.join("src/main.rs"), "fn foo() {}\nfn foo() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3132,7 +3262,7 @@ async fn test_multi_str_replace_success() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3170,7 +3300,7 @@ async fn test_multi_str_replace_atomic_failure() { fs::write(project.join("src/main.rs"), "fn foo() {}\nfn baz() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3212,7 +3342,7 @@ async fn test_multi_str_replace_unicode_preview_does_not_panic() { let original = "fn main() {}\n"; fs::write(project.join("src/main.rs"), original).unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let missing_old = format!("{}é", "a".repeat(19)); @@ -3251,7 +3381,7 @@ async fn test_str_replace_unsupported_file_type_succeeds() { fs::write(project.join("style.css"), ".foo {\n\tfont-size: 14px;\n}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3287,7 +3417,7 @@ async fn ast_grep_rewrite_has_literal_fallback_when_binary_missing() { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn old_name() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -3324,7 +3454,7 @@ async fn ast_grep_rewrite_uses_current_cli_update_flag() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -3362,7 +3492,7 @@ async fn branch_diff_returns_empty_when_base_equals_head() { let (cg, _dir) = setup_project().await; // branch_diff requires branch tracking metadata to be present. - let tracedecay_dir = tracedecay::config::get_tracedecay_dir(cg.project_root()); + let tracedecay_dir = project_data_dir(&cg); let meta = tracedecay::branch_meta::BranchMeta::new("master"); tracedecay::branch_meta::save_branch_meta(&tracedecay_dir, &meta).unwrap(); @@ -3400,7 +3530,7 @@ async fn ast_grep_rewrite_surfaces_useful_error_on_empty_stderr() { fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn foo() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -3441,7 +3571,7 @@ async fn test_multi_str_replace_unsupported_file_type_succeeds() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3484,7 +3614,7 @@ async fn test_insert_at_string_anchor_before() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3530,7 +3660,7 @@ async fn test_insert_at_line_number() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3573,7 +3703,7 @@ async fn test_insert_at_anchor_not_found() { fs::write(project.join("src/main.rs"), "line one\nline two\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3606,7 +3736,7 @@ async fn test_insert_at_unicode_anchor_prefix_does_not_panic() { let original = "line one\nline two\n"; fs::write(project.join("src/main.rs"), original).unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let long_anchor = format!("{}é", "a".repeat(99)); @@ -3646,7 +3776,7 @@ async fn test_insert_at_ambiguous_anchor() { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3683,7 +3813,7 @@ async fn test_insert_at_preserves_trailing_newline() { let original = "fn hello() {}\n\nfn world() {}\n"; fs::write(project.join("src/lib.rs"), original).unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -3882,7 +4012,7 @@ pub fn unrelated(x: i32) -> i32 { ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -4251,7 +4381,7 @@ async fn test_test_risk_excludes_non_src_functions_from_denominator_and_risks() #[tokio::test] async fn test_session_start() { - let (cg, dir) = setup_project().await; + let (cg, _dir) = setup_project().await; let result = handle_tool_call(&cg, "tracedecay_session_start", json!({}), None, None) .await .unwrap(); @@ -4259,13 +4389,13 @@ async fn test_session_start() { let output: serde_json::Value = serde_json::from_str(text).unwrap(); assert!(output["quality_signal"].as_u64().is_some()); assert_eq!(output["status"].as_str().unwrap(), "baseline_saved"); - let baseline_path = dir.path().join(".tracedecay/session_baseline.json"); + let baseline_path = project_data_dir(&cg).join("session_baseline.json"); assert!(baseline_path.exists(), "baseline file should exist"); } #[tokio::test] async fn test_session_end() { - let (cg, dir) = setup_project().await; + let (cg, _dir) = setup_project().await; handle_tool_call(&cg, "tracedecay_session_start", json!({}), None, None) .await .unwrap(); @@ -4277,7 +4407,7 @@ async fn test_session_end() { assert!(output["signal_before"].as_u64().is_some()); assert!(output["signal_after"].as_u64().is_some()); assert!(output["delta"].is_number()); - let baseline_path = dir.path().join(".tracedecay/session_baseline.json"); + let baseline_path = project_data_dir(&cg).join("session_baseline.json"); assert!( !baseline_path.exists(), "baseline should be removed after session_end" @@ -4406,7 +4536,7 @@ fn helper() { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_todos", json!({}), None, None) @@ -4453,7 +4583,7 @@ fn main() { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -5045,6 +5175,90 @@ async fn memory_feedback_and_status_include_trust_fields() { assert!(status["memory"].get("missing_vector_count").is_some()); } +#[tokio::test] +async fn memory_fact_store_uses_project_store_when_serving_branch_db() { + fn git(project: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(project) + .output() + .unwrap_or_else(|err| panic!("git {args:?} failed to spawn: {err}")); + assert!( + output.status.success(), + "git {args:?} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let _guard = GLOBAL_DB_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let home = dir.path().join("home"); + let _home_guard = HomeEnvGuard::set(&home); + let _global_db_guard = GlobalDbEnvGuard::set(&home.join(".tracedecay/global.db")); + + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn f() -> u32 { 1 }\n").unwrap(); + git(&project, &["init"]); + git(&project, &["config", "user.email", "test@test.com"]); + git(&project, &["config", "user.name", "Test"]); + git(&project, &["add", "."]); + git(&project, &["commit", "-m", "initial"]); + git(&project, &["branch", "-M", "main"]); + + let cg = TraceDecay::init(&project).await.unwrap(); + index_all_retrying_sync_lock(&cg).await; + git(&project, &["checkout", "-b", "feature"]); + let cg = TraceDecay::open(&project).await.unwrap(); + assert_ne!( + cg.db_path(), + cg.store_layout().graph_db_path, + "test must serve a branch DB distinct from the shared project store" + ); + + let added = handle_tool_call( + &cg, + "tracedecay_fact_store", + json!({ + "action": "add", + "content": "Branch memory writes stay project-scoped", + "category": "project", + "entity": "Branch memory" + }), + None, + None, + ) + .await + .unwrap(); + let added: Value = serde_json::from_str(extract_text(&added.value)).unwrap(); + let fact_id = added["fact"]["fact_id"] + .as_i64() + .expect("fact_store add should return numeric id"); + + let (branch_db, _) = Database::open(&cg.db_path()).await.unwrap(); + assert!( + MemoryStore::new(branch_db.conn()) + .get_fact(fact_id) + .await + .unwrap() + .is_none(), + "MCP memory writes must not be scoped to the branch graph DB" + ); + + let (project_db, _) = Database::open(&cg.store_layout().graph_db_path) + .await + .unwrap(); + assert!( + MemoryStore::new(project_db.conn()) + .get_fact(fact_id) + .await + .unwrap() + .is_some(), + "MCP memory writes must land in the shared project memory store" + ); +} + #[tokio::test] async fn memory_tools_validate_malformed_inputs() { let (cg, _dir) = setup_project().await; @@ -5502,7 +5716,7 @@ async fn seed_lcm_session_message_in_db( } async fn project_lcm_conn(cg: &TraceDecay) -> libsql::Connection { - let db = libsql::Builder::new_local(cg.project_root().join(".tracedecay/sessions.db")) + let db = libsql::Builder::new_local(project_session_db_path(cg)) .build() .await .unwrap(); @@ -5912,15 +6126,9 @@ async fn lcm_doctor_reports_missing_and_orphan_payloads_without_payload_bodies() .await .expect("externalized raw message should load"); let payload_ref = raw.payload_ref.expect("external payload ref"); - fs::remove_file( - cg.project_root() - .join(".tracedecay/lcm-payloads") - .join(&payload_ref), - ) - .unwrap(); + fs::remove_file(lcm_payload_dir(&cg).join(&payload_ref)).unwrap(); fs::write( - cg.project_root() - .join(".tracedecay/lcm-payloads/payload_unreferenced_test.payload"), + lcm_payload_dir(&cg).join("payload_unreferenced_test.payload"), "orphan body that must not be returned", ) .unwrap(); @@ -5960,7 +6168,7 @@ async fn lcm_doctor_reports_placeholder_recovery_and_gc_candidates_without_bodie ) .await; - let payload_dir = cg.project_root().join(".tracedecay/lcm-payloads"); + let payload_dir = lcm_payload_dir(&cg); fs::create_dir_all(&payload_dir).unwrap(); fs::write( payload_dir.join("payload_gc_candidate_test.payload"), @@ -6026,7 +6234,7 @@ async fn lcm_doctor_gc_mode_preview_and_apply_reports_without_body_leaks() { 1, ) .await; - let payload_dir = cg.project_root().join(".tracedecay/lcm-payloads"); + let payload_dir = lcm_payload_dir(&cg); fs::create_dir_all(&payload_dir).unwrap(); let payload_ref = "payload_cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.payload"; @@ -6392,7 +6600,7 @@ async fn lcm_doctor_scopes_orphan_lifecycle_debt_to_requested_session() { #[tokio::test] async fn lcm_doctor_diagnose_does_not_create_missing_project_session_db() { let (cg, _dir) = setup_project().await; - let db_path = tracedecay::sessions::cursor::project_session_db_path(cg.project_root()); + let db_path = project_session_db_path(&cg); if db_path.exists() { fs::remove_file(&db_path).unwrap(); } @@ -7255,7 +7463,7 @@ async fn lcm_status_response_is_valid_json_and_omits_payload_secrets() { ); let secret = format!("MCP_STATUS_SECRET_PAYLOAD\n{}", "Q".repeat(300_000)); - db.lcm_store(cg.project_root().join(".tracedecay")) + db.lcm_store(project_data_dir(&cg)) .ingest_raw_message(&SessionMessageRecord { provider: "cursor".to_string(), message_id: "lcm-status-secret-message".to_string(), @@ -8573,7 +8781,7 @@ async fn memory_status_repairs_dirty_banks_before_reporting() { .unwrap(); let added: Value = serde_json::from_str(extract_text(&added.value)).unwrap(); let fact_id = added["fact"]["fact_id"].as_i64().unwrap(); - let db_path = cg.project_root().join(".tracedecay").join("tracedecay.db"); + let db_path = project_graph_db(&cg); let (db, _) = Database::open(&db_path).await.unwrap(); db.conn() .execute( @@ -8640,7 +8848,7 @@ pub fn gmres(x: u32) -> u32 { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); (cg, dir) } @@ -8694,7 +8902,7 @@ pub fn second() { dep::shared(); } ) .unwrap(); fs::write(project.join("src/dep.rs"), "pub fn shared() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -8738,7 +8946,7 @@ pub fn nonrecursive() -> u32 { 42 } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await @@ -8782,7 +8990,7 @@ impl Triplet { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await @@ -8815,7 +9023,7 @@ pub fn c() { a(); } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_recursion", json!({}), None, None) .await @@ -8892,7 +9100,7 @@ async fn changelog_filters_directory_paths() { .current_dir(project) .output() .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; // Intentionally skipping `index_all` — the changelog handler reads from // git directly, not the index, and including the index sync subjects // this test to a pre-existing SyncLock contention flake. @@ -8945,7 +9153,7 @@ pub fn used_one() -> HashMap { HashMap::new() } ) .unwrap(); fs::write(project.join("src/inner.rs"), "pub fn inner_fn() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_unused_imports", json!({}), None, None) @@ -8980,7 +9188,7 @@ pub fn caller() { called(); } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let default_result = handle_tool_call(&cg, "tracedecay_dead_code", json!({}), None, None) @@ -9053,7 +9261,7 @@ pub trait T {} "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let qm = GraphQueryManager::new(cg.db()); @@ -9105,7 +9313,7 @@ fn edited_only_test() { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -9154,7 +9362,7 @@ async fn diagnose_normalizes_absolute_and_backslash_paths() { let project = dir.path(); fs::create_dir_all(project.join("src")).unwrap(); fs::write(project.join("src/lib.rs"), "pub fn target() {}\n").unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let abs_path = project.join("src/lib.rs"); @@ -9211,7 +9419,7 @@ pub fn helper() {} "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let caller_id = find_node_id(&cg, "caller").await; @@ -9267,7 +9475,7 @@ impl Default for B { fn default() -> Self { B } } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -9321,7 +9529,7 @@ async fn circular_reports_one_entry_per_scc_not_per_walk() { "use crate::a::a_fn;\npub fn c_fn() { a_fn(); }\n", ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_circular", json!({}), None, None) .await @@ -9367,7 +9575,7 @@ pub fn leaf() {} "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -9429,7 +9637,7 @@ pub fn h() { a(); } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -9507,7 +9715,7 @@ impl Triplet { "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -9543,7 +9751,7 @@ pub trait Leaf: Middle {} "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_inheritance_depth", json!({}), None, None) .await @@ -9604,7 +9812,7 @@ async fn circular_emits_disjoint_sccs_under_load() { ) .unwrap(); } - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_circular", json!({}), None, None) .await @@ -9643,7 +9851,7 @@ async fn diff_context_dedupes_modified_symbols_on_duplicate_input() { "pub struct S; pub fn one() {} pub fn two() {}\n", ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( @@ -9699,7 +9907,7 @@ async fn changelog_filters_deleted_directory_entries() { fs::remove_dir_all(project.join("crates")).unwrap(); git(project, &["add", "-A"]); git(project, &["commit", "-m", "drop crates"]); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; // Intentionally skipping `index_all` — the changelog handler reads from // git directly and the sync lock has a pre-existing parallel-test flake. let result = handle_tool_call( @@ -9766,7 +9974,7 @@ async fn pr_context_collapses_cargo_toml_keys() { git(project, &["add", "."]); git(project, &["commit", "-m", "deps"]); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; // Intentionally skipping `index_all()` — pr_context reads the diff // from git directly and classifies Cargo.toml as `config` before any // index lookup, so we don't need the index to verify the collapse @@ -9832,7 +10040,7 @@ pub fn used() -> HashMap { HashMap::new() } "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_unused_imports", json!({}), None, None) .await @@ -9899,7 +10107,7 @@ fn dead_helper_with_attr() {} "#, ) .unwrap(); - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call(&cg, "tracedecay_dead_code", json!({}), None, None) @@ -9958,7 +10166,7 @@ pub mod e; ) .unwrap(); } - let cg = TraceDecay::init(project).await.unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let result = handle_tool_call( &cg, @@ -9989,9 +10197,7 @@ async fn refresh_file_token_map_picks_up_new_files() { let project = tmp.path(); std::fs::write(project.join("a.rs"), "fn a() {}").unwrap(); - let cg = tracedecay::tracedecay::TraceDecay::init(project) - .await - .unwrap(); + let (cg, _env) = init_test_project(project).await; cg.sync().await.unwrap(); let server = tracedecay::mcp::McpServer::new(cg, None).await; @@ -10025,9 +10231,7 @@ async fn mcp_server_owns_watcher_and_refreshes_token_map_on_change() { let project = tmp.path(); std::fs::write(project.join("a.rs"), "fn a() {}").unwrap(); - let cg = tracedecay::tracedecay::TraceDecay::init(project) - .await - .unwrap(); + let (cg, _env) = init_test_project(project).await; cg.sync().await.unwrap(); let server = tracedecay::mcp::McpServer::new(cg, None).await; @@ -10434,7 +10638,7 @@ async fn repeated_lcm_calls_skip_schema_reensure_per_process() { json!(tracedecay::sessions::lcm::LCM_SCHEMA_VERSION) ); - let db_path = tracedecay::sessions::cursor::project_session_db_path(cg.project_root()); + let db_path = project_session_db_path(&cg); { let db = libsql::Builder::new_local(&db_path).build().await.unwrap(); let conn = db.connect().unwrap(); @@ -10537,9 +10741,7 @@ async fn lcm_read_only_tools_return_not_ingested_without_creating_sessions_db() let dir = tempfile::tempdir().unwrap(); let project = dir.path(); std::fs::write(project.join("lib.rs"), "fn f() {}").unwrap(); - let cg = tracedecay::tracedecay::TraceDecay::init(project) - .await - .unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let db_path = tracedecay::sessions::cursor::project_session_db_path(project); @@ -10611,9 +10813,7 @@ async fn lcm_expand_query_context_max_tokens_is_independent_of_max_tokens() { let dir = tempfile::tempdir().unwrap(); let project = dir.path(); std::fs::write(project.join("lib.rs"), "fn f() {}").unwrap(); - let cg = tracedecay::tracedecay::TraceDecay::init(project) - .await - .unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); // With no sessions.db the tool returns not_ingested — that is fine here; @@ -10660,9 +10860,7 @@ async fn wait_for_startup_catch_up_waits_for_transcript_ingest_flag() { let project = dir.path(); std::fs::write(project.join("lib.rs"), "fn f() {}").unwrap(); - let cg = tracedecay::tracedecay::TraceDecay::init(project) - .await - .unwrap(); + let (cg, _env) = init_test_project(project).await; cg.index_all().await.unwrap(); let server = tracedecay::mcp::McpServer::new(cg, None).await; diff --git a/tests/mcp_server_test.rs b/tests/mcp_server_test.rs index be64046c..92dbddcf 100644 --- a/tests/mcp_server_test.rs +++ b/tests/mcp_server_test.rs @@ -1974,14 +1974,10 @@ async fn ledger_records_by_default_without_env_opt_in() { let mut env = vec![EnvVarGuard::set("HOME", tmp_home.path().as_os_str())]; #[cfg(target_os = "windows")] env.push(EnvVarGuard::set("USERPROFILE", tmp_home.path().as_os_str())); - // Simulate a real (non-cargo) launch: neither the legacy opt-in nor the + // Simulate a real (non-cargo) launch: neither the opt-in nor the // cargo-test opt-out is present, so the default-on path is exercised. - // Legacy `TOKENSAVE_*` spellings are still honored at runtime (and - // `.cargo/config.toml` injects the legacy opt-out), so clear both. env.push(EnvVarGuard::unset("TRACEDECAY_ENABLE_GLOBAL_DB")); env.push(EnvVarGuard::unset("TRACEDECAY_DISABLE_GLOBAL_DB")); - env.push(EnvVarGuard::unset("TOKENSAVE_ENABLE_GLOBAL_DB")); - env.push(EnvVarGuard::unset("TOKENSAVE_DISABLE_GLOBAL_DB")); assert!(tracedecay::global_db::global_accounting_enabled()); let (server, proj_tmp) = setup_server().await; @@ -2021,12 +2017,8 @@ fn global_accounting_env_overrides() { .unwrap_or_else(std::sync::PoisonError::into_inner); use tracedecay::global_db::{global_accounting_mode, AccountingMode}; - // Clear the legacy `TOKENSAVE_*` spellings too: they remain honored as a - // runtime fallback, and `.cargo/config.toml` injects the legacy opt-out. let _clear_enable = EnvVarGuard::unset("TRACEDECAY_ENABLE_GLOBAL_DB"); let _clear_disable = EnvVarGuard::unset("TRACEDECAY_DISABLE_GLOBAL_DB"); - let _clear_legacy_enable = EnvVarGuard::unset("TOKENSAVE_ENABLE_GLOBAL_DB"); - let _clear_legacy_disable = EnvVarGuard::unset("TOKENSAVE_DISABLE_GLOBAL_DB"); assert_eq!(global_accounting_mode(), AccountingMode::Default); assert!(global_accounting_mode().enabled()); diff --git a/tests/memory_eval_test.rs b/tests/memory_eval_test.rs index 5e8a9291..71e0d241 100644 --- a/tests/memory_eval_test.rs +++ b/tests/memory_eval_test.rs @@ -3,7 +3,7 @@ //! Each scenario in `eval/scenarios/*.json` seeds a throwaway fixture project, //! replays a scripted tool-call sequence through the real `tracedecay` binary //! (the same write/curation paths an agent hits over MCP), then asserts on -//! end-state with plain SQL against the fixture's `.tracedecay/tracedecay.db`. +//! end-state with plain SQL against the fixture's resolved project graph DB. //! No LLM is involved; the cost-gated real-model layer lives in //! `eval/run_real_model.py`. //! @@ -208,25 +208,33 @@ fn load_scenario(id: &str) -> Scenario { } struct Fixture { - home: TempDir, - project: TempDir, + _home: TempDir, + home_path: PathBuf, + _project: TempDir, + project_path: PathBuf, } impl Fixture { fn db_path(&self) -> PathBuf { - self.project.path().join(".tracedecay/tracedecay.db") + tracedecay::storage::resolve_layout(&self.project_path, &self.home_path.join(".tracedecay")) + .expect("resolve fixture storage layout") + .graph_db_path } fn command(&self) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); command - .current_dir(self.project.path()) - .env("HOME", self.home.path()) - .env("USERPROFILE", self.home.path()) - .env("XDG_CONFIG_HOME", self.home.path().join(".config")) + .current_dir(&self.project_path) + .env("HOME", &self.home_path) + .env("USERPROFILE", &self.home_path) + .env("XDG_CONFIG_HOME", self.home_path.join(".config")) + .env( + tracedecay::config::USER_DATA_DIR_ENV, + self.home_path.join(".tracedecay"), + ) .env( "TRACEDECAY_GLOBAL_DB", - self.home.path().join(".tracedecay/global.db"), + self.home_path.join(".tracedecay/global.db"), ) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -353,16 +361,29 @@ fn fact_ids_by_source(fixture: &Fixture) -> HashMap> { }) } +fn canonical_test_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path) + .unwrap_or_else(|e| panic!("failed to create test dir {}: {e}", path.display())); + path.canonicalize() + .unwrap_or_else(|e| panic!("failed to canonicalize test dir {}: {e}", path.display())) +} + fn build_fixture(setup: &Setup) -> Fixture { + let home = TempDir::new().expect("home tempdir"); + let project = TempDir::new().expect("project tempdir"); + let home_path = canonical_test_dir(home.path()); + let project_path = canonical_test_dir(project.path()); let fixture = Fixture { - home: TempDir::new().expect("home tempdir"), - project: TempDir::new().expect("project tempdir"), + _home: home, + home_path, + _project: project, + project_path, }; - let src = fixture.project.path().join("src"); + let src = fixture.project_path.join("src"); std::fs::create_dir_all(&src).expect("create src dir"); std::fs::write(src.join("lib.rs"), "pub fn eval_fixture_marker() {}\n").expect("write lib.rs"); for (name, contents) in &setup.files { - std::fs::write(fixture.project.path().join(name), contents) + std::fs::write(fixture.project_path.join(name), contents) .unwrap_or_else(|e| panic!("write fixture file {name}: {e}")); } run_ok(&fixture, &["init"]); diff --git a/tests/memory_test.rs b/tests/memory_test.rs index 1189dcae..fed2eb1c 100644 --- a/tests/memory_test.rs +++ b/tests/memory_test.rs @@ -119,8 +119,9 @@ async fn memory_bank_fact_count(db: &Database, bank_name: &str) -> Option { } async fn clear_fact_vector(cg: &TraceDecay, fact_id: i64) { - let db_path = cg.project_root().join(".tracedecay").join("tracedecay.db"); - let (db, _) = Database::open(&db_path).await.unwrap(); + let (db, _) = Database::open(&cg.store_layout().graph_db_path) + .await + .unwrap(); db.conn() .execute( "UPDATE memory_facts @@ -134,8 +135,9 @@ async fn clear_fact_vector(cg: &TraceDecay, fact_id: i64) { } async fn set_fact_updated_at(cg: &TraceDecay, fact_id: i64, updated_at: i64) { - let db_path = cg.project_root().join(".tracedecay").join("tracedecay.db"); - let (db, _) = Database::open(&db_path).await.unwrap(); + let (db, _) = Database::open(&cg.store_layout().graph_db_path) + .await + .unwrap(); db.conn() .execute( "UPDATE memory_facts SET updated_at = ?2 WHERE fact_id = ?1", @@ -147,8 +149,9 @@ async fn set_fact_updated_at(cg: &TraceDecay, fact_id: i64, updated_at: i64) { } async fn fact_updated_at(cg: &TraceDecay, fact_id: i64) -> i64 { - let db_path = cg.project_root().join(".tracedecay").join("tracedecay.db"); - let (db, _) = Database::open(&db_path).await.unwrap(); + let (db, _) = Database::open(&cg.store_layout().graph_db_path) + .await + .unwrap(); let mut rows = db .conn() .query( diff --git a/tests/migrate_inventory_test.rs b/tests/migrate_inventory_test.rs index 2ed25e68..03ba4ae5 100644 --- a/tests/migrate_inventory_test.rs +++ b/tests/migrate_inventory_test.rs @@ -75,12 +75,6 @@ fn make_project_store(root: &Path) { fs::write(data_dir.join("tracedecay.db"), b"not sqlite").unwrap(); } -fn make_legacy_store(root: &Path) { - let data_dir = root.join(".tokensave"); - fs::create_dir_all(&data_dir).unwrap(); - fs::write(data_dir.join("tokensave.db"), b"legacy sqlite placeholder").unwrap(); -} - fn single_ok_inventory(project: &Path, data_dir: &Path, graph_db: &Path) -> MigrationInventory { MigrationInventory { stores: vec![StoreInventory { @@ -252,31 +246,6 @@ async fn inventory_does_not_open_or_recover_dirty_project_db() { assert!(store.statuses.contains(&StoreStatus::Corrupt)); } -#[tokio::test] -async fn inventory_discovers_legacy_tokensave_store() { - let dir = TempDir::new().unwrap(); - let legacy = dir.path().join("legacy"); - fs::create_dir_all(&legacy).unwrap(); - make_legacy_store(&legacy); - - let report = build_inventory(MigrationInventoryOptions { - roots: vec![dir.path().to_path_buf()], - ..MigrationInventoryOptions::default() - }) - .await - .unwrap(); - - let store = report - .stores - .iter() - .find(|store| store.project_root == legacy) - .expect("legacy store should be inventoried"); - assert_eq!(store.brand, StoreBrand::LegacyTokensave); - assert_eq!(store.role, StoreRole::CodeProjectStore); - assert_eq!(store.registry_status, RegistryStatus::Unregistered); - assert_eq!(store.db_path, legacy.join(".tokensave/tokensave.db")); -} - #[tokio::test] async fn inventory_records_project_store_sidecar_artifacts() { let dir = TempDir::new().unwrap(); @@ -603,12 +572,12 @@ fn inventory_discovers_hermes_home_profiles_and_state_dbs() { let hermes_home = dir.path().join("custom-hermes"); let default_store = hermes_home.join(".tracedecay"); let work_profile = hermes_home.join("profiles/work"); - let work_store = work_profile.join(".tokensave"); + let work_store = work_profile.join(".tracedecay"); fs::create_dir_all(&default_store).unwrap(); fs::create_dir_all(&work_store).unwrap(); fs::write(default_store.join("tracedecay.db"), b"not sqlite").unwrap(); fs::write(hermes_home.join("state.db"), b"not sqlite").unwrap(); - fs::write(work_store.join("tokensave.db"), b"not sqlite").unwrap(); + fs::write(work_store.join("tracedecay.db"), b"not sqlite").unwrap(); fs::write(work_profile.join("state.db"), b"not sqlite").unwrap(); let report = with_env_vars(&[("HERMES_HOME", Some(&hermes_home))], || { @@ -631,9 +600,9 @@ fn inventory_discovers_hermes_home_profiles_and_state_dbs() { .stores .iter() .find(|store| store.data_dir == work_store) - .expect("named Hermes profile legacy store should be inventoried"); + .expect("named Hermes profile store should be inventoried"); assert_eq!(work.role, StoreRole::HermesProfileStore); - assert_eq!(work.brand, StoreBrand::LegacyTokensave); + assert_eq!(work.brand, StoreBrand::TraceDecay); assert!(report.stores.iter().any(|store| { store.role == StoreRole::HermesStateDbSource diff --git a/tests/regression_core_engine_test.rs b/tests/regression_core_engine_test.rs index 5880d61a..ef190b54 100644 --- a/tests/regression_core_engine_test.rs +++ b/tests/regression_core_engine_test.rs @@ -6,6 +6,7 @@ use std::fs; use tempfile::TempDir; +use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; /// Finds the node ID for a symbol by name, panicking if not found. @@ -374,8 +375,10 @@ async fn repeated_target_edits_keep_unresolved_refs_bounded() { async fn stale_sync_lock_with_dead_pid_is_reclaimed() { let dir = TempDir::new().unwrap(); let project = dir.path(); - fs::create_dir_all(project.join(".tracedecay")).unwrap(); - let lock_path = project.join(".tracedecay/sync.lock"); + TraceDecay::init(project).await.unwrap(); + let lock_path = resolve_layout_for_current_profile(project) + .unwrap() + .sync_lock_path; // A PID well out of range can never be alive -> the lock is stale. fs::write(&lock_path, "4294967294").unwrap(); @@ -397,8 +400,10 @@ async fn stale_sync_lock_with_dead_pid_is_reclaimed() { async fn live_sync_lock_is_not_reclaimed() { let dir = TempDir::new().unwrap(); let project = dir.path(); - fs::create_dir_all(project.join(".tracedecay")).unwrap(); - let lock_path = project.join(".tracedecay/sync.lock"); + TraceDecay::init(project).await.unwrap(); + let lock_path = resolve_layout_for_current_profile(project) + .unwrap() + .sync_lock_path; // Our own PID is alive -> the lock must be treated as in-progress. fs::write(&lock_path, format!("{}", std::process::id())).unwrap(); diff --git a/tests/storage_resolver_test.rs b/tests/storage_resolver_test.rs index 1d2a2de0..cddf0411 100644 --- a/tests/storage_resolver_test.rs +++ b/tests/storage_resolver_test.rs @@ -10,16 +10,17 @@ use tracedecay::branch_meta::{self, BranchMeta}; use tracedecay::config::{discover_project_root, get_config_path, load_config}; use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::db::Database; +use tracedecay::global_db::GlobalDb; use tracedecay::mcp::response_handles::{ retrieve_response_handle, store_response_handle, ResponseHandleLookup, }; use tracedecay::sessions::cursor::project_session_db_path; use tracedecay::storage::{ - profile_sharded_layout, project_local_layout, read_enrollment_marker, read_store_manifest, - resolve_layout, resolve_lcm_payload_root, resolve_project_session_db_path, - resolve_response_handle_root, write_store_manifest, ActiveProjectContext, EnrollmentMarker, - GraphScopeId, PrivateStoreIo, ProjectPath, StorageMode, StoreArtifactPath, - STORE_MANIFEST_FILENAME, + default_profile_project_id, default_profile_sharded_layout, profile_sharded_layout, + read_enrollment_marker, read_store_manifest, resolve_layout, resolve_lcm_payload_root, + resolve_project_session_db_path, resolve_response_handle_root, write_store_manifest, + ActiveProjectContext, EnrollmentMarker, GraphScopeId, PrivateStoreIo, ProjectPath, StorageMode, + StoreArtifactPath, STORE_MANIFEST_FILENAME, }; use tracedecay::tracedecay::TraceDecay; @@ -36,8 +37,10 @@ impl HomeGuard { let previous_home = std::env::var_os("HOME"); let previous_userprofile = std::env::var_os("USERPROFILE"); let previous_data_dir = std::env::var_os(USER_DATA_DIR_ENV); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); + fs::create_dir_all(home).unwrap(); + let home = canonical_temp_path(home); + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); std::env::set_var(USER_DATA_DIR_ENV, home.join(".tracedecay")); Self { previous_home, @@ -84,6 +87,12 @@ fn canonical_temp_path(path: &Path) -> PathBuf { } } +fn test_home(dir: &TempDir) -> PathBuf { + let home = dir.path().join("home"); + fs::create_dir_all(&home).unwrap(); + canonical_temp_path(&home) +} + #[test] fn enrollment_marker_is_discovered_without_graph_db() { let dir = TempDir::new().unwrap(); @@ -167,23 +176,6 @@ fn project_local_marker_without_graph_db_is_not_initialized() { assert!(!TraceDecay::is_initialized(root)); } -#[test] -fn tracedecay_marker_directory_does_not_hide_legacy_project_db() { - let dir = TempDir::new().unwrap(); - let root = dir.path(); - fs::create_dir_all(root.join(".tracedecay")).unwrap(); - fs::write( - root.join(".tracedecay/enrollment.json"), - r#"{"project_id":"proj_local","storage_mode":"project_local"}"#, - ) - .unwrap(); - fs::create_dir_all(root.join(".tokensave")).unwrap(); - fs::write(root.join(".tokensave/tokensave.db"), b"legacy").unwrap(); - - assert_eq!(discover_project_root(root), Some(root.to_path_buf())); - assert!(TraceDecay::is_initialized(root)); -} - #[test] fn profile_sharded_layout_maps_marker_to_profile_store_paths() { let dir = TempDir::new().unwrap(); @@ -296,19 +288,28 @@ fn store_manifest_write_rejects_symlinked_parent_components() { } #[test] -fn resolve_layout_uses_project_local_paths_without_profile_marker() { +fn resolve_layout_defaults_to_profile_shard_without_marker_or_local_db() { let dir = TempDir::new().unwrap(); let root = dir.path().join("repo"); let profile = dir.path().join("profile"); - fs::create_dir_all(root.join(".tracedecay")).unwrap(); + fs::create_dir_all(&root).unwrap(); let layout = resolve_layout(&root, &profile).unwrap(); - let local = project_local_layout(&root); + let project_id = default_profile_project_id(&root); - assert_eq!(layout.storage_mode, StorageMode::ProjectLocal); - assert_eq!(layout.data_root, root.join(".tracedecay")); - assert_eq!(layout.graph_db_path, root.join(".tracedecay/tracedecay.db")); - assert_eq!(layout, local); + assert_eq!(layout.storage_mode, StorageMode::ProfileSharded); + assert_eq!( + layout.identity.project_id.as_deref(), + Some(project_id.as_str()) + ); + assert_eq!( + layout.data_root, + profile.join(format!("projects/{project_id}")) + ); + assert_eq!( + layout.graph_db_path, + profile.join(format!("projects/{project_id}/tracedecay.db")) + ); } #[tokio::test] @@ -316,7 +317,7 @@ async fn config_path_uses_profile_shard_when_enrolled() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); - let home = dir.path().join("home"); + let home = test_home(&dir); let shard_root = home.join(".tracedecay/projects/proj_123"); fs::create_dir_all(project.join(".tracedecay")).unwrap(); fs::create_dir_all(&shard_root).unwrap(); @@ -349,15 +350,20 @@ async fn config_path_uses_profile_shard_when_enrolled() { ); } -#[test] -fn project_local_config_path_is_unchanged_without_enrollment() { +#[tokio::test] +async fn config_path_defaults_to_profile_shard_without_enrollment() { + let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); fs::create_dir_all(&project).unwrap(); + let _home_guard = HomeGuard::set(&home); + let project_id = default_profile_project_id(&project); assert_eq!( get_config_path(&project), - project.join(".tracedecay/config.json") + profile_root.join(format!("projects/{project_id}/config.json")) ); } @@ -366,7 +372,8 @@ fn active_project_context_keeps_layout_and_scope_identity() { let dir = TempDir::new().unwrap(); let root = dir.path().join("repo"); fs::create_dir_all(&root).unwrap(); - let layout = project_local_layout(&root); + let profile = dir.path().join("profile"); + let layout = default_profile_sharded_layout(&root, &profile).unwrap(); let context = ActiveProjectContext::new(layout.clone(), GraphScopeId::Project); @@ -374,7 +381,10 @@ fn active_project_context_keeps_layout_and_scope_identity() { assert_eq!(context.scope_id, GraphScopeId::Project); assert_eq!( context.query_target.graph_db_path, - root.join(".tracedecay/tracedecay.db") + profile.join(format!( + "projects/{}/tracedecay.db", + default_profile_project_id(&root) + )) ); } @@ -523,7 +533,7 @@ async fn resolved_project_store_helpers_route_profile_sharded_session_artifacts( let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); - let home = dir.path().join("home"); + let home = test_home(&dir); let profile_root = home.join(".tracedecay"); fs::create_dir_all(&project).unwrap(); let _home_guard = HomeGuard::set(&home); @@ -548,38 +558,99 @@ async fn resolved_project_store_helpers_route_profile_sharded_session_artifacts( } #[tokio::test] -async fn resolved_project_store_helpers_preserve_repo_local_artifact_paths() { +async fn resolved_project_store_helpers_default_to_profile_sharded_artifact_paths() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); - let home = dir.path().join("home"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); fs::create_dir_all(&project).unwrap(); let _home_guard = HomeGuard::set(&home); + let project_id = default_profile_project_id(&project); assert_eq!( resolve_project_session_db_path(&project).unwrap(), - project.join(".tracedecay/sessions.db") - ); - assert_eq!( - resolve_response_handle_root(&project).unwrap(), - project.join(".tracedecay/response-handles") + profile_root.join(format!("projects/{project_id}/sessions.db")) ); assert_eq!( - resolve_lcm_payload_root(&project).unwrap(), - project.join(".tracedecay/lcm-payloads") + project_session_db_path(&project), + profile_root.join(format!("projects/{project_id}/sessions.db")) ); +} + +#[tokio::test] +async fn hermes_profile_home_session_path_wins_over_default_profile_shard() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let hermes_home = dir.path().join(".hermes"); + let home = test_home(&dir); + fs::create_dir_all(&hermes_home).unwrap(); + fs::write( + hermes_home.join("config.yaml"), + "memory:\n provider: tracedecay\n", + ) + .unwrap(); + let _home_guard = HomeGuard::set(&home); + + let expected = hermes_home.join(".tracedecay/sessions.db"); + assert_eq!(project_session_db_path(&hermes_home), expected); assert_eq!( - project_session_db_path(&project), - project.join(".tracedecay/sessions.db") + tracedecay::sessions::cursor::resolved_project_session_db_path(&hermes_home) + .await + .unwrap(), + expected ); } +#[tokio::test] +async fn trace_decay_init_defaults_to_profile_shard_without_repo_marker() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let child = project.join("src"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + fs::create_dir_all(&child).unwrap(); + let _home_guard = HomeGuard::set(&home); + let project_id = default_profile_project_id(&project); + let shard_root = profile_root.join(format!("projects/{project_id}")); + + assert!(!TraceDecay::is_initialized(&project)); + + let cg = TraceDecay::init(&project).await.unwrap(); + + assert_eq!(cg.store_layout().storage_mode, StorageMode::ProfileSharded); + assert_eq!(cg.store_layout().data_root, shard_root); + assert_eq!(cg.db_path(), shard_root.join("tracedecay.db")); + assert_eq!(discover_project_root(&child), Some(project.clone())); + assert!(!project.join(".tracedecay").exists()); + assert!(shard_root.join("config.json").exists()); + assert!(shard_root.join(STORE_MANIFEST_FILENAME).exists()); +} + +#[tokio::test] +async fn trace_decay_init_registers_default_profile_shard_globally() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let home = test_home(&dir); + fs::create_dir_all(&project).unwrap(); + let _home_guard = HomeGuard::set(&home); + let project_id = default_profile_project_id(&project); + + TraceDecay::init(&project).await.unwrap(); + let db = GlobalDb::open().await.unwrap(); + let resolution = db.resolve_project_store_by_alias(&project).await.unwrap(); + + assert_eq!(resolution.project.project_id, project_id); +} + #[tokio::test] async fn response_handles_route_to_profile_shard_when_enrolled() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); - let home = dir.path().join("home"); + let home = test_home(&dir); let shard_root = home.join(".tracedecay/projects/proj_123"); fs::create_dir_all(&project).unwrap(); let _home_guard = HomeGuard::set(&home); @@ -603,7 +674,7 @@ async fn trace_decay_open_uses_profile_shard_paths_from_enrollment_marker() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); - let home = dir.path().join("home"); + let home = test_home(&dir); let profile_root = home.join(".tracedecay"); let shard_root = profile_root.join("projects/proj_123"); fs::create_dir_all(project.join(".tracedecay")).unwrap(); diff --git a/tests/tool_first_touch_test.rs b/tests/tool_first_touch_test.rs index f1fbf8e1..6e606328 100644 --- a/tests/tool_first_touch_test.rs +++ b/tests/tool_first_touch_test.rs @@ -11,9 +11,23 @@ use std::process::Command; use tempfile::TempDir; -fn run_tool(cwd: &Path, args: &[&str]) -> std::process::Output { +fn canonical_temp_path(path: &Path) -> std::path::PathBuf { + #[cfg(windows)] + { + path.to_path_buf() + } + #[cfg(not(windows))] + { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) + } +} + +fn run_tool(cwd: &Path, home: &Path, args: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_tracedecay")) .current_dir(cwd) + .env("HOME", home) + .env("USERPROFILE", home) + .env("TRACEDECAY_GLOBAL_DB", home.join(".tracedecay/global.db")) .arg("tool") .args(args) .output() @@ -24,12 +38,15 @@ fn run_tool(cwd: &Path, args: &[&str]) -> std::process::Output { fn fact_store_creates_profile_store_on_first_touch() { let home = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); - let profile = home.path().join(".hermes"); + let home_path = canonical_temp_path(home.path()); + let cwd_path = canonical_temp_path(cwd.path()); + let profile = home_path.join(".hermes"); std::fs::create_dir_all(&profile).unwrap(); let profile_arg = profile.to_string_lossy().to_string(); let output = run_tool( - cwd.path(), + &cwd_path, + &home_path, &[ "--project", &profile_arg, @@ -45,14 +62,20 @@ fn fact_store_creates_profile_store_on_first_touch() { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let graph_db_path = + tracedecay::storage::resolve_layout(&profile, &home_path.join(".tracedecay")) + .unwrap() + .graph_db_path; assert!( - profile.join(".tracedecay").join("tracedecay.db").is_file(), - "first touch should have created .tracedecay/tracedecay.db under the profile home" + graph_db_path.is_file(), + "first touch should have created the resolved profile graph DB at {}", + graph_db_path.display() ); // The store persists: a follow-up search finds the fact. let output = run_tool( - cwd.path(), + &cwd_path, + &home_path, &[ "--project", &profile_arg, @@ -73,8 +96,12 @@ fn fact_store_creates_profile_store_on_first_touch() { #[test] fn store_tools_without_explicit_project_still_require_init() { let cwd = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let cwd_path = canonical_temp_path(cwd.path()); + let home_path = canonical_temp_path(home.path()); let output = run_tool( - cwd.path(), + &cwd_path, + &home_path, &["fact_store", "--args", r#"{"action":"list"}"#], ); assert!( @@ -82,7 +109,7 @@ fn store_tools_without_explicit_project_still_require_init() { "without --project an uninitialised cwd must keep the init guidance" ); assert!( - !cwd.path().join(".tracedecay").exists(), + !cwd_path.join(".tracedecay").exists(), "no store may be silently created in the working directory" ); } @@ -91,8 +118,16 @@ fn store_tools_without_explicit_project_still_require_init() { fn code_graph_tools_keep_strict_init_requirement() { let target = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap(); - let target_arg = target.path().to_string_lossy().to_string(); - let output = run_tool(cwd.path(), &["--project", &target_arg, "status", "--json"]); + let home = TempDir::new().unwrap(); + let target_path = canonical_temp_path(target.path()); + let cwd_path = canonical_temp_path(cwd.path()); + let home_path = canonical_temp_path(home.path()); + let target_arg = target_path.to_string_lossy().to_string(); + let output = run_tool( + &cwd_path, + &home_path, + &["--project", &target_arg, "status", "--json"], + ); assert!( !output.status.success(), "code-graph tools must not bootstrap stores on first touch" @@ -102,5 +137,5 @@ fn code_graph_tools_keep_strict_init_requirement() { stderr.contains("no TraceDecay index found"), "expected init guidance, got:\n{stderr}" ); - assert!(!target.path().join(".tracedecay").exists()); + assert!(!target_path.join(".tracedecay").exists()); } diff --git a/tests/update_plugin_test.rs b/tests/update_plugin_test.rs index 3c7b0eb5..47146cf8 100644 --- a/tests/update_plugin_test.rs +++ b/tests/update_plugin_test.rs @@ -40,6 +40,12 @@ fn ctx(home: &Path, tracedecay_bin: &str) -> InstallContext { } } +fn ctx_with_project(home: &Path, tracedecay_bin: &str, project_root: &Path) -> InstallContext { + let mut ctx = ctx(home, tracedecay_bin); + ctx.project_root = Some(project_root.to_path_buf()); + ctx +} + fn bytes(path: &Path) -> Vec { std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) } @@ -240,6 +246,7 @@ fn cursor_update_plugin_reports_not_installed_without_a_bundle() { #[test] fn codex_update_plugin_refreshes_bundle_without_touching_config() { let home = TempDir::new().unwrap(); + let project_root = home.path().join("workspace"); let codex = get_integration("codex").unwrap(); codex.install(&ctx(home.path(), OLD_BIN)).unwrap(); @@ -250,7 +257,9 @@ fn codex_update_plugin_refreshes_bundle_without_touching_config() { std::fs::write(plugin_dir.join("user-note.txt"), "mine\n").unwrap(); let config_before = bytes(&codex_config); - let outcome = codex.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + let outcome = codex + .update_plugin(&ctx_with_project(home.path(), NEW_BIN, &project_root)) + .unwrap(); let UpdatePluginOutcome::Refreshed(paths) = outcome else { panic!("expected codex update_plugin to refresh the bundle"); }; @@ -263,9 +272,106 @@ fn codex_update_plugin_refreshes_bundle_without_touching_config() { assert!(text(&plugin_dir.join(".codex-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION"))); } +#[test] +fn codex_update_plugin_refreshes_cache_and_removes_bootstrap_source() { + let home = TempDir::new().unwrap(); + let project_root = home.path().join("workspace"); + let cached_plugin_dir = home + .path() + .join(".codex/plugins/cache/personal/tracedecay") + .join(env!("CARGO_PKG_VERSION")); + std::fs::create_dir_all(cached_plugin_dir.join(".codex-plugin")).unwrap(); + std::fs::write( + cached_plugin_dir.join(".codex-plugin/plugin.json"), + r#"{"name":"tracedecay","version":"0.0.0"}"#, + ) + .unwrap(); + + let bootstrap_dir = home.path().join("plugins/tracedecay"); + std::fs::create_dir_all(bootstrap_dir.join(".codex-plugin")).unwrap(); + std::fs::write( + bootstrap_dir.join(".codex-plugin/plugin.json"), + r#"{"name":"tracedecay","version":"0.0.0"}"#, + ) + .unwrap(); + std::fs::create_dir_all(bootstrap_dir.join("skills/stale-skill")).unwrap(); + std::fs::write( + bootstrap_dir.join("skills/stale-skill/SKILL.md"), + "---\nname: tracedecay:stale-skill\n---\n", + ) + .unwrap(); + std::fs::create_dir_all(home.path().join(".agents/plugins")).unwrap(); + std::fs::write( + home.path().join(".agents/plugins/marketplace.json"), + r#"{"interface":{"displayName":"Personal"},"name":"personal","plugins":[{"name":"tracedecay","source":{"source":"local","path":"./plugins/tracedecay"}}]}"#, + ) + .unwrap(); + + let codex = get_integration("codex").unwrap(); + let outcome = codex + .update_plugin(&ctx_with_project(home.path(), NEW_BIN, &project_root)) + .unwrap(); + let UpdatePluginOutcome::Refreshed(paths) = outcome else { + panic!("expected codex update_plugin to refresh the installed cache"); + }; + assert_eq!(paths, vec![cached_plugin_dir.clone()]); + + assert!(text(&cached_plugin_dir.join(".mcp.json")).contains(NEW_BIN)); + assert!(text(&cached_plugin_dir.join("hooks/hooks.json")).contains(NEW_BIN)); + assert!(!bootstrap_dir.exists()); + + let marketplace = text(&home.path().join(".agents/plugins/marketplace.json")); + assert!(!marketplace.contains(r#""name": "tracedecay""#)); + assert!(!marketplace.contains(r#""name":"tracedecay""#)); +} + +#[test] +fn codex_update_plugin_refreshes_repo_local_bundle_from_project_root() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let codex = get_integration("codex").unwrap(); + codex + .install_local(&ctx(home.path(), OLD_BIN), project.path()) + .unwrap(); + let plugin_dir = project.path().join("plugins/tracedecay"); + std::fs::write(plugin_dir.join("user-note.txt"), "mine\n").unwrap(); + + let outcome = codex + .update_plugin(&ctx_with_project(home.path(), NEW_BIN, project.path())) + .unwrap(); + let UpdatePluginOutcome::Refreshed(paths) = outcome else { + panic!("expected codex update_plugin to refresh the repo-local bundle"); + }; + + assert_eq!(paths, vec![plugin_dir.clone()]); + assert_eq!(text(&plugin_dir.join("user-note.txt")), "mine\n"); + assert!(text(&plugin_dir.join(".mcp.json")).contains(NEW_BIN)); + assert!(text(&plugin_dir.join("hooks/hooks.json")).contains(NEW_BIN)); + assert!(text(&plugin_dir.join(".codex-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION"))); +} + +#[test] +fn codex_uninstall_removes_repo_local_bundle_from_project_root() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let codex = get_integration("codex").unwrap(); + let install_ctx = ctx_with_project(home.path(), OLD_BIN, project.path()); + codex.install_local(&install_ctx, project.path()).unwrap(); + let plugin_dir = project.path().join("plugins/tracedecay"); + let marketplace = project.path().join(".agents/plugins/marketplace.json"); + assert!(plugin_dir.exists()); + + codex.uninstall(&install_ctx).unwrap(); + + assert!(!plugin_dir.exists()); + assert!(!text(&marketplace).contains(r#""name":"tracedecay""#)); + assert!(!text(&marketplace).contains(r#""name": "tracedecay""#)); +} + #[test] fn codex_update_plugin_reports_config_only_for_legacy_config_only_install() { let home = TempDir::new().unwrap(); + let project_root = home.path().join("workspace"); let codex_dir = home.path().join(".codex"); std::fs::create_dir_all(&codex_dir).unwrap(); std::fs::write( @@ -276,7 +382,9 @@ fn codex_update_plugin_reports_config_only_for_legacy_config_only_install() { let before = bytes(&codex_dir.join("config.toml")); let codex = get_integration("codex").unwrap(); - let outcome = codex.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + let outcome = codex + .update_plugin(&ctx_with_project(home.path(), NEW_BIN, &project_root)) + .unwrap(); assert!(matches!(outcome, UpdatePluginOutcome::ConfigOnly)); assert_eq!(bytes(&codex_dir.join("config.toml")), before); assert!(!home.path().join("plugins/tracedecay").exists()); @@ -285,9 +393,12 @@ fn codex_update_plugin_reports_config_only_for_legacy_config_only_install() { #[test] fn codex_update_plugin_reports_not_installed_without_bundle_or_legacy_config() { let home = TempDir::new().unwrap(); + let project_root = home.path().join("workspace"); std::fs::create_dir_all(home.path().join(".codex")).unwrap(); let codex = get_integration("codex").unwrap(); - let outcome = codex.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + let outcome = codex + .update_plugin(&ctx_with_project(home.path(), NEW_BIN, &project_root)) + .unwrap(); assert!(matches!(outcome, UpdatePluginOutcome::NotInstalled)); assert!(!home.path().join("plugins").exists()); }