From e1dcbedfa85c9c42ec22a8345dd4f6784ead4725 Mon Sep 17 00:00:00 2001 From: Dakera Ops Date: Sat, 13 Jun 2026 16:43:49 +0000 Subject: [PATCH 1/2] feat: add batch-recall and hybrid-search memory subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CLI feature parity gaps identified in the 100x org audit (DAK-6584). Both commands were available in the MCP server and Python SDK but not exposed through the CLI. - `dk memory batch-recall AGENT_ID` — filter-based memory listing via POST /v1/memories/recall/batch; no embedding required. Supports --tags, --min-importance, --max-importance, --type, --session-id, --limit flags. Maps to dakera_client::batch_recall. - `dk memory hybrid-search NAMESPACE QUERY` — BM25 + vector ANN search via POST /v1/namespaces/{ns}/hybrid. Supports --top-k and --vector-weight (0.0=BM25 only, 1.0=vector only). Maps to dakera_client::hybrid_search. Adds HybridRow output struct and two new unit tests for serialization. Co-Authored-By: Claude Sonnet 4.6 --- src/cli.rs | 65 ++++++++++++++++++++ src/commands/memory.rs | 134 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 7fa38c0..6738758 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -480,6 +480,71 @@ pub fn build_memory_command() -> Command { .help("Preview deletions without removing any memories"), ), ) + .subcommand( + Command::new("batch-recall") + .about("Filter-based memory listing by tags, importance, time range, or type (no embedding required)") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("tags") + .short('T') + .long("tags") + .help("Comma-separated tags to filter by (all tags must match)"), + ) + .arg( + Arg::new("min-importance") + .long("min-importance") + .value_parser(value_parser!(f32)) + .help("Minimum importance score (0.0–1.0, inclusive)"), + ) + .arg( + Arg::new("max-importance") + .long("max-importance") + .value_parser(value_parser!(f32)) + .help("Maximum importance score (0.0–1.0, inclusive)"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Filter by memory type"), + ) + .arg( + Arg::new("session-id") + .short('s') + .long("session-id") + .help("Filter by session ID"), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .default_value("100") + .value_parser(value_parser!(usize)) + .help("Maximum number of results to return"), + ), + ) + .subcommand( + Command::new("hybrid-search") + .about("Hybrid BM25 + vector ANN search in a namespace (omit vector for BM25-only)") + .arg(Arg::new("namespace").required(true).help("Namespace to search")) + .arg(Arg::new("query").required(true).help("Text query")) + .arg( + Arg::new("top-k") + .short('k') + .long("top-k") + .default_value("10") + .value_parser(value_parser!(u32)) + .help("Number of results to return"), + ) + .arg( + Arg::new("vector-weight") + .long("vector-weight") + .default_value("0.5") + .value_parser(value_parser!(f32)) + .help("Vector weight 0.0–1.0 (0.0=BM25 only, 1.0=vector only)"), + ), + ) } pub fn build_session_command() -> Command { diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 921d6dc..9098743 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -3,10 +3,10 @@ use anyhow::Result; use clap::ArgMatches; use dakera_client::memory::{ - ConsolidateRequest, FeedbackRequest, MemoryType, RecallRequest, StoreMemoryRequest, - UpdateImportanceRequest, UpdateMemoryRequest, + BatchMemoryFilter, BatchRecallRequest, ConsolidateRequest, FeedbackRequest, MemoryType, + RecallRequest, StoreMemoryRequest, UpdateImportanceRequest, UpdateMemoryRequest, }; -use dakera_client::DakeraClient; +use dakera_client::{DakeraClient, HybridSearchRequest}; use serde::Serialize; use crate::context::Context; @@ -21,6 +21,12 @@ pub struct MemoryRow { pub score: f32, } +#[derive(Debug, Serialize)] +pub struct HybridRow { + pub id: String, + pub score: f32, +} + fn parse_memory_type(s: &str) -> MemoryType { match s.to_lowercase().as_str() { "semantic" => MemoryType::Semantic, @@ -365,6 +371,103 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { } } + Some(("batch-recall", sub_matches)) => { + let agent_id = sub_matches.get_one::("agent_id").unwrap(); + let limit = *sub_matches.get_one::("limit").unwrap(); + let min_importance = sub_matches.get_one::("min-importance").copied(); + let max_importance = sub_matches.get_one::("max-importance").copied(); + let memory_type = sub_matches.get_one::("type"); + let session_id = sub_matches.get_one::("session-id").cloned(); + let tags: Option> = sub_matches + .get_one::("tags") + .map(|s| s.split(',').map(|t| t.trim().to_string()).collect()); + + let mut filter = BatchMemoryFilter::default(); + if let Some(t) = tags { + filter = filter.with_tags(t); + } + if let Some(mi) = min_importance { + filter = filter.with_min_importance(mi); + } + if let Some(ma) = max_importance { + filter = filter.with_max_importance(ma); + } + if let Some(mt) = memory_type { + filter.memory_type = Some(parse_memory_type(mt)); + } + if let Some(sid) = session_id { + filter = filter.with_session(sid); + } + + let request = BatchRecallRequest::new(agent_id.clone()) + .with_filter(filter) + .with_limit(limit); + + let t = ctx.log_request("POST", "/v1/memories/recall/batch"); + let response = client.batch_recall(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; + + if response.memories.is_empty() { + output::info("No memories found"); + } else { + output::info(&format!( + "Found {} memories (total: {}, filtered: {})", + response.memories.len(), + response.total, + response.filtered + )); + let rows: Vec = response + .memories + .into_iter() + .map(|m| MemoryRow { + id: m.id, + content: m.content, + memory_type: memory_type_to_string(&m.memory_type), + importance: m.importance, + score: m.score, + }) + .collect(); + output::print_data(&rows, ctx.format); + } + } + + Some(("hybrid-search", sub_matches)) => { + let namespace = sub_matches.get_one::("namespace").unwrap(); + let query = sub_matches.get_one::("query").unwrap(); + let top_k = *sub_matches.get_one::("top-k").unwrap(); + let vector_weight = *sub_matches.get_one::("vector-weight").unwrap(); + + let request = HybridSearchRequest::text_only(query.clone(), top_k) + .with_vector_weight(vector_weight); + + let t = ctx.log_request("POST", &format!("/v1/namespaces/{}/hybrid", namespace)); + let response = client.hybrid_search(namespace, request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; + + if response.matches.is_empty() { + output::info("No results found"); + } else { + output::info(&format!("Found {} results", response.matches.len())); + let rows: Vec = response + .matches + .into_iter() + .map(|m| HybridRow { + id: m.id, + score: m.score, + }) + .collect(); + output::print_data(&rows, ctx.format); + } + } + _ => { output::error("Unknown memory subcommand. Use --help for usage."); std::process::exit(1); @@ -436,4 +539,29 @@ mod tests { ); } } + + #[test] + fn hybrid_row_serializes_id_and_score() { + let row = HybridRow { + id: "vec-1".into(), + score: 0.87, + }; + let json = serde_json::to_value(&row).unwrap(); + assert_eq!(json["id"], "vec-1"); + assert!((json["score"].as_f64().unwrap() - 0.87).abs() < 1e-6); + } + + #[test] + fn memory_row_serializes_all_fields() { + let row = MemoryRow { + id: "mem-1".into(), + content: "hello".into(), + memory_type: "episodic".into(), + importance: 0.8, + score: 0.9, + }; + let json = serde_json::to_value(&row).unwrap(); + assert_eq!(json["id"], "mem-1"); + assert_eq!(json["memory_type"], "episodic"); + } } From 657658b1c807d1b40d56aafa098fe6504b445ff9 Mon Sep 17 00:00:00 2001 From: Dakera Ops Date: Sat, 13 Jun 2026 18:11:51 +0000 Subject: [PATCH 2/2] fix: use response.results not response.matches for HybridSearchResponse --- src/commands/memory.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 9098743..c5a1912 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -452,12 +452,12 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { } let response = response?; - if response.matches.is_empty() { + if response.results.is_empty() { output::info("No results found"); } else { - output::info(&format!("Found {} results", response.matches.len())); + output::info(&format!("Found {} results", response.results.len())); let rows: Vec = response - .matches + .results .into_iter() .map(|m| HybridRow { id: m.id,