diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 7ab3432c..f6de67f7 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -923,6 +923,17 @@ fn doctor_check_session_ingest(dc: &mut DoctorCounters, project_path: &Path) { let health = tokio::task::block_in_place(|| { handle.block_on(async { let db = crate::sessions::cursor::open_project_session_db(project_path).await?; + let placeholder_paths = db.literal_workspace_placeholder_transcript_paths(10).await; + if !placeholder_paths.is_empty() { + dc.warn(&format!( + "Cursor transcript ingest has {} path(s) with a literal workspace placeholder; \ + Cursor did not expand `${{workspaceFolder}}`, so session recall will miss those transcripts", + placeholder_paths.len(), + )); + for path in &placeholder_paths { + dc.info(&format!(" - {path}")); + } + } Some(db.session_ingest_health().await) }) }); diff --git a/src/daemon.rs b/src/daemon.rs index 095ceafa..ab3a29f6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::fmt::Write; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(unix)] @@ -293,11 +295,7 @@ pub fn uninstall_service(stop: bool) -> Result { } pub fn service_status(socket_path: &Path) -> String { - let socket_state = if socket_path.exists() { - "present" - } else { - "missing" - }; + let socket_state = daemon_socket_state(socket_path); format!( "service: {}\nsocket: {} ({})\nlogs: journalctl --user -u {} -f\n", systemd_user_service_path().map_or_else( @@ -310,6 +308,28 @@ pub fn service_status(socket_path: &Path) -> String { ) } +#[cfg(unix)] +fn daemon_socket_state(socket_path: &Path) -> &'static str { + if !socket_path.exists() { + return "missing"; + } + match StdUnixStream::connect(socket_path) { + Ok(_) => "connectable", + Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => "stale", + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => "present but not accessible", + Err(_) => "present but unreachable", + } +} + +#[cfg(not(unix))] +fn daemon_socket_state(socket_path: &Path) -> &'static str { + if socket_path.exists() { + "present" + } else { + "missing" + } +} + fn format_daemon_log_line(event: &str, fields: &[(&str, String)]) -> String { let mut line = format!("[tracedecay] event={}", quote_log_value(event)); for (key, value) in fields { @@ -1602,6 +1622,54 @@ mod tests { assert!(status.contains("logs: journalctl --user -u tracedecay.service -f")); } + #[cfg(unix)] + #[test] + fn service_status_reports_missing_socket() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("missing.sock"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (missing)", socket.display())), + "status should report missing socket, got:\n{status}" + ); + } + + #[cfg(unix)] + #[test] + fn service_status_reports_unconnectable_socket_file() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("unconnectable.sock"); + std::fs::write(&socket, "").expect("unconnectable socket placeholder"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (stale)", socket.display())) + || status.contains(&format!( + "socket: {} (present but unreachable)", + socket.display() + )), + "status should report an unconnectable socket, got:\n{status}" + ); + } + + #[cfg(unix)] + #[test] + fn service_status_reports_connectable_socket() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("daemon.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&socket).expect("bind socket"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (connectable)", socket.display())), + "status should report connectable socket, got:\n{status}" + ); + } + #[cfg(unix)] #[test] fn scheduler_task_start_log_uses_task_key_and_project() { diff --git a/src/db/search.rs b/src/db/search.rs index 81ce5f42..0b13cb9b 100644 --- a/src/db/search.rs +++ b/src/db/search.rs @@ -213,31 +213,56 @@ impl Database { &self, query: &str, limit: usize, + path_prefix: Option<&str>, ) -> Result> { let query = query.trim(); if query.is_empty() || limit == 0 { return Ok(Vec::new()); } let like_pattern = format!("%{query}%"); - let mut rows = self - .conn() - .query( - "SELECT name, signature, file_path, start_line - FROM nodes - WHERE kind = 'use' - AND signature LIKE ?1 - AND name NOT LIKE './%' - AND name NOT LIKE '../%' - AND name NOT LIKE '/%' - ORDER BY file_path ASC, start_line ASC - LIMIT ?2", - params![like_pattern.as_str(), limit.saturating_mul(4) as i64], - ) - .await - .map_err(|e| TraceDecayError::Database { - message: format!("failed to query dependency import uses: {e}"), - operation: "dependency_import_uses".to_string(), - })?; + let limit = limit.saturating_mul(4) as i64; + let mut rows = if let Some(prefix) = path_prefix { + let with_slash = if prefix.ends_with('/') { + prefix.to_string() + } else { + format!("{prefix}/") + }; + let prefix_like = format!("{with_slash}%"); + self.conn() + .query( + "SELECT name, signature, file_path, start_line + FROM nodes + WHERE kind = 'use' + AND signature LIKE ?1 + AND name NOT LIKE './%' + AND name NOT LIKE '../%' + AND name NOT LIKE '/%' + AND (file_path = ?2 OR file_path LIKE ?3) + ORDER BY file_path ASC, start_line ASC + LIMIT ?4", + params![like_pattern.as_str(), prefix, prefix_like.as_str(), limit], + ) + .await + } else { + self.conn() + .query( + "SELECT name, signature, file_path, start_line + FROM nodes + WHERE kind = 'use' + AND signature LIKE ?1 + AND name NOT LIKE './%' + AND name NOT LIKE '../%' + AND name NOT LIKE '/%' + ORDER BY file_path ASC, start_line ASC + LIMIT ?2", + params![like_pattern.as_str(), limit], + ) + .await + } + .map_err(|e| TraceDecayError::Database { + message: format!("failed to query dependency import uses: {e}"), + operation: "dependency_import_uses".to_string(), + })?; let mut imports = Vec::new(); while let Some(row) = rows.next().await.map_err(|e| TraceDecayError::Database { diff --git a/src/doctor.rs b/src/doctor.rs index 6fea6dca..44b1e435 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -368,49 +368,77 @@ fn classify_registry_storage( if store.storage_mode != "profile_sharded" { return None; } + let artifacts = registry_store_artifacts(profile_root, store); + if artifacts + .iter() + .any(|artifacts| artifacts.graph_db_path.exists()) + { + Some(DoctorStorageStatus::ProfileSharded) + } else if artifacts + .iter() + .any(|artifacts| artifacts.manifest_path.is_some()) + { + Some(DoctorStorageStatus::ManifestReconstructable) + } else if artifacts.is_empty() { + None + } else { + Some(DoctorStorageStatus::Stale) + } +} + +#[derive(Debug, Clone)] +struct RegistryStoreArtifacts { + graph_db_path: PathBuf, + manifest_path: Option, +} + +fn registry_store_artifacts( + profile_root: &Path, + store: &crate::global_db::StoreInstanceRecord, +) -> Vec { + if store.storage_mode != "profile_sharded" { + return Vec::new(); + } let store_relpath = registry_relpath(&store.store_relpath); let manifest_relpath = store .manifest_relpath .as_ref() .map(|relpath| registry_relpath(relpath)); - let mut resolved_any_root = false; - let mut manifest_exists = false; + let mut artifacts = Vec::new(); for profile_root in registry_profile_roots(profile_root) { let Ok(data_root) = crate::storage::StoreArtifactPath::resolve(&profile_root, &store_relpath) else { continue; }; - resolved_any_root = true; let data_root = data_root.absolute_path(); - if data_root - .join(crate::config::db_filename(&data_root)) - .exists() - { - return Some(DoctorStorageStatus::ProfileSharded); - } - manifest_exists |= manifest_relpath.as_ref().map_or_else( - || { - data_root - .join(crate::storage::STORE_MANIFEST_FILENAME) - .is_file() - }, - |relpath| { - [&profile_root, &data_root].iter().any(|root| { - crate::storage::StoreArtifactPath::resolve(root, relpath) - .ok() - .is_some_and(|path| path.absolute_path().is_file()) - }) - }, - ); - } - if manifest_exists { - Some(DoctorStorageStatus::ManifestReconstructable) - } else if resolved_any_root { - Some(DoctorStorageStatus::Stale) - } else { - None + artifacts.push(RegistryStoreArtifacts { + graph_db_path: data_root.join(crate::config::db_filename(&data_root)), + manifest_path: registry_manifest_path( + &profile_root, + &data_root, + manifest_relpath.as_deref(), + ), + }); } + artifacts +} + +fn registry_manifest_path( + profile_root: &Path, + data_root: &Path, + manifest_relpath: Option<&Path>, +) -> Option { + if let Some(relpath) = manifest_relpath { + return [profile_root, data_root].iter().find_map(|root| { + crate::storage::StoreArtifactPath::resolve(root, relpath) + .ok() + .map(|path| path.absolute_path()) + .filter(|path| path.is_file()) + }); + } + let path = data_root.join(crate::storage::STORE_MANIFEST_FILENAME); + path.is_file().then_some(path) } fn registry_relpath(value: &str) -> PathBuf { diff --git a/src/extraction/typescript_extractor.rs b/src/extraction/typescript_extractor.rs index 91beb6b8..2b86cbc9 100644 --- a/src/extraction/typescript_extractor.rs +++ b/src/extraction/typescript_extractor.rs @@ -1080,7 +1080,7 @@ impl TypeScriptExtractor { // Unresolved Uses reference. state.unresolved_refs.push(UnresolvedRef { - from_node_id: id, + from_node_id: id.clone(), reference_name: name, reference_kind: EdgeKind::Uses, line: start_line, diff --git a/src/global_db.rs b/src/global_db.rs index 992a0bab..009078aa 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -1106,6 +1106,42 @@ impl GlobalDb { health } + /// Returns tracked transcript paths that still contain an unresolved + /// workspace placeholder. Cursor should expand `${workspaceFolder}` before + /// a transcript path is persisted; if it reaches the session DB literally, + /// catch-up and recall will look at a non-existent path. + pub async fn literal_workspace_placeholder_transcript_paths( + &self, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + let Ok(mut rows) = self + .conn + .query( + "SELECT DISTINCT transcript_path FROM sessions + WHERE transcript_path IS NOT NULL + AND transcript_path != '' + AND (transcript_path LIKE '%${workspaceFolder}%' + OR transcript_path LIKE '%$workspaceFolder%') + ORDER BY transcript_path + LIMIT ?1", + params![i64::try_from(limit).unwrap_or(i64::MAX)], + ) + .await + else { + return Vec::new(); + }; + let mut paths = Vec::new(); + while let Ok(Some(row)) = rows.next().await { + if let Ok(path) = row.get::(0) { + paths.push(path); + } + } + paths + } + /// Canonical registry key for a project path. Falls back to the lossy path /// string when canonicalization fails (e.g. the path no longer exists) so /// upserts and lookups always agree on a single key per project, instead of diff --git a/src/hooks.rs b/src/hooks.rs index 3389407c..22c47917 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -1426,6 +1426,7 @@ pub fn build_codex_session_context_for_workspace( or shell search for codebase exploration, symbol lookup, call graphs, and \ impact analysis. Fall back to file reads only when tracedecay cannot answer.\n", ); + append_codex_recall_and_registry_guidance(&mut s); match status { HookWorkspaceStatus::Initialized => match staleness_hint { Some(hint) => { @@ -1446,8 +1447,11 @@ pub fn build_codex_session_context_for_workspace( s.push_str( "TraceDecay session context is available via MCP. For prior conversation \ recovery, use tracedecay_lcm_expand_query, tracedecay_message_search, and \ - tracedecay_lcm_describe; use project memory tools only when durable \ - preferences or decisions matter.\n", + tracedecay_lcm_describe before asking the user to repeat themselves. Use \ + tracedecay_fact_store only for durable preferences, environment details, \ + tool quirks, or decisions that will still matter later. Do not store task \ + progress, temporary TODOs, or soon-stale session outcomes; recover those \ + from transcripts instead.\n", ); s.push_str("Workspace status: no active project workspace; no setup guidance needed for this prompt.\n"); } @@ -1455,6 +1459,21 @@ pub fn build_codex_session_context_for_workspace( s } +fn append_codex_recall_and_registry_guidance(s: &mut String) { + s.push_str( + "For other registered projects or sibling workspaces, check \ + tracedecay_project_list or tracedecay_project_search first; use \ + tracedecay_project_context to confirm the target and pass project_id or \ + project_path to tracedecay_context/search for cross-project code context before \ + scanning parent directories. When the user references prior conversation or \ + missing context, use tracedecay_message_search or tracedecay_lcm_expand_query \ + before asking the user to repeat themselves. Use tracedecay_fact_store only for \ + durable preferences, environment details, tool quirks, or decisions that will \ + still matter later. Do not store task progress, temporary TODOs, or soon-stale \ + session outcomes; recover those from transcripts instead.\n", + ); +} + fn append_context_recovery_hint(context: &mut String) { if !context.ends_with('\n') { context.push('\n'); diff --git a/src/hooks/tool_hints.rs b/src/hooks/tool_hints.rs index 5de05e0a..f15ad3a3 100644 --- a/src/hooks/tool_hints.rs +++ b/src/hooks/tool_hints.rs @@ -527,6 +527,8 @@ fn mentions_external_project_scope(text: &str) -> bool { "project search", "cross-project", "cross project", + "orchestrator repo", + "orchestrator repository", ], ) } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 935580bc..7402a37d 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1793,7 +1793,7 @@ impl McpServer { timestamp: ts, request_id: &request_id, arguments: &analytics_arguments, - response: Some(&result.value), + internal_analytics: result.internal_analytics(), }); self.spawn_observed_ledger_write(async move { gdb.record_savings( @@ -1975,7 +1975,7 @@ impl McpServer { timestamp: crate::tracedecay::current_timestamp(), request_id, arguments, - response: None, + internal_analytics: None, }); self.spawn_observed_ledger_write(async move { if let Err(e) = gdb.append_analytics_event(&event).await { diff --git a/src/mcp/tool_analytics.rs b/src/mcp/tool_analytics.rs index f6e5bfd6..5de9d7c1 100644 --- a/src/mcp/tool_analytics.rs +++ b/src/mcp/tool_analytics.rs @@ -13,7 +13,7 @@ pub(super) struct McpToolAnalyticsEvent<'a> { pub(super) timestamp: i64, pub(super) request_id: &'a Value, pub(super) arguments: &'a Value, - pub(super) response: Option<&'a Value>, + pub(super) internal_analytics: Option<&'a Value>, } pub(super) fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> AnalyticsEventInsert { @@ -39,7 +39,7 @@ pub(super) fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> Anal append_tool_response_analytics( input.tool_name, input.arguments, - input.response, + input.internal_analytics, &mut metadata, ); AnalyticsEventInsert { @@ -62,7 +62,7 @@ pub(super) fn mcp_tool_analytics_event(input: McpToolAnalyticsEvent<'_>) -> Anal fn append_tool_response_analytics( tool_name: &str, arguments: &Value, - response: Option<&Value>, + internal_analytics: Option<&Value>, metadata: &mut Value, ) { if tool_name != "tracedecay_context" { @@ -82,41 +82,16 @@ fn append_tool_response_analytics( .and_then(Value::as_f64) .unwrap_or(0.5) .clamp(0.0, 1.0); - let payload = response.and_then(tool_result_json_payload); - let memory_matches = payload - .as_ref() - .and_then(|payload| payload.get("memory_matches")) - .and_then(Value::as_array); - let fact_ids: Vec = memory_matches - .into_iter() - .flatten() - .filter_map(|hit| { - hit.get("fact") - .and_then(|fact| fact.get("fact_id")) - .and_then(Value::as_i64) - }) - .map(Value::from) - .collect(); - let match_count = fact_ids.len(); - let memory_error = payload - .as_ref() - .and_then(|payload| payload.get("memory_matches_error")) - .and_then(Value::as_str); + if let Some(context_memory) = internal_analytics.and_then(|value| value.get("context_memory")) { + metadata["context_memory"] = context_memory.clone(); + return; + } metadata["context_memory"] = json!({ "include_memory": include_memory, "limit": limit, "min_trust": min_trust, - "match_count": match_count, - "fact_ids": fact_ids, - "error": memory_error, + "match_count": 0, + "fact_ids": [], + "error": null, }); } - -fn tool_result_json_payload(response: &Value) -> Option { - response - .get("content") - .and_then(Value::as_array)? - .iter() - .filter_map(|item| item.get("text").and_then(Value::as_str)) - .find_map(|text| serde_json::from_str::(text).ok()) -} diff --git a/src/mcp/tools/handlers/analysis.rs b/src/mcp/tools/handlers/analysis.rs index 9be93644..e1d8be84 100644 --- a/src/mcp/tools/handlers/analysis.rs +++ b/src/mcp/tools/handlers/analysis.rs @@ -211,12 +211,12 @@ pub(super) async fn handle_dead_code( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, - }) + )) } /// Handles `tracedecay_module_api` tool calls. @@ -277,12 +277,12 @@ pub(super) async fn handle_module_api( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, - }) + )) } /// Handles `tracedecay_circular` tool calls. @@ -299,12 +299,12 @@ pub(super) async fn handle_circular(cg: &TraceDecay, args: Value) -> Result Result = HashSet::new(); @@ -1712,12 +1712,12 @@ pub(super) async fn handle_constructors( let text = render::finalize(Some(cg.project_root()), &args, &payload, || { render::generic_md(&payload) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } #[derive(Debug, Clone, Copy)] @@ -2091,12 +2091,12 @@ pub(super) async fn handle_field_sites( let text = render::finalize(Some(cg.project_root()), &args, &payload, || { render::generic_md(&payload) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } #[derive(Debug, Clone, Copy)] diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 397934b7..58f4fa40 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -45,12 +45,12 @@ fn validate_mcp_dashboard_host(host: &str) -> Result<&str> { fn dashboard_tool_result(cg: &TraceDecay, payload: &Value) -> ToolResult { let formatted = serde_json::to_string(payload).unwrap_or_default(); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": truncated_json_envelope_with_handle(Some(cg.project_root()), &formatted) }] }), - touched_files: vec![], - } + vec![], + ) } /// Handles `tracedecay_dashboard` tool calls. diff --git a/src/mcp/tools/handlers/dependency_hints.rs b/src/mcp/tools/handlers/dependency_hints.rs index 39ebab40..7fa6b5fc 100644 --- a/src/mcp/tools/handlers/dependency_hints.rs +++ b/src/mcp/tools/handlers/dependency_hints.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use serde_json::{json, Value}; use crate::dependency_imports::candidates_from_type_only_import; @@ -5,44 +7,62 @@ use crate::errors::Result; use crate::mcp::tools::render::{self, Md}; use crate::tracedecay::TraceDecay; +pub(super) fn should_check_ignored_dependency_hint(result_count: usize, limit: usize) -> bool { + result_count == 0 || result_count < limit.clamp(1, 20) +} + pub(super) async fn ignored_dependency_hint( cg: &TraceDecay, query: &str, limit: usize, + scope_prefix: Option<&str>, ) -> Result> { let query = query.trim(); if query.is_empty() { return Ok(None); } - let limit = limit.clamp(1, 20); - let db = cg.open_project_store_db().await?; + let candidate_limit = limit.clamp(1, 20); + let db = if cg.is_read_only() { + cg.open_project_store_db_read_only().await? + } else { + cg.open_project_store_db().await? + }; let query_lower = query.to_ascii_lowercase(); - let candidates = db - .dependency_import_uses(query, limit) - .await? - .into_iter() - .flat_map(|import_use| { - candidates_from_type_only_import( - &import_use.signature, - &import_use.module, - &import_use.file_path, - import_use.line, - ) - }) - .filter(|candidate| { - candidate.symbol.to_ascii_lowercase().contains(&query_lower) - || candidate.module.to_ascii_lowercase().contains(&query_lower) - }) - .take(limit) - .map(|candidate| { - json!({ - "module": candidate.module, - "symbol": candidate.symbol, - "import_file": candidate.import_file, - "line": user_line(candidate.line), - }) - }) - .collect::>(); + let imports = db + .dependency_import_uses(query, candidate_limit, scope_prefix) + .await?; + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + for candidate in imports.into_iter().flat_map(|import_use| { + candidates_from_type_only_import( + &import_use.signature, + &import_use.module, + &import_use.file_path, + import_use.line, + ) + }) { + let haystack = format!("{} {}", candidate.module, candidate.symbol).to_ascii_lowercase(); + if !haystack.contains(&query_lower) { + continue; + } + if !seen.insert(( + candidate.module.clone(), + candidate.symbol.clone(), + candidate.import_file.clone(), + candidate.line, + )) { + continue; + } + candidates.push(json!({ + "module": candidate.module, + "symbol": candidate.symbol, + "import_file": candidate.import_file, + "line": user_line(candidate.line), + })); + if candidates.len() >= candidate_limit { + break; + } + } if candidates.is_empty() { return Ok(None); } diff --git a/src/mcp/tools/handlers/edit.rs b/src/mcp/tools/handlers/edit.rs index 027a5a44..d5dab370 100644 --- a/src/mcp/tools/handlers/edit.rs +++ b/src/mcp/tools/handlers/edit.rs @@ -29,12 +29,12 @@ fn required_array<'a>(args: &'a Value, name: &str) -> Result<&'a [Value]> { } fn text_tool_result(result: &T, touched_files: Vec) -> ToolResult { - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": serde_json::to_string(result).unwrap_or_default() }] }), touched_files, - } + ) } pub(super) async fn handle_str_replace(cg: &TraceDecay, args: Value) -> Result { diff --git a/src/mcp/tools/handlers/git.rs b/src/mcp/tools/handlers/git.rs index f3cb1cf4..6c8a5cb8 100644 --- a/src/mcp/tools/handlers/git.rs +++ b/src/mcp/tools/handlers/git.rs @@ -31,12 +31,12 @@ fn git_error_result(cg: &TraceDecay, operation: &str, message: &str) -> ToolResu } }); let formatted = serde_json::to_string(&output).unwrap_or_default(); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": project_response_text(cg, &formatted) }] }), - touched_files: vec![], - } + vec![], + ) } fn require_string_array_arg(args: &Value, name: &str) -> Result> { @@ -141,12 +141,12 @@ pub(super) async fn handle_affected(cg: &TraceDecay, args: Value) -> Result Result< let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, - }) + )) } /// Diff two git refs and return changed file paths with coarse status. @@ -660,12 +660,12 @@ pub(super) async fn handle_changelog(cg: &TraceDecay, args: Value) -> Result Resul let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - return Ok(ToolResult { - value: json!({"content": [{"type": "text", "text": text}]}), - touched_files: vec![], - }); + return Ok(ToolResult::new( + json!({"content": [{"type": "text", "text": text}]}), + vec![], + )); } // Pre-compute files with inline test modules. @@ -755,10 +755,10 @@ pub(super) async fn handle_commit_context(cg: &TraceDecay, args: Value) -> Resul let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({"content": [{"type": "text", "text": text}]}), - touched_files: changed_files, - }) + Ok(ToolResult::new( + json!({"content": [{"type": "text", "text": text}]}), + changed_files, + )) } /// Handles `tracedecay_pr_context` tool calls. @@ -892,10 +892,10 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result ToolResult { } let output = serde_json::to_string(&result).unwrap_or_default(); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": project_response_text(cg, &output) }] }), - touched_files: vec![], - } + vec![], + ) } /// Handles `tracedecay_branch_search` tool calls. @@ -962,12 +962,12 @@ pub(super) async fn handle_branch_search(cg: &TraceDecay, args: Value) -> Result let text = render::finalize(Some(cg.project_root()), &args, &items, || { render::generic_md(&items) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } /// Handles `tracedecay_branch_diff` tool calls. @@ -1014,12 +1014,12 @@ pub(super) async fn handle_branch_diff(cg: &TraceDecay, args: Value) -> Result Result( where F: FnOnce() -> String, { + let internal_analytics = value.get(CONTEXT_MEMORY_ANALYTICS_KEY).cloned(); + let public_value; + let value = if internal_analytics.is_some() { + public_value = strip_internal_context_memory_analytics(value); + &public_value + } else { + value + }; let text = render::finalize(Some(cg.project_root()), args, value, md); - text_tool_result(&text, touched_files) + let result = text_tool_result(&text, touched_files); + if let Some(internal_analytics) = internal_analytics { + result.with_internal_analytics(internal_analytics) + } else { + result + } +} + +fn strip_internal_context_memory_analytics(value: &Value) -> Value { + let mut value = value.clone(); + if let Some(object) = value.as_object_mut() { + object.remove(CONTEXT_MEMORY_ANALYTICS_KEY); + } + value } fn text_tool_result(text: &str, touched_files: Vec) -> ToolResult { - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, - } + ) } /// Handles `tracedecay_search` tool calls. @@ -73,14 +95,12 @@ pub(super) async fn handle_search( let results = cg.search(query, limit).await?; let results = filter_by_scope(results, scope_prefix, |r| &r.node.file_path); let coverage_hint = cg.index_coverage_hint(results.len()); - let has_symbol_result = results - .iter() - .any(|result| result.node.kind != NodeKind::Use); - let ignored_dependency_hint = if has_symbol_result { - None - } else { - dependency_hints::ignored_dependency_hint(cg, query, limit).await? - }; + let ignored_dependency_hint = + if dependency_hints::should_check_ignored_dependency_hint(results.len(), limit) { + dependency_hints::ignored_dependency_hint(cg, query, limit, scope_prefix).await? + } else { + None + }; let touched_files = unique_file_paths(results.iter().map(|r| r.node.file_path.as_str())); @@ -252,6 +272,16 @@ pub(super) async fn handle_context( "memory_matches".to_string(), serde_json::to_value(&memory_matches).unwrap_or_else(|_| json!([])), ); + object.insert( + CONTEXT_MEMORY_ANALYTICS_KEY.to_string(), + json!({ + "context_memory": context_memory_analytics_value( + &memory_options, + &memory_matches, + memory_matches_error.as_deref() + ), + }), + ); if let Some(err) = memory_matches_error { object.insert("memory_matches_error".to_string(), json!(err)); } @@ -293,6 +323,25 @@ fn context_memory_options(args: &Value) -> ContextMemoryOptions { } } +fn context_memory_analytics_value( + options: &ContextMemoryOptions, + memory_matches: &[FactSearchResult], + memory_matches_error: Option<&str>, +) -> Value { + let fact_ids: Vec = memory_matches + .iter() + .map(|hit| Value::from(hit.fact.fact_id)) + .collect(); + json!({ + "include_memory": options.include_memory, + "limit": options.limit, + "min_trust": options.min_trust, + "match_count": fact_ids.len(), + "fact_ids": fact_ids, + "error": memory_matches_error, + }) +} + async fn context_memory_matches( cg: &TraceDecay, task: &str, diff --git a/src/mcp/tools/handlers/health.rs b/src/mcp/tools/handlers/health.rs index 53fd759f..02db198d 100644 --- a/src/mcp/tools/handlers/health.rs +++ b/src/mcp/tools/handlers/health.rs @@ -334,12 +334,12 @@ pub(super) async fn handle_gini( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } /// Handles `tracedecay_dependency_depth` tool calls. @@ -383,12 +383,12 @@ pub(super) async fn handle_dependency_depth( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } /// Handles `tracedecay_health` tool calls. @@ -461,12 +461,12 @@ pub(super) async fn handle_health( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } /// Handles `tracedecay_runtime` tool calls. @@ -480,12 +480,12 @@ pub(super) async fn handle_runtime(cg: &TraceDecay, args: Value) -> Result ToolRes let text = render::finalize(Some(cg.project_root()), args, output, || { render::generic_md(output) }); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - } + vec![], + ) } /// Handles `tracedecay_session_start` tool calls. diff --git a/src/mcp/tools/handlers/info.rs b/src/mcp/tools/handlers/info.rs index 81a0dc7e..ff2e9afb 100644 --- a/src/mcp/tools/handlers/info.rs +++ b/src/mcp/tools/handlers/info.rs @@ -141,12 +141,12 @@ pub(super) async fn handle_status( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render_status_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } fn render_status_md(value: &Value) -> String { @@ -260,12 +260,12 @@ pub(super) fn handle_active_project( let branch = cg.branch_diagnostics(); let output = active_project_context(cg, &branch, server_stats, scope_prefix); let formatted = serde_json::to_string(&output).unwrap_or_default(); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": project_response_text(cg, &formatted) }] }), - touched_files: vec![], - } + vec![], + ) } /// Handles `tracedecay_storage_status` tool calls. @@ -326,12 +326,12 @@ pub(super) async fn handle_storage_status( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } fn bounded_limit(args: &Value, default: usize, max: usize) -> usize { @@ -397,12 +397,12 @@ fn registry_result(args: &Value, payload: &Value) -> ToolResult { fn render_registry_result(root: Option<&Path>, args: &Value, payload: &Value) -> ToolResult { let text = render::finalize(root, args, payload, || render::generic_md(payload)); - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - } + vec![], + ) } fn registry_missing_payload() -> Value { @@ -620,12 +620,12 @@ pub(super) async fn handle_files( lines.join("\n") }; - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": truncate_response(&output) }] }), touched_files, - }) + )) } /// Default node kinds for port comparisons. @@ -747,12 +747,12 @@ pub(super) async fn handle_port_status(cg: &TraceDecay, args: Value) -> Result Result Result Result Result String { @@ -1524,10 +1524,10 @@ pub(super) async fn handle_type_hierarchy(cg: &TraceDecay, args: Value) -> Resul build_type_tree(cg, &root.id, max_depth, 0, &mut output, &mut all_files).await?; let touched_files = unique_file_paths(all_files.iter().map(std::string::String::as_str)); - Ok(ToolResult { - value: json!({"content": [{"type": "text", "text": truncate_response(&output)}]}), + Ok(ToolResult::new( + json!({"content": [{"type": "text", "text": truncate_response(&output)}]}), touched_files, - }) + )) } /// Recursively appends type hierarchy lines to the output string. @@ -1608,12 +1608,12 @@ pub(super) async fn handle_body( let chosen = body_candidates(cg, symbol, limit, scope_prefix).await?; if chosen.is_empty() { - return Ok(ToolResult { - value: json!({ + return Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": format!("No symbol named '{symbol}' found.") }] }), - touched_files: vec![], - }); + vec![], + )); } let project_root = cg.project_root(); @@ -1649,12 +1649,12 @@ pub(super) async fn handle_body( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } async fn body_candidates( @@ -1884,12 +1884,12 @@ pub(super) async fn handle_todos( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } fn relative_source_key(path: &Path) -> Result> { @@ -2056,12 +2056,12 @@ pub(super) async fn handle_read(cg: &TraceDecay, args: Value) -> Result Result String { @@ -2180,12 +2180,12 @@ pub(super) async fn handle_outline(cg: &TraceDecay, args: Value) -> Result Result { @@ -2354,12 +2354,12 @@ pub(super) fn handle_config(cg: &TraceDecay, args: &Value) -> Result let text = render::finalize(Some(cg.project_root()), args, &payload, || { render::generic_md(&payload) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } #[derive(Debug, Clone, Copy)] @@ -2558,12 +2558,12 @@ pub(super) async fn handle_signature_search( let text = render::finalize(Some(cg.project_root()), &args, &payload, || { render::generic_md(&payload) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: touched, - }) + touched, + )) } fn returns_substring(signature: &str) -> &str { diff --git a/src/mcp/tools/handlers/memory.rs b/src/mcp/tools/handlers/memory.rs index 30da4163..60cb7d63 100644 --- a/src/mcp/tools/handlers/memory.rs +++ b/src/mcp/tools/handlers/memory.rs @@ -32,10 +32,10 @@ struct TargetMemoryDb { } fn text_tool_result(text: &str) -> ToolResult { - ToolResult { - value: json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - } + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + vec![], + ) } fn tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index ba3b4223..64c9133e 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -145,10 +145,10 @@ fn handle_retrieve(cg: &TraceDecay, args: &Value) -> Result { }), }; let formatted = serde_json::to_string(&payload).unwrap_or_default(); - Ok(ToolResult { - value: json!({ "content": [{ "type": "text", "text": formatted }] }), - touched_files: Vec::new(), - }) + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": formatted }] }), + Vec::new(), + )) } /// Dispatches a tool call to the appropriate handler. diff --git a/src/mcp/tools/handlers/redundancy.rs b/src/mcp/tools/handlers/redundancy.rs index c7dcb292..6525cc68 100644 --- a/src/mcp/tools/handlers/redundancy.rs +++ b/src/mcp/tools/handlers/redundancy.rs @@ -59,12 +59,12 @@ pub(super) async fn handle_redundancy( let text = render::finalize(Some(cg.project_root()), &args, &output, || { render::generic_md(&output) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: vec![], - }) + vec![], + )) } struct RedundancyOptions<'a> { diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 69d8e210..1b8a3c49 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -38,10 +38,10 @@ fn tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { } else { truncated_json_envelope_with_handle(project_root, &formatted) }; - ToolResult { - value: json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: Vec::new(), - } + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + Vec::new(), + ) } #[derive(Clone, Copy)] @@ -144,10 +144,10 @@ fn lcm_preflight_tool_json(value: &Value) -> ToolResult { } } }; - ToolResult { - value: json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: Vec::new(), - } + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + Vec::new(), + ) } fn compact_lcm_preflight_payload( @@ -305,10 +305,10 @@ fn lcm_expand_query_tool_json(project_root: Option<&Path>, value: &Value) -> Too } else { truncated_json_envelope_with_handle(project_root, &text) }; - ToolResult { - value: json!({ "content": [{ "type": "text", "text": text }] }), - touched_files: Vec::new(), - } + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + Vec::new(), + ) } #[derive(Copy, Clone)] diff --git a/src/mcp/tools/handlers/skills.rs b/src/mcp/tools/handlers/skills.rs index d9ef7162..e824bb99 100644 --- a/src/mcp/tools/handlers/skills.rs +++ b/src/mcp/tools/handlers/skills.rs @@ -31,10 +31,10 @@ fn config_error(message: impl Into) -> TraceDecayError { fn tool_json(value: &Value) -> ToolResult { let formatted = serde_json::to_string_pretty(value).unwrap_or_default(); - ToolResult { - value: json!({ "content": [{ "type": "text", "text": formatted }] }), - touched_files: vec![], - } + ToolResult::new( + json!({ "content": [{ "type": "text", "text": formatted }] }), + vec![], + ) } fn optional_bool(args: &Value, key: &str, default: bool) -> bool { diff --git a/src/mcp/tools/handlers/workflow.rs b/src/mcp/tools/handlers/workflow.rs index 72769283..60739f3f 100644 --- a/src/mcp/tools/handlers/workflow.rs +++ b/src/mcp/tools/handlers/workflow.rs @@ -204,12 +204,12 @@ pub(super) async fn handle_diagnose(cg: &TraceDecay, args: Value) -> Result &'static str { @@ -287,12 +287,12 @@ pub(super) async fn handle_run_affected_tests(cg: &TraceDecay, args: Value) -> R let text = render::finalize(Some(cg.project_root()), &args, &body, || { render::generic_md(&body) }); - Ok(ToolResult { - value: json!({ + Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, - }) + )) } async fn resolve_changed_paths( @@ -486,19 +486,19 @@ fn covered_source_ids(name: &str, selected_targets: &[TestTarget]) -> Vec ToolResult { - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": serde_json::to_string(&json!({ "passed": 0, "failed": 0, "results": [], "note": message })).unwrap_or_default() }] }), - touched_files: vec![], - } + vec![], + ) } fn error_result(kind: &str, operation: &str, message: &str) -> ToolResult { - ToolResult { - value: json!({ + ToolResult::new( + json!({ "content": [{ "type": "text", "text": serde_json::to_string(&json!({ "passed": 0, "failed": 0, @@ -510,8 +510,8 @@ fn error_result(kind: &str, operation: &str, message: &str) -> ToolResult { } })).unwrap_or_default() }] }), - touched_files: vec![], - } + vec![], + ) } /// Returns the last `n` characters of `s`, trimmed to a char boundary. diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 8e1b5757..905e4ac1 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -50,4 +50,47 @@ pub struct ToolResult { pub value: Value, /// Unique file paths referenced in the result. pub touched_files: Vec, + /// Internal analytics metadata for the server runtime. This must never be + /// serialized into the tool response payload. + internal_analytics: Option, +} + +impl ToolResult { + pub fn new(value: Value, touched_files: Vec) -> Self { + Self { + value, + touched_files, + internal_analytics: None, + } + } + + #[must_use] + pub fn with_internal_analytics(mut self, internal_analytics: Value) -> Self { + self.internal_analytics = Some(internal_analytics); + self + } + + pub fn internal_analytics(&self) -> Option<&Value> { + self.internal_analytics.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_result_constructors_keep_internal_analytics_explicit() { + let result = ToolResult::new(json!({"content": []}), vec!["src/lib.rs".to_string()]); + assert_eq!(result.value, json!({"content": []})); + assert_eq!(result.touched_files, vec!["src/lib.rs"]); + assert!(result.internal_analytics().is_none()); + + let result = result.with_internal_analytics(json!({"context_memory": {"match_count": 1}})); + assert_eq!( + result.internal_analytics(), + Some(&json!({"context_memory": {"match_count": 1}})) + ); + } } diff --git a/src/tracedecay.rs b/src/tracedecay.rs index b2905afc..ebfedc35 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -3862,6 +3862,11 @@ impl TraceDecay { Ok(db) } + pub async fn open_project_store_db_read_only(&self) -> Result { + let (db, _) = Database::open_read_only(&self.store_layout.graph_db_path).await?; + Ok(db) + } + fn build_branch_diagnostics( project_root: &Path, data_root: &Path, diff --git a/tests/agent_test.rs b/tests/agent_test.rs index a0369167..a02ec314 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -12,6 +12,7 @@ use tracedecay::automation::managed_skills::{ }; use tracedecay::branch_meta; use tracedecay::config::USER_DATA_DIR_ENV; +use tracedecay::sessions::SessionRecord; use tracedecay::storage::resolve_layout_for_current_profile; use tracedecay::tracedecay::TraceDecay; @@ -4560,6 +4561,52 @@ fn test_healthcheck_cursor_local_install_checks_project_config() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn test_cursor_healthcheck_warns_on_literal_workspace_folder_transcript_path() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + CursorIntegration + .install(&make_install_ctx(home.path())) + .unwrap(); + let cg = TraceDecay::init(project.path()).await.unwrap(); + let db = tracedecay::sessions::cursor::open_project_session_db(project.path()) + .await + .expect("session db should open for initialized project"); + assert!( + db.upsert_session(&SessionRecord { + provider: "cursor".to_string(), + session_id: "cursor-placeholder-session".to_string(), + project_key: project.path().to_string_lossy().to_string(), + project_path: project.path().to_string_lossy().to_string(), + title: Some("placeholder path".to_string()), + started_at: Some(1_715_000_000), + ended_at: None, + transcript_path: Some( + "${workspaceFolder}/.cursor/sessions/cursor-placeholder-session.jsonl".to_string(), + ), + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }) + .await, + "session row should insert" + ); + cg.close(); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + CursorIntegration.healthcheck(&mut dc, &hctx); + assert!( + dc.warnings > 0, + "literal workspaceFolder transcript paths should be reported" + ); +} + #[test] fn test_healthcheck_hermes_profile_install_checks_named_profiles() { let home = TempDir::new().unwrap(); diff --git a/tests/db_query_test.rs b/tests/db_query_test.rs index aed68dc0..0e4af3dd 100644 --- a/tests/db_query_test.rs +++ b/tests/db_query_test.rs @@ -104,7 +104,7 @@ async fn test_dependency_import_uses_query_use_nodes_without_unresolved_refs() { .expect("insert_nodes failed"); let imports = db - .dependency_import_uses("Foo", 5) + .dependency_import_uses("Foo", 5, None) .await .expect("dependency_import_uses failed"); @@ -118,6 +118,38 @@ async fn test_dependency_import_uses_query_use_nodes_without_unresolved_refs() { assert_eq!(imports[0].line, 4); } +#[tokio::test] +async fn test_dependency_import_uses_applies_scope_before_limit() { + let db = setup_db().await; + let mut nodes = Vec::new(); + for index in 0..8 { + let mut import_node = sample_node( + &format!("dep-import-{index}"), + "pkg", + &format!("src/{index}.ts"), + ); + import_node.kind = NodeKind::Use; + import_node.start_line = index; + import_node.signature = Some("import type { Foo } from \"pkg\";".to_string()); + nodes.push(import_node); + } + let mut scoped_import = sample_node("dep-import-scoped", "pkg", "tests/app.ts"); + scoped_import.kind = NodeKind::Use; + scoped_import.start_line = 9; + scoped_import.signature = Some("import type { Foo } from \"pkg\";".to_string()); + nodes.push(scoped_import); + + db.insert_nodes(&nodes).await.expect("insert_nodes failed"); + + let imports = db + .dependency_import_uses("Foo", 1, Some("tests")) + .await + .expect("dependency_import_uses failed"); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].file_path, "tests/app.ts"); +} + // ------------------------------------------------------------------------- // public db module exports // ------------------------------------------------------------------------- diff --git a/tests/hooks_test.rs b/tests/hooks_test.rs index 1cea0776..a5d6c142 100644 --- a/tests/hooks_test.rs +++ b/tests/hooks_test.rs @@ -781,8 +781,14 @@ fn test_build_codex_session_context_carries_full_steering() { assert!(context.contains("tracedecay_context")); assert!(context.contains("tracedecay_callers")); assert!(context.contains("last indexed 2m ago")); + assert!(context.contains("tracedecay_project_search")); + assert!(context.contains("tracedecay_message_search")); + assert!(context.contains("tracedecay_fact_store")); + assert!(context.contains("before asking the user to repeat")); let uninit = tracedecay::hooks::build_codex_session_context(false, None); assert!(uninit.contains("tracedecay init")); + assert!(uninit.contains("tracedecay_project_search")); + assert!(uninit.contains("tracedecay_message_search")); } #[test] @@ -794,6 +800,9 @@ fn test_build_codex_session_context_for_unindexed_project_suggests_init() { assert!(context.contains("tracedecay_context")); assert!(context.contains("tracedecay init")); + assert!(context.contains("tracedecay_project_list")); + assert!(context.contains("tracedecay_project_search")); + assert!(context.contains("tracedecay_message_search")); } #[test] @@ -806,6 +815,9 @@ fn test_build_codex_session_context_for_generic_workspace_uses_session_guidance( assert!(context.contains("TraceDecay session context")); assert!(context.contains("tracedecay_lcm_expand_query")); assert!(context.contains("tracedecay_message_search")); + assert!(context.contains("tracedecay_fact_store")); + assert!(context.contains("before asking the user to repeat")); + assert!(context.contains("Do not store task progress")); assert!( !context.contains("tracedecay init"), "non-project chats should not be told to initialize a code graph: {context}" diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index 95a0ba0d..afa0ecd9 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -1699,6 +1699,92 @@ export const value = 1; .contains("ignored dependency")); } +#[tokio::test] +async fn test_search_ignored_dependency_hint_respects_scope_prefix() { + let dir = test_temp_dir(); + let project = dir.path(); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join("tests")).unwrap(); + fs::create_dir_all(project.join("node_modules/pkg")).unwrap(); + fs::write( + project.join("src/app.ts"), + r#"import type { Foo } from "pkg"; +export const value = 1; +"#, + ) + .unwrap(); + fs::write( + project.join("tests/app.ts"), + r#"import type { Foo } from "pkg"; +export const value = 1; +"#, + ) + .unwrap(); + fs::write( + project.join("node_modules/pkg/index.d.ts"), + "export interface Foo { value: string }\n", + ) + .unwrap(); + + let (cg, _env) = init_test_project(project).await; + cg.index_all().await.unwrap(); + + let result = handle_tool_call( + &cg, + "tracedecay_search", + json!({"query": "Foo", "limit": 5, "format": "json"}), + None, + Some("tests"), + ) + .await + .unwrap(); + let payload: Value = serde_json::from_str(extract_text(&result.value)).unwrap(); + let candidates = payload["ignored_dependency_hint"]["candidates"] + .as_array() + .expect("scoped ignored dependency candidates"); + assert!(!candidates.is_empty()); + assert!(candidates.iter().all(|candidate| candidate["import_file"] + .as_str() + .is_some_and(|path| path.starts_with("tests/")))); +} + +#[tokio::test] +async fn test_search_skips_ignored_dependency_hint_when_results_fill_limit() { + let dir = test_temp_dir(); + let project = dir.path(); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join("node_modules/pkg")).unwrap(); + fs::write( + project.join("src/app.ts"), + r#"export function Foo() { + return "local"; +} +"#, + ) + .unwrap(); + fs::write( + project.join("node_modules/pkg/index.d.ts"), + "export interface Foo { value: string }\n", + ) + .unwrap(); + + let (cg, _env) = init_test_project(project).await; + cg.index_all().await.unwrap(); + + let result = handle_tool_call( + &cg, + "tracedecay_search", + json!({"query": "Foo", "limit": 1, "format": "json"}), + None, + None, + ) + .await + .unwrap(); + let payload: Value = serde_json::from_str(extract_text(&result.value)).unwrap(); + assert_eq!(payload.as_array().map(Vec::len), Some(1)); + assert_eq!(payload[0]["name"].as_str(), Some("Foo")); +} + #[tokio::test] async fn test_context_appends_index_coverage_hint_for_skipped_generated_dirs() { let (cg, _env, _dir) = setup_generated_dir_project(false).await; @@ -2129,6 +2215,14 @@ async fn context_includes_matching_memory_facts() { .await .unwrap(); let payload: Value = serde_json::from_str(extract_text(&json_result.value)).unwrap(); + assert!( + payload.get("context_memory_analytics").is_none(), + "internal context analytics must not be serialized in direct tool payloads" + ); + assert!( + json_result.internal_analytics().is_some(), + "direct tool results should carry context analytics on the internal side channel" + ); assert!(payload["memory_matches"] .as_array() .is_some_and(|matches| matches diff --git a/tests/mcp_server_test.rs b/tests/mcp_server_test.rs index daf1d66a..68b25dac 100644 --- a/tests/mcp_server_test.rs +++ b/tests/mcp_server_test.rs @@ -2162,12 +2162,22 @@ async fn context_call_writes_memory_match_analytics_without_fact_bodies() { "tracedecay_context", json!({ "task": "context memory analytics", - "format": "json", "session_id": "mcp-session-9006" }), ) .await; assert!(resp["error"].is_null(), "context should not error"); + assert!( + resp["result"].get("_tracedecay_analytics").is_none(), + "internal analytics metadata must not leak to clients" + ); + assert!( + !resp["result"] + .to_string() + .contains("context_memory_analytics") + && !resp["result"].to_string().contains("_tracedecay_analytics"), + "internal analytics metadata must not leak inside response content" + ); server_handle.ledger_writes_settled().await; let event = expect_mcp_runtime_event(