Skip to main content

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`.
326fn build_save_args(
327    id: &str,
328    entry: &MemoryEntry,
329    scope: &str,
330    namespace: &str,
331    user_id: Option<&str>,
332) -> Value {
333    let mut role: Option<String> = None;
334    let mut session: Option<String> = None;
335    let mut other_tags: Vec<String> = Vec::new();
336    for t in &entry.tags {
337        if let Some(r) = t.strip_prefix("role:") {
338            role = Some(r.to_string());
339        } else if let Some(s) = t.strip_prefix("session:") {
340            session = Some(s.to_string());
341        } else {
342            other_tags.push(t.clone());
343        }
344    }
345
346    let mut metadata = serde_json::Map::new();
347    if !other_tags.is_empty() {
348        metadata.insert("tags".into(), json!(other_tags));
349    }
350    if let Some(s) = &entry.source {
351        metadata.insert("source".into(), json!(s));
352    }
353
354    let mut args = json!({
355        "memory_id": id,
356        "content": entry.content,
357        "scope": scope,
358        "namespace": namespace,
359        "metadata": Value::Object(metadata),
360    });
361    if let Some(r) = role {
362        args["role"] = json!(r);
363    }
364    if let Some(s) = session {
365        args["session_id"] = json!(s);
366    }
367    if let Some(u) = user_id {
368        args["user_id"] = json!(u);
369    }
370    args
371}
372
373/// Build the `knowledge_memory_consolidate` args: base scope/namespace/user_id,
374/// merge caller `extra_args`, then fill the server-required `reflect.recall.query`
375/// (+ `promote_to_knowledge`) defaults if the caller didn't supply them.
376fn build_consolidate_args(
377    scope: &str,
378    namespace: &str,
379    user_id: Option<&str>,
380    extra_args: Value,
381) -> Value {
382    let mut args = json!({ "scope": scope, "namespace": namespace });
383    if let Some(u) = user_id {
384        args["user_id"] = json!(u);
385    }
386    if let Value::Object(extra) = extra_args
387        && let Value::Object(base) = &mut args
388    {
389        base.extend(extra);
390    }
391    if args.get("reflect").is_none() {
392        args["reflect"] = json!({
393            "recall": {
394                "query": "key facts, entities, decisions, tasks, and tools from recent conversations",
395                "max_memory_items": 30
396            }
397        });
398    }
399    if args.get("promote_to_knowledge").is_none() {
400        args["promote_to_knowledge"] = json!(true);
401    }
402    args
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn consolidate_null_fills_required_reflect_defaults() {
411        let args = build_consolidate_args("global", "harness", Some("u1"), Value::Null);
412        assert_eq!(args["scope"], "global");
413        assert_eq!(args["user_id"], "u1");
414        // Server-required reflect.recall.query is supplied.
415        assert!(args["reflect"]["recall"]["query"].is_string());
416        assert_eq!(args["reflect"]["recall"]["max_memory_items"], 30);
417        assert_eq!(args["promote_to_knowledge"], true);
418    }
419
420    #[test]
421    fn consolidate_extra_args_override_defaults() {
422        let extra = json!({
423            "reflect": { "recall": { "query": "custom", "max_memory_items": 5 } },
424            "promote_to_knowledge": false
425        });
426        let args = build_consolidate_args("session", "app", None, extra);
427        assert_eq!(args["reflect"]["recall"]["query"], "custom");
428        assert_eq!(args["reflect"]["recall"]["max_memory_items"], 5);
429        assert_eq!(args["promote_to_knowledge"], false);
430        assert!(args.get("user_id").is_none());
431    }
432
433    #[test]
434    fn role_and_session_promoted_to_native_params() {
435        let entry = MemoryEntry::new("hi there")
436            .with_source("transcript")
437            .with_tags(["role:assistant", "session:sess-1", "topic:deploy"]);
438        let args = build_save_args("m1", &entry, "session", "myapp", Some("u42"));
439
440        // Promoted to first-class columns.
441        assert_eq!(args["role"], "assistant");
442        assert_eq!(args["session_id"], "sess-1");
443        assert_eq!(args["user_id"], "u42");
444        assert_eq!(args["scope"], "session");
445        assert_eq!(args["namespace"], "myapp");
446
447        // role:/session: stripped from metadata tags; unrelated tag kept.
448        let tags = args["metadata"]["tags"].as_array().unwrap();
449        assert_eq!(tags.len(), 1);
450        assert_eq!(tags[0], "topic:deploy");
451        assert_eq!(args["metadata"]["source"], "transcript");
452    }
453
454    #[test]
455    fn no_role_session_tags_omits_native_params() {
456        let entry = MemoryEntry::new("note").with_tags(["misc"]);
457        let args = build_save_args("m2", &entry, "global", "harness", None);
458        assert!(args.get("role").is_none());
459        assert!(args.get("session_id").is_none());
460        assert!(args.get("user_id").is_none());
461        assert_eq!(args["metadata"]["tags"][0], "misc");
462    }
463}