Skip to main content

supercode_harness/
memory.rs

1//! ORCH-12 — the `memory` noun at the OBSERVED tier: read and search the
2//! persistent cross-session memory documents a harness keeps on disk.
3//!
4//! supercode never runs a memory engine (charter). There is no index, no
5//! embedding, no ranking here: `show` opens the files the harness's own
6//! loader opens and reports them; `search` is a plain substring (or, opt-in,
7//! a regular-expression) scan over those same files. Every mutation —
8//! `hermes memory off`, `openclaw memory forget|reset`, Claude Code's
9//! `/memory` — stays the harness's own verb.
10//!
11//! Three harnesses have a memory store at the pinned versions, each read
12//! from its own primary source:
13//!
14//! * **Claude Code** — the Claude-maintained per-project auto-memory
15//!   directory `<claude home>/projects/<encoded-cwd>/memory/`: a `MEMORY.md`
16//!   index plus one file per topic
17//!   (`docs/composable-harness/inventory/claude-code.md` §2 "Auto memory",
18//!   `docs:memory#auto-memory`). The directory is keyed by the enclosing git
19//!   repository and shared across its worktrees, so the project is resolved
20//!   from the repo root of `cwd`. Relocation via the `autoMemoryDirectory`
21//!   setting is NOT modelled; name a relocated directory directly with
22//!   `profile` (an absolute path is accepted).
23//! * **Hermes 0.21.0** — `get_memory_dir()` is `get_hermes_home()/"memories"`
24//!   holding `MEMORY.md` (the agent's own notes) and `USER.md` (what it knows
25//!   about the user) (`tools/memory_tool.py:55-57`, `hermes_cli/web_server.py`
26//!   memory-status endpoint, both at tag `v2026.7.20`). Profile mode points
27//!   `HERMES_HOME` at `<root>/profiles/<name>`, so every profile home is read
28//!   the same way. The root-level `MEMORY.md`/`USER.md` names Hermes's own
29//!   profile export manifest lists (`hermes_cli/profiles.py:238`) are read
30//!   too, so an install that still keeps them there is not silently empty.
31//! * **OpenClaw 2026.7.1-2** — the bundled `memory-core` extension's memory
32//!   files are, verbatim from its own classifier
33//!   (`extensions/memory-core/src/memory/qmd-manager.ts:156-165` at tag
34//!   `v2026.7.1-2`), `MEMORY.md`, `DREAMS.md`/`dreams.md`, and everything
35//!   under `memory/` — all relative to the AGENT WORKSPACE
36//!   ([`openclaw_workspace`], transcribed from `resolveAgentWorkspaceDir`).
37//!   `openclaw memory search`
38//!   is that extension's own door over the same files; supercode reads the
39//!   files rather than shelling out, so no gateway or index is required.
40//!
41//! Codex, opencode and pi have no memory store at the pinned versions
42//! (`docs/composable-harness/inventory/orchestration.md` row 14 — pi is
43//! config-only), so they are refused with [`MemoryError::UnsupportedHarness`],
44//! never answered with an empty list.
45
46use std::path::{Path, PathBuf};
47
48use serde::{Deserialize, Serialize};
49
50use crate::{HarnessHomes, HarnessId};
51
52/// Stable row schema shared by Rust, JSON-RPC, the SDKs, and the CLI.
53pub const MEMORY_SCHEMA: &str = "supercode.memory.v1";
54
55/// Harnesses with a memory store supercode reads, in product order. Every
56/// other harness id is [`MemoryError::UnsupportedHarness`].
57pub const MEMORY_HARNESSES: &[&str] = &[
58    HarnessId::CLAUDE_CODE,
59    HarnessId::HERMES,
60    HarnessId::OPENCLAW,
61];
62
63/// Hermes's implicit profile — `HERMES_HOME` itself.
64const HERMES_DEFAULT_PROFILE: &str = crate::profiles::HERMES_DEFAULT_PROFILE;
65
66/// Lines of a document returned as its `preview`. A memory file is the
67/// user's own prose; the default answer names it and shows its head, never
68/// its whole body.
69const PREVIEW_LINES: usize = 5;
70/// Characters kept from one previewed or matched line.
71const EXCERPT_CHARS: usize = 200;
72/// Bytes read from one memory document. Far above any real memory file, and
73/// a ceiling so a mistaken path cannot hang a read.
74const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
75/// How deep a memory directory is walked (`memory/topic/notes.md`).
76const MAX_WALK_DEPTH: usize = 4;
77/// Ceiling on documents from one home.
78const MAX_DOCUMENTS: usize = 512;
79/// Ceiling on match rows from one search.
80const MAX_MATCHES: usize = 512;
81/// Extensions a memory document may have. Memory homes sit beside config and
82/// credentials; only prose files are ever opened.
83const DOCUMENT_EXTENSIONS: &[&str] = &["md", "markdown", "txt"];
84
85/// Which config home a memory document belongs to, in the vocabulary shared
86/// by all three harnesses.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum MemoryScope {
90    /// The user's own config home (Hermes's implicit `default` profile).
91    User,
92    /// A per-project store (Claude Code's auto-memory directory).
93    Project,
94    /// A named Hermes profile home.
95    Profile,
96    /// An OpenClaw agent's workspace.
97    Agent,
98}
99
100impl MemoryScope {
101    /// Stable wire spelling, identical to the serde representation.
102    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/// One memory document, as one harness holds it.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct MemoryDocument {
115    /// Path relative to the store's own root (`MEMORY.md`,
116    /// `memories/2026-09-01-notes.md`).
117    pub name: String,
118    /// Owning harness id.
119    pub harness: String,
120    /// Which config home this document belongs to.
121    pub scope: MemoryScope,
122    /// Profile / agent / project this store belongs to, when the harness
123    /// names one.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub profile: Option<String>,
126    /// Absolute path of the document.
127    pub path: PathBuf,
128    /// Size on disk, in bytes.
129    pub size: u64,
130    /// Last modification time, RFC3339 UTC; `None` when the filesystem does
131    /// not report one.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub updated_at: Option<String>,
134    /// First [`PREVIEW_LINES`] lines, each clipped to [`EXCERPT_CHARS`].
135    pub preview: Vec<String>,
136    /// Whether the document has more than the preview shows.
137    pub truncated: bool,
138    /// Whole document text, only when the caller asked for `full`.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub content: Option<String>,
141}
142
143/// One line of one memory document that matched a search.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct MemoryMatch {
146    /// Owning harness id.
147    pub harness: String,
148    /// Which config home the document belongs to.
149    pub scope: MemoryScope,
150    /// Profile / agent / project the store belongs to.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub profile: Option<String>,
153    /// Path relative to the store's own root.
154    pub name: String,
155    /// Absolute path of the document.
156    pub path: PathBuf,
157    /// 1-based line number of the match.
158    pub line: usize,
159    /// The matching line, clipped to [`EXCERPT_CHARS`].
160    pub excerpt: String,
161}
162
163/// `harness.v1.memory.show` request.
164#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
165#[serde(default)]
166pub struct MemoryQuery {
167    /// Harness whose store is read. Required — memory documents are the
168    /// user's own prose, so nothing is read without an explicit target.
169    pub harness: String,
170    /// Hermes profile, OpenClaw agent id, or Claude Code project directory
171    /// (its slug, or an absolute path to a memory directory). `None` reads
172    /// every store the harness has.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub profile: Option<String>,
175    /// A Claude Code session id, used to resolve which project directory's
176    /// auto-memory to read. Rejected for the other harnesses, whose stores
177    /// are profile-scoped rather than session-scoped.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub session: Option<String>,
180    /// Include each document's whole text. Off by default: `show` names the
181    /// documents and previews their heads.
182    pub full: bool,
183    /// Working tree whose project store is read (Claude Code). Defaults to
184    /// the process working directory.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub cwd: Option<PathBuf>,
187    /// Storage roots to read.
188    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/// `harness.v1.memory.search` request.
205#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
206#[serde(default)]
207pub struct MemorySearchQuery {
208    /// Harness whose store is searched. Required, as for `show`.
209    pub harness: String,
210    /// The needle. Case-insensitive substring by default.
211    pub query: String,
212    /// Restrict to one profile / agent / project.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub profile: Option<String>,
215    /// Treat `query` as a regular expression instead of a literal.
216    pub regex: bool,
217    /// Working tree whose project store is searched (Claude Code).
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub cwd: Option<PathBuf>,
220    /// Storage roots to read.
221    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/// Read-only memory failures.
238#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
239pub enum MemoryError {
240    /// The harness has no memory store supercode reads.
241    #[error("harness `{harness}` has no memory store (memory exists for: {})", MEMORY_HARNESSES.join(", "))]
242    UnsupportedHarness {
243        /// The harness id that was asked for.
244        harness: String,
245    },
246    /// The harness has memory stores, but not for this profile / agent /
247    /// project.
248    #[error("`{harness}` has no memory store for `{profile}`")]
249    UnknownProfile {
250        /// Harness that was searched.
251        harness: String,
252        /// Profile / agent / project that was asked for.
253        profile: String,
254    },
255    /// `session` only selects a store for Claude Code.
256    #[error(
257        "`{harness}` scopes memory by profile, not by session — drop `session` or use `profile`"
258    )]
259    SessionNotScoped {
260        /// Harness that was asked for.
261        harness: String,
262    },
263    /// No Claude Code project directory holds that session.
264    #[error("no Claude Code project directory holds session `{session}`")]
265    SessionNotFound {
266        /// The session id that was asked for.
267        session: String,
268    },
269    /// An empty needle would match every line.
270    #[error("memory search needs a query")]
271    EmptyQuery,
272    /// `regex` was set and the pattern does not compile.
273    #[error("`{pattern}` is not a valid regular expression: {reason}")]
274    BadRegex {
275        /// The pattern as given.
276        pattern: String,
277        /// The compiler's complaint.
278        reason: String,
279    },
280}
281
282/// Whether supercode reads a memory store for this harness.
283pub fn supports_memory(harness: &str) -> bool {
284    MEMORY_HARNESSES.contains(&harness)
285}
286
287/// Read the memory documents the query selects.
288///
289/// Read-only: nothing here creates, writes, or removes a path.
290pub 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
310/// Search the same documents [`show_memory`] reports, line by line.
311///
312/// No engine: a case-insensitive substring scan, or a regular expression
313/// when `regex` is set.
314pub 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// ---------------------------------------------------------------------------
372// Stores
373// ---------------------------------------------------------------------------
374
375/// One harness's memory store for one profile / agent / project: a root plus
376/// the file names that harness's own loader treats as memory.
377#[derive(Debug, Clone)]
378struct MemoryStore {
379    harness: &'static str,
380    scope: MemoryScope,
381    profile: Option<String>,
382    /// The store's own root; document names are relative to it.
383    root: PathBuf,
384    /// The named files this harness treats as memory, in its own order.
385    files: Vec<PathBuf>,
386    /// Directories walked for further memory documents.
387    directories: Vec<PathBuf>,
388}
389
390impl MemoryStore {
391    /// Every `(name, absolute path)` this store holds, deduplicated and in a
392    /// stable order: the named files first (they are the indexes), then the
393    /// walked directories.
394    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    /// A document's name: its path relative to the store's own root.
415    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
423/// Resolve every store the request selects, or the reason there is none.
424fn 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
450// ---------------------------------------------------------------------------
451// Claude Code — the per-project auto-memory directory
452// ---------------------------------------------------------------------------
453
454/// Claude Code's auto-memory lives in the project directory that also holds
455/// the project's transcripts: `<claude home>/projects/<encoded-cwd>/memory/`,
456/// a `MEMORY.md` index plus one file per topic
457/// (`inventory/claude-code.md` §2 "Auto memory").
458///
459/// `homes.claude_code` already points at `<claude home>/projects`, the same
460/// root discovery walks.
461fn 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        // An absolute path names a relocated directory directly (the
470        // `autoMemoryDirectory` escape hatch); anything else is a project
471        // slug under the projects root.
472        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        // Auto-memory is keyed by the enclosing git repository and shared
495        // across its worktrees, so the repo root is the project, not cwd.
496        let project = repository_root(&cwd).unwrap_or(cwd);
497        projects.join(claude_project_slug(&project))
498    };
499    // A `memory` subdirectory is the store; a directory that IS one (the
500    // relocated case) is used as-is.
501    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        // `MEMORY.md` is the index Claude Code auto-loads each session; the
516        // topic files beside it are read on demand, so the directory itself
517        // is walked.
518        files: vec![root.join("MEMORY.md")],
519        directories: vec![root.clone()],
520        root,
521    }])
522}
523
524/// Claude Code's project directory name: the absolute path with every
525/// character outside `[A-Za-z0-9]` replaced by `-` (verified against the
526/// directory names under a real `~/.claude/projects`).
527fn 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
540/// The first ancestor of `path` (inclusive) holding a `.git` entry — the key
541/// Claude Code's auto-memory directory is shared across worktrees by.
542fn repository_root(path: &Path) -> Option<PathBuf> {
543    path.ancestors()
544        .find(|ancestor| ancestor.join(".git").exists())
545        .map(Path::to_path_buf)
546}
547
548/// Which project directory holds `<session>.jsonl`.
549fn 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
561// ---------------------------------------------------------------------------
562// Hermes — MEMORY.md / USER.md per profile home
563// ---------------------------------------------------------------------------
564
565/// Hermes keeps its built-in memory in `HERMES_HOME/memories/` — `MEMORY.md`
566/// (the agent's notes) and `USER.md` (what it knows about the user) —
567/// with profile mode pointing `HERMES_HOME` at `<root>/profiles/<name>`.
568///
569/// `homes.hermes` is the `state.db` path, so its parent is `HERMES_HOME`
570/// (the same derivation `crate::profiles` uses).
571fn 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            // Root-level names are the ones Hermes's own profile export
616            // manifest lists; `memories/` is where `get_memory_dir()` writes.
617            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
625// ---------------------------------------------------------------------------
626// OpenClaw — memory-core's files in the agent workspace
627// ---------------------------------------------------------------------------
628
629/// memory-core classifies a workspace-relative path as memory when it is
630/// `MEMORY.md`, `DREAMS.md`/`dreams.md`, or under `memory/`
631/// (`extensions/memory-core/src/memory/qmd-manager.ts:156-165` at tag
632/// `v2026.7.1-2`). The workspace is [`openclaw_workspace`]'s.
633fn 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        // No config and no agent directories: the default workspace is still
646        // where memory-core would look.
647        wanted.push((crate::profiles::OPENCLAW_DEFAULT_AGENT.to_string(), true));
648    } else if !wanted.iter().any(|(_, is_default)| *is_default) {
649        // A config-less install declares no default, but memory-core still
650        // resolves ONE agent to the default workspace. Same convention the
651        // profile rows use: the agent literally named `main`, else the first.
652        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
689/// One agent's workspace directory, per the pinned `resolveAgentWorkspaceDir`
690/// precedence (`src/agents/agent-scope-config.ts:181`,
691/// `src/agents/workspace-default.ts:12` at tag `v2026.7.1-2`):
692///
693/// 1. the agent's own `workspace` key in `openclaw.json`;
694/// 2. the default agent: `$OPENCLAW_WORKSPACE_DIR`, else
695///    `<openclaw home>/workspace`, else `<openclaw home>/workspace-<profile>`
696///    when `$OPENCLAW_PROFILE` names a non-default profile;
697/// 3. any other agent: `<openclaw home>/workspace-<id>`.
698///
699/// One deliberate difference, measured against `openclaw memory status` on
700/// the pinned CLI: openclaw derives the DEFAULT agent's workspace from
701/// `$HOME/.openclaw` while the non-default fallback uses the state dir.
702/// supercode hangs both off `homes.openclaw`, the one root every read-only
703/// method takes as "this openclaw install" — the same directory unless
704/// `OPENCLAW_STATE_DIR` points the state somewhere other than
705/// `$HOME/.openclaw`, and the only form a fixture or a probe can target.
706fn 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
727/// `agents.list[].workspace` (or `agents.entries.<id>.workspace`) from
728/// `openclaw.json`, which openclaw reads as JSON5.
729fn 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
750/// `~`-relative paths are the shape openclaw's own `resolveUserPath` accepts.
751fn 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
760// ---------------------------------------------------------------------------
761// Reading
762// ---------------------------------------------------------------------------
763
764/// Collect every memory document under `dir`, bounded in depth and count.
765fn 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
831/// Read at most [`MAX_DOCUMENT_BYTES`] of a document. Binary or unreadable
832/// files yield `None` rather than failing the whole listing.
833fn 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}