Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/automation/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ pub struct SkillWriterAutomationOptions {
pub trigger: AutomationTrigger,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default = "default_session_provider")]
#[serde(default = "default_skill_writer_provider")]
pub provider: String,
#[serde(default = "default_skill_writer_query")]
pub query: String,
Expand All @@ -123,7 +123,7 @@ impl Default for SkillWriterAutomationOptions {
Self {
trigger: AutomationTrigger::ManualCli,
run_id: None,
provider: default_session_provider(),
provider: default_skill_writer_provider(),
query: default_skill_writer_query(),
evidence_limit: default_skill_writer_evidence_limit(),
profile_root: None,
Expand Down Expand Up @@ -500,7 +500,8 @@ async fn build_skill_writer_evidence(
Some(path) => path,
None => crate::storage::default_profile_root()?,
};
let provider = normalized_non_empty(&options.provider).unwrap_or_else(default_session_provider);
let provider =
normalized_non_empty(&options.provider).unwrap_or_else(default_skill_writer_provider);
let query = normalized_non_empty(&options.query).unwrap_or_else(default_skill_writer_query);
let evidence_limit = options.evidence_limit.clamp(1, 50);

Expand Down Expand Up @@ -703,6 +704,10 @@ fn default_session_provider() -> String {
"cursor".to_string()
}

fn default_skill_writer_provider() -> String {
"all".to_string()
}

fn default_lcm_storage_scope() -> String {
"project_local".to_string()
}
Expand Down
37 changes: 37 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,11 @@ pub enum Commands {
#[command(subcommand)]
action: SessionsAction,
},
/// Inspect registered TraceDecay projects from the global registry
Projects {
#[command(subcommand)]
action: ProjectsAction,
},
/// Manage multi-branch indexing
Branch {
#[command(subcommand)]
Expand Down Expand Up @@ -395,6 +400,38 @@ pub enum Commands {
},
}

#[derive(Subcommand)]
pub enum ProjectsAction {
/// List registered projects
List {
/// Maximum projects to show
#[arg(long, default_value_t = 25)]
limit: usize,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Search registered projects by id, path, alias, remote, or branch
Search {
/// Query text
query: String,
/// Maximum projects to show
#[arg(long, default_value_t = 25)]
limit: usize,
/// Output as JSON
#[arg(long)]
json: bool,
},
/// Show registry context for one project id or path
Context {
/// Project id, root path, or registered alias
selector: String,
/// Output as JSON
#[arg(long)]
json: bool,
},
}

#[derive(Subcommand)]
pub enum LspAction {
/// List supported language servers, availability, and install hints
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod cost_cmd;
mod global;
mod hook_cmd;
mod lsp_cmd;
mod project_cmd;
mod sessions_cmd;
mod status_cmd;
mod tool_command;
Expand Down Expand Up @@ -834,6 +835,9 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> {
Commands::Sessions { action } => {
sessions_cmd::handle_sessions_action(action).await?;
}
Commands::Projects { action } => {
project_cmd::handle_projects_action(action).await?;
}
Commands::Branch { action } => {
commands::handle_branch_action(action).await?;
}
Expand Down Expand Up @@ -904,6 +908,7 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool {
| Commands::Lsp { .. }
| Commands::Doctor { .. }
| Commands::Migrate { .. }
| Commands::Projects { .. }
| Commands::HookPreToolUse
| Commands::HookPromptSubmit
| Commands::HookStop
Expand Down Expand Up @@ -968,6 +973,7 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool {
| Commands::Lsp { .. }
| Commands::Doctor { .. }
| Commands::Migrate { .. }
| Commands::Projects { .. }
| Commands::Tool { .. }
| Commands::Daemon { .. }
)
Expand Down
21 changes: 19 additions & 2 deletions src/mcp/tools/definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ pub fn explore_call_budget(total_nodes: u64) -> u8 {
pub fn context_description(node_count: u64, budget: u8) -> String {
format!(
"Build an AI-ready context for a task description. Returns relevant symbols, \
relationships, and optionally code snippets.\n\n\
relationships, up to three untracked project memory matches when available, \
and optionally code snippets.\n\n\
CALL BUDGET: {budget} calls maximum for this project ({node_count} nodes). \
Stop after {budget} calls. If the question is not fully answered, synthesise \
from what you have — do not exceed the budget."
Expand Down Expand Up @@ -715,6 +716,18 @@ fn def_context() -> ToolDefinition {
"max_per_file": {
"type": "number",
"description": "Maximum symbols from a single file in results. Prevents one large file from dominating (default: max_nodes/3, minimum 3)"
},
"include_memory": {
"type": "boolean",
"description": "When true, include up to memory_limit matching project memory facts as a separate context lane (default: true)"
},
"memory_limit": {
"type": "number",
"description": "Maximum memory facts to include when include_memory is true (default: 3, max: 10)"
},
"memory_min_trust": {
"type": "number",
"description": "Minimum trust score for memory facts included in context (default: 0.5)"
}
})),
"required": ["task"]
Expand Down Expand Up @@ -2276,7 +2289,7 @@ fn def_message_search() -> ToolDefinition {
def(
"tracedecay_message_search",
"Message Search",
"Search ingested transcript messages across all supported providers by default. Every search first catches up all supported provider adapters for the selected project; pass provider only when explicitly scoping results to one provider.",
"Search ingested transcript messages across all supported providers by default. Every search first catches up all supported provider adapters for the selected project unless catch_up is false; pass provider only when explicitly scoping results to one provider.",
json!({
"type": "object",
"properties": {
Expand All @@ -2297,6 +2310,10 @@ fn def_message_search() -> ToolDefinition {
"type": "boolean",
"description": "Whether to include child subagent sessions in results (default: true)."
},
"catch_up": {
"type": "boolean",
"description": "Whether to ingest/catch up local provider transcripts before searching (default: true). Set false for strictly read-only audits of already-ingested messages."
},
"parent_session_id": {
"type": "string",
"description": "Optional parent session id filter. Primarily useful with scope=subagents_only."
Expand Down
102 changes: 101 additions & 1 deletion src/mcp/tools/handlers/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ use serde_json::{json, Value};

use crate::context::format_context_as_markdown;
use crate::errors::{Result, TraceDecayError};
use crate::memory::types::{FactSearchResult, SearchFactsRequest};
use crate::path_tree::format_compact_path_list;
use crate::tracedecay::TraceDecay;
use crate::types::{BuildContextOptions, EdgeKind, Node, NodeKind, TaskContext, Visibility};

const CONTEXT_MEMORY_MATCH_LIMIT: usize = 3;
const CONTEXT_MEMORY_MATCH_LIMIT_MAX: usize = 10;
const CONTEXT_MEMORY_SNIPPET_CHARS: usize = 240;

use super::super::render::{self, Md};
use super::super::ToolResult;
use super::support::{
Expand Down Expand Up @@ -164,6 +169,15 @@ pub(super) async fn handle_context(
let options = build_context_options(&args, scope_prefix);

let context = cg.build_context(task, &options).await?;
let memory_options = context_memory_options(&args);
let (memory_matches, memory_matches_error) = if memory_options.include_memory {
match context_memory_matches(cg, task, &memory_options).await {
Ok(matches) => (matches, None),
Err(err) => (Vec::new(), Some(err.to_string())),
}
} else {
(Vec::new(), None)
};
let touched_files = unique_file_paths(
context
.subgraph
Expand All @@ -178,6 +192,23 @@ pub(super) async fn handle_context(
),
);
let mut output = format_context_as_markdown(&context);
if !memory_matches.is_empty() {
let _ = writeln!(output, "\n### Memory Matches");
for hit in &memory_matches {
let fact = &hit.fact;
let _ = writeln!(
output,
"- fact_id={} category={} trust={:.2} score={:.3}: {}",
fact.fact_id,
fact.category,
fact.trust_score,
hit.score,
compact_memory_content(&fact.content)
);
}
} else if let Some(err) = &memory_matches_error {
let _ = writeln!(output, "\n### Memory Matches\nUnavailable: {err}");
}
if let Some(hint) = cg.index_coverage_hint(context.subgraph.nodes.len()) {
let _ = writeln!(
output,
Expand All @@ -201,7 +232,16 @@ pub(super) async fn handle_context(
);
}

let value = serde_json::to_value(&context).unwrap_or_else(|_| json!({}));
let mut value = serde_json::to_value(&context).unwrap_or_else(|_| json!({}));
if let Some(object) = value.as_object_mut() {
object.insert(
"memory_matches".to_string(),
serde_json::to_value(&memory_matches).unwrap_or_else(|_| json!([])),
);
if let Some(err) = memory_matches_error {
object.insert("memory_matches_error".to_string(), json!(err));
}
}
Ok(rendered_tool_result(
cg,
&args,
Expand All @@ -211,6 +251,66 @@ pub(super) async fn handle_context(
))
}

struct ContextMemoryOptions {
include_memory: bool,
limit: usize,
min_trust: f64,
}

fn context_memory_options(args: &Value) -> ContextMemoryOptions {
let include_memory = args
.get("include_memory")
.and_then(Value::as_bool)
.unwrap_or(true);
let limit = args
.get("memory_limit")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(CONTEXT_MEMORY_MATCH_LIMIT)
.clamp(1, CONTEXT_MEMORY_MATCH_LIMIT_MAX);
let min_trust = args
.get("memory_min_trust")
.and_then(Value::as_f64)
.unwrap_or(0.5)
.clamp(0.0, 1.0);
ContextMemoryOptions {
include_memory,
limit,
min_trust,
}
}

async fn context_memory_matches(
cg: &TraceDecay,
task: &str,
options: &ContextMemoryOptions,
) -> Result<Vec<FactSearchResult>> {
cg.search_facts_untracked(SearchFactsRequest {
query: task.to_string(),
category: None,
limit: Some(options.limit),
min_trust: Some(options.min_trust),
include_why: false,
})
.await
}

fn compact_memory_content(content: &str) -> String {
let mut snippet = String::new();
let mut truncated = false;
for (idx, ch) in content.chars().enumerate() {
if idx >= CONTEXT_MEMORY_SNIPPET_CHARS {
truncated = true;
break;
}
snippet.push(ch);
}
if truncated {
snippet.push_str("...");
}
snippet
}

fn build_context_options(args: &Value, scope_prefix: Option<&str>) -> BuildContextOptions {
let max_nodes = args
.get("max_nodes")
Expand Down
9 changes: 8 additions & 1 deletion src/mcp/tools/handlers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,10 @@ pub(super) async fn handle_message_search(
.get("include_subagents")
.and_then(Value::as_bool)
.unwrap_or(true);
let catch_up = args
.get("catch_up")
.and_then(Value::as_bool)
.unwrap_or(true);
let mut scope = parse_message_search_scope(&args)?;
if !include_subagents && matches!(scope, SessionSearchScope::All) {
scope = SessionSearchScope::ParentsOnly;
Expand Down Expand Up @@ -1439,7 +1443,9 @@ pub(super) async fn handle_message_search(
}),
));
};
let _ = crate::sessions::ingest_global_sources(&db, &target_root).await;
if catch_up {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Open sessions DB read-only when catch_up is false

catch_up=false is documented for read-only audits, but by this point the handler has already called open_session_db_with_cached_ensure, which creates/ensures/migrates the sessions DB before the catch_up guard. In a missing or older session store, a supposedly read-only message search can create or mutate sessions.db even though ingestion is skipped; choose a read-only existing open path when catch_up is false.

Useful? React with 👍 / 👎.

let _ = crate::sessions::ingest_global_sources(&db, &target_root).await;
}
let results = if let Some(provider) = requested_provider {
db.search_session_messages_filtered(
provider,
Expand Down Expand Up @@ -1471,6 +1477,7 @@ pub(super) async fn handle_message_search(
"project_key": project_key,
"parent_session_id": parent_session_id,
"include_subagents": include_subagents,
"catch_up": catch_up,
"scope": match scope {
SessionSearchScope::All => "all",
SessionSearchScope::ParentsOnly => "parents_only",
Expand Down
39 changes: 32 additions & 7 deletions src/memory/retrieval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,29 @@ impl<'a> FactRetriever<'a> {
category: Option<MemoryCategory>,
min_trust: Option<f64>,
limit: usize,
) -> Result<Vec<FactSearchResult>> {
self.search_with_tracking(query, category, min_trust, limit, true)
.await
}

pub async fn search_untracked(
&self,
query: &str,
category: Option<MemoryCategory>,
min_trust: Option<f64>,
limit: usize,
) -> Result<Vec<FactSearchResult>> {
self.search_with_tracking(query, category, min_trust, limit, false)
.await
}

async fn search_with_tracking(
&self,
query: &str,
category: Option<MemoryCategory>,
min_trust: Option<f64>,
limit: usize,
track_recalls: bool,
) -> Result<Vec<FactSearchResult>> {
let min_trust = min_trust.unwrap_or(DEFAULT_MIN_TRUST);
let limit = normalized_limit(limit);
Expand Down Expand Up @@ -135,13 +158,15 @@ impl<'a> FactRetriever<'a> {
});
results.truncate(limit);

// Access tracking for the facts actually RETURNED to the caller —
// candidates scanned and dropped above never count, and the other
// retrieval modes (probe/list/related/reason) deliberately do not
// bump access_count. Batched single UPDATE, fire-and-forget: a
// tracking failure must never fail the search itself.
let returned_ids: Vec<i64> = results.iter().map(|result| result.fact.fact_id).collect();
let _ = self.store.record_fact_recalls(&returned_ids).await;
if track_recalls {
// Access tracking for the facts actually RETURNED to the caller —
// candidates scanned and dropped above never count, and the other
// retrieval modes (probe/list/related/reason) deliberately do not
// bump access_count. Batched single UPDATE, fire-and-forget: a
// tracking failure must never fail the search itself.
let returned_ids: Vec<i64> = results.iter().map(|result| result.fact.fact_id).collect();
let _ = self.store.record_fact_recalls(&returned_ids).await;
}

Ok(results)
}
Expand Down
Loading
Loading