記憶檔案
職責
「記憶檔案」(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 或重啟閘道。
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 主循環。