diff --git a/CHANGELOG.md b/CHANGELOG.md index c87bdb60a3a..29492b7f310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/doctor: keep root durable memory canonicalized on `MEMORY.md`, stop treating lowercase `memory.md` as a runtime fallback, and let `openclaw doctor --fix` merge true split-brain root files into `MEMORY.md` with a backup. (#70621) Thanks @mbelinky. - Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime. - Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses. - Providers/Anthropic Vertex: restore ADC-backed model discovery after the lightweight provider-discovery path by resolving emitted discovery entries, exposing synthetic auth on bootstrap discovery, and honoring copied env snapshots when probing the default GCP ADC path. Fixes #65715. (#65716) Thanks @feiskyer. diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index df680281402..6c6624b334b 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -52,8 +52,7 @@ legacy `--mask` collection flags and older MCP tool names when needed. configured `memory.qmd.paths`, then runs `qmd update` + `qmd embed` on boot and periodically (default every 5 minutes). - The default workspace collection tracks `MEMORY.md` plus the `memory/` - tree. Lowercase `memory.md` remains a bootstrap fallback, not a separate QMD - collection. + tree. Lowercase `memory.md` is not indexed as a root memory file. - Boot refresh runs in the background so chat startup is not blocked. - Searches use the configured `searchMode` (default: `search`; also supports `vsearch` and `query`). If a mode fails, OpenClaw retries with `qmd query`. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 71fdc403e4b..18497037b06 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -109,7 +109,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model - `USER.md` - `HEARTBEAT.md` - `BOOTSTRAP.md` (only on brand-new workspaces) -- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback +- `MEMORY.md` when present All of these files are **injected into the context window** on every turn unless a file-specific gate applies. `HEARTBEAT.md` is omitted on normal runs when diff --git a/docs/reference/AGENTS.default.md b/docs/reference/AGENTS.default.md index 7bfb2351d0d..4f59ba9b552 100644 --- a/docs/reference/AGENTS.default.md +++ b/docs/reference/AGENTS.default.md @@ -49,7 +49,7 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md ## Session start (required) - Read `SOUL.md`, `USER.md`, and today+yesterday in `memory/`. -- Read `MEMORY.md` when present; only fall back to lowercase `memory.md` when `MEMORY.md` is absent. +- Read `MEMORY.md` when present. - Do it before responding. ## Soul (required) @@ -67,8 +67,8 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md - Daily log: `memory/YYYY-MM-DD.md` (create `memory/` if needed). - Long-term memory: `MEMORY.md` for durable facts, preferences, and decisions. -- Lowercase `memory.md` is legacy fallback only; do not keep both root files on purpose. -- On session start, read today + yesterday + `MEMORY.md` when present, otherwise `memory.md`. +- Lowercase `memory.md` is legacy repair input only; do not keep both root files on purpose. +- On session start, read today + yesterday + `MEMORY.md` when present. - Capture: decisions, preferences, constraints, open loops. - Avoid secrets unless explicitly requested. diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index 501d94e5e7e..56a8d4defe5 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -530,17 +530,12 @@ async function scanMemoryFiles( ): Promise { const issues: string[] = []; const memoryFile = path.join(workspaceDir, "MEMORY.md"); - const altMemoryFile = path.join(workspaceDir, "memory.md"); const memoryDir = path.join(workspaceDir, "memory"); const primary = await checkReadableFile(memoryFile); - const alt = await checkReadableFile(altMemoryFile); if (primary.issue) { issues.push(primary.issue); } - if (alt.issue) { - issues.push(alt.issue); - } const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); for (const extraPath of resolvedExtraPaths) { @@ -606,9 +601,6 @@ async function scanMemoryFiles( if (primary.exists) { files.add(memoryFile); } - if (alt.exists) { - files.add(altMemoryFile); - } } totalFiles = files.size; } diff --git a/extensions/memory-core/src/memory/manager-sync-ops.ts b/extensions/memory-core/src/memory/manager-sync-ops.ts index d1055f73bfa..5985fa3ef01 100644 --- a/extensions/memory-core/src/memory/manager-sync-ops.ts +++ b/extensions/memory-core/src/memory/manager-sync-ops.ts @@ -370,7 +370,6 @@ export abstract class MemoryManagerSyncOps { } const watchPaths = new Set([ path.join(this.workspaceDir, "MEMORY.md"), - path.join(this.workspaceDir, "memory.md"), path.join(this.workspaceDir, "memory"), ]); const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths); diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 9381bb9f865..31fa7b1135f 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -139,7 +139,6 @@ describe("memory watcher config", () => { expect(watchedPaths).toEqual( expect.arrayContaining([ path.join(workspaceDir, "MEMORY.md"), - path.join(workspaceDir, "memory.md"), path.join(workspaceDir, "memory"), path.join(extraDir, "**", "*.md"), ]), diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index cde98b6e4bd..643c9d91ae0 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -878,7 +878,7 @@ describe("QmdMemoryManager", () => { expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); }); - it("rebinds legacy memory-alt when it still owns the root slot for MEMORY.md", async () => { + it("adds canonical memory-root without treating legacy memory-alt as equivalent", async () => { await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); cfg = { ...cfg, @@ -930,15 +930,10 @@ describe("QmdMemoryManager", () => { const pathArg = args[2] ?? ""; const name = args[args.indexOf("--name") + 1] ?? ""; const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? ""; - const hasConflict = [...listedCollections.entries()].some(([existingName, info]) => { - if (existingName === name || info.path !== pathArg) { - return false; - } - const isRootPatternPair = - (info.pattern === "MEMORY.md" || info.pattern === "memory.md") && - (pattern === "MEMORY.md" || pattern === "memory.md"); - return info.pattern === pattern || isRootPatternPair; - }); + const hasConflict = [...listedCollections.entries()].some( + ([existingName, info]) => + existingName !== name && info.path === pathArg && info.pattern === pattern, + ); if (hasConflict) { emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); return child; @@ -953,10 +948,10 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager({ mode: "full" }); await manager.close(); - expect(removeCalls).toContain("memory-alt"); + expect(removeCalls).not.toContain("memory-alt"); expect(listedCollections.has("memory-root-main")).toBe(true); - expect(listedCollections.has("memory-alt")).toBe(false); - expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); + expect(listedCollections.has("memory-alt")).toBe(true); + expect(logWarnMock).not.toHaveBeenCalledWith(expect.stringContaining("rebinding")); }); it("warns instead of silently succeeding when add conflict metadata is unavailable", async () => { diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 44e7d012971..7b5de1612e4 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -92,12 +92,7 @@ function isDefaultMemoryPath(relPath: string): boolean { if (!normalized) { return false; } - if ( - normalized === "MEMORY.md" || - normalized === "memory.md" || - normalized === "DREAMS.md" || - normalized === "dreams.md" - ) { + if (normalized === "MEMORY.md" || normalized === "DREAMS.md" || normalized === "dreams.md") { return true; } return normalized.startsWith("memory/"); @@ -894,29 +889,22 @@ export class QmdMemoryManager implements MemorySearchManager { return false; } try { - let sawCanonical = false; - let sawLegacyFallback = false; for (const entry of fsSync.readdirSync(collectionPath, { withFileTypes: true })) { if (entry.isSymbolicLink() || !entry.isFile()) { continue; } if (entry.name === "MEMORY.md") { - sawCanonical = true; - } else if (entry.name === "memory.md") { - sawLegacyFallback = true; + return true; } } - if (sawCanonical && sawLegacyFallback) { - return false; - } - return sawCanonical || sawLegacyFallback; + return false; } catch { return false; } } private isDefaultMemoryRootPattern(pattern: string): boolean { - return pattern === "MEMORY.md" || pattern === "memory.md"; + return pattern === "MEMORY.md"; } private pathsMatch(left: string, right: string): boolean { diff --git a/extensions/memory-core/src/memory/temporal-decay.ts b/extensions/memory-core/src/memory/temporal-decay.ts index d3643fc5c21..8066630bf00 100644 --- a/extensions/memory-core/src/memory/temporal-decay.ts +++ b/extensions/memory-core/src/memory/temporal-decay.ts @@ -70,7 +70,7 @@ function parseMemoryDateFromPath(filePath: string): Date | null { function isEvergreenMemoryPath(filePath: string): boolean { const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, ""); - if (normalized === "MEMORY.md" || normalized === "memory.md") { + if (normalized === "MEMORY.md") { return true; } if (!normalized.startsWith("memory/")) { diff --git a/extensions/memory-core/src/public-artifacts.test.ts b/extensions/memory-core/src/public-artifacts.test.ts index caf67f7bd35..ff0f521ada8 100644 --- a/extensions/memory-core/src/public-artifacts.test.ts +++ b/extensions/memory-core/src/public-artifacts.test.ts @@ -87,7 +87,7 @@ describe("listMemoryCorePublicArtifacts", () => { ]); }); - it("lists lowercase memory root when only the legacy filename exists", async () => { + it("ignores lowercase memory root when only the legacy filename exists", async () => { const workspaceDir = path.join(fixtureRoot, "workspace-lowercase-root"); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "memory.md"), "# Legacy Durable Memory\n", "utf8"); @@ -98,15 +98,6 @@ describe("listMemoryCorePublicArtifacts", () => { }, }; - await expect(listMemoryCorePublicArtifacts({ cfg })).resolves.toEqual([ - { - kind: "memory-root", - workspaceDir, - relativePath: "memory.md", - absolutePath: path.join(workspaceDir, "memory.md"), - agentIds: ["main"], - contentType: "markdown", - }, - ]); + await expect(listMemoryCorePublicArtifacts({ cfg })).resolves.toEqual([]); }); }); diff --git a/extensions/memory-core/src/public-artifacts.ts b/extensions/memory-core/src/public-artifacts.ts index 501b67f7568..9c155ffbbbd 100644 --- a/extensions/memory-core/src/public-artifacts.ts +++ b/extensions/memory-core/src/public-artifacts.ts @@ -40,7 +40,7 @@ async function collectWorkspaceArtifacts(params: { .filter((entry) => entry.isFile()) .map((entry) => entry.name), ); - for (const relativePath of ["MEMORY.md", "memory.md"]) { + for (const relativePath of ["MEMORY.md"]) { if (!workspaceEntries.has(relativePath)) { continue; } diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 0840b3b40cf..4f94b0affdc 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -91,7 +91,7 @@ describe("resolveMemoryBackendConfig", () => { expect(rootCollection?.pattern).toBe("MEMORY.md"); }); - it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", () => { + it("keeps uppercase MEMORY.md as the root pattern when only lowercase memory.md exists", () => { const workspaceDir = "/workspace/root"; withMemoryRootEntries([memoryFileEntry("memory.md")], () => { const cfg = rootMemoryConfig(workspaceDir); @@ -99,7 +99,7 @@ describe("resolveMemoryBackendConfig", () => { const rootCollection = resolved.qmd?.collections.find( (collection) => collection.name === "memory-root-main", ); - expect(rootCollection?.pattern).toBe("memory.md"); + expect(rootCollection?.pattern).toBe("MEMORY.md"); expect(collectionNames(resolved).has("memory-alt-main")).toBe(false); }); }); diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index a66286cb3e9..0588b0ade66 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -318,34 +318,6 @@ function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcport return parsed; } -function isRegularDefaultMemoryEntry( - entry: Pick, - expectedName: string, -): boolean { - return entry.name === expectedName && entry.isFile() && !entry.isSymbolicLink(); -} - -function findDefaultMemoryRootPattern(workspaceDir: string): string | null { - try { - let sawLegacyFallback = false; - for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) { - if (isRegularDefaultMemoryEntry(entry, "MEMORY.md")) { - return "MEMORY.md"; - } - if (isRegularDefaultMemoryEntry(entry, "memory.md")) { - sawLegacyFallback = true; - } - } - return sawLegacyFallback ? "memory.md" : null; - } catch { - return null; - } -} - -function resolveDefaultMemoryRootPattern(workspaceDir: string): string { - return findDefaultMemoryRootPattern(workspaceDir) ?? "MEMORY.md"; -} - function resolveDefaultCollections( include: boolean, workspaceDir: string, @@ -356,13 +328,7 @@ function resolveDefaultCollections( return []; } const entries: Array<{ path: string; pattern: string; base: string }> = [ - // The root memory slot is singular: prefer MEMORY.md, but keep lowercase - // memory.md as a legacy fallback when the canonical file is absent. - { - path: workspaceDir, - pattern: resolveDefaultMemoryRootPattern(workspaceDir), - base: "memory-root", - }, + { path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" }, { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, ]; return entries.map((entry) => ({ diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 6aa17953741..ff40cc255e1 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -89,13 +89,13 @@ describe("listMemoryFiles", () => { expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true); }); - it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", async () => { + it("ignores lowercase root memory.md when canonical MEMORY.md is absent", async () => { const tmpDir = getTmpDir(); await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory"); - const files = await listMemoryFiles(tmpDir); + const files = await listMemoryFiles(tmpDir, [path.join(tmpDir, "memory.md")]); - expect(files).toEqual([path.join(tmpDir, "memory.md")]); + expect(files).toEqual([]); }); it("prefers MEMORY.md when both root files exist", async () => { @@ -103,11 +103,24 @@ describe("listMemoryFiles", () => { await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory"); - const files = await listMemoryFiles(tmpDir); + const files = await listMemoryFiles(tmpDir, [path.join(tmpDir, "memory.md"), tmpDir]); expect(files).toEqual([path.join(tmpDir, "MEMORY.md")]); }); + it("skips root-memory repair backups from extra workspace paths", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); + await fs.mkdir(repairDir, { recursive: true }); + await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); + + const files = await listMemoryFiles(tmpDir, [tmpDir]); + + expect(files).toHaveLength(1); + expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md")); + }); + it("handles relative paths in additional paths", async () => { const tmpDir = getTmpDir(); await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index c8967e572cc..2b8b5d85074 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -77,7 +77,7 @@ export function isMemoryPath(relPath: string): boolean { if (!normalized) { return false; } - if (normalized === "MEMORY.md" || normalized === "memory.md" || normalized === "dreams.md") { + if (normalized === "MEMORY.md" || normalized === "dreams.md") { return true; } return normalized.startsWith("memory/"); @@ -92,15 +92,26 @@ function isAllowedMemoryFilePath(filePath: string, multimodal?: MemoryMultimodal ); } -async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimodalSettings) { +async function walkDir( + dir: string, + files: string[], + multimodal?: MemoryMultimodalSettings, + shouldSkipPath?: (absPath: string) => boolean, +) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); + if (shouldSkipPath?.(full)) { + continue; + } if (entry.isSymbolicLink()) { continue; } if (entry.isDirectory()) { - await walkDir(full, files, multimodal); + if (entry.name === ".openclaw-repair") { + continue; + } + await walkDir(full, files, multimodal, shouldSkipPath); continue; } if (!entry.isFile()) { @@ -113,25 +124,16 @@ async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimod } } -async function resolveDefaultMemoryRootFile(workspaceDir: string): Promise { +async function resolveCanonicalMemoryRootFile(workspaceDir: string): Promise { try { - let legacyFallback: string | null = null; const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isSymbolicLink() || !entry.isFile()) { - continue; - } - if (entry.name === "MEMORY.md") { + if (entry.name === "MEMORY.md" && entry.isFile() && !entry.isSymbolicLink()) { return path.join(workspaceDir, entry.name); } - if (entry.name === "memory.md") { - legacyFallback = path.join(workspaceDir, entry.name); - } } - return legacyFallback; - } catch { - return null; - } + } catch {} + return null; } export async function listMemoryFiles( @@ -142,6 +144,24 @@ export async function listMemoryFiles( const result: string[] = []; const memoryDir = path.join(workspaceDir, "memory"); + const shouldSkipWorkspaceMemoryPath = (absPath: string): boolean => { + const relative = path.relative(workspaceDir, absPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return false; + } + const normalized = relative.replace(/\\/g, "/"); + if (!normalized) { + return false; + } + if (normalized === "memory.md") { + return true; + } + return ( + normalized === ".openclaw-repair/root-memory" || + normalized.startsWith(".openclaw-repair/root-memory/") + ); + }; + const addMarkdownFile = async (absPath: string) => { try { const stat = await fs.lstat(absPath); @@ -155,27 +175,30 @@ export async function listMemoryFiles( } catch {} }; - const rootMemoryFile = await resolveDefaultMemoryRootFile(workspaceDir); - if (rootMemoryFile) { - await addMarkdownFile(rootMemoryFile); + const memoryFile = await resolveCanonicalMemoryRootFile(workspaceDir); + if (memoryFile) { + await addMarkdownFile(memoryFile); } try { const dirStat = await fs.lstat(memoryDir); if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) { - await walkDir(memoryDir, result); + await walkDir(memoryDir, result, multimodal, shouldSkipWorkspaceMemoryPath); } } catch {} const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); if (normalizedExtraPaths.length > 0) { for (const inputPath of normalizedExtraPaths) { + if (shouldSkipWorkspaceMemoryPath(inputPath)) { + continue; + } try { const stat = await fs.lstat(inputPath); if (stat.isSymbolicLink()) { continue; } if (stat.isDirectory()) { - await walkDir(inputPath, result, multimodal); + await walkDir(inputPath, result, multimodal, shouldSkipWorkspaceMemoryPath); continue; } if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) { diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index f427decc213..2579ab7e96f 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -8,7 +8,6 @@ import { DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_HEARTBEAT_FILENAME, DEFAULT_IDENTITY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, DEFAULT_MEMORY_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_USER_FILENAME, @@ -214,9 +213,7 @@ describe("ensureAgentWorkspace", () => { describe("loadWorkspaceBootstrapFiles", () => { const getMemoryEntries = (files: Awaited>) => - files.filter((file) => - [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), - ); + files.filter((file) => file.name === DEFAULT_MEMORY_FILENAME); const expectSingleMemoryEntry = ( files: Awaited>, @@ -236,12 +233,12 @@ describe("loadWorkspaceBootstrapFiles", () => { expectSingleMemoryEntry(files, "memory"); }); - it("includes memory.md when MEMORY.md is absent", async () => { + it("ignores lowercase memory.md when MEMORY.md is absent", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" }); const files = await loadWorkspaceBootstrapFiles(tempDir); - expectSingleMemoryEntry(files, "alt"); + expect(getMemoryEntries(files)).toHaveLength(0); }); it("omits memory entries when no memory files exist", async () => { diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index ac504de338e..8ff01c6961d 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -31,7 +31,6 @@ export const DEFAULT_USER_FILENAME = "USER.md"; export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md"; export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; export const DEFAULT_MEMORY_FILENAME = "MEMORY.md"; -export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md"; const WORKSPACE_STATE_DIRNAME = ".openclaw"; const WORKSPACE_STATE_FILENAME = "workspace-state.json"; const WORKSPACE_STATE_VERSION = 1; @@ -138,8 +137,7 @@ export type WorkspaceBootstrapFileName = | typeof DEFAULT_USER_FILENAME | typeof DEFAULT_HEARTBEAT_FILENAME | typeof DEFAULT_BOOTSTRAP_FILENAME - | typeof DEFAULT_MEMORY_FILENAME - | typeof DEFAULT_MEMORY_ALT_FILENAME; + | typeof DEFAULT_MEMORY_FILENAME; export type WorkspaceBootstrapFile = { name: WorkspaceBootstrapFileName; @@ -176,7 +174,6 @@ const VALID_BOOTSTRAP_NAMES: ReadonlySet = new Set([ DEFAULT_HEARTBEAT_FILENAME, DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_MEMORY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, ]); async function writeFileIfMissing(filePath: string, content: string): Promise { @@ -204,6 +201,15 @@ async function fileExists(filePath: string): Promise { } } +async function exactWorkspaceEntryExists(dir: string, name: string): Promise { + try { + const entries = await fs.readdir(dir); + return entries.includes(name); + } catch { + return false; + } +} + function resolveWorkspaceStatePath(dir: string): string { return path.join(dir, WORKSPACE_STATE_DIRNAME, WORKSPACE_STATE_FILENAME); } @@ -371,11 +377,7 @@ export async function ensureAgentWorkspace(params?: { const isBrandNewWorkspace = await (async () => { const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath]; - const userContentPaths = [ - path.join(dir, "memory"), - path.join(dir, DEFAULT_MEMORY_FILENAME), - path.join(dir, ".git"), - ]; + const userContentPaths = [path.join(dir, "memory"), path.join(dir, ".git")]; const paths = [...templatePaths, ...userContentPaths]; const existing = await Promise.all( paths.map(async (p) => { @@ -387,7 +389,8 @@ export async function ensureAgentWorkspace(params?: { } }), ); - return existing.every((v) => !v); + const hasCanonicalRootMemory = await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME); + return existing.every((v) => !v) && !hasCanonicalRootMemory; })(); const agentsTemplate = await loadTemplate(DEFAULT_AGENTS_FILENAME); @@ -429,11 +432,7 @@ export async function ensureAgentWorkspace(params?: { fs.readFile(userPath, "utf-8"), ]); const hasUserContent = await (async () => { - const indicators = [ - path.join(dir, "memory"), - path.join(dir, DEFAULT_MEMORY_FILENAME), - path.join(dir, ".git"), - ]; + const indicators = [path.join(dir, "memory"), path.join(dir, ".git")]; for (const indicator of indicators) { try { await fs.access(indicator); @@ -442,7 +441,7 @@ export async function ensureAgentWorkspace(params?: { // continue } } - return false; + return await exactWorkspaceEntryExists(dir, DEFAULT_MEMORY_FILENAME); })(); const legacySetupCompleted = identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent; @@ -480,26 +479,6 @@ export async function ensureAgentWorkspace(params?: { }; } -async function resolveMemoryBootstrapEntry( - resolvedDir: string, -): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> { - // Prefer MEMORY.md; fall back to memory.md only when absent. - // Checking both and deduplicating via realpath is unreliable on case-insensitive - // file systems mounted in Docker (e.g. macOS volumes), where both names pass - // fs.access() but realpath does not normalise case through the mount layer, - // causing the same content to be injected twice and wasting tokens. - for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) { - const filePath = path.join(resolvedDir, name); - try { - await fs.access(filePath); - return { name, filePath }; - } catch { - // try next candidate - } - } - return null; -} - export async function loadWorkspaceBootstrapFiles(dir: string): Promise { const resolvedDir = resolveUserPath(dir); @@ -535,15 +514,20 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise vi.fn()); const auditShortTermPromotionArtifacts = vi.hoisted(() => vi.fn()); const repairDreamingArtifacts = vi.hoisted(() => vi.fn()); const repairShortTermPromotionArtifacts = vi.hoisted(() => vi.fn()); +const detectRootMemoryFiles = vi.hoisted(() => vi.fn()); +const migrateLegacyRootMemoryFile = vi.hoisted(() => vi.fn()); vi.mock("../terminal/note.js", () => ({ note, @@ -83,9 +85,18 @@ vi.mock("../plugin-sdk/memory-core-engine-runtime.js", () => ({ ]), })); +vi.mock("./doctor-workspace.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectRootMemoryFiles, + migrateLegacyRootMemoryFile, + }; +}); + import { noteMemorySearchHealth } from "./doctor-memory-search.js"; import { maybeRepairMemoryRecallHealth, noteMemoryRecallHealth } from "./doctor-memory-search.js"; -import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js"; +import { detectLegacyWorkspaceDirs, formatRootMemoryFilesWarning } from "./doctor-workspace.js"; function resetMemoryRecallMocks() { auditShortTermPromotionArtifacts.mockReset(); @@ -126,6 +137,22 @@ function resetMemoryRecallMocks() { rewroteStore: false, removedStaleLock: false, }); + detectRootMemoryFiles.mockReset(); + detectRootMemoryFiles.mockResolvedValue({ + workspaceDir: "/tmp/agent-default/workspace", + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + canonicalExists: false, + legacyExists: false, + }); + migrateLegacyRootMemoryFile.mockReset(); + migrateLegacyRootMemoryFile.mockResolvedValue({ + changed: false, + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + removedLegacy: false, + mergedLegacy: false, + }); } describe("noteMemorySearchHealth", () => { @@ -534,6 +561,28 @@ describe("noteMemorySearchHealth", () => { const message = String(note.mock.calls[0]?.[0] ?? ""); expect(message).toContain("OPENAI_API_KEY"); }); + + it("does not warn when only lowercase memory.md exists", async () => { + resolveAgentWorkspaceDir.mockReturnValue("/tmp/agent-default/workspace"); + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + detectRootMemoryFiles.mockResolvedValueOnce({ + workspaceDir: "/tmp/agent-default/workspace", + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + canonicalExists: false, + legacyExists: true, + legacyBytes: 10, + }); + + await noteMemorySearchHealth(cfg); + + const workspaceNote = note.mock.calls.find(([, title]) => title === "Workspace memory"); + expect(workspaceNote).toBeUndefined(); + }); }); describe("memory recall doctor integration", () => { @@ -683,6 +732,60 @@ describe("memory recall doctor integration", () => { expect(message).toContain("archived session corpus"); expect(message).toContain("archived session-ingestion state"); }); + + it("does not migrate lowercase-only root memory during doctor --fix", async () => { + resolveAgentWorkspaceDir.mockReturnValue("/tmp/agent-default/workspace"); + detectRootMemoryFiles.mockResolvedValueOnce({ + workspaceDir: "/tmp/agent-default/workspace", + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + canonicalExists: false, + legacyExists: true, + legacyBytes: 24, + }); + const prompter = createPrompter(); + + await maybeRepairMemoryRecallHealth({ cfg, prompter }); + + expect(migrateLegacyRootMemoryFile).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + }); + + it("merges split-brain root memory during doctor --fix", async () => { + resolveAgentWorkspaceDir.mockReturnValue("/tmp/agent-default/workspace"); + detectRootMemoryFiles.mockResolvedValueOnce({ + workspaceDir: "/tmp/agent-default/workspace", + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + canonicalExists: true, + legacyExists: true, + canonicalBytes: 32, + legacyBytes: 24, + }); + migrateLegacyRootMemoryFile.mockResolvedValueOnce({ + changed: true, + canonicalPath: "/tmp/agent-default/workspace/MEMORY.md", + legacyPath: "/tmp/agent-default/workspace/memory.md", + removedLegacy: true, + mergedLegacy: true, + archivedLegacyPath: + "/tmp/agent-default/workspace/.openclaw-repair/root-memory/archive/memory.md", + copiedBytes: 24, + }); + const prompter = createPrompter(); + + await maybeRepairMemoryRecallHealth({ cfg, prompter }); + + expect(prompter.confirmRuntimeRepair).toHaveBeenCalledWith({ + message: "Merge legacy root memory.md into canonical MEMORY.md and remove the shadowed file?", + initialValue: true, + }); + expect(migrateLegacyRootMemoryFile).toHaveBeenCalledWith("/tmp/agent-default/workspace"); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain("Workspace memory root merged:"); + expect(message).toContain("backup:"); + expect(message).toContain("merged legacy content from:"); + }); }); describe("detectLegacyWorkspaceDirs", () => { @@ -693,3 +796,20 @@ describe("detectLegacyWorkspaceDirs", () => { expect(detection.legacyDirs).toEqual([]); }); }); + +describe("formatRootMemoryFilesWarning", () => { + it("explains split-brain when both root memory files exist", () => { + const message = formatRootMemoryFilesWarning({ + workspaceDir: "/workspace", + canonicalPath: "/workspace/MEMORY.md", + legacyPath: "/workspace/memory.md", + canonicalExists: true, + legacyExists: true, + canonicalBytes: 12, + legacyBytes: 34, + }); + expect(message).toContain("Split root durable memory files detected"); + expect(message).toContain("shadowed"); + expect(message).toContain("doctor --fix"); + }); +}); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 6f903642a13..6a8475776d7 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -34,6 +34,11 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; +import { + detectRootMemoryFiles, + formatRootMemoryFilesWarning, + migrateLegacyRootMemoryFile, +} from "./doctor-workspace.js"; import { isRecord } from "./doctor/shared/legacy-config-record-shared.js"; type RuntimeMemoryAuditContext = { @@ -224,6 +229,36 @@ export async function maybeRepairMemoryRecallHealth(params: { cfg: OpenClawConfig; prompter: DoctorPrompter; }): Promise { + try { + const agentId = resolveDefaultAgentId(params.cfg); + const configuredWorkspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); + const rootMemoryFiles = await detectRootMemoryFiles(configuredWorkspaceDir); + if (rootMemoryFiles.canonicalExists && rootMemoryFiles.legacyExists) { + const approvedLegacyMigration = await params.prompter.confirmRuntimeRepair({ + message: + "Merge legacy root memory.md into canonical MEMORY.md and remove the shadowed file?", + initialValue: true, + }); + if (approvedLegacyMigration) { + const migration = await migrateLegacyRootMemoryFile(configuredWorkspaceDir); + if (migration.changed) { + const lines = [ + "Workspace memory root merged:", + `- canonical: ${migration.canonicalPath}`, + migration.archivedLegacyPath ? `- backup: ${migration.archivedLegacyPath}` : null, + migration.mergedLegacy ? `- merged legacy content from: ${migration.legacyPath}` : null, + migration.removedLegacy + ? `- removed legacy file: ${migration.legacyPath}` + : `- legacy file still present: ${migration.legacyPath}`, + ].filter(Boolean); + note(lines.join("\n"), "Doctor changes"); + } + } + } + } catch (err) { + note(`Workspace memory repair could not be completed: ${formatErrorMessage(err)}`, "Doctor"); + } + try { const context = await resolveRuntimeMemoryAuditContext(params.cfg); const workspaceDir = context?.workspaceDir?.trim(); @@ -314,6 +349,11 @@ export async function noteMemorySearchHealth( ): Promise { const agentId = resolveDefaultAgentId(cfg); const agentDir = resolveAgentDir(cfg, agentId); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const rootMemoryWarning = formatRootMemoryFilesWarning(await detectRootMemoryFiles(workspaceDir)); + if (rootMemoryWarning) { + note(rootMemoryWarning, "Workspace memory"); + } const resolved = resolveMemorySearchConfig(cfg, agentId); const hasRemoteApiKey = hasConfiguredMemorySecretInput(resolved?.remote?.apiKey); diff --git a/src/commands/doctor-workspace.test.ts b/src/commands/doctor-workspace.test.ts new file mode 100644 index 00000000000..89e3a99d1f1 --- /dev/null +++ b/src/commands/doctor-workspace.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + detectRootMemoryFiles, + formatRootMemoryFilesWarning, + migrateLegacyRootMemoryFile, + shouldSuggestMemorySystem, +} from "./doctor-workspace.js"; + +describe("root memory repair", () => { + let tmpDir = ""; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-root-memory-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("ignores lowercase-only root memory for automatic repair", async () => { + await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy\n", "utf8"); + + const detection = await detectRootMemoryFiles(tmpDir); + expect(detection.canonicalExists).toBe(false); + expect(detection.legacyExists).toBe(true); + expect(formatRootMemoryFilesWarning(detection)).toBeNull(); + + const migration = await migrateLegacyRootMemoryFile(tmpDir); + expect(migration.changed).toBe(false); + await expect(fs.readFile(path.join(tmpDir, "memory.md"), "utf8")).resolves.toBe("# Legacy\n"); + const entries = await fs.readdir(tmpDir); + expect(entries).toContain("memory.md"); + expect(entries).not.toContain("MEMORY.md"); + await expect(shouldSuggestMemorySystem(tmpDir)).resolves.toBe(true); + }); + + it("merges true split-brain root memory files into MEMORY.md", async () => { + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Canonical\n", "utf8"); + await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy\n", "utf8"); + const entries = new Set(await fs.readdir(tmpDir)); + if (!entries.has("MEMORY.md") || !entries.has("memory.md")) { + return; + } + + const detection = await detectRootMemoryFiles(tmpDir); + expect(formatRootMemoryFilesWarning(detection)).toContain("Split root durable memory"); + + const migration = await migrateLegacyRootMemoryFile(tmpDir); + expect(migration.changed).toBe(true); + expect(migration.removedLegacy).toBe(true); + expect(migration.mergedLegacy).toBe(true); + + const canonical = await fs.readFile(path.join(tmpDir, "MEMORY.md"), "utf8"); + expect(canonical).toContain("# Canonical"); + expect(canonical).toContain("# Legacy"); + await expect(fs.access(path.join(tmpDir, "memory.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(migration.archivedLegacyPath).toBeTruthy(); + await expect(fs.access(migration.archivedLegacyPath ?? "")).resolves.toBeUndefined(); + }); +}); diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index 6ac387a08ad..f961a4e4c89 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -13,12 +13,13 @@ export const MEMORY_SYSTEM_PROMPT = [ ].join("\n"); export async function shouldSuggestMemorySystem(workspaceDir: string): Promise { - const memoryPaths = [path.join(workspaceDir, "MEMORY.md"), path.join(workspaceDir, "memory.md")]; - - for (const memoryPath of memoryPaths) { + const entries = await listWorkspaceEntries(workspaceDir); + if (entries.has("MEMORY.md")) { try { - await fs.promises.access(memoryPath); - return false; + const stat = await fs.promises.stat(path.join(workspaceDir, "MEMORY.md")); + if (stat.isFile()) { + return false; + } } catch { // keep scanning } @@ -27,7 +28,7 @@ export async function shouldSuggestMemorySystem(workspaceDir: string): Promise { + try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile()) { + return { exists: false }; + } + return { exists: true, bytes: stat.size }; + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return { exists: false }; + } + throw err; + } +} + +async function listWorkspaceEntries(workspaceDir: string): Promise> { + try { + return new Set(await fs.promises.readdir(workspaceDir)); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { + return new Set(); + } + throw err; + } +} + +export async function detectRootMemoryFiles( + workspaceDir: string, +): Promise { + const resolvedWorkspace = path.resolve(workspaceDir); + const canonicalPath = path.join(resolvedWorkspace, "MEMORY.md"); + const legacyPath = path.join(resolvedWorkspace, "memory.md"); + const entries = await listWorkspaceEntries(resolvedWorkspace); + const [canonical, legacy] = await Promise.all([ + entries.has("MEMORY.md") + ? statIfExists(canonicalPath) + : Promise.resolve({ exists: false }), + entries.has("memory.md") + ? statIfExists(legacyPath) + : Promise.resolve({ exists: false }), + ]); + return { + workspaceDir: resolvedWorkspace, + canonicalPath, + legacyPath, + canonicalExists: canonical.exists, + legacyExists: legacy.exists, + ...(typeof canonical.bytes === "number" ? { canonicalBytes: canonical.bytes } : {}), + ...(typeof legacy.bytes === "number" ? { legacyBytes: legacy.bytes } : {}), + }; +} + +function formatBytes(bytes?: number): string { + return typeof bytes === "number" ? `${bytes} bytes` : "size unknown"; +} + +export function formatRootMemoryFilesWarning(detection: RootMemoryFilesDetection): string | null { + if (detection.canonicalExists && detection.legacyExists) { + return [ + "Split root durable memory files detected:", + `- canonical: ${shortenHomePath(detection.canonicalPath)} (${formatBytes(detection.canonicalBytes)})`, + `- legacy: ${shortenHomePath(detection.legacyPath)} (${formatBytes(detection.legacyBytes)})`, + "OpenClaw uses MEMORY.md as the canonical durable memory file.", + "Dreaming writes durable promotions to MEMORY.md, so older facts in memory.md can be shadowed.", + 'Run "openclaw doctor --fix" to merge the legacy file into MEMORY.md with a backup.', + ].join("\n"); + } + return null; +} + +export type RootMemoryMigrationResult = { + changed: boolean; + canonicalPath: string; + legacyPath: string; + removedLegacy: boolean; + mergedLegacy: boolean; + archivedLegacyPath?: string; + copiedBytes?: number; +}; + +function buildRootMemoryRepairDir(workspaceDir: string): string { + return path.join(workspaceDir, ".openclaw-repair", "root-memory"); +} + +async function moveLegacyRootMemoryFileToArchive(params: { + workspaceDir: string; + legacyPath: string; +}): Promise { + const repairDir = buildRootMemoryRepairDir(params.workspaceDir); + await fs.promises.mkdir(repairDir, { recursive: true }); + const archiveDir = path.join( + repairDir, + new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"), + ); + await fs.promises.mkdir(archiveDir, { recursive: true }); + const archivePath = path.join(archiveDir, "memory.md"); + try { + await fs.promises.rename(params.legacyPath, archivePath); + } catch (err) { + if ((err as NodeJS.ErrnoException | undefined)?.code !== "EXDEV") { + throw err; + } + await fs.promises.copyFile(params.legacyPath, archivePath); + await fs.promises.unlink(params.legacyPath); + } + return archivePath; +} + +function buildMergedLegacyRootMemorySection(params: { + legacyText: string; + archivedLegacyPath: string; +}): string { + return [ + "", + "## Imported From Legacy Root memory.md", + "", + ``, + "This content came from legacy root `memory.md`, which was shadowed by `MEMORY.md`.", + "", + params.legacyText.trim(), + "", + ].join("\n"); +} + +export async function migrateLegacyRootMemoryFile( + workspaceDir: string, +): Promise { + const detection = await detectRootMemoryFiles(workspaceDir); + if (!detection.canonicalExists || !detection.legacyExists) { + return { + changed: false, + canonicalPath: detection.canonicalPath, + legacyPath: detection.legacyPath, + removedLegacy: false, + mergedLegacy: false, + }; + } + const archivedLegacyPath = await moveLegacyRootMemoryFileToArchive({ + workspaceDir: detection.workspaceDir, + legacyPath: detection.legacyPath, + }); + const [canonicalText, legacyText] = await Promise.all([ + fs.promises.readFile(detection.canonicalPath, "utf-8"), + fs.promises.readFile(archivedLegacyPath, "utf-8"), + ]); + if (canonicalText !== legacyText) { + const merged = `${canonicalText.trimEnd()}\n${buildMergedLegacyRootMemorySection({ + legacyText, + archivedLegacyPath: shortenHomePath(archivedLegacyPath), + })}`; + await fs.promises.writeFile(detection.canonicalPath, merged, "utf-8"); + } + return { + changed: true, + canonicalPath: detection.canonicalPath, + legacyPath: detection.legacyPath, + removedLegacy: true, + mergedLegacy: canonicalText !== legacyText, + archivedLegacyPath, + ...(typeof detection.legacyBytes === "number" ? { copiedBytes: detection.legacyBytes } : {}), + }; +} diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index b3df9a30471..a87dc2f82e3 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -12,7 +12,6 @@ import { DEFAULT_BOOTSTRAP_FILENAME, DEFAULT_HEARTBEAT_FILENAME, DEFAULT_IDENTITY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, DEFAULT_MEMORY_FILENAME, DEFAULT_SOUL_FILENAME, DEFAULT_TOOLS_FILENAME, @@ -95,7 +94,7 @@ export const __testing = { }, }; -const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const; +const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME] as const; const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_FILE_NAMES]); @@ -213,22 +212,11 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: updatedAtMs: primaryMeta.updatedAtMs, }); } else { - const altMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME); - if (altMeta) { - files.push({ - name: DEFAULT_MEMORY_ALT_FILENAME, - path: path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME), - missing: false, - size: altMeta.size, - updatedAtMs: altMeta.updatedAtMs, - }); - } else { - files.push({ - name: DEFAULT_MEMORY_FILENAME, - path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME), - missing: true, - }); - } + files.push({ + name: DEFAULT_MEMORY_FILENAME, + path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME), + missing: true, + }); } return files; diff --git a/src/hooks/bundled/bootstrap-extra-files/HOOK.md b/src/hooks/bundled/bootstrap-extra-files/HOOK.md index a46a07efd68..37ba56caa0b 100644 --- a/src/hooks/bundled/bootstrap-extra-files/HOOK.md +++ b/src/hooks/bundled/bootstrap-extra-files/HOOK.md @@ -50,4 +50,4 @@ workspace root. All paths are resolved from the workspace and must stay inside it (including realpath checks). Only recognized bootstrap basenames are loaded (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, -`IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, `MEMORY.md`, `memory.md`). +`IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, `MEMORY.md`). diff --git a/src/memory-host-sdk/host/internal.test.ts b/src/memory-host-sdk/host/internal.test.ts index 298a3e78dd4..934261e5fc7 100644 --- a/src/memory-host-sdk/host/internal.test.ts +++ b/src/memory-host-sdk/host/internal.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { buildMultimodalChunkForIndexing, buildFileEntry, @@ -89,6 +89,97 @@ describe("listMemoryFiles", () => { expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true); }); + it("ignores lowercase root memory.md when canonical MEMORY.md is absent", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory"); + + const files = await listMemoryFiles(tmpDir, [path.join(tmpDir, "memory.md")]); + + expect(files).toEqual([]); + }); + + it("prefers canonical MEMORY.md over legacy root memory.md even through extra paths", async () => { + const tmpDir = getTmpDir(); + const canonicalPath = path.join(tmpDir, "MEMORY.md"); + const legacyPath = path.join(tmpDir, "memory.md"); + const actualLstat = fs.lstat.bind(fs); + const actualReaddir = fs.readdir.bind(fs); + const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation(async (target) => { + if (target === canonicalPath || target === legacyPath) { + return { + isSymbolicLink: () => false, + isFile: () => true, + isDirectory: () => false, + } as Awaited>; + } + return actualLstat(target); + }); + const readdirSpy = vi.spyOn(fs, "readdir").mockImplementation((async ( + target: unknown, + options: unknown, + ) => { + if ( + target === tmpDir && + typeof options === "object" && + options !== null && + "withFileTypes" in options && + options.withFileTypes + ) { + return [ + { + name: "MEMORY.md", + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + }, + { + name: "memory.md", + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + }, + ] as unknown as Awaited>; + } + return actualReaddir(target as never, options as never); + }) as never); + + try { + const files = await listMemoryFiles(tmpDir, [legacyPath, path.join(tmpDir, ".")]); + expect(files).toEqual([canonicalPath]); + } finally { + lstatSpy.mockRestore(); + readdirSpy.mockRestore(); + } + }); + + it("skips root-memory repair backups from extra workspace paths", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); + await fs.mkdir(repairDir, { recursive: true }); + await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); + + const files = await listMemoryFiles(tmpDir, [tmpDir]); + + expect(files).toHaveLength(1); + expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md")); + }); + + it("skips explicit root-memory repair directories from extra paths", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); + await fs.mkdir(repairDir, { recursive: true }); + await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); + + const files = await listMemoryFiles(tmpDir, [ + path.join(tmpDir, ".openclaw-repair", "root-memory"), + ]); + + expect(files).toHaveLength(1); + expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md")); + }); + it("handles relative paths in additional paths", async () => { const tmpDir = getTmpDir(); await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); diff --git a/src/memory-host-sdk/host/internal.ts b/src/memory-host-sdk/host/internal.ts index d67b60bf57d..b2f2f817836 100644 --- a/src/memory-host-sdk/host/internal.ts +++ b/src/memory-host-sdk/host/internal.ts @@ -77,7 +77,7 @@ export function isMemoryPath(relPath: string): boolean { if (!normalized) { return false; } - if (normalized === "MEMORY.md" || normalized === "memory.md" || normalized === "DREAMS.md") { + if (normalized === "MEMORY.md" || normalized === "DREAMS.md") { return true; } return normalized.startsWith("memory/"); @@ -92,15 +92,26 @@ function isAllowedMemoryFilePath(filePath: string, multimodal?: MemoryMultimodal ); } -async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimodalSettings) { +async function walkDir( + dir: string, + files: string[], + multimodal?: MemoryMultimodalSettings, + shouldSkipPath?: (absPath: string) => boolean, +) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); + if (shouldSkipPath?.(full)) { + continue; + } if (entry.isSymbolicLink()) { continue; } if (entry.isDirectory()) { - await walkDir(full, files, multimodal); + if (entry.name === ".openclaw-repair") { + continue; + } + await walkDir(full, files, multimodal, shouldSkipPath); continue; } if (!entry.isFile()) { @@ -113,16 +124,44 @@ async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimod } } +async function resolveCanonicalMemoryRootFile(workspaceDir: string): Promise { + try { + const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === "MEMORY.md" && entry.isFile() && !entry.isSymbolicLink()) { + return path.join(workspaceDir, entry.name); + } + } + } catch {} + return null; +} + export async function listMemoryFiles( workspaceDir: string, extraPaths?: string[], multimodal?: MemoryMultimodalSettings, ): Promise { const result: string[] = []; - const memoryFile = path.join(workspaceDir, "MEMORY.md"); - const altMemoryFile = path.join(workspaceDir, "memory.md"); const memoryDir = path.join(workspaceDir, "memory"); + const shouldSkipWorkspaceMemoryPath = (absPath: string): boolean => { + const relative = path.relative(workspaceDir, absPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return false; + } + const normalized = relative.replace(/\\/g, "/"); + if (!normalized) { + return false; + } + if (normalized === "memory.md") { + return true; + } + return ( + normalized === ".openclaw-repair/root-memory" || + normalized.startsWith(".openclaw-repair/root-memory/") + ); + }; + const addMarkdownFile = async (absPath: string) => { try { const stat = await fs.lstat(absPath); @@ -136,25 +175,30 @@ export async function listMemoryFiles( } catch {} }; - await addMarkdownFile(memoryFile); - await addMarkdownFile(altMemoryFile); + const memoryFile = await resolveCanonicalMemoryRootFile(workspaceDir); + if (memoryFile) { + await addMarkdownFile(memoryFile); + } try { const dirStat = await fs.lstat(memoryDir); if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) { - await walkDir(memoryDir, result); + await walkDir(memoryDir, result, multimodal, shouldSkipWorkspaceMemoryPath); } } catch {} const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths); if (normalizedExtraPaths.length > 0) { for (const inputPath of normalizedExtraPaths) { + if (shouldSkipWorkspaceMemoryPath(inputPath)) { + continue; + } try { const stat = await fs.lstat(inputPath); if (stat.isSymbolicLink()) { continue; } if (stat.isDirectory()) { - await walkDir(inputPath, result, multimodal); + await walkDir(inputPath, result, multimodal, shouldSkipWorkspaceMemoryPath); continue; } if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) {