1use async_trait::async_trait;
48use harness_core::{Memory, MemoryEntry, MemoryError, Tool};
49use harness_mcp_client::McpClient;
50use serde_json::{Value, json};
51use std::sync::Arc;
52use std::sync::atomic::{AtomicU64, Ordering};
53
54pub struct CortexdbMemory {
56 _client: McpClient,
58 save: Arc<dyn Tool>,
59 search: Arc<dyn Tool>,
60 consolidate: Option<Arc<dyn Tool>>,
63 scope: String,
64 namespace: String,
65 user_id: Option<String>,
66 seq: AtomicU64,
67}
68
69impl CortexdbMemory {
70 pub async fn connect_stdio(program: &str, args: &[&str]) -> anyhow::Result<Self> {
73 Self::from_client(McpClient::connect_stdio(program, args).await?)
74 }
75
76 pub fn from_client(client: McpClient) -> anyhow::Result<Self> {
78 let tools = client.tools();
79 let find = |name: &str| tools.iter().find(|t| t.name() == name).cloned();
80 let save = find("memory_save")
81 .ok_or_else(|| anyhow::anyhow!("CortexDB MCP server exposes no `memory_save` tool"))?;
82 let search = find("memory_search").ok_or_else(|| {
83 anyhow::anyhow!("CortexDB MCP server exposes no `memory_search` tool")
84 })?;
85 let consolidate = find("knowledge_memory_consolidate");
87 Ok(Self {
88 _client: client,
89 save,
90 search,
91 consolidate,
92 scope: "global".into(),
93 namespace: "harness".into(),
94 user_id: None,
95 seq: AtomicU64::new(0),
96 })
97 }
98
99 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
101 self.scope = scope.into();
102 self
103 }
104 pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
106 self.namespace = ns.into();
107 self
108 }
109 pub fn with_user_id(mut self, user: impl Into<String>) -> Self {
111 self.user_id = Some(user.into());
112 self
113 }
114
115 pub fn can_consolidate(&self) -> bool {
117 self.consolidate.is_some()
118 }
119
120 pub async fn consolidate(&self, extra_args: Value) -> Result<Value, MemoryError> {
142 let tool = self.consolidate.as_ref().ok_or_else(|| {
143 MemoryError::Backend(
144 "this CortexDB build exposes no `knowledge_memory_consolidate` tool".into(),
145 )
146 })?;
147 let args = build_consolidate_args(
148 &self.scope,
149 &self.namespace,
150 self.user_id.as_deref(),
151 extra_args,
152 );
153 let mut world = harness_context::default_world(".");
154 let res = tool
155 .invoke(args, &mut world)
156 .await
157 .map_err(|e| MemoryError::Backend(e.to_string()))?;
158 if !res.ok {
159 return Err(MemoryError::Backend(format!(
160 "knowledge_memory_consolidate: {}",
161 res.content
162 )));
163 }
164 Ok(res.content)
165 }
166
167 fn next_id(&self) -> String {
168 let n = self.seq.fetch_add(1, Ordering::SeqCst);
169 let t = std::time::SystemTime::now()
170 .duration_since(std::time::UNIX_EPOCH)
171 .map(|d| d.as_nanos())
172 .unwrap_or(0);
173 format!("harness-{t}-{n}")
174 }
175}
176
177#[async_trait]
178impl Memory for CortexdbMemory {
179 async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError> {
180 let id = if entry.id.is_empty() {
181 self.next_id()
182 } else {
183 entry.id.clone()
184 };
185 let args = build_save_args(
186 &id,
187 &entry,
188 &self.scope,
189 &self.namespace,
190 self.user_id.as_deref(),
191 );
192
193 let mut world = harness_context::default_world(".");
194 let res = self
195 .save
196 .invoke(args, &mut world)
197 .await
198 .map_err(|e| MemoryError::Backend(e.to_string()))?;
199 if !res.ok {
200 return Err(MemoryError::Backend(format!(
201 "memory_save: {}",
202 res.content
203 )));
204 }
205 Ok(())
206 }
207
208 async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
209 if k == 0 || query.trim().is_empty() {
210 return Ok(Vec::new());
211 }
212 let mut args = json!({
213 "query": query,
214 "top_k": k,
215 "scope": self.scope,
216 "namespace": self.namespace,
217 });
218 if let Some(u) = &self.user_id {
219 args["user_id"] = json!(u);
220 }
221
222 let mut world = harness_context::default_world(".");
223 let res = self
224 .search
225 .invoke(args, &mut world)
226 .await
227 .map_err(|e| MemoryError::Backend(e.to_string()))?;
228 if !res.ok {
229 return Err(MemoryError::Backend(format!(
230 "memory_search: {}",
231 res.content
232 )));
233 }
234 let results = res
236 .content
237 .get("results")
238 .and_then(|v| v.as_array())
239 .cloned()
240 .unwrap_or_default();
241
242 let mut out = Vec::with_capacity(results.len());
243 for hit in &results {
244 let rec = hit.get("memory").unwrap_or(hit);
246 let content = rec
247 .get("content")
248 .and_then(|v| v.as_str())
249 .unwrap_or_default()
250 .to_string();
251 if content.trim().is_empty() {
252 continue;
253 }
254 let mut entry = MemoryEntry::new(content);
255 if let Some(id) = rec.get("id").and_then(|v| v.as_str()) {
256 entry.id = id.to_string();
257 }
258 if let Some(meta) = rec.get("metadata") {
259 if let Some(tags) = meta.get("tags").and_then(|v| v.as_array()) {
260 entry.tags = tags
261 .iter()
262 .filter_map(|t| t.as_str().map(String::from))
263 .collect();
264 }
265 if let Some(src) = meta.get("source").and_then(|v| v.as_str()) {
266 entry.source = Some(src.to_string());
267 }
268 }
269 out.push(entry);
270 }
271 Ok(out)
272 }
273}
274
275fn build_save_args(
280 id: &str,
281 entry: &MemoryEntry,
282 scope: &str,
283 namespace: &str,
284 user_id: Option<&str>,
285) -> Value {
286 let mut role: Option<String> = None;
287 let mut session: Option<String> = None;
288 let mut other_tags: Vec<String> = Vec::new();
289 for t in &entry.tags {
290 if let Some(r) = t.strip_prefix("role:") {
291 role = Some(r.to_string());
292 } else if let Some(s) = t.strip_prefix("session:") {
293 session = Some(s.to_string());
294 } else {
295 other_tags.push(t.clone());
296 }
297 }
298
299 let mut metadata = serde_json::Map::new();
300 if !other_tags.is_empty() {
301 metadata.insert("tags".into(), json!(other_tags));
302 }
303 if let Some(s) = &entry.source {
304 metadata.insert("source".into(), json!(s));
305 }
306
307 let mut args = json!({
308 "memory_id": id,
309 "content": entry.content,
310 "scope": scope,
311 "namespace": namespace,
312 "metadata": Value::Object(metadata),
313 });
314 if let Some(r) = role {
315 args["role"] = json!(r);
316 }
317 if let Some(s) = session {
318 args["session_id"] = json!(s);
319 }
320 if let Some(u) = user_id {
321 args["user_id"] = json!(u);
322 }
323 args
324}
325
326fn build_consolidate_args(
330 scope: &str,
331 namespace: &str,
332 user_id: Option<&str>,
333 extra_args: Value,
334) -> Value {
335 let mut args = json!({ "scope": scope, "namespace": namespace });
336 if let Some(u) = user_id {
337 args["user_id"] = json!(u);
338 }
339 if let Value::Object(extra) = extra_args
340 && let Value::Object(base) = &mut args
341 {
342 base.extend(extra);
343 }
344 if args.get("reflect").is_none() {
345 args["reflect"] = json!({
346 "recall": {
347 "query": "key facts, entities, decisions, tasks, and tools from recent conversations",
348 "max_memory_items": 30
349 }
350 });
351 }
352 if args.get("promote_to_knowledge").is_none() {
353 args["promote_to_knowledge"] = json!(true);
354 }
355 args
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn consolidate_null_fills_required_reflect_defaults() {
364 let args = build_consolidate_args("global", "harness", Some("u1"), Value::Null);
365 assert_eq!(args["scope"], "global");
366 assert_eq!(args["user_id"], "u1");
367 assert!(args["reflect"]["recall"]["query"].is_string());
369 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 30);
370 assert_eq!(args["promote_to_knowledge"], true);
371 }
372
373 #[test]
374 fn consolidate_extra_args_override_defaults() {
375 let extra = json!({
376 "reflect": { "recall": { "query": "custom", "max_memory_items": 5 } },
377 "promote_to_knowledge": false
378 });
379 let args = build_consolidate_args("session", "app", None, extra);
380 assert_eq!(args["reflect"]["recall"]["query"], "custom");
381 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 5);
382 assert_eq!(args["promote_to_knowledge"], false);
383 assert!(args.get("user_id").is_none());
384 }
385
386 #[test]
387 fn role_and_session_promoted_to_native_params() {
388 let entry = MemoryEntry::new("hi there")
389 .with_source("transcript")
390 .with_tags(["role:assistant", "session:sess-1", "topic:deploy"]);
391 let args = build_save_args("m1", &entry, "session", "myapp", Some("u42"));
392
393 assert_eq!(args["role"], "assistant");
395 assert_eq!(args["session_id"], "sess-1");
396 assert_eq!(args["user_id"], "u42");
397 assert_eq!(args["scope"], "session");
398 assert_eq!(args["namespace"], "myapp");
399
400 let tags = args["metadata"]["tags"].as_array().unwrap();
402 assert_eq!(tags.len(), 1);
403 assert_eq!(tags[0], "topic:deploy");
404 assert_eq!(args["metadata"]["source"], "transcript");
405 }
406
407 #[test]
408 fn no_role_session_tags_omits_native_params() {
409 let entry = MemoryEntry::new("note").with_tags(["misc"]);
410 let args = build_save_args("m2", &entry, "global", "harness", None);
411 assert!(args.get("role").is_none());
412 assert!(args.get("session_id").is_none());
413 assert!(args.get("user_id").is_none());
414 assert_eq!(args["metadata"]["tags"][0], "misc");
415 }
416}