feat: add ecc2 graph recall memory ranking

This commit is contained in:
Affaan Mustafa
2026-04-10 05:49:43 -07:00
parent 23348a21a6
commit 7a13564a8b
4 changed files with 506 additions and 27 deletions

View File

@@ -457,6 +457,20 @@ enum GraphCommands {
#[arg(long)]
json: bool,
},
/// Recall relevant context graph entities for a query
Recall {
/// Filter by source session ID or alias
#[arg(long)]
session_id: Option<String>,
/// Natural-language query used for recall scoring
query: String,
/// Maximum entities to return
#[arg(long, default_value_t = 8)]
limit: usize,
/// Emit machine-readable JSON instead of the human summary
#[arg(long)]
json: bool,
},
/// Show one entity plus its incoming and outgoing relations
Show {
/// Entity ID
@@ -1229,6 +1243,27 @@ async fn main() -> Result<()> {
println!("{}", format_graph_relations_human(&relations));
}
}
GraphCommands::Recall {
session_id,
query,
limit,
json,
} => {
let resolved_session_id = session_id
.as_deref()
.map(|value| resolve_session_id(&db, value))
.transpose()?;
let entries =
db.recall_context_entities(resolved_session_id.as_deref(), &query, limit)?;
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else {
println!(
"{}",
format_graph_recall_human(&entries, resolved_session_id.as_deref(), &query)
);
}
}
GraphCommands::Show {
entity_id,
limit,
@@ -2214,6 +2249,49 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) ->
lines.join("\n")
}
fn format_graph_recall_human(
entries: &[session::ContextGraphRecallEntry],
session_id: Option<&str>,
query: &str,
) -> String {
if entries.is_empty() {
return format!("No relevant context graph entities found for query: {query}");
}
let scope = session_id
.map(short_session)
.unwrap_or_else(|| "all sessions".to_string());
let mut lines = vec![format!(
"Relevant memory: {} entries for \"{}\" ({scope})",
entries.len(),
query
)];
for entry in entries {
let mut line = format!(
"- #{} [{}] {} | score {} | relations {}",
entry.entity.id,
entry.entity.entity_type,
entry.entity.name,
entry.score,
entry.relation_count
);
if let Some(session_id) = entry.entity.session_id.as_deref() {
line.push_str(&format!(" | {}", short_session(session_id)));
}
lines.push(line);
if !entry.matched_terms.is_empty() {
lines.push(format!(" matches {}", entry.matched_terms.join(", ")));
}
if let Some(path) = entry.entity.path.as_deref() {
lines.push(format!(" path {path}"));
}
if !entry.entity.summary.is_empty() {
lines.push(format!(" summary {}", entry.entity.summary));
}
}
lines.join("\n")
}
fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String {
let mut lines = vec![format_graph_entity_human(&detail.entity)];
lines.push(String::new());
@@ -4114,6 +4192,40 @@ mod tests {
}
}
#[test]
fn cli_parses_graph_recall_command() {
let cli = Cli::try_parse_from([
"ecc",
"graph",
"recall",
"--session-id",
"latest",
"--limit",
"4",
"--json",
"auth callback recovery",
])
.expect("graph recall should parse");
match cli.command {
Some(Commands::Graph {
command:
GraphCommands::Recall {
session_id,
query,
limit,
json,
},
}) => {
assert_eq!(session_id.as_deref(), Some("latest"));
assert_eq!(query, "auth callback recovery");
assert_eq!(limit, 4);
assert!(json);
}
_ => panic!("expected graph recall subcommand"),
}
}
#[test]
fn format_decisions_human_renders_details() {
let text = format_decisions_human(
@@ -4196,6 +4308,43 @@ mod tests {
assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics"));
}
#[test]
fn format_graph_recall_human_renders_scores_and_matches() {
let text = format_graph_recall_human(
&[session::ContextGraphRecallEntry {
entity: session::ContextGraphEntity {
id: 11,
session_id: Some("sess-12345678".to_string()),
entity_type: "file".to_string(),
name: "callback.ts".to_string(),
path: Some("src/routes/auth/callback.ts".to_string()),
summary: "Handles auth callback recovery".to_string(),
metadata: BTreeMap::new(),
created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z")
.unwrap()
.with_timezone(&chrono::Utc),
updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z")
.unwrap()
.with_timezone(&chrono::Utc),
},
score: 319,
matched_terms: vec![
"auth".to_string(),
"callback".to_string(),
"recovery".to_string(),
],
relation_count: 2,
}],
Some("sess-12345678"),
"auth callback recovery",
);
assert!(text.contains("Relevant memory: 1 entries"));
assert!(text.contains("[file] callback.ts | score 319 | relations 2"));
assert!(text.contains("matches auth, callback, recovery"));
assert!(text.contains("path src/routes/auth/callback.ts"));
}
#[test]
fn format_graph_sync_stats_human_renders_counts() {
let text = format_graph_sync_stats_human(