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//! 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:
23//!
24//! ```ignore
25//! let mem = Arc::new(CortexdbMemory::connect_http("https://cortex.internal/mcp").await?);
26//! ```
27//!
28//! Harness `MemoryEntry` fields round-trip through CortexDB: `content` maps to
29//! the memory content; `tags` + `source` are stored under CortexDB's
30//! `metadata` and read back on recall.
31//!
32//! ## Record conversations, then distill them into the graph
33//!
34//! Pair the [`TranscriptRecorder`](harness_experience::TranscriptRecorder) hook
35//! (turns → CortexDB) with periodic [`CortexdbMemory::consolidate`] (memories →
36//! knowledge graph, server-side):
37//!
38//! ```ignore
39//! let mem = Arc::new(CortexdbMemory::connect_stdio("cortexdb-mcp-stdio", &[]).await?
40//!     .with_scope("session").with_namespace("myapp-chat"));
41//!
42//! // 1. capture every turn as it happens
43//! let (recorder, rx) = harness_experience::TranscriptRecorder::new("sess-1");
44//! harness_experience::spawn_transcript_writer(rx, mem.clone());
45//! let loop_ = AgentLoop::new(model).with_hook(Arc::new(recorder));
46//!
47//! // 2. every 10 min, let CortexDB distill accumulated turns into the graph
48//! let m = mem.clone();
49//! tokio::spawn(async move {
50//!     let mut tick = tokio::time::interval(std::time::Duration::from_secs(600));
51//!     loop { tick.tick().await; let _ = m.consolidate(serde_json::Value::Null).await; }
52//! });
53//! ```
54
55use 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
62/// A [`Memory`] backed by CortexDB's MCP `memory_save` / `memory_search` tools.
63pub struct CortexdbMemory {
64    // Owns the MCP session so the CortexDB child process stays alive.
65    _client: McpClient,
66    save: Arc<dyn Tool>,
67    search: Arc<dyn Tool>,
68    /// Optional server-side distillation tool (`knowledge_memory_consolidate`),
69    /// present only if the connected CortexDB exposes it. See [`consolidate`].
70    consolidate: Option<Arc<dyn Tool>>,
71    scope: String,
72    namespace: String,
73    user_id: Option<String>,
74    seq: AtomicU64,
75}
76
77impl CortexdbMemory {
78    /// Spawn `program args...` as CortexDB's MCP stdio server and adapt it.
79    /// (e.g. `connect_stdio("cortexdb-mcp-stdio", &[])`.)
80    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    /// Connect to a **remote** CortexDB over MCP Streamable HTTP.
85    ///
86    /// The memory does not have to be a process this agent spawns. One CortexDB
87    /// behind an HTTP endpoint is the same brain for every client that reaches
88    /// it — a fleet of agents, an editor, a chat bot — instead of one private
89    /// store per process, and it can be made highly available in the ordinary
90    /// way. `connect_stdio` was the only documented path, which read as though
91    /// it were the only one.
92    ///
93    /// Requires the `http` feature of `harness-rs-mcp-client` (on by default,
94    /// with rustls). For an untrusted URL use
95    /// [`McpClient::connect_http_with_client`] and [`from_client`](Self::from_client),
96    /// which leaves the redirect and DNS policy with the caller.
97    ///
98    /// ```ignore
99    /// let mem = CortexdbMemory::connect_http("https://cortex.internal/mcp").await?;
100    /// ```
101    #[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    /// Adapt an already-connected CortexDB MCP client — stdio, HTTP, or any
107    /// transport [`McpClient`] grows later.
108    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        // Optional — older/lighter CortexDB builds may not expose it.
117        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    /// CortexDB memory scope: `global` (default, shared), `user`, or `session`.
131    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
132        self.scope = scope.into();
133        self
134    }
135    /// CortexDB namespace (default `harness`). Isolates this app's memories.
136    pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
137        self.namespace = ns.into();
138        self
139    }
140    /// Scope memories to a user id (for `scope = user`).
141    pub fn with_user_id(mut self, user: impl Into<String>) -> Self {
142        self.user_id = Some(user.into());
143        self
144    }
145
146    /// Whether the connected CortexDB exposes the graph-distillation tool.
147    pub fn can_consolidate(&self) -> bool {
148        self.consolidate.is_some()
149    }
150
151    /// Distill accumulated memories into CortexDB's **knowledge graph**
152    /// (entities + relations), server-side, via `knowledge_memory_consolidate`.
153    /// CortexDB does the extraction — harness just triggers it, so this costs no
154    /// tokens on our side. Run it periodically (e.g. from `harness-scheduler`)
155    /// after a batch of turns has been recorded.
156    ///
157    /// Operates on this instance's `scope`/`namespace`/`user_id`. The server
158    /// **requires** a `reflect.recall.query`; this method supplies a broad
159    /// default plus `promote_to_knowledge: true`, so `consolidate(Value::Null)`
160    /// works out of the box. Override either via `extra_args`, e.g.:
161    ///
162    /// ```ignore
163    /// mem.consolidate(serde_json::json!({
164    ///     "reflect": { "recall": { "query": "past tasks and tools", "max_memory_items": 30 } }
165    /// })).await?;
166    /// ```
167    ///
168    /// **Quality note:** in pure-lexical mode (no embedder configured on the
169    /// CortexDB side) entity extraction is poor — configure an embedder for a
170    /// clean graph. Returns the raw tool result. Errors if the server doesn't
171    /// expose the tool (check [`can_consolidate`](Self::can_consolidate)).
172    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        // MemorySearchResponse { results: [ { memory: MemoryRecord, score } ] }
266        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            // The record is under `memory`; tolerate a flattened shape too.
276            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
306/// Build the `memory_save` args for `entry`. Promotes `role:`/`session:` tags to
307/// CortexDB's first-class `role` / `session_id` columns (so a transcript can be
308/// queried and ordered by `session_id + role`, not just `json_extract` on
309/// metadata); all remaining tags + `source` go into `metadata`.
310fn 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
357/// Build the `knowledge_memory_consolidate` args: base scope/namespace/user_id,
358/// merge caller `extra_args`, then fill the server-required `reflect.recall.query`
359/// (+ `promote_to_knowledge`) defaults if the caller didn't supply them.
360fn 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        // Server-required reflect.recall.query is supplied.
399        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        // Promoted to first-class columns.
425        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        // role:/session: stripped from metadata tags; unrelated tag kept.
432        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}