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_experience::ExperienceRecorder::new(mem); // semantic!
18//! ```
19//!
20//! Harness `MemoryEntry` fields round-trip through CortexDB: `content` maps to
21//! the memory content; `tags` + `source` are stored under CortexDB's
22//! `metadata` and read back on recall.
23//!
24//! ## Record conversations, then distill them into the graph
25//!
26//! Pair the [`TranscriptRecorder`](harness_experience::TranscriptRecorder) hook
27//! (turns → CortexDB) with periodic [`CortexdbMemory::consolidate`] (memories →
28//! knowledge graph, server-side):
29//!
30//! ```ignore
31//! let mem = Arc::new(CortexdbMemory::connect_stdio("cortexdb-mcp-stdio", &[]).await?
32//!     .with_scope("session").with_namespace("myapp-chat"));
33//!
34//! // 1. capture every turn as it happens
35//! let (recorder, rx) = harness_experience::TranscriptRecorder::new("sess-1");
36//! harness_experience::spawn_transcript_writer(rx, mem.clone());
37//! let loop_ = AgentLoop::new(model).with_hook(Arc::new(recorder));
38//!
39//! // 2. every 10 min, let CortexDB distill accumulated turns into the graph
40//! let m = mem.clone();
41//! tokio::spawn(async move {
42//!     let mut tick = tokio::time::interval(std::time::Duration::from_secs(600));
43//!     loop { tick.tick().await; let _ = m.consolidate(serde_json::Value::Null).await; }
44//! });
45//! ```
46
47use 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
54/// A [`Memory`] backed by CortexDB's MCP `memory_save` / `memory_search` tools.
55pub struct CortexdbMemory {
56    // Owns the MCP session so the CortexDB child process stays alive.
57    _client: McpClient,
58    save: Arc<dyn Tool>,
59    search: Arc<dyn Tool>,
60    /// Optional server-side distillation tool (`knowledge_memory_consolidate`),
61    /// present only if the connected CortexDB exposes it. See [`consolidate`].
62    consolidate: Option<Arc<dyn Tool>>,
63    scope: String,
64    namespace: String,
65    user_id: Option<String>,
66    seq: AtomicU64,
67}
68
69impl CortexdbMemory {
70    /// Spawn `program args...` as CortexDB's MCP stdio server and adapt it.
71    /// (e.g. `connect_stdio("cortexdb-mcp-stdio", &[])`.)
72    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    /// Adapt an already-connected CortexDB MCP client.
77    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        // Optional — older/lighter CortexDB builds may not expose it.
86        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    /// CortexDB memory scope: `global` (default, shared), `user`, or `session`.
100    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
101        self.scope = scope.into();
102        self
103    }
104    /// CortexDB namespace (default `harness`). Isolates this app's memories.
105    pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
106        self.namespace = ns.into();
107        self
108    }
109    /// Scope memories to a user id (for `scope = user`).
110    pub fn with_user_id(mut self, user: impl Into<String>) -> Self {
111        self.user_id = Some(user.into());
112        self
113    }
114
115    /// Whether the connected CortexDB exposes the graph-distillation tool.
116    pub fn can_consolidate(&self) -> bool {
117        self.consolidate.is_some()
118    }
119
120    /// Distill accumulated memories into CortexDB's **knowledge graph**
121    /// (entities + relations), server-side, via `knowledge_memory_consolidate`.
122    /// CortexDB does the extraction — harness just triggers it, so this costs no
123    /// tokens on our side. Run it periodically (e.g. from `harness-scheduler`)
124    /// after a batch of turns has been recorded.
125    ///
126    /// Operates on this instance's `scope`/`namespace`/`user_id`. The server
127    /// **requires** a `reflect.recall.query`; this method supplies a broad
128    /// default plus `promote_to_knowledge: true`, so `consolidate(Value::Null)`
129    /// works out of the box. Override either via `extra_args`, e.g.:
130    ///
131    /// ```ignore
132    /// mem.consolidate(serde_json::json!({
133    ///     "reflect": { "recall": { "query": "past tasks and tools", "max_memory_items": 30 } }
134    /// })).await?;
135    /// ```
136    ///
137    /// **Quality note:** in pure-lexical mode (no embedder configured on the
138    /// CortexDB side) entity extraction is poor — configure an embedder for a
139    /// clean graph. Returns the raw tool result. Errors if the server doesn't
140    /// expose the tool (check [`can_consolidate`](Self::can_consolidate)).
141    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        // MemorySearchResponse { results: [ { memory: MemoryRecord, score } ] }
235        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            // The record is under `memory`; tolerate a flattened shape too.
245            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
275/// Build the `memory_save` args for `entry`. Promotes `role:`/`session:` tags to
276/// CortexDB's first-class `role` / `session_id` columns (so a transcript can be
277/// queried and ordered by `session_id + role`, not just `json_extract` on
278/// metadata); all remaining tags + `source` go into `metadata`.
279fn 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
326/// Build the `knowledge_memory_consolidate` args: base scope/namespace/user_id,
327/// merge caller `extra_args`, then fill the server-required `reflect.recall.query`
328/// (+ `promote_to_knowledge`) defaults if the caller didn't supply them.
329fn 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        // Server-required reflect.recall.query is supplied.
368        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        // Promoted to first-class columns.
394        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        // role:/session: stripped from metadata tags; unrelated tag kept.
401        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}