记忆文件
职责
「记忆文件」(memory files) 是 OpenClaw agent 长期持久化的 user 偏好、行为指引、项目上下文。它和 session transcript 不一样:session 是一段对话的逐条消息,记忆 (memory) 是跨 session 复用的「这条规则永远生效」式笔记。OpenClaw 默认认 workspace 根目录下的 MEMORY.md 作为根记忆文件 (root memory file),memory/ 子目录下的所有 .md 作为辅助记忆,agent 启动时把它们读进 system prompt,之后整段对话都按这些规则走。
记忆系统同时支持 multimodal:memory/ 下不只有 .md,还可以放图片、音频这类被 MemoryMultimodalSettings 允许的扩展名文件,由 memory-host-sdk 做分类和索引。
设计动机
为什么不直接把所有规则写进 system prompt?三个理由:
- 跨 session 复用:system prompt 每次模型调用都消耗 token,记忆文件只在 agent 启动时加载一次,之后整个 session 复用;长期偏好写进文件比写进 prompt 划算。
- 用户可编辑:记忆文件就是普通 markdown,用户用任何编辑器改完下次启动就生效,不必改 config 或重启 gateway。
openclaw doctor --fix还能自动迁移 legacy 文件、合并分裂的 canonical/legacy 副本。 - plugin 可索引:
memory-host-sdk把记忆文件注册成 QMD collection,plugin 可以建向量索引、做语义检索——这样大段记忆不必整段塞 prompt,而是按 query 动态召回相关片段。
代价是「根记忆」文件名有 legacy 兼容历史,大小写敏感,且 symlink 会被忽略——这些细节都由 root-memory-files.ts 统一管理。
关键文件
CANONICAL_ROOT_MEMORY_FILENAME:6-7— 常量"MEMORY.md",legacy 是小写"memory.md"。resolveCanonicalRootMemoryPath:13-15— 拼${workspaceDir}/MEMORY.md。resolveLegacyRootMemoryPath:17-19— 拼${workspaceDir}/memory.md。resolveRootMemoryRepairDir:22-24— doctor 修复目录${workspaceDir}/.openclaw-repair/root-memory/。resolveCanonicalRootMemoryFile:41-54— 扫 workspace 目录条目,返回真实文件(非 symlink)的MEMORY.md路径,不存在返回 null。shouldSkipRootMemoryAuxiliaryPath:56-72— 扫描辅助文件时跳过 legacy 文件和 repair 目录。normalizeExtraMemoryPaths:91-101— 把 config 里声明的 extra paths 做 tilde 展开 + 去重 + 转绝对路径。isMemoryPath:103-112— 判断相对路径是否属于记忆:根MEMORY.md、memory/子目录、或dreams.md。listMemoryFiles:150-210— 列出所有记忆文件:canonical root → memory/ 递归 → extra paths。resolveDefaultCollections:409-418— 默认 QMD collections:memory-root(精确匹配MEMORY.md)+memory-dir(memory/**/*.md)。readMemoryFile:67-158— 读单个记忆文件,支持from/lines分页,带suggestReadFallback提示。readAgentMemoryFile:161-182— 按 agent id 解析 workspace 后委托给readMemoryFile。openclaw-runtime-memory.ts— 给 plugin SDK 的稳定 facade,re-exportresolveCanonicalRootMemoryFile等内部 seam。CANONICAL_ROOT_MEMORY_FILENAME 副本:168— SDK 侧也保留同名常量,避免循环依赖回 core。system-prompt 引导:229— system prompt 里告诉模型MEMORY.md是「持久用户偏好和行为指引,session 内持续遵循」。workspace 导入:15-18— workspace 模块导入CANONICAL_ROOT_MEMORY_FILENAME常量做初始化检测。detectRootMemoryFiles:97-121— doctor 扫描 canonical + legacy 两份文件,返回是否存在、字节数。formatRootMemoryFilesWarning:128-140— 两份并存时给出合并提示。RootMemoryMigrationResult:142-150— 迁移结果类型:mergedLegacy/removedLegacy/archivedLegacyPath。
数据流
listMemoryFiles(listMemoryFiles:150)是核心扫描入口:
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 {}三层: canonical root 文件,resolveCanonicalRootMemoryFile 只返回真实文件(非 symlink),避免软链接把外部目录拖进来; memory/ 目录走 collectMemoryFilesFromDir 递归,descend 钩子拒绝 .openclaw-repair 目录,include 钩子用 isAllowedMemoryFilePath 过滤扩展名; extra paths 来自 config 声明,每个路径都过 shouldSkipRootMemoryAuxiliaryPath 防止把 legacy 文件重复算进来。
backend-config 把这些文件注册成 QMD collections(resolveDefaultCollections:409)给向量索引用:
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 是精确文件名匹配,不递归;memory-dir 是 glob **/*.md,递归整个 memory/ 子树。scopeCollectionBase 给每个 collection name 加上 agent id 前缀,这样多 agent 共享一个 workspace 时各自的 collection 不会撞名。
doctor 扫描分裂文件的逻辑(detectRootMemoryFiles:97)用 exactWorkspaceEntryExists 同时检查 MEMORY.md 和 memory.md:
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 }),
]);注意 listWorkspaceEntries 用 readdir 拿到精确目录条目(大小写敏感),macOS 默认文件系统大小写不敏感,但 OpenClaw 还是按大小写敏感处理:canonical 永远是大写 MEMORY.md,legacy 是小写 memory.md,两份同时存在时 canonical 优先,legacy 被 archive 到 .openclaw-repair/root-memory/<timestamp>/memory.md。
整体读写链路:
边界与失败
- symlink 被忽略:
resolveCanonicalRootMemoryFile用entry.isFile() && !entry.isSymbolicLink()双重判断(src/memory/root-memory-files.ts:41-54),memory/目录也用!dirStat.isSymbolicLink()守门。这是安全边界,防止用户(或恶意 plugin)用软链接把 workspace 外的文件拖进记忆索引。 - 大小写区分:文件系统大小写不敏感(macOS HFS+/APFS 默认)时,
MEMORY.md和memory.md是同一个文件;但 OpenClaw 用exactWorkspaceEntryExists拿到readdir的精确条目名,按字面匹配。这意味着 macOS 上两份都写会撞文件,doctor 会把它们当 legacy-only 处理。 - legacy 迁移不丢内容:
moveLegacyRootMemoryFileToArchive先 rename,失败(EXDEV 跨设备)退化为 copy + unlink(src/commands/doctor-workspace.ts:152-174)。archive 路径带时间戳,多次跑 doctor 不会互相覆盖。 - extra paths 跨 workspace:config 声明的 extra paths 可以是绝对路径或 workspace 相对路径,
normalizeExtraMemoryPaths(packages/memory-host-sdk/src/host/internal.ts:91-101)做 tilde 展开 + 去重,但不会把它们从 QMD collection 移出——plugin 索引时会按 absolute path 去重,避免同一文件被索引两次。 dreams.md特殊路径:isMemoryPath把dreams.md也认作记忆文件(packages/memory-host-sdk/src/host/internal.ts:108-110),这个 legacy 约定保留是为了兼容老用户。canonical 永远是MEMORY.md,dreams.md只是辅助记忆。- multimodal 扩展名:
isAllowedMemoryFilePath默认只接受.md,但MemoryMultimodalSettings启用后允许图片/音频扩展名走classifyMemoryMultimodalPath分类。这是 plugin 扩展点,core 不内置 multimodal 索引。 - readMemoryFile 分页:读记忆文件支持
from/lines参数(packages/memory-host-sdk/src/host/read-file.ts:67),defaultLines和maxChars由resolveAgentContextLimits按 agent 配置决定,防止单次读把超大记忆文件全塞进 prompt。 - doctor 检测分裂:
formatRootMemoryFilesWarning在 canonical 和 legacy 同时存在时给出警告(src/commands/doctor-workspace.ts:128-140):「Dreaming 写 durable promotions 到 MEMORY.md,legacy 里旧事实会被 shadow」。这是 OpenClaw 内置的「dreaming」机制(自动 promote 记忆到 MEMORY.md)和 legacy 文件冲突的提醒。 - plugin SDK 不直接导 core 内部:
openclaw-runtime-memory.ts(packages/memory-host-sdk/src/host/openclaw-runtime-memory.ts)只 re-export 稳定 seam(resolveCanonicalRootMemoryFile/shouldSkipRootMemoryAuxiliaryPath/buildActiveMemoryPromptSection),plugin 通过 SDK 调用这些函数,不直接 importsrc/memory/——这样 core 重构时 plugin 不破。 - workspace attestation:workspace 目录在首次使用时会被 attest(
src/agents/workspace.ts:44WORKSPACE_ATTESTATION_DIRNAME),attestation 里记录 inode/dev,防止用户在工作区中途换文件系统导致记忆文件路径漂移。
小结
记忆文件是 OpenClaw agent 跨 session 的持久化偏好层。MEMORY.md 是 canonical 根文件,memory/ 子目录是辅助记忆,extra paths 让用户跨 workspace 引用。memory-host-sdk 负责扫描、注册 QMD collection、分页读取,plugin 通过 SDK facade 接入;doctor 命令处理 legacy 文件迁移和分裂修复。memory 怎么被 context engine 在压缩时同步看 Context Engine;记忆加载进哪个 session 看会话生命周期 Agent 主循环。