diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index c606c68..588721b 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -276,23 +276,73 @@ async fn query(args: &serde_json::Value) -> Result { let store = arc.lock().await; let graph = store.build_graph(); + let stages = crate::query_parser::parse_stages(query_str) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let results = crate::search::run_query(&stages, vec![], &graph, &store, false, 100); + let keys: Vec = results.into_iter().map(|(k, _)| k).collect(); + match format { "full" => { // Rich output with full content, graph metrics, hub analysis - let results = crate::query_parser::execute_query(&store, &graph, query_str) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let keys: Vec = results.into_iter().map(|r| r.key).collect(); let items = crate::subconscious::defs::keys_to_replay_items(&store, &keys, &graph); Ok(crate::subconscious::prompts::format_nodes_section(&store, &items, &graph)) } _ => { - // Compact output: handles count, select, and all expression types - crate::query_parser::query_to_string(&store, &graph, query_str) - .map_err(|e| anyhow::anyhow!("{}", e)) + // Compact output: check for count/select stages, else just list keys + use crate::search::{Stage, Transform}; + let has_count = stages.iter().any(|s| matches!(s, Stage::Transform(Transform::Count))); + if has_count { + return Ok(keys.len().to_string()); + } + if keys.is_empty() { + return Ok("no results".to_string()); + } + let select_fields: Option<&Vec> = stages.iter().find_map(|s| match s { + Stage::Transform(Transform::Select(f)) => Some(f), + _ => None, + }); + if let Some(fields) = select_fields { + let mut out = String::from("key\t"); + out.push_str(&fields.join("\t")); + out.push('\n'); + for key in &keys { + out.push_str(key); + for f in fields { + out.push('\t'); + out.push_str(&resolve_field_str(&store, &graph, key, f)); + } + out.push('\n'); + } + Ok(out) + } else { + Ok(keys.join("\n")) + } } } } +fn resolve_field_str(store: &crate::store::Store, graph: &crate::graph::Graph, key: &str, field: &str) -> String { + let node = match store.nodes.get(key) { + Some(n) => n, + None => return "-".to_string(), + }; + match field { + "key" => key.to_string(), + "weight" => format!("{:.3}", node.weight), + "node_type" => format!("{:?}", node.node_type), + "provenance" => node.provenance.clone(), + "emotion" => format!("{}", node.emotion), + "retrievals" => format!("{}", node.retrievals), + "uses" => format!("{}", node.uses), + "wrongs" => format!("{}", node.wrongs), + "created" => format!("{}", node.created_at), + "timestamp" => format!("{}", node.timestamp), + "degree" => format!("{}", graph.degree(key)), + "content_len" => format!("{}", node.content.len()), + _ => "-".to_string(), + } +} + // ── Journal tools ────────────────────────────────────────────── async fn journal_tail(args: &serde_json::Value) -> Result { diff --git a/src/hippocampus/query/parser.rs b/src/hippocampus/query/parser.rs index b84935d..e755367 100644 --- a/src/hippocampus/query/parser.rs +++ b/src/hippocampus/query/parser.rs @@ -28,7 +28,7 @@ use std::collections::BTreeMap; // Re-export engine types used by Query pub use super::engine::{ - Stage, Filter, Transform, Generator, SortField, ScoreField, + Stage, Filter, Transform, Generator, SortField, Algorithm, AlgoStage, Cmp, }; @@ -92,16 +92,13 @@ peg::parser! { / "connectivity" { Stage::Transform(Transform::Connectivity) } / "dominating-set" { Stage::Transform(Transform::DominatingSet) } // Pipeline syntax (colon-separated) - / "sort:" c:composite_sort() { Stage::Transform(Transform::Sort(c)) } + / "sort:" f:field() { Stage::Transform(Transform::Sort(make_sort_field(&f, false))) } / "limit:" n:integer() { Stage::Transform(Transform::Limit(n)) } / "select:" f:field_list_colon() { Stage::Transform(Transform::Select(f)) } / "type:" t:ident() { make_type_filter(&t) } / "age:" c:cmp_duration() { Stage::Filter(Filter::Age(c)) } - / "key:" g:glob_pattern() { Stage::Filter(Filter::KeyGlob(g)) } - / "!key:" g:glob_pattern() { Stage::Filter(Filter::Negated(Box::new(Filter::KeyGlob(g)))) } + / "key:" g:ident() { Stage::Filter(Filter::KeyGlob(g)) } / "provenance:" p:ident() { Stage::Filter(Filter::Provenance(p)) } - / "not-visited:" a:ident() "," d:integer() { Stage::Filter(Filter::NotVisited { agent: a, duration: d as i64 }) } - / "visited:" a:ident() { Stage::Filter(Filter::Visited { agent: a }) } / "all" { Stage::Generator(Generator::All) } // Graph algorithms / "spread" { Stage::Algorithm(AlgoStage { algo: Algorithm::Spread, params: std::collections::HashMap::new() }) } @@ -112,27 +109,6 @@ peg::parser! { / "desc" { false } / { false } // default: descending - // Composite sort: degree*0.5+isolation*0.3+recency(organize)*0.2 - // Falls back to simple field if no weighted terms found. - rule composite_sort() -> SortField - = t:score_term() ts:("+" t:score_term() { t })+ { - let mut terms = vec![t]; - terms.extend(ts); - SortField::Composite(terms) - } - / f:field() { make_sort_field(&f, false) } - - rule score_term() -> (ScoreField, f64) - = "recency(" a:ident() ")" "*" w:number() { (ScoreField::Recency(a), w) } - / f:score_field_name() "*" w:number() { (f, w) } - - rule score_field_name() -> ScoreField - = "isolation" { ScoreField::Isolation } - / "degree" { ScoreField::Degree } - / "weight" { ScoreField::Weight } - / "content-len" { ScoreField::ContentLen } - / "priority" { ScoreField::Priority } - rule field_list_colon() -> Vec = f:field() fs:("," f:field() { f })* { let mut v = vec![f]; @@ -201,22 +177,9 @@ peg::parser! { rule value() -> Value = f:fn_call() { Value::FnCall(f) } + / n:number() { Value::Num(n) } / s:string() { Value::Str(s) } - / t:token() { t } - - // Token: number or identifier, with alphanumeric fallback (e.g., "27b") - rule token() -> Value - = n:$(['0'..='9']+ ("." ['0'..='9']+)?) !['a'..='z' | 'A'..='Z'] { - Value::Num(n.parse().unwrap()) - } - / s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+) { - // Try as number first, fall back to string - if let Ok(n) = s.parse::() { - Value::Num(n) - } else { - Value::Str(s.to_string()) - } - } + / i:ident() { Value::Ident(i) } rule fn_call() -> FnCall = "community" _ "(" _ k:string() _ ")" { FnCall::Community(k) } @@ -229,24 +192,11 @@ peg::parser! { rule string() -> String = "'" s:$([^ '\'']*) "'" { s.to_string() } - / "\"" s:$([^ '"']*) "\"" { s.to_string() } rule ident() -> String = s:$(['a'..='z' | 'A'..='Z' | '_']['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']*) { s.to_string() } - - // Bare word for matching (allows digits at start, e.g. "27b") - rule word() -> String - = s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+) { - s.to_string() - } - - // Glob pattern for key matching (allows * and ?) - rule glob_pattern() -> String - = s:$(['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.' | '*' | '?']+) { - s.to_string() - } } } @@ -850,113 +800,3 @@ fn print_connectivity(results: &[QueryResult], graph: &Graph) { } } } - -// -- Tests -- - -#[cfg(test)] -mod tests { - use super::*; - - // Helper to check if a query parses successfully - fn parses(s: &str) -> bool { - query_parser::query(s).is_ok() - } - - // Helper to get parse error for debugging - fn parse_err(s: &str) -> String { - query_parser::query(s).err().map(|e| format!("{}", e)).unwrap_or_default() - } - - #[test] - fn test_generators() { - assert!(parses("all")); - assert!(parses("*")); - assert!(parses("all | limit:10")); - } - - #[test] - fn test_pipeline_filters() { - assert!(parses("all | type:semantic")); - assert!(parses("all | type:episodic")); - assert!(parses("all | provenance:observe")); - assert!(parses("all | key:journal-*")); - assert!(parses("all | !key:_*")); // negated key glob - assert!(parses("all | age:>7d")); - assert!(parses("all | not-visited:organize,86400")); - } - - #[test] - fn test_pipeline_transforms() { - assert!(parses("all | sort:weight")); - assert!(parses("all | sort:timestamp")); - assert!(parses("all | sort:degree")); - assert!(parses("all | limit:20")); - assert!(parses("all | sort:weight | limit:10")); - } - - #[test] - fn test_composite_sort() { - // Weighted composite sort expressions (require 2+ terms with +) - assert!(parses("all | sort:degree*0.5+isolation*0.3")); - assert!(parses("all | sort:degree*0.5+isolation*0.3+recency(organize)*0.2")); - assert!(parses("all | sort:weight*0.5+degree*0.5")); - // Single field (no weight) falls back to simple sort - assert!(parses("all | sort:weight")); - } - - #[test] - fn test_expression_syntax() { - // Expression comparisons (legacy syntax) - assert!(parses("weight > 0.5")); - assert!(parses("degree >= 10")); - assert!(parses("key ~ 'journal.*'")); - assert!(parses("content ~ 27b"), "alphanumeric pattern: {}", parse_err("content ~ 27b")); - assert!(parses("content ~ qwen35")); - // Both single and double quotes work for strings - assert!(parses("content ~ '27b'")); - assert!(parses("content ~ \"27b\""), "double quotes: {}", parse_err("content ~ \"27b\"")); - assert!(parses("neighbors(\"my-key\")")); - } - - #[test] - fn test_boolean_expressions() { - assert!(parses("weight > 0.5 AND degree > 10")); - assert!(parses("key ~ 'a' OR key ~ 'b'")); - assert!(parses("NOT weight < 0.1")); - } - - #[test] - fn test_duration_parsing() { - assert!(parses("all | age:>1d")); - assert!(parses("all | age:>=24h")); - assert!(parses("all | age:<30m")); - assert!(parses("all | age:=3600s")); - assert!(parses("all | age:>86400")); // raw seconds - } - - #[test] - fn test_glob_patterns() { - assert!(parses("all | key:*")); - assert!(parses("all | key:journal-*")); - assert!(parses("all | key:*-2026-*")); - assert!(parses("all | key:dream-cycle-?")); - assert!(parses("all | !key:subconscious-*")); - } - - #[test] - fn test_complex_pipelines() { - assert!(parses("all | type:semantic | sort:weight | limit:50")); - assert!(parses("all | !key:_* | sort:degree*0.5+isolation*0.5 | limit:10")); - assert!(parses("all | provenance:observe | age:>1d | sort:timestamp | limit:20")); - } - - #[test] - fn test_parse_stages_output() { - // Ensure parse_stages produces expected Stage types - let stages = parse_stages("all | type:semantic | limit:10").unwrap(); - assert_eq!(stages.len(), 3); - assert!(matches!(stages[0], Stage::Generator(Generator::All))); - assert!(matches!(stages[1], Stage::Filter(Filter::Type(_)))); - assert!(matches!(stages[2], Stage::Transform(Transform::Limit(10)))); - } -} diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index 983a5db..a26e6ee 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -111,10 +111,8 @@ impl Unconscious { } agents.sort_by(|a, b| a.name.cmp(&b.name)); - let max_concurrent = crate::config::get().llm_concurrency; - Self { - agents, max_concurrent, + agents, max_concurrent: 2, graph_health: None, last_health_check: None, }