1use async_trait::async_trait;
56use harness_core::{Memory, MemoryEntry, MemoryError, Tool};
57use harness_mcp_client::McpClient;
58use serde_json::{Value, json};
59use std::sync::Arc;
60use std::sync::atomic::{AtomicU64, Ordering};
61
62pub struct CortexdbMemory {
64 _client: McpClient,
66 save: Arc<dyn Tool>,
67 search: Arc<dyn Tool>,
68 consolidate: Option<Arc<dyn Tool>>,
71 scope: String,
72 namespace: String,
73 user_id: Option<String>,
74 seq: AtomicU64,
75}
76
77impl CortexdbMemory {
78 pub async fn connect_stdio(program: &str, args: &[&str]) -> anyhow::Result<Self> {
81 Self::from_client(McpClient::connect_stdio(program, args).await?)
82 }
83
84 #[cfg(feature = "http")]
102 pub async fn connect_http(url: &str) -> anyhow::Result<Self> {
103 Self::from_client(McpClient::connect_http(url).await?)
104 }
105
106 pub fn from_client(client: McpClient) -> anyhow::Result<Self> {
109 let tools = client.tools();
110 let find = |name: &str| tools.iter().find(|t| t.name() == name).cloned();
111 let save = find("memory_save")
112 .ok_or_else(|| anyhow::anyhow!("CortexDB MCP server exposes no `memory_save` tool"))?;
113 let search = find("memory_search").ok_or_else(|| {
114 anyhow::anyhow!("CortexDB MCP server exposes no `memory_search` tool")
115 })?;
116 let consolidate = find("knowledge_memory_consolidate");
118 Ok(Self {
119 _client: client,
120 save,
121 search,
122 consolidate,
123 scope: "global".into(),
124 namespace: "harness".into(),
125 user_id: None,
126 seq: AtomicU64::new(0),
127 })
128 }
129
130 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
132 self.scope = scope.into();
133 self
134 }
135 pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
137 self.namespace = ns.into();
138 self
139 }
140 pub fn with_user_id(mut self, user: impl Into<String>) -> Self {
142 self.user_id = Some(user.into());
143 self
144 }
145
146 pub fn can_consolidate(&self) -> bool {
148 self.consolidate.is_some()
149 }
150
151 pub async fn consolidate(&self, extra_args: Value) -> Result<Value, MemoryError> {
173 let tool = self.consolidate.as_ref().ok_or_else(|| {
174 MemoryError::Backend(
175 "this CortexDB build exposes no `knowledge_memory_consolidate` tool".into(),
176 )
177 })?;
178 let args = build_consolidate_args(
179 &self.scope,
180 &self.namespace,
181 self.user_id.as_deref(),
182 extra_args,
183 );
184 let mut world = harness_context::default_world(".");
185 let res = tool
186 .invoke(args, &mut world)
187 .await
188 .map_err(|e| MemoryError::Backend(e.to_string()))?;
189 if !res.ok {
190 return Err(MemoryError::Backend(format!(
191 "knowledge_memory_consolidate: {}",
192 res.content
193 )));
194 }
195 Ok(res.content)
196 }
197
198 fn next_id(&self) -> String {
199 let n = self.seq.fetch_add(1, Ordering::SeqCst);
200 let t = std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .map(|d| d.as_nanos())
203 .unwrap_or(0);
204 format!("harness-{t}-{n}")
205 }
206}
207
208#[async_trait]
209impl Memory for CortexdbMemory {
210 async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError> {
211 let id = if entry.id.is_empty() {
212 self.next_id()
213 } else {
214 entry.id.clone()
215 };
216 let args = build_save_args(
217 &id,
218 &entry,
219 &self.scope,
220 &self.namespace,
221 self.user_id.as_deref(),
222 );
223
224 let mut world = harness_context::default_world(".");
225 let res = self
226 .save
227 .invoke(args, &mut world)
228 .await
229 .map_err(|e| MemoryError::Backend(e.to_string()))?;
230 if !res.ok {
231 return Err(MemoryError::Backend(format!(
232 "memory_save: {}",
233 res.content
234 )));
235 }
236 Ok(())
237 }
238
239 async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
240 if k == 0 || query.trim().is_empty() {
241 return Ok(Vec::new());
242 }
243 let mut args = json!({
244 "query": query,
245 "top_k": k,
246 "scope": self.scope,
247 "namespace": self.namespace,
248 });
249 if let Some(u) = &self.user_id {
250 args["user_id"] = json!(u);
251 }
252
253 let mut world = harness_context::default_world(".");
254 let res = self
255 .search
256 .invoke(args, &mut world)
257 .await
258 .map_err(|e| MemoryError::Backend(e.to_string()))?;
259 if !res.ok {
260 return Err(MemoryError::Backend(format!(
261 "memory_search: {}",
262 res.content
263 )));
264 }
265 let results = res
267 .content
268 .get("results")
269 .and_then(|v| v.as_array())
270 .cloned()
271 .unwrap_or_default();
272
273 let mut out = Vec::with_capacity(results.len());
274 for hit in &results {
275 let rec = hit.get("memory").unwrap_or(hit);
277 let content = rec
278 .get("content")
279 .and_then(|v| v.as_str())
280 .unwrap_or_default()
281 .to_string();
282 if content.trim().is_empty() {
283 continue;
284 }
285 let mut entry = MemoryEntry::new(content);
286 if let Some(id) = rec.get("id").and_then(|v| v.as_str()) {
287 entry.id = id.to_string();
288 }
289 if let Some(meta) = rec.get("metadata") {
290 if let Some(tags) = meta.get("tags").and_then(|v| v.as_array()) {
291 entry.tags = tags
292 .iter()
293 .filter_map(|t| t.as_str().map(String::from))
294 .collect();
295 }
296 if let Some(src) = meta.get("source").and_then(|v| v.as_str()) {
297 entry.source = Some(src.to_string());
298 }
299 }
300 out.push(entry);
301 }
302 Ok(out)
303 }
304}
305
306fn build_save_args(
311 id: &str,
312 entry: &MemoryEntry,
313 scope: &str,
314 namespace: &str,
315 user_id: Option<&str>,
316) -> Value {
317 let mut role: Option<String> = None;
318 let mut session: Option<String> = None;
319 let mut other_tags: Vec<String> = Vec::new();
320 for t in &entry.tags {
321 if let Some(r) = t.strip_prefix("role:") {
322 role = Some(r.to_string());
323 } else if let Some(s) = t.strip_prefix("session:") {
324 session = Some(s.to_string());
325 } else {
326 other_tags.push(t.clone());
327 }
328 }
329
330 let mut metadata = serde_json::Map::new();
331 if !other_tags.is_empty() {
332 metadata.insert("tags".into(), json!(other_tags));
333 }
334 if let Some(s) = &entry.source {
335 metadata.insert("source".into(), json!(s));
336 }
337
338 let mut args = json!({
339 "memory_id": id,
340 "content": entry.content,
341 "scope": scope,
342 "namespace": namespace,
343 "metadata": Value::Object(metadata),
344 });
345 if let Some(r) = role {
346 args["role"] = json!(r);
347 }
348 if let Some(s) = session {
349 args["session_id"] = json!(s);
350 }
351 if let Some(u) = user_id {
352 args["user_id"] = json!(u);
353 }
354 args
355}
356
357fn build_consolidate_args(
361 scope: &str,
362 namespace: &str,
363 user_id: Option<&str>,
364 extra_args: Value,
365) -> Value {
366 let mut args = json!({ "scope": scope, "namespace": namespace });
367 if let Some(u) = user_id {
368 args["user_id"] = json!(u);
369 }
370 if let Value::Object(extra) = extra_args
371 && let Value::Object(base) = &mut args
372 {
373 base.extend(extra);
374 }
375 if args.get("reflect").is_none() {
376 args["reflect"] = json!({
377 "recall": {
378 "query": "key facts, entities, decisions, tasks, and tools from recent conversations",
379 "max_memory_items": 30
380 }
381 });
382 }
383 if args.get("promote_to_knowledge").is_none() {
384 args["promote_to_knowledge"] = json!(true);
385 }
386 args
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn consolidate_null_fills_required_reflect_defaults() {
395 let args = build_consolidate_args("global", "harness", Some("u1"), Value::Null);
396 assert_eq!(args["scope"], "global");
397 assert_eq!(args["user_id"], "u1");
398 assert!(args["reflect"]["recall"]["query"].is_string());
400 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 30);
401 assert_eq!(args["promote_to_knowledge"], true);
402 }
403
404 #[test]
405 fn consolidate_extra_args_override_defaults() {
406 let extra = json!({
407 "reflect": { "recall": { "query": "custom", "max_memory_items": 5 } },
408 "promote_to_knowledge": false
409 });
410 let args = build_consolidate_args("session", "app", None, extra);
411 assert_eq!(args["reflect"]["recall"]["query"], "custom");
412 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 5);
413 assert_eq!(args["promote_to_knowledge"], false);
414 assert!(args.get("user_id").is_none());
415 }
416
417 #[test]
418 fn role_and_session_promoted_to_native_params() {
419 let entry = MemoryEntry::new("hi there")
420 .with_source("transcript")
421 .with_tags(["role:assistant", "session:sess-1", "topic:deploy"]);
422 let args = build_save_args("m1", &entry, "session", "myapp", Some("u42"));
423
424 assert_eq!(args["role"], "assistant");
426 assert_eq!(args["session_id"], "sess-1");
427 assert_eq!(args["user_id"], "u42");
428 assert_eq!(args["scope"], "session");
429 assert_eq!(args["namespace"], "myapp");
430
431 let tags = args["metadata"]["tags"].as_array().unwrap();
433 assert_eq!(tags.len(), 1);
434 assert_eq!(tags[0], "topic:deploy");
435 assert_eq!(args["metadata"]["source"], "transcript");
436 }
437
438 #[test]
439 fn no_role_session_tags_omits_native_params() {
440 let entry = MemoryEntry::new("note").with_tags(["misc"]);
441 let args = build_save_args("m2", &entry, "global", "harness", None);
442 assert!(args.get("role").is_none());
443 assert!(args.get("session_id").is_none());
444 assert!(args.get("user_id").is_none());
445 assert_eq!(args["metadata"]["tags"][0], "misc");
446 }
447}