use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{HarnessHomes, HarnessId};
pub const MEMORY_SCHEMA: &str = "supercode.memory.v1";
pub const MEMORY_HARNESSES: &[&str] = &[
HarnessId::CLAUDE_CODE,
HarnessId::HERMES,
HarnessId::OPENCLAW,
];
const HERMES_DEFAULT_PROFILE: &str = crate::profiles::HERMES_DEFAULT_PROFILE;
const PREVIEW_LINES: usize = 5;
const EXCERPT_CHARS: usize = 200;
const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
const MAX_WALK_DEPTH: usize = 4;
const MAX_DOCUMENTS: usize = 512;
const MAX_MATCHES: usize = 512;
const DOCUMENT_EXTENSIONS: &[&str] = &["md", "markdown", "txt"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryScope {
User,
Project,
Profile,
Agent,
}
impl MemoryScope {
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
Self::Profile => "profile",
Self::Agent => "agent",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryDocument {
pub name: String,
pub harness: String,
pub scope: MemoryScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
pub path: PathBuf,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
pub preview: Vec<String>,
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryMatch {
pub harness: String,
pub scope: MemoryScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
pub name: String,
pub path: PathBuf,
pub line: usize,
pub excerpt: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct MemoryQuery {
pub harness: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
pub full: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub homes: HarnessHomes,
}
impl Default for MemoryQuery {
fn default() -> Self {
Self {
harness: String::new(),
profile: None,
session: None,
full: false,
cwd: None,
homes: HarnessHomes::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct MemorySearchQuery {
pub harness: String,
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
pub regex: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub homes: HarnessHomes,
}
impl Default for MemorySearchQuery {
fn default() -> Self {
Self {
harness: String::new(),
query: String::new(),
profile: None,
regex: false,
cwd: None,
homes: HarnessHomes::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MemoryError {
#[error("harness `{harness}` has no memory store (memory exists for: {})", MEMORY_HARNESSES.join(", "))]
UnsupportedHarness {
harness: String,
},
#[error("`{harness}` has no memory store for `{profile}`")]
UnknownProfile {
harness: String,
profile: String,
},
#[error(
"`{harness}` scopes memory by profile, not by session — drop `session` or use `profile`"
)]
SessionNotScoped {
harness: String,
},
#[error("no Claude Code project directory holds session `{session}`")]
SessionNotFound {
session: String,
},
#[error("memory search needs a query")]
EmptyQuery,
#[error("`{pattern}` is not a valid regular expression: {reason}")]
BadRegex {
pattern: String,
reason: String,
},
}
pub fn supports_memory(harness: &str) -> bool {
MEMORY_HARNESSES.contains(&harness)
}
pub fn show_memory(query: &MemoryQuery) -> Result<Vec<MemoryDocument>, MemoryError> {
let stores = resolve_stores(
&query.harness,
query.profile.as_deref(),
query.session.as_deref(),
query.cwd.as_deref(),
&query.homes,
)?;
let mut documents = Vec::new();
for store in &stores {
for (name, path) in store.documents() {
if documents.len() >= MAX_DOCUMENTS {
return Ok(documents);
}
documents.push(read_document(store, name, &path, query.full));
}
}
Ok(documents)
}
pub fn search_memory(query: &MemorySearchQuery) -> Result<Vec<MemoryMatch>, MemoryError> {
if query.query.trim().is_empty() {
return Err(MemoryError::EmptyQuery);
}
let stores = resolve_stores(
&query.harness,
query.profile.as_deref(),
None,
query.cwd.as_deref(),
&query.homes,
)?;
let pattern = if query.regex {
Some(
regex::RegexBuilder::new(&query.query)
.case_insensitive(true)
.build()
.map_err(|error| MemoryError::BadRegex {
pattern: query.query.clone(),
reason: error.to_string(),
})?,
)
} else {
None
};
let needle = query.query.to_lowercase();
let mut matches = Vec::new();
for store in &stores {
for (name, path) in store.documents() {
let Some(text) = read_capped(&path) else {
continue;
};
for (index, line) in text.lines().enumerate() {
let hit = match &pattern {
Some(regex) => regex.is_match(line),
None => line.to_lowercase().contains(&needle),
};
if !hit {
continue;
}
matches.push(MemoryMatch {
harness: store.harness.to_string(),
scope: store.scope,
profile: store.profile.clone(),
name: name.clone(),
path: path.clone(),
line: index + 1,
excerpt: clip(line),
});
if matches.len() >= MAX_MATCHES {
return Ok(matches);
}
}
}
}
Ok(matches)
}
#[derive(Debug, Clone)]
struct MemoryStore {
harness: &'static str,
scope: MemoryScope,
profile: Option<String>,
root: PathBuf,
files: Vec<PathBuf>,
directories: Vec<PathBuf>,
}
impl MemoryStore {
fn documents(&self) -> Vec<(String, PathBuf)> {
let mut out: Vec<(String, PathBuf)> = Vec::new();
for path in &self.files {
if path.is_file() {
out.push((self.relative(path), path.clone()));
}
}
for directory in &self.directories {
let mut found = Vec::new();
walk_documents(directory, 0, &mut found);
found.sort();
for path in found {
if !out.iter().any(|(_, existing)| *existing == path) {
out.push((self.relative(&path), path));
}
}
}
out
}
fn relative(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
}
fn resolve_stores(
harness: &str,
profile: Option<&str>,
session: Option<&str>,
cwd: Option<&Path>,
homes: &HarnessHomes,
) -> Result<Vec<MemoryStore>, MemoryError> {
if !supports_memory(harness) {
return Err(MemoryError::UnsupportedHarness {
harness: harness.to_string(),
});
}
if session.is_some() && harness != HarnessId::CLAUDE_CODE {
return Err(MemoryError::SessionNotScoped {
harness: harness.to_string(),
});
}
let stores = match harness {
HarnessId::CLAUDE_CODE => claude_code_stores(homes, profile, session, cwd)?,
HarnessId::HERMES => hermes_stores(homes, profile)?,
HarnessId::OPENCLAW => openclaw_stores(homes, profile)?,
_ => Vec::new(),
};
Ok(stores)
}
fn claude_code_stores(
homes: &HarnessHomes,
profile: Option<&str>,
session: Option<&str>,
cwd: Option<&Path>,
) -> Result<Vec<MemoryStore>, MemoryError> {
let projects = homes.claude_code.clone();
let project_dir = if let Some(profile) = profile {
let candidate = PathBuf::from(profile);
let dir = if candidate.is_absolute() {
candidate
} else {
projects.join(profile)
};
if !dir.is_dir() {
return Err(MemoryError::UnknownProfile {
harness: HarnessId::CLAUDE_CODE.to_string(),
profile: profile.to_string(),
});
}
dir
} else if let Some(session) = session {
project_dir_for_session(&projects, session).ok_or_else(|| MemoryError::SessionNotFound {
session: session.to_string(),
})?
} else {
let cwd = cwd
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
let project = repository_root(&cwd).unwrap_or(cwd);
projects.join(claude_project_slug(&project))
};
let root = if project_dir.join("memory").is_dir() {
project_dir.join("memory")
} else {
project_dir.clone()
};
if !root.is_dir() {
return Ok(Vec::new());
}
Ok(vec![MemoryStore {
harness: HarnessId::CLAUDE_CODE,
scope: MemoryScope::Project,
profile: project_dir
.file_name()
.map(|name| name.to_string_lossy().to_string()),
files: vec![root.join("MEMORY.md")],
directories: vec![root.clone()],
root,
}])
}
fn claude_project_slug(path: &Path) -> String {
path.to_string_lossy()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect()
}
fn repository_root(path: &Path) -> Option<PathBuf> {
path.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
.map(Path::to_path_buf)
}
fn project_dir_for_session(projects: &Path, session: &str) -> Option<PathBuf> {
let transcript = format!("{session}.jsonl");
let entries = std::fs::read_dir(projects).ok()?;
let mut found: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && path.join(&transcript).is_file())
.collect();
found.sort();
found.into_iter().next()
}
fn hermes_stores(
homes: &HarnessHomes,
profile: Option<&str>,
) -> Result<Vec<MemoryStore>, MemoryError> {
let Some(home) = homes.hermes.parent() else {
return Ok(Vec::new());
};
let mut stores = Vec::new();
let mut wanted = vec![(HERMES_DEFAULT_PROFILE.to_string(), home.to_path_buf())];
if let Ok(entries) = std::fs::read_dir(home.join("profiles")) {
let mut found: Vec<(String, PathBuf)> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|path| {
let name = path.file_name()?.to_string_lossy().to_string();
Some((name, path))
})
.collect();
found.sort();
wanted.extend(found);
}
if let Some(profile) = profile {
wanted.retain(|(name, _)| name == profile);
if wanted.is_empty() {
return Err(MemoryError::UnknownProfile {
harness: HarnessId::HERMES.to_string(),
profile: profile.to_string(),
});
}
}
for (name, root) in wanted {
if !root.is_dir() {
continue;
}
let is_default = name == HERMES_DEFAULT_PROFILE;
stores.push(MemoryStore {
harness: HarnessId::HERMES,
scope: if is_default {
MemoryScope::User
} else {
MemoryScope::Profile
},
profile: Some(name),
files: vec![root.join("MEMORY.md"), root.join("USER.md")],
directories: vec![root.join("memories")],
root,
});
}
Ok(stores)
}
fn openclaw_stores(
homes: &HarnessHomes,
profile: Option<&str>,
) -> Result<Vec<MemoryStore>, MemoryError> {
let config = homes.openclaw.clone();
let agents = crate::profiles::list_profiles(homes, Some(HarnessId::OPENCLAW))
.unwrap_or_default()
.into_iter()
.map(|row| (row.name, row.default))
.collect::<Vec<_>>();
let mut wanted: Vec<(String, bool)> = agents;
if wanted.is_empty() {
wanted.push((crate::profiles::OPENCLAW_DEFAULT_AGENT.to_string(), true));
} else if !wanted.iter().any(|(_, is_default)| *is_default) {
let fallback = wanted
.iter()
.position(|(name, _)| name == crate::profiles::OPENCLAW_DEFAULT_AGENT)
.unwrap_or(0);
wanted[fallback].1 = true;
}
if let Some(profile) = profile {
wanted.retain(|(name, _)| name == profile);
if wanted.is_empty() {
return Err(MemoryError::UnknownProfile {
harness: HarnessId::OPENCLAW.to_string(),
profile: profile.to_string(),
});
}
}
let mut stores = Vec::new();
for (name, is_default) in wanted {
let root = openclaw_workspace(&config, &name, is_default);
if !root.is_dir() {
continue;
}
stores.push(MemoryStore {
harness: HarnessId::OPENCLAW,
scope: MemoryScope::Agent,
profile: Some(name),
files: vec![
root.join("MEMORY.md"),
root.join("DREAMS.md"),
root.join("dreams.md"),
],
directories: vec![root.join("memory")],
root,
});
}
Ok(stores)
}
fn openclaw_workspace(config: &Path, agent: &str, is_default: bool) -> PathBuf {
if let Some(configured) = openclaw_configured_workspace(config, agent) {
return configured;
}
if !is_default {
return config.join(format!("workspace-{agent}"));
}
if let Some(explicit) = std::env::var_os("OPENCLAW_WORKSPACE_DIR")
.map(PathBuf::from)
.filter(|dir| !dir.as_os_str().is_empty())
{
return explicit;
}
match std::env::var("OPENCLAW_PROFILE") {
Ok(profile) if !profile.trim().is_empty() && profile.trim() != "default" => {
config.join(format!("workspace-{}", profile.trim()))
}
_ => config.join("workspace"),
}
}
fn openclaw_configured_workspace(config: &Path, agent: &str) -> Option<PathBuf> {
let document = crate::profiles::read_json5(&config.join("openclaw.json"));
let agents = document.get("agents")?;
let entry = agents
.get("list")
.and_then(|list| list.as_array())
.and_then(|list| {
list.iter()
.find(|item| item.get("id").and_then(serde_json::Value::as_str) == Some(agent))
})
.or_else(|| agents.get("entries").and_then(|entries| entries.get(agent)))?;
let workspace = entry
.get("workspace")
.and_then(serde_json::Value::as_str)?
.trim();
if workspace.is_empty() {
return None;
}
Some(expand_home(workspace))
}
fn expand_home(value: &str) -> PathBuf {
if let Some(rest) = value.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(value)
}
fn walk_documents(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth >= MAX_WALK_DEPTH || out.len() >= MAX_DOCUMENTS {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut children: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
children.sort();
for path in children {
if out.len() >= MAX_DOCUMENTS {
return;
}
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string());
if name.as_deref().is_some_and(|name| name.starts_with('.')) {
continue;
}
if path.is_dir() {
walk_documents(&path, depth + 1, out);
} else if is_document(&path) {
out.push(path);
}
}
}
fn is_document(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.is_some_and(|extension| DOCUMENT_EXTENSIONS.contains(&extension.as_str()))
}
fn read_document(store: &MemoryStore, name: String, path: &Path, full: bool) -> MemoryDocument {
let metadata = std::fs::metadata(path).ok();
let size = metadata.as_ref().map_or(0, std::fs::Metadata::len);
let updated_at = metadata
.as_ref()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|since| crate::sidecar::ms_to_rfc3339(since.as_millis().min(i64::MAX as u128) as i64));
let text = read_capped(path);
let preview: Vec<String> = text
.as_deref()
.map(|text| text.lines().take(PREVIEW_LINES).map(clip).collect())
.unwrap_or_default();
let truncated = text
.as_deref()
.map(|text| text.lines().count() > preview.len())
.unwrap_or(false)
|| size > MAX_DOCUMENT_BYTES as u64;
MemoryDocument {
name,
harness: store.harness.to_string(),
scope: store.scope,
profile: store.profile.clone(),
path: path.to_path_buf(),
size,
updated_at,
preview,
truncated,
content: if full { text } else { None },
}
}
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buffer = Vec::new();
file.take(MAX_DOCUMENT_BYTES as u64)
.read_to_end(&mut buffer)
.ok()?;
String::from_utf8(buffer).ok()
}
fn clip(line: &str) -> String {
let trimmed = line.trim_end();
if trimmed.chars().count() <= EXCERPT_CHARS {
return trimmed.to_string();
}
let mut out: String = trimmed.chars().take(EXCERPT_CHARS).collect();
out.push('…');
out
}