diff --git a/src/agent/tools/memory.rs b/src/agent/tools/memory.rs index 588721b..c606c68 100644 --- a/src/agent/tools/memory.rs +++ b/src/agent/tools/memory.rs @@ -276,73 +276,23 @@ 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: 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")) - } + // 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)) } } } -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 e755367..b84935d 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, + Stage, Filter, Transform, Generator, SortField, ScoreField, Algorithm, AlgoStage, Cmp, }; @@ -92,13 +92,16 @@ peg::parser! { / "connectivity" { Stage::Transform(Transform::Connectivity) } / "dominating-set" { Stage::Transform(Transform::DominatingSet) } // Pipeline syntax (colon-separated) - / "sort:" f:field() { Stage::Transform(Transform::Sort(make_sort_field(&f, false))) } + / "sort:" c:composite_sort() { Stage::Transform(Transform::Sort(c)) } / "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:ident() { Stage::Filter(Filter::KeyGlob(g)) } + / "key:" g:glob_pattern() { Stage::Filter(Filter::KeyGlob(g)) } + / "!key:" g:glob_pattern() { Stage::Filter(Filter::Negated(Box::new(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() }) } @@ -109,6 +112,27 @@ 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]; @@ -177,9 +201,22 @@ peg::parser! { rule value() -> Value = f:fn_call() { Value::FnCall(f) } - / n:number() { Value::Num(n) } / s:string() { Value::Str(s) } - / i:ident() { Value::Ident(i) } + / 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()) + } + } rule fn_call() -> FnCall = "community" _ "(" _ k:string() _ ")" { FnCall::Community(k) } @@ -192,11 +229,24 @@ 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() + } } } @@ -800,3 +850,113 @@ 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 a26e6ee..983a5db 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -111,8 +111,10 @@ impl Unconscious { } agents.sort_by(|a, b| a.name.cmp(&b.name)); + let max_concurrent = crate::config::get().llm_concurrency; + Self { - agents, max_concurrent: 2, + agents, max_concurrent, graph_health: None, last_health_check: None, }