use harness_core::{Memory, MemoryEntry, MemoryError};
use std::sync::atomic::{AtomicU64, Ordering};
#[allow(dead_code)]
pub(crate) mod pb {
tonic::include_proto!("cortexdb.v1");
}
use pb::memory_service_client::MemoryServiceClient;
use pb::{MemoryRecord, SaveMemoryRequest, SearchMemoryRequest};
pub struct CortexdbGrpcMemory {
client: MemoryServiceClient<tonic::transport::Channel>,
scope: String,
namespace: String,
user_id: Option<String>,
session_id: Option<String>,
token: Option<String>,
seq: AtomicU64,
}
impl CortexdbGrpcMemory {
pub async fn connect(endpoint: impl Into<String>) -> anyhow::Result<Self> {
let endpoint = endpoint.into();
let client = MemoryServiceClient::connect(endpoint).await?;
Ok(Self {
client,
scope: "global".into(),
namespace: "default".into(),
user_id: None,
session_id: None,
token: std::env::var("CORTEXDB_GRPC_TOKEN")
.ok()
.filter(|t| !t.is_empty()),
seq: AtomicU64::new(0),
})
}
pub fn from_client(client: MemoryServiceClient<tonic::transport::Channel>) -> Self {
Self {
client,
scope: "global".into(),
namespace: "default".into(),
user_id: None,
session_id: None,
token: std::env::var("CORTEXDB_GRPC_TOKEN")
.ok()
.filter(|t| !t.is_empty()),
seq: AtomicU64::new(0),
}
}
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scope = scope.into();
self
}
pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = namespace.into();
self
}
pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
fn authed<T>(&self, msg: T) -> tonic::Request<T> {
let mut req = tonic::Request::new(msg);
if let Some(t) = &self.token
&& let Ok(v) = format!("Bearer {t}").parse()
{
req.metadata_mut().insert("authorization", v);
}
req
}
fn next_id(&self) -> String {
format!(
"harness-{}-{}",
std::process::id(),
self.seq.fetch_add(1, Ordering::Relaxed)
)
}
}
pub(crate) fn split_tags(tags: &[String]) -> (Option<String>, Option<String>, Vec<String>) {
let (mut role, mut session) = (None, None);
let mut rest = Vec::new();
for t in tags {
if let Some(r) = t.strip_prefix("role:") {
role = Some(r.to_string());
} else if let Some(s) = t.strip_prefix("session:") {
session = Some(s.to_string());
} else {
rest.push(t.clone());
}
}
(role, session, rest)
}
fn build_metadata(tags: &[String], source: Option<&str>) -> Option<prost_types::Struct> {
use prost_types::{ListValue, Struct, Value, value::Kind};
let mut fields = std::collections::BTreeMap::new();
if !tags.is_empty() {
fields.insert(
"tags".to_string(),
Value {
kind: Some(Kind::ListValue(ListValue {
values: tags
.iter()
.map(|t| Value {
kind: Some(Kind::StringValue(t.clone())),
})
.collect(),
})),
},
);
}
if let Some(s) = source {
fields.insert(
"source".to_string(),
Value {
kind: Some(Kind::StringValue(s.to_string())),
},
);
}
if fields.is_empty() {
None
} else {
Some(Struct {
fields: fields.into_iter().collect(),
})
}
}
fn entry_from(rec: &MemoryRecord) -> MemoryEntry {
use prost_types::value::Kind;
let mut tags: Vec<String> = Vec::new();
let mut source: Option<String> = None;
if let Some(md) = &rec.metadata {
if let Some(Kind::ListValue(list)) = md.fields.get("tags").and_then(|v| v.kind.as_ref()) {
for v in &list.values {
if let Some(Kind::StringValue(s)) = &v.kind {
tags.push(s.clone());
}
}
}
if let Some(Kind::StringValue(s)) = md.fields.get("source").and_then(|v| v.kind.as_ref()) {
source = Some(s.clone());
}
}
if !rec.role.is_empty() {
tags.push(format!("role:{}", rec.role));
}
if !rec.session_id.is_empty() {
tags.push(format!("session:{}", rec.session_id));
}
let mut entry = MemoryEntry::new(rec.content.clone());
entry.id = rec.id.clone();
entry.tags = tags;
entry.source = source;
entry
}
#[async_trait::async_trait]
impl Memory for CortexdbGrpcMemory {
async fn recall(&self, query: &str, k: usize) -> Result<Vec<MemoryEntry>, MemoryError> {
if k == 0 || query.trim().is_empty() {
return Ok(Vec::new());
}
let req = SearchMemoryRequest {
query: query.to_string(),
user_id: self.user_id.clone().unwrap_or_default(),
session_id: self.session_id.clone().unwrap_or_default(),
scope: self.scope.clone(),
namespace: self.namespace.clone(),
top_k: k as i32,
..Default::default()
};
let resp = self
.client
.clone()
.search_memory(self.authed(req))
.await
.map_err(|e| MemoryError::Backend(format!("SearchMemory: {e}")))?
.into_inner();
Ok(resp
.results
.iter()
.filter_map(|hit| hit.memory.as_ref())
.map(entry_from)
.collect())
}
async fn write(&self, entry: MemoryEntry) -> Result<(), MemoryError> {
let id = if entry.id.is_empty() {
self.next_id()
} else {
entry.id.clone()
};
let (role, session, tags) = split_tags(&entry.tags);
let req = SaveMemoryRequest {
memory_id: id,
user_id: self.user_id.clone().unwrap_or_default(),
session_id: session
.or_else(|| self.session_id.clone())
.unwrap_or_default(),
scope: self.scope.clone(),
namespace: self.namespace.clone(),
role: role.unwrap_or_default(),
content: entry.content,
metadata: build_metadata(&tags, entry.source.as_deref()),
..Default::default()
};
self.client
.clone()
.save_memory(self.authed(req))
.await
.map_err(|e| MemoryError::Backend(format!("SaveMemory: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tags_split_the_way_the_mcp_path_splits_them() {
let tags = vec![
"role:user".to_string(),
"session:s1".to_string(),
"project".to_string(),
];
let (role, session, rest) = split_tags(&tags);
assert_eq!(role.as_deref(), Some("user"));
assert_eq!(session.as_deref(), Some("s1"));
assert_eq!(rest, vec!["project".to_string()]);
}
#[test]
fn metadata_carries_tags_and_source() {
let md = build_metadata(&["a".into(), "b".into()], Some("session")).expect("some");
assert!(md.fields.contains_key("tags"));
assert!(md.fields.contains_key("source"));
assert!(build_metadata(&[], None).is_none());
}
#[test]
fn a_record_round_trips_into_an_entry() {
let entry = MemoryEntry::new("the deploy needs a tag push")
.with_source("chat")
.with_tags(["role:user", "session:s1", "release"]);
let (role, session, rest) = split_tags(&entry.tags);
let rec = MemoryRecord {
id: "m1".into(),
role: role.unwrap_or_default(),
session_id: session.unwrap_or_default(),
content: entry.content.clone(),
metadata: build_metadata(&rest, entry.source.as_deref()),
..Default::default()
};
let back = entry_from(&rec);
assert_eq!(back.content, entry.content);
assert_eq!(back.source.as_deref(), Some("chat"));
for t in ["release", "role:user", "session:s1"] {
assert!(
back.tags.contains(&t.to_string()),
"lost {t}: {:?}",
back.tags
);
}
}
}