1use std::path::{Path, PathBuf};
47
48use serde::{Deserialize, Serialize};
49
50use crate::{HarnessHomes, HarnessId};
51
52pub const MEMORY_SCHEMA: &str = "supercode.memory.v1";
54
55pub const MEMORY_HARNESSES: &[&str] = &[
58 HarnessId::CLAUDE_CODE,
59 HarnessId::HERMES,
60 HarnessId::OPENCLAW,
61];
62
63const HERMES_DEFAULT_PROFILE: &str = crate::profiles::HERMES_DEFAULT_PROFILE;
65
66const PREVIEW_LINES: usize = 5;
70const EXCERPT_CHARS: usize = 200;
72const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
75const MAX_WALK_DEPTH: usize = 4;
77const MAX_DOCUMENTS: usize = 512;
79const MAX_MATCHES: usize = 512;
81const DOCUMENT_EXTENSIONS: &[&str] = &["md", "markdown", "txt"];
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum MemoryScope {
90 User,
92 Project,
94 Profile,
96 Agent,
98}
99
100impl MemoryScope {
101 pub const fn as_str(self) -> &'static str {
103 match self {
104 Self::User => "user",
105 Self::Project => "project",
106 Self::Profile => "profile",
107 Self::Agent => "agent",
108 }
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct MemoryDocument {
115 pub name: String,
118 pub harness: String,
120 pub scope: MemoryScope,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub profile: Option<String>,
126 pub path: PathBuf,
128 pub size: u64,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub updated_at: Option<String>,
134 pub preview: Vec<String>,
136 pub truncated: bool,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub content: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct MemoryMatch {
146 pub harness: String,
148 pub scope: MemoryScope,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub profile: Option<String>,
153 pub name: String,
155 pub path: PathBuf,
157 pub line: usize,
159 pub excerpt: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
165#[serde(default)]
166pub struct MemoryQuery {
167 pub harness: String,
170 #[serde(skip_serializing_if = "Option::is_none")]
174 pub profile: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
179 pub session: Option<String>,
180 pub full: bool,
183 #[serde(skip_serializing_if = "Option::is_none")]
186 pub cwd: Option<PathBuf>,
187 pub homes: HarnessHomes,
189}
190
191impl Default for MemoryQuery {
192 fn default() -> Self {
193 Self {
194 harness: String::new(),
195 profile: None,
196 session: None,
197 full: false,
198 cwd: None,
199 homes: HarnessHomes::default(),
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
206#[serde(default)]
207pub struct MemorySearchQuery {
208 pub harness: String,
210 pub query: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub profile: Option<String>,
215 pub regex: bool,
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub cwd: Option<PathBuf>,
220 pub homes: HarnessHomes,
222}
223
224impl Default for MemorySearchQuery {
225 fn default() -> Self {
226 Self {
227 harness: String::new(),
228 query: String::new(),
229 profile: None,
230 regex: false,
231 cwd: None,
232 homes: HarnessHomes::default(),
233 }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
239pub enum MemoryError {
240 #[error("harness `{harness}` has no memory store (memory exists for: {})", MEMORY_HARNESSES.join(", "))]
242 UnsupportedHarness {
243 harness: String,
245 },
246 #[error("`{harness}` has no memory store for `{profile}`")]
249 UnknownProfile {
250 harness: String,
252 profile: String,
254 },
255 #[error(
257 "`{harness}` scopes memory by profile, not by session — drop `session` or use `profile`"
258 )]
259 SessionNotScoped {
260 harness: String,
262 },
263 #[error("no Claude Code project directory holds session `{session}`")]
265 SessionNotFound {
266 session: String,
268 },
269 #[error("memory search needs a query")]
271 EmptyQuery,
272 #[error("`{pattern}` is not a valid regular expression: {reason}")]
274 BadRegex {
275 pattern: String,
277 reason: String,
279 },
280}
281
282pub fn supports_memory(harness: &str) -> bool {
284 MEMORY_HARNESSES.contains(&harness)
285}
286
287pub fn show_memory(query: &MemoryQuery) -> Result<Vec<MemoryDocument>, MemoryError> {
291 let stores = resolve_stores(
292 &query.harness,
293 query.profile.as_deref(),
294 query.session.as_deref(),
295 query.cwd.as_deref(),
296 &query.homes,
297 )?;
298 let mut documents = Vec::new();
299 for store in &stores {
300 for (name, path) in store.documents() {
301 if documents.len() >= MAX_DOCUMENTS {
302 return Ok(documents);
303 }
304 documents.push(read_document(store, name, &path, query.full));
305 }
306 }
307 Ok(documents)
308}
309
310pub fn search_memory(query: &MemorySearchQuery) -> Result<Vec<MemoryMatch>, MemoryError> {
315 if query.query.trim().is_empty() {
316 return Err(MemoryError::EmptyQuery);
317 }
318 let stores = resolve_stores(
319 &query.harness,
320 query.profile.as_deref(),
321 None,
322 query.cwd.as_deref(),
323 &query.homes,
324 )?;
325 let pattern = if query.regex {
326 Some(
327 regex::RegexBuilder::new(&query.query)
328 .case_insensitive(true)
329 .build()
330 .map_err(|error| MemoryError::BadRegex {
331 pattern: query.query.clone(),
332 reason: error.to_string(),
333 })?,
334 )
335 } else {
336 None
337 };
338 let needle = query.query.to_lowercase();
339 let mut matches = Vec::new();
340 for store in &stores {
341 for (name, path) in store.documents() {
342 let Some(text) = read_capped(&path) else {
343 continue;
344 };
345 for (index, line) in text.lines().enumerate() {
346 let hit = match &pattern {
347 Some(regex) => regex.is_match(line),
348 None => line.to_lowercase().contains(&needle),
349 };
350 if !hit {
351 continue;
352 }
353 matches.push(MemoryMatch {
354 harness: store.harness.to_string(),
355 scope: store.scope,
356 profile: store.profile.clone(),
357 name: name.clone(),
358 path: path.clone(),
359 line: index + 1,
360 excerpt: clip(line),
361 });
362 if matches.len() >= MAX_MATCHES {
363 return Ok(matches);
364 }
365 }
366 }
367 }
368 Ok(matches)
369}
370
371#[derive(Debug, Clone)]
378struct MemoryStore {
379 harness: &'static str,
380 scope: MemoryScope,
381 profile: Option<String>,
382 root: PathBuf,
384 files: Vec<PathBuf>,
386 directories: Vec<PathBuf>,
388}
389
390impl MemoryStore {
391 fn documents(&self) -> Vec<(String, PathBuf)> {
395 let mut out: Vec<(String, PathBuf)> = Vec::new();
396 for path in &self.files {
397 if path.is_file() {
398 out.push((self.relative(path), path.clone()));
399 }
400 }
401 for directory in &self.directories {
402 let mut found = Vec::new();
403 walk_documents(directory, 0, &mut found);
404 found.sort();
405 for path in found {
406 if !out.iter().any(|(_, existing)| *existing == path) {
407 out.push((self.relative(&path), path));
408 }
409 }
410 }
411 out
412 }
413
414 fn relative(&self, path: &Path) -> String {
416 path.strip_prefix(&self.root)
417 .unwrap_or(path)
418 .to_string_lossy()
419 .replace('\\', "/")
420 }
421}
422
423fn resolve_stores(
425 harness: &str,
426 profile: Option<&str>,
427 session: Option<&str>,
428 cwd: Option<&Path>,
429 homes: &HarnessHomes,
430) -> Result<Vec<MemoryStore>, MemoryError> {
431 if !supports_memory(harness) {
432 return Err(MemoryError::UnsupportedHarness {
433 harness: harness.to_string(),
434 });
435 }
436 if session.is_some() && harness != HarnessId::CLAUDE_CODE {
437 return Err(MemoryError::SessionNotScoped {
438 harness: harness.to_string(),
439 });
440 }
441 let stores = match harness {
442 HarnessId::CLAUDE_CODE => claude_code_stores(homes, profile, session, cwd)?,
443 HarnessId::HERMES => hermes_stores(homes, profile)?,
444 HarnessId::OPENCLAW => openclaw_stores(homes, profile)?,
445 _ => Vec::new(),
446 };
447 Ok(stores)
448}
449
450fn claude_code_stores(
462 homes: &HarnessHomes,
463 profile: Option<&str>,
464 session: Option<&str>,
465 cwd: Option<&Path>,
466) -> Result<Vec<MemoryStore>, MemoryError> {
467 let projects = homes.claude_code.clone();
468 let project_dir = if let Some(profile) = profile {
469 let candidate = PathBuf::from(profile);
473 let dir = if candidate.is_absolute() {
474 candidate
475 } else {
476 projects.join(profile)
477 };
478 if !dir.is_dir() {
479 return Err(MemoryError::UnknownProfile {
480 harness: HarnessId::CLAUDE_CODE.to_string(),
481 profile: profile.to_string(),
482 });
483 }
484 dir
485 } else if let Some(session) = session {
486 project_dir_for_session(&projects, session).ok_or_else(|| MemoryError::SessionNotFound {
487 session: session.to_string(),
488 })?
489 } else {
490 let cwd = cwd
491 .map(Path::to_path_buf)
492 .or_else(|| std::env::current_dir().ok())
493 .unwrap_or_else(|| PathBuf::from("."));
494 let project = repository_root(&cwd).unwrap_or(cwd);
497 projects.join(claude_project_slug(&project))
498 };
499 let root = if project_dir.join("memory").is_dir() {
502 project_dir.join("memory")
503 } else {
504 project_dir.clone()
505 };
506 if !root.is_dir() {
507 return Ok(Vec::new());
508 }
509 Ok(vec![MemoryStore {
510 harness: HarnessId::CLAUDE_CODE,
511 scope: MemoryScope::Project,
512 profile: project_dir
513 .file_name()
514 .map(|name| name.to_string_lossy().to_string()),
515 files: vec![root.join("MEMORY.md")],
519 directories: vec![root.clone()],
520 root,
521 }])
522}
523
524fn claude_project_slug(path: &Path) -> String {
528 path.to_string_lossy()
529 .chars()
530 .map(|character| {
531 if character.is_ascii_alphanumeric() {
532 character
533 } else {
534 '-'
535 }
536 })
537 .collect()
538}
539
540fn repository_root(path: &Path) -> Option<PathBuf> {
543 path.ancestors()
544 .find(|ancestor| ancestor.join(".git").exists())
545 .map(Path::to_path_buf)
546}
547
548fn project_dir_for_session(projects: &Path, session: &str) -> Option<PathBuf> {
550 let transcript = format!("{session}.jsonl");
551 let entries = std::fs::read_dir(projects).ok()?;
552 let mut found: Vec<PathBuf> = entries
553 .flatten()
554 .map(|entry| entry.path())
555 .filter(|path| path.is_dir() && path.join(&transcript).is_file())
556 .collect();
557 found.sort();
558 found.into_iter().next()
559}
560
561fn hermes_stores(
572 homes: &HarnessHomes,
573 profile: Option<&str>,
574) -> Result<Vec<MemoryStore>, MemoryError> {
575 let Some(home) = homes.hermes.parent() else {
576 return Ok(Vec::new());
577 };
578 let mut stores = Vec::new();
579 let mut wanted = vec![(HERMES_DEFAULT_PROFILE.to_string(), home.to_path_buf())];
580 if let Ok(entries) = std::fs::read_dir(home.join("profiles")) {
581 let mut found: Vec<(String, PathBuf)> = entries
582 .flatten()
583 .map(|entry| entry.path())
584 .filter(|path| path.is_dir())
585 .filter_map(|path| {
586 let name = path.file_name()?.to_string_lossy().to_string();
587 Some((name, path))
588 })
589 .collect();
590 found.sort();
591 wanted.extend(found);
592 }
593 if let Some(profile) = profile {
594 wanted.retain(|(name, _)| name == profile);
595 if wanted.is_empty() {
596 return Err(MemoryError::UnknownProfile {
597 harness: HarnessId::HERMES.to_string(),
598 profile: profile.to_string(),
599 });
600 }
601 }
602 for (name, root) in wanted {
603 if !root.is_dir() {
604 continue;
605 }
606 let is_default = name == HERMES_DEFAULT_PROFILE;
607 stores.push(MemoryStore {
608 harness: HarnessId::HERMES,
609 scope: if is_default {
610 MemoryScope::User
611 } else {
612 MemoryScope::Profile
613 },
614 profile: Some(name),
615 files: vec![root.join("MEMORY.md"), root.join("USER.md")],
618 directories: vec![root.join("memories")],
619 root,
620 });
621 }
622 Ok(stores)
623}
624
625fn openclaw_stores(
634 homes: &HarnessHomes,
635 profile: Option<&str>,
636) -> Result<Vec<MemoryStore>, MemoryError> {
637 let config = homes.openclaw.clone();
638 let agents = crate::profiles::list_profiles(homes, Some(HarnessId::OPENCLAW))
639 .unwrap_or_default()
640 .into_iter()
641 .map(|row| (row.name, row.default))
642 .collect::<Vec<_>>();
643 let mut wanted: Vec<(String, bool)> = agents;
644 if wanted.is_empty() {
645 wanted.push((crate::profiles::OPENCLAW_DEFAULT_AGENT.to_string(), true));
648 } else if !wanted.iter().any(|(_, is_default)| *is_default) {
649 let fallback = wanted
653 .iter()
654 .position(|(name, _)| name == crate::profiles::OPENCLAW_DEFAULT_AGENT)
655 .unwrap_or(0);
656 wanted[fallback].1 = true;
657 }
658 if let Some(profile) = profile {
659 wanted.retain(|(name, _)| name == profile);
660 if wanted.is_empty() {
661 return Err(MemoryError::UnknownProfile {
662 harness: HarnessId::OPENCLAW.to_string(),
663 profile: profile.to_string(),
664 });
665 }
666 }
667 let mut stores = Vec::new();
668 for (name, is_default) in wanted {
669 let root = openclaw_workspace(&config, &name, is_default);
670 if !root.is_dir() {
671 continue;
672 }
673 stores.push(MemoryStore {
674 harness: HarnessId::OPENCLAW,
675 scope: MemoryScope::Agent,
676 profile: Some(name),
677 files: vec![
678 root.join("MEMORY.md"),
679 root.join("DREAMS.md"),
680 root.join("dreams.md"),
681 ],
682 directories: vec![root.join("memory")],
683 root,
684 });
685 }
686 Ok(stores)
687}
688
689fn openclaw_workspace(config: &Path, agent: &str, is_default: bool) -> PathBuf {
707 if let Some(configured) = openclaw_configured_workspace(config, agent) {
708 return configured;
709 }
710 if !is_default {
711 return config.join(format!("workspace-{agent}"));
712 }
713 if let Some(explicit) = std::env::var_os("OPENCLAW_WORKSPACE_DIR")
714 .map(PathBuf::from)
715 .filter(|dir| !dir.as_os_str().is_empty())
716 {
717 return explicit;
718 }
719 match std::env::var("OPENCLAW_PROFILE") {
720 Ok(profile) if !profile.trim().is_empty() && profile.trim() != "default" => {
721 config.join(format!("workspace-{}", profile.trim()))
722 }
723 _ => config.join("workspace"),
724 }
725}
726
727fn openclaw_configured_workspace(config: &Path, agent: &str) -> Option<PathBuf> {
730 let document = crate::profiles::read_json5(&config.join("openclaw.json"));
731 let agents = document.get("agents")?;
732 let entry = agents
733 .get("list")
734 .and_then(|list| list.as_array())
735 .and_then(|list| {
736 list.iter()
737 .find(|item| item.get("id").and_then(serde_json::Value::as_str) == Some(agent))
738 })
739 .or_else(|| agents.get("entries").and_then(|entries| entries.get(agent)))?;
740 let workspace = entry
741 .get("workspace")
742 .and_then(serde_json::Value::as_str)?
743 .trim();
744 if workspace.is_empty() {
745 return None;
746 }
747 Some(expand_home(workspace))
748}
749
750fn expand_home(value: &str) -> PathBuf {
752 if let Some(rest) = value.strip_prefix("~/") {
753 if let Some(home) = std::env::var_os("HOME") {
754 return PathBuf::from(home).join(rest);
755 }
756 }
757 PathBuf::from(value)
758}
759
760fn walk_documents(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
766 if depth >= MAX_WALK_DEPTH || out.len() >= MAX_DOCUMENTS {
767 return;
768 }
769 let Ok(entries) = std::fs::read_dir(dir) else {
770 return;
771 };
772 let mut children: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
773 children.sort();
774 for path in children {
775 if out.len() >= MAX_DOCUMENTS {
776 return;
777 }
778 let name = path
779 .file_name()
780 .map(|name| name.to_string_lossy().to_string());
781 if name.as_deref().is_some_and(|name| name.starts_with('.')) {
782 continue;
783 }
784 if path.is_dir() {
785 walk_documents(&path, depth + 1, out);
786 } else if is_document(&path) {
787 out.push(path);
788 }
789 }
790}
791
792fn is_document(path: &Path) -> bool {
793 path.extension()
794 .and_then(|extension| extension.to_str())
795 .map(|extension| extension.to_ascii_lowercase())
796 .is_some_and(|extension| DOCUMENT_EXTENSIONS.contains(&extension.as_str()))
797}
798
799fn read_document(store: &MemoryStore, name: String, path: &Path, full: bool) -> MemoryDocument {
800 let metadata = std::fs::metadata(path).ok();
801 let size = metadata.as_ref().map_or(0, std::fs::Metadata::len);
802 let updated_at = metadata
803 .as_ref()
804 .and_then(|metadata| metadata.modified().ok())
805 .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
806 .map(|since| crate::sidecar::ms_to_rfc3339(since.as_millis().min(i64::MAX as u128) as i64));
807 let text = read_capped(path);
808 let preview: Vec<String> = text
809 .as_deref()
810 .map(|text| text.lines().take(PREVIEW_LINES).map(clip).collect())
811 .unwrap_or_default();
812 let truncated = text
813 .as_deref()
814 .map(|text| text.lines().count() > preview.len())
815 .unwrap_or(false)
816 || size > MAX_DOCUMENT_BYTES as u64;
817 MemoryDocument {
818 name,
819 harness: store.harness.to_string(),
820 scope: store.scope,
821 profile: store.profile.clone(),
822 path: path.to_path_buf(),
823 size,
824 updated_at,
825 preview,
826 truncated,
827 content: if full { text } else { None },
828 }
829}
830
831fn read_capped(path: &Path) -> Option<String> {
834 use std::io::Read;
835 let file = std::fs::File::open(path).ok()?;
836 let mut buffer = Vec::new();
837 file.take(MAX_DOCUMENT_BYTES as u64)
838 .read_to_end(&mut buffer)
839 .ok()?;
840 String::from_utf8(buffer).ok()
841}
842
843fn clip(line: &str) -> String {
844 let trimmed = line.trim_end();
845 if trimmed.chars().count() <= EXCERPT_CHARS {
846 return trimmed.to_string();
847 }
848 let mut out: String = trimmed.chars().take(EXCERPT_CHARS).collect();
849 out.push('…');
850 out
851}