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}