harness_cortexdb/lib.rs
1//! # harness-cortexdb — CortexDB as a harness `Memory`
2//!
3//! Implements [`harness_core::Memory`] over [CortexDB](https://github.com/liliang-cn/cortexdb)'s
4//! MCP server: `recall` calls `memory_search` (semantic / lexical vector
5//! retrieval) and `write` calls `memory_save`. Drop it in anywhere a
6//! `Memory` is expected — the `MemoryGuide`, the experience layer
7//! (`harness-experience`), a scheduler, etc. — to get **semantic recall** and a
8//! brain that can be *shared* with other tools (Claude Code / Codex all use
9//! `~/.cortexdb` by default).
10//!
11//! ```ignore
12//! use harness_cortexdb::CortexdbMemory;
13//! use std::sync::Arc;
14//!
15//! // Spawn CortexDB's MCP server; share the global brain (~/.cortexdb).
16//! let mem = Arc::new(CortexdbMemory::connect_stdio("cortexdb-mcp-stdio", &[]).await?);
17//! let recorder = harness_loop::experience::ExperienceRecorder::new(mem); // semantic!
18//! ```
19//!
20//! The memory does not have to be a process this agent spawns. Point it at a
21//! CortexDB that is already running — one brain shared by a fleet of agents, an
22//! editor and a chat client, and highly available in the ordinary way. Which
23//! constructor depends on what that CortexDB serves, and the two look alike
24//! written down:
25//!
26//! ```ignore
27//! // An MCP server reachable over Streamable HTTP:
28//! let mem = Arc::new(CortexdbMemory::connect_http("https://cortex.internal/mcp").await?);
29//!
30//! // CortexDB's own gRPC API — what the openclaw sidecar runs, on :47821.
31//! // Requires the `grpc` feature; see `grpc::CortexdbGrpcMemory`.
32//! let mem = Arc::new(CortexdbGrpcMemory::connect("http://127.0.0.1:47821").await?);
33//! ```
34//!
35//! Harness `MemoryEntry` fields round-trip through CortexDB: `content` maps to
36//! the memory content; `tags` + `source` are stored under CortexDB's
37//! `metadata` and read back on recall.
38//!
39//! ## Record conversations, then distill them into the graph
40//!
41//! Pair the [`TranscriptRecorder`](harness_loop::experience::TranscriptRecorder) hook
42//! (turns → CortexDB) with periodic [`CortexdbMemory::consolidate`] (memories →
43//! knowledge graph, server-side):
44//!
45//! ```ignore
46//! let mem = Arc::new(CortexdbMemory::connect_stdio("cortexdb-mcp-stdio", &[]).await?
47//! .with_scope("session").with_namespace("myapp-chat"));
48//!
49//! // 1. capture every turn as it happens
50//! let (recorder, rx) = harness_loop::experience::TranscriptRecorder::new("sess-1");
51//! harness_loop::experience::spawn_transcript_writer(rx, mem.clone());
52//! let loop_ = AgentLoop::new(model).with_hook(Arc::new(recorder));
53//!
54//! // 2. every 10 min, let CortexDB distill accumulated turns into the graph
55//! let m = mem.clone();
56//! tokio::spawn(async move {
57//! let mut tick = tokio::time::interval(std::time::Duration::from_secs(600));
58//! loop { tick.tick().await; let _ = m.consolidate(serde_json::Value::Null).await; }
59//! });
60//! ```
61
62use async_trait::async_trait;
63/// `Memory` over CortexDB's own gRPC API — what a deployment already serves.
64/// See [`grpc::CortexdbGrpcMemory`]. Requires the `grpc` feature.
65#[cfg(feature = "grpc")]
66pub mod grpc;
67#[cfg(feature = "grpc")]
68pub use grpc::CortexdbGrpcMemory;
69
70use harness_core::{Memory, MemoryEntry, MemoryError, Tool};
71use harness_mcp_client::McpClient;
72use serde_json::{Value, json};
73use std::sync::Arc;
74use std::sync::atomic::{AtomicU64, Ordering};
75
76/// A [`Memory`] backed by CortexDB's MCP `memory_save` / `memory_search` tools.
77pub struct CortexdbMemory {
78 // Owns the MCP session so the CortexDB child process stays alive.
79 _client: McpClient,
80 save: Arc<dyn Tool>,
81 search: Arc<dyn Tool>,
82 /// Optional server-side distillation tool (`knowledge_memory_consolidate`),
83 /// present only if the connected CortexDB exposes it. See [`consolidate`].
84 consolidate: Option<Arc<dyn Tool>>,
85 scope: String,
86 namespace: String,
87 user_id: Option<String>,
88 seq: AtomicU64,
89}
90
91impl CortexdbMemory {
92 /// Spawn `program args...` as CortexDB's MCP stdio server and adapt it.
93 /// (e.g. `connect_stdio("cortexdb-mcp-stdio", &[])`.)
94 pub async fn connect_stdio(program: &str, args: &[&str]) -> anyhow::Result<Self> {
95 Self::from_client(McpClient::connect_stdio(program, args).await?)
96 }
97
98 /// Connect to a remote **MCP** server over Streamable HTTP.
99 ///
100 /// This is an MCP endpoint, not CortexDB's own API. The distinction is easy
101 /// to lose because both are written as a URL: a CortexDB started the way the
102 /// openclaw sidecar starts one serves **gRPC** on `host:47821` and answers
103 /// nothing at all to HTTP, so pointing this at it fails at the transport.
104 /// For that deployment use [`CortexdbGrpcMemory`](crate::grpc::CortexdbGrpcMemory)
105 /// (`grpc` feature), which speaks the protocol the server actually serves.
106 ///
107 /// Use this one when the CortexDB in front of you exposes MCP over HTTP.
108 ///
109 /// Requires the `http` feature of `harness-rs-mcp-client` (on by default,
110 /// with rustls). For an untrusted URL use
111 /// [`McpClient::connect_http_with_client`] and [`from_client`](Self::from_client),
112 /// which leaves the redirect and DNS policy with the caller.
113 ///
114 /// ```ignore
115 /// let mem = CortexdbMemory::connect_http("https://cortex.internal/mcp").await?;
116 /// ```
117 #[cfg(feature = "http")]
118 pub async fn connect_http(url: &str) -> anyhow::Result<Self> {
119 Self::from_client(McpClient::connect_http(url).await?)
120 }
121
122 /// Adapt an already-connected CortexDB MCP client — stdio, HTTP, or any
123 /// transport [`McpClient`] grows later.
124 pub fn from_client(client: McpClient) -> anyhow::Result<Self> {
125 let tools = client.tools();
126 let find = |name: &str| tools.iter().find(|t| t.name() == name).cloned();
127 let save = find("memory_save")
128 .ok_or_else(|| anyhow::anyhow!("CortexDB MCP server exposes no `memory_save` tool"))?;
129 let search = find("memory_search").ok_or_else(|| {
130 anyhow::anyhow!("CortexDB MCP server exposes no `memory_search` tool")
131 })?;
132 // Optional — older/lighter CortexDB builds may not expose it.
133 let consolidate = find("knowledge_memory_consolidate");
134 Ok(Self {
135 _client: client,
136 save,
137 search,
138 consolidate,
139 scope: "global".into(),
140 namespace: "harness".into(),
141 user_id: None,
142 seq: AtomicU64::new(0),
143 })
144 }
145
146 /// CortexDB memory scope: `global` (default, shared), `user`, or `session`.
147 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
148 self.scope = scope.into();
149 self
150 }
151 /// CortexDB namespace (default `harness`). Isolates this app's memories.
152 pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
153 self.namespace = ns.into();
154 self
155 }
156 /// Scope memories to a user id (for `scope = user`).
157 pub fn with_user_id(mut self, user: impl Into<String>) -> Self {
158 self.user_id = Some(user.into());
159 self
160 }
161
162 /// Whether the connected CortexDB exposes the graph-distillation tool.
163 pub fn can_consolidate(&self) -> bool {
164 self.consolidate.is_some()
165 }
166
167 /// Distill accumulated memories into CortexDB's **knowledge graph**
168 /// (entities + relations), server-side, via `knowledge_memory_consolidate`.
169 /// CortexDB does the extraction — harness just triggers it, so this costs no
170 /// tokens on our side. Run it periodically (e.g. from `harness-scheduler`)
171 /// after a batch of turns has been recorded.
172 ///
173 /// Operates on this instance's `scope`/`namespace`/`user_id`. The server
174 /// **requires** a `reflect.recall.query`; this method supplies a broad
175 /// default plus `promote_to_knowledge: true`, so `consolidate(Value::Null)`
176 /// works out of the box. Override either via `extra_args`, e.g.:
177 ///
178 /// ```ignore
179 /// mem.consolidate(serde_json::json!({
180 /// "reflect": { "recall": { "query": "past tasks and tools", "max_memory_items": 30 } }
181 /// })).await?;
182 /// ```
183 ///
184 /// **Quality note:** in pure-lexical mode (no embedder configured on the
185 /// CortexDB side) entity extraction is poor — configure an embedder for a
186 /// clean graph. Returns the raw tool result. Errors if the server doesn't
187 /// expose the tool (check [`can_consolidate`](Self::can_consolidate)).
188 pub async fn consolidate(&self, extra_args: Value) -> Result<Value, MemoryError> {
189 let tool = self.consolidate.as_ref().ok_or_else(|| {
190 MemoryError::Backend(
191 "this CortexDB build exposes no `knowledge_memory_consolidate` tool".into(),
192 )
193 })?;
194 let args = build_consolidate_args(
195 &self.scope,
196 &self.namespace,
197 self.user_id.as_deref(),
198 extra_args,
199 );
200 let mut world = harness_context::default_world(".");
201 let res = tool
202 .invoke(args, &mut world)
203 .await
204 .map_err(|e| MemoryError::Backend(e.to_string()))?;
205 if !res.ok {
206 return Err(MemoryError::Backend(format!(
207 "knowledge_memory_consolidate: {}",
208 res.content
209 )));
210 }
211 Ok(res.content)
212 }
213
214 fn next_id(&self) -> String {
215 let n = self.seq.fetch_add(1, Ordering::SeqCst);
216 let t = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .map(|d| d.as_nanos())
219 .unwrap_or(0);
220 format!("harness-{t}-{n}")
221 }
222}
223
224#[async_trait]
225impl Memory for CortexdbMemory {
226 async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError> {
227 let id = if entry.id.is_empty() {
228 self.next_id()
229 } else {
230 entry.id.clone()
231 };
232 let args = build_save_args(
233 &id,
234 &entry,
235 &self.scope,
236 &self.namespace,
237 self.user_id.as_deref(),
238 );
239
240 let mut world = harness_context::default_world(".");
241 let res = self
242 .save
243 .invoke(args, &mut world)
244 .await
245 .map_err(|e| MemoryError::Backend(e.to_string()))?;
246 if !res.ok {
247 return Err(MemoryError::Backend(format!(
248 "memory_save: {}",
249 res.content
250 )));
251 }
252 Ok(())
253 }
254
255 async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
256 if k == 0 || query.trim().is_empty() {
257 return Ok(Vec::new());
258 }
259 let mut args = json!({
260 "query": query,
261 "top_k": k,
262 "scope": self.scope,
263 "namespace": self.namespace,
264 });
265 if let Some(u) = &self.user_id {
266 args["user_id"] = json!(u);
267 }
268
269 let mut world = harness_context::default_world(".");
270 let res = self
271 .search
272 .invoke(args, &mut world)
273 .await
274 .map_err(|e| MemoryError::Backend(e.to_string()))?;
275 if !res.ok {
276 return Err(MemoryError::Backend(format!(
277 "memory_search: {}",
278 res.content
279 )));
280 }
281 // MemorySearchResponse { results: [ { memory: MemoryRecord, score } ] }
282 let results = res
283 .content
284 .get("results")
285 .and_then(|v| v.as_array())
286 .cloned()
287 .unwrap_or_default();
288
289 let mut out = Vec::with_capacity(results.len());
290 for hit in &results {
291 // The record is under `memory`; tolerate a flattened shape too.
292 let rec = hit.get("memory").unwrap_or(hit);
293 let content = rec
294 .get("content")
295 .and_then(|v| v.as_str())
296 .unwrap_or_default()
297 .to_string();
298 if content.trim().is_empty() {
299 continue;
300 }
301 let mut entry = MemoryEntry::new(content);
302 if let Some(id) = rec.get("id").and_then(|v| v.as_str()) {
303 entry.id = id.to_string();
304 }
305 if let Some(meta) = rec.get("metadata") {
306 if let Some(tags) = meta.get("tags").and_then(|v| v.as_array()) {
307 entry.tags = tags
308 .iter()
309 .filter_map(|t| t.as_str().map(String::from))
310 .collect();
311 }
312 if let Some(src) = meta.get("source").and_then(|v| v.as_str()) {
313 entry.source = Some(src.to_string());
314 }
315 }
316 out.push(entry);
317 }
318 Ok(out)
319 }
320}
321
322/// Build the `memory_save` args for `entry`. Promotes `role:`/`session:` tags to
323/// CortexDB's first-class `role` / `session_id` columns (so a transcript can be
324/// queried and ordered by `session_id + role`, not just `json_extract` on
325/// metadata); all remaining tags + `source` go into `metadata`.
326/// Translate `MemoryEntry::expires_ms` — an absolute instant — into the
327/// duration CortexDB wants. `None` stays `None`: absent, not zero, because
328/// CortexDB reads `ttl_seconds: 0` as "retain forever", which is the opposite
329/// of what an expired entry means. An instant already past floors at one
330/// second so it dies on its own rather than being sent as a negative.
331///
332/// Both transports call this. They previously disagreed — the MCP path sent a
333/// TTL and the gRPC path left the field at its `Default`, i.e. permanent — so
334/// the same entry had two retentions depending on how it was written.
335pub(crate) fn ttl_seconds_from(expires_ms: Option<i64>) -> Option<i64> {
336 let expires_ms = expires_ms?;
337 let now_ms = std::time::SystemTime::now()
338 .duration_since(std::time::UNIX_EPOCH)
339 .map(|d| d.as_millis() as i64)
340 .unwrap_or(0);
341 Some((expires_ms - now_ms).div_euclid(1000).max(1))
342}
343
344fn build_save_args(
345 id: &str,
346 entry: &MemoryEntry,
347 scope: &str,
348 namespace: &str,
349 user_id: Option<&str>,
350) -> Value {
351 let mut role: Option<String> = None;
352 let mut session: Option<String> = None;
353 let mut other_tags: Vec<String> = Vec::new();
354 for t in &entry.tags {
355 if let Some(r) = t.strip_prefix("role:") {
356 role = Some(r.to_string());
357 } else if let Some(s) = t.strip_prefix("session:") {
358 session = Some(s.to_string());
359 } else {
360 other_tags.push(t.clone());
361 }
362 }
363
364 let mut metadata = serde_json::Map::new();
365 if !other_tags.is_empty() {
366 metadata.insert("tags".into(), json!(other_tags));
367 }
368 if let Some(s) = &entry.source {
369 metadata.insert("source".into(), json!(s));
370 }
371
372 let mut args = json!({
373 "memory_id": id,
374 "content": entry.content,
375 "scope": scope,
376 "namespace": namespace,
377 "metadata": Value::Object(metadata),
378 });
379 if let Some(r) = role {
380 args["role"] = json!(r);
381 }
382 if let Some(s) = session {
383 args["session_id"] = json!(s);
384 }
385 if let Some(u) = user_id {
386 args["user_id"] = json!(u);
387 }
388 // `expires_ms` is an absolute instant; CortexDB wants a duration. Translate
389 // rather than drop: a dropped TTL is invisible — the write succeeds and the
390 // entry simply never expires, so the caller learns nothing until the brain
391 // is full of transcripts. Floor at 1s, because CortexDB reads `0` as
392 // "retain forever", which is the opposite of what an expired entry means.
393 if let Some(secs) = ttl_seconds_from(entry.expires_ms) {
394 args["ttl_seconds"] = json!(secs);
395 }
396 args
397}
398
399/// Build the `knowledge_memory_consolidate` args: base scope/namespace/user_id,
400/// merge caller `extra_args`, then fill the server-required `reflect.recall.query`
401/// (+ `promote_to_knowledge`) defaults if the caller didn't supply them.
402fn build_consolidate_args(
403 scope: &str,
404 namespace: &str,
405 user_id: Option<&str>,
406 extra_args: Value,
407) -> Value {
408 let mut args = json!({ "scope": scope, "namespace": namespace });
409 if let Some(u) = user_id {
410 args["user_id"] = json!(u);
411 }
412 if let Value::Object(extra) = extra_args
413 && let Value::Object(base) = &mut args
414 {
415 base.extend(extra);
416 }
417 if args.get("reflect").is_none() {
418 args["reflect"] = json!({
419 "recall": {
420 "query": "key facts, entities, decisions, tasks, and tools from recent conversations",
421 "max_memory_items": 30
422 }
423 });
424 }
425 if args.get("promote_to_knowledge").is_none() {
426 args["promote_to_knowledge"] = json!(true);
427 }
428 args
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn consolidate_null_fills_required_reflect_defaults() {
437 let args = build_consolidate_args("global", "harness", Some("u1"), Value::Null);
438 assert_eq!(args["scope"], "global");
439 assert_eq!(args["user_id"], "u1");
440 // Server-required reflect.recall.query is supplied.
441 assert!(args["reflect"]["recall"]["query"].is_string());
442 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 30);
443 assert_eq!(args["promote_to_knowledge"], true);
444 }
445
446 #[test]
447 fn consolidate_extra_args_override_defaults() {
448 let extra = json!({
449 "reflect": { "recall": { "query": "custom", "max_memory_items": 5 } },
450 "promote_to_knowledge": false
451 });
452 let args = build_consolidate_args("session", "app", None, extra);
453 assert_eq!(args["reflect"]["recall"]["query"], "custom");
454 assert_eq!(args["reflect"]["recall"]["max_memory_items"], 5);
455 assert_eq!(args["promote_to_knowledge"], false);
456 assert!(args.get("user_id").is_none());
457 }
458
459 #[test]
460 fn role_and_session_promoted_to_native_params() {
461 let entry = MemoryEntry::new("hi there")
462 .with_source("transcript")
463 .with_tags(["role:assistant", "session:sess-1", "topic:deploy"]);
464 let args = build_save_args("m1", &entry, "session", "myapp", Some("u42"));
465
466 // Promoted to first-class columns.
467 assert_eq!(args["role"], "assistant");
468 assert_eq!(args["session_id"], "sess-1");
469 assert_eq!(args["user_id"], "u42");
470 assert_eq!(args["scope"], "session");
471 assert_eq!(args["namespace"], "myapp");
472
473 // role:/session: stripped from metadata tags; unrelated tag kept.
474 let tags = args["metadata"]["tags"].as_array().unwrap();
475 assert_eq!(tags.len(), 1);
476 assert_eq!(tags[0], "topic:deploy");
477 assert_eq!(args["metadata"]["source"], "transcript");
478 }
479
480 #[test]
481 fn ttl_is_carried_to_the_server_as_seconds() {
482 // `MemoryEntry::expires_ms` is a contract: the doc on it says backends
483 // MUST filter expired entries out of recall. Dropping it here made every
484 // caller's TTL silently permanent — `memory_save`'s `ttl_days` argument
485 // and `memory_layer`'s ttl both landed as "keep forever" in CortexDB
486 // while the SurrealDB backend honoured them. Same entry, two answers.
487 let entry = MemoryEntry::new("ephemeral").with_ttl_days(7);
488 let args = build_save_args("m3", &entry, "session", "app", None);
489 let ttl = args["ttl_seconds"].as_i64().expect("ttl_seconds sent");
490 // 7 days, minus whatever the test itself took.
491 assert!(
492 (7 * 86_400 - 60..=7 * 86_400).contains(&ttl),
493 "expected ~7d in seconds, got {ttl}"
494 );
495 }
496
497 #[test]
498 fn no_ttl_means_no_ttl_field() {
499 // Absent, not zero: CortexDB reads `ttl_seconds: 0` as "retain forever",
500 // so sending 0 would be right by accident. Say nothing instead.
501 let entry = MemoryEntry::new("durable fact");
502 let args = build_save_args("m4", &entry, "global", "app", None);
503 assert!(args.get("ttl_seconds").is_none());
504 }
505
506 #[test]
507 fn already_expired_entry_gets_the_shortest_ttl_not_a_negative_one() {
508 // A negative ttl_seconds is not in CortexDB's vocabulary; it would be
509 // rejected or, worse, read as unset. Floor at 1s so it dies on its own.
510 let mut entry = MemoryEntry::new("stale");
511 entry.expires_ms = Some(1); // 1970
512 let args = build_save_args("m5", &entry, "session", "app", None);
513 assert_eq!(args["ttl_seconds"], 1);
514 }
515
516 #[test]
517 fn no_role_session_tags_omits_native_params() {
518 let entry = MemoryEntry::new("note").with_tags(["misc"]);
519 let args = build_save_args("m2", &entry, "global", "harness", None);
520 assert!(args.get("role").is_none());
521 assert!(args.get("session_id").is_none());
522 assert!(args.get("user_id").is_none());
523 assert_eq!(args["metadata"]["tags"][0], "misc");
524 }
525}