Skip to content

Memory files

源码版本v2026.6.11

Responsibilities

"Memory files" are OpenClaw agent's long-term persisted user preferences, behavioral guidelines, and project context. They differ from session transcript: a session is per-conversation messages; memory is "this rule always applies" notes reused across sessions. OpenClaw treats MEMORY.md at the workspace root as the canonical root memory file, and all .md files under the memory/ subdirectory as auxiliary memory. The agent reads them into the system prompt at startup and follows these rules for the rest of the conversation.

The memory system also supports multimodal: memory/ isn't limited to .md; it can hold images, audio, and other file types with extensions allowed by MemoryMultimodalSettings, classified and indexed by the memory-host-sdk.

Design motivation

Why not just write all rules into the system prompt? Three reasons:

  1. Reuse across sessions: the system prompt consumes tokens on every model call; memory files are loaded once at agent startup and reused across the whole session. Long-term preferences are cheaper written to a file than to the prompt.
  2. User-editable: memory files are plain markdown; users can edit them in any editor and the change takes effect on the next startup — no config change or gateway restart needed. openclaw doctor --fix can also auto-migrate legacy files and merge split canonical/legacy copies.
  3. Plugin-indexable: memory-host-sdk registers memory files as a QMD collection; plugins can build vector indexes and do semantic retrieval — so large memory doesn't have to be stuffed into the prompt; relevant fragments are recalled dynamically by query.

The cost is that the "root memory" filename has legacy compatibility history, is case-sensitive, and symlinks are ignored — all these details are handled uniformly by root-memory-files.ts.

Key files

Data flow

listMemoryFiles (listMemoryFiles:L150) is the core scan entry:

typescript
export async function listMemoryFiles(
  workspaceDir: string,
  extraPaths?: string[],
  multimodal?: MemoryMultimodalSettings,
): Promise<string[]> {
  const result: string[] = [];
  const memoryDir = path.join(workspaceDir, "memory");

  const shouldSkipWorkspaceMemoryPath = (absPath: string): boolean =>
    shouldSkipRootMemoryAuxiliaryPath({ workspaceDir, absPath });

  const addMarkdownFile = async (absPath: string) => {
    try {
      const stat = await statRegularFile(absPath);
      if (stat.missing) {
        return;
      }
      if (!absPath.endsWith(".md")) {
        return;
      }
      result.push(absPath);
    } catch {}
  };

  const memoryFile = await resolveCanonicalRootMemoryFile(workspaceDir);
  if (memoryFile) {
    await addMarkdownFile(memoryFile);
  }
  try {
    const dirStat = await fs.lstat(memoryDir);
    if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
      await collectMemoryFilesFromDir(memoryDir, result, multimodal, shouldSkipWorkspaceMemoryPath);
    }
  } catch {}

Three layers: canonical root file — resolveCanonicalRootMemoryFile only returns a real file (not symlink), preventing symlinks from pulling in external directories; memory/ directory goes through collectMemoryFilesFromDir recursively — the descend hook rejects the .openclaw-repair directory, and the include hook filters extensions with isAllowedMemoryFilePath; extra paths come from config, and each path goes through shouldSkipRootMemoryAuxiliaryPath to prevent double-counting legacy files.

backend-config registers these files as QMD collections (resolveDefaultCollections:L409) for vector indexing:

typescript
function resolveDefaultCollections(
  include: boolean,
  workspaceDir: string,
  existing: Set<string>,
  agentId: string,
): ResolvedQmdCollection[] {
  if (!include) {
    return [];
  }
  const entries: Array<{ path: string; pattern: string; base: string }> = [
    { path: workspaceDir, pattern: CANONICAL_ROOT_MEMORY_FILENAME, base: "memory-root" },
    { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" },
  ];
  return entries.map((entry) => ({
    name: ensureUniqueName(scopeCollectionBase(entry.base, agentId), existing),
    path: entry.path,
    pattern: entry.pattern,
    kind: "memory",
  }));
}

memory-root matches exact filename, non-recursive; memory-dir is glob **/*.md, recursing the whole memory/ subtree. scopeCollectionBase prefixes each collection name with the agent id, so when multiple agents share a workspace, their collections don't collide.

The doctor's split-file scan logic (detectRootMemoryFiles:L97) uses exactWorkspaceEntryExists to check both MEMORY.md and memory.md:

typescript
export async function detectRootMemoryFiles(
  workspaceDir: string,
): Promise<RootMemoryFilesDetection> {
  const resolvedWorkspace = path.resolve(workspaceDir);
  const canonicalPath = resolveCanonicalRootMemoryPath(resolvedWorkspace);
  const legacyPath = resolveLegacyRootMemoryPath(resolvedWorkspace);
  const entries = await listWorkspaceEntries(resolvedWorkspace);
  const [canonical, legacy] = await Promise.all([
    entries.has(CANONICAL_ROOT_MEMORY_FILENAME)
      ? statIfExists(canonicalPath)
      : Promise.resolve<RootMemoryStatResult>({ exists: false }),
    entries.has(LEGACY_ROOT_MEMORY_FILENAME)
      ? statIfExists(legacyPath)
      : Promise.resolve<RootMemoryStatResult>({ exists: false }),
  ]);

Note listWorkspaceEntries uses readdir to get exact directory entries (case-sensitive). macOS's default filesystem is case-insensitive, but OpenClaw still treats it case-sensitively: canonical is always uppercase MEMORY.md, legacy is lowercase memory.md. When both exist, canonical wins and legacy is archived to .openclaw-repair/root-memory/<timestamp>/memory.md.

The whole read-write chain:

Boundaries and failures

  • symlinks ignored: resolveCanonicalRootMemoryFile uses entry.isFile() && !entry.isSymbolicLink() double-check (src/memory/root-memory-files.ts:L41-L54); the memory/ directory also gates with !dirStat.isSymbolicLink(). This is a security boundary, preventing users (or malicious plugins) from pulling files outside the workspace into the memory index via symlinks.
  • case distinction: on case-insensitive filesystems (macOS HFS+/APFS default), MEMORY.md and memory.md are the same file; but OpenClaw uses exactWorkspaceEntryExists to get the exact readdir entry name and matches literally. This means writing both on macOS will collide, and doctor treats them as legacy-only.
  • legacy migration loses no content: moveLegacyRootMemoryFileToArchive first renames; on failure (EXDEV cross-device) it falls back to copy + unlink (src/commands/doctor-workspace.ts:L152-L174). The archive path is timestamped; running doctor multiple times won't overwrite each other.
  • extra paths cross workspace: extra paths declared in config can be absolute or workspace-relative. normalizeExtraMemoryPaths (packages/memory-host-sdk/src/host/internal.ts:L91-L101) does tilde expansion + dedup, but doesn't remove them from the QMD collection — plugins dedupe by absolute path at index time, avoiding indexing the same file twice.
  • dreams.md special path: isMemoryPath also treats dreams.md as a memory file (packages/memory-host-sdk/src/host/internal.ts:L108-L110) — this legacy convention is preserved for backward compatibility with old users. Canonical is always MEMORY.md; dreams.md is just auxiliary memory.
  • multimodal extensions: isAllowedMemoryFilePath by default only accepts .md, but when MemoryMultimodalSettings is enabled, it allows image/audio extensions classified by classifyMemoryMultimodalPath. This is a plugin extension point; core doesn't ship multimodal indexing.
  • readMemoryFile pagination: reading a memory file supports from / lines parameters (packages/memory-host-sdk/src/host/read-file.ts:L67). defaultLines and maxChars are decided by resolveAgentContextLimits based on the agent config, preventing a single read from stuffing an oversized memory file into the prompt.
  • doctor detects split: formatRootMemoryFilesWarning warns when canonical and legacy both exist (src/commands/doctor-workspace.ts:L128-L140): "Dreaming writes durable promotions to MEMORY.md; legacy facts will be shadowed." This is the conflict reminder between OpenClaw's built-in "dreaming" mechanism (auto-promoting memory to MEMORY.md) and legacy files.
  • plugin SDK doesn't directly import core internals: openclaw-runtime-memory.ts (packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts) only re-exports stable seams (resolveCanonicalRootMemoryFile / shouldSkipRootMemoryAuxiliaryPath / buildActiveMemoryPromptSection). Plugins call these through the SDK, not by directly importing src/memory/ — so core refactors don't break plugins.
  • workspace attestation: the workspace directory is attested on first use (src/agents/workspace.ts:L44 WORKSPACE_ATTESTATION_DIRNAME). The attestation records inode/dev, preventing the user from swapping the filesystem mid-session and causing memory file path drift.

Summary

Memory files are OpenClaw agent's cross-session persistent preference layer. MEMORY.md is the canonical root file; memory/ subdirectory holds auxiliary memory; extra paths let users reference across workspaces. memory-host-sdk handles scanning, QMD collection registration, and paginated reads; plugins access through the SDK facade; the doctor command handles legacy migration and split-file repair. How memory is synced by the context engine during compaction is in Context Engine; which session the memory loads into is in the session lifecycle Agent main loop.

Official references: Memory docs · README.