From deb1364dfbe6e4561f1d6f388a837832186b3c7f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 16:39:52 +0100 Subject: [PATCH] refactor: centralize root memory file policy --- docs/help/faq.md | 5 +- docs/reference/token-use.md | 2 +- .../src/host/backend-config.ts | 3 +- packages/memory-host-sdk/src/host/internal.ts | 40 ++------ src/agents/workspace.ts | 15 +-- src/commands/doctor-memory-search.test.ts | 91 ++--------------- src/commands/doctor-memory-search.ts | 43 +------- src/commands/doctor-workspace.test.ts | 50 +++++++++- src/commands/doctor-workspace.ts | 98 +++++++++++++++---- src/memory-host-sdk/host/backend-config.ts | 3 +- src/memory-host-sdk/host/internal.ts | 40 ++------ src/memory/root-memory-files.ts | 63 ++++++++++++ 12 files changed, 235 insertions(+), 218 deletions(-) create mode 100644 src/memory/root-memory-files.ts diff --git a/docs/help/faq.md b/docs/help/faq.md index bcc1759e07e..4d4fb852855 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1410,8 +1410,9 @@ for usage/billing and raise limits as needed. These files live in the **agent workspace**, not `~/.openclaw`. - **Workspace (per agent)**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, - `MEMORY.md` (or legacy fallback `memory.md` when `MEMORY.md` is absent), - `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. + `MEMORY.md`, `memory/YYYY-MM-DD.md`, optional `HEARTBEAT.md`. + Lowercase root `memory.md` is legacy repair input only; `openclaw doctor --fix` + can merge it into `MEMORY.md` when both files exist. - **State dir (`~/.openclaw`)**: config, channel/provider state, auth profiles, sessions, logs, and shared skills (`~/.openclaw/skills`). diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 5ae89ae3105..4955f616854 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -21,7 +21,7 @@ OpenClaw assembles its own system prompt on every run. It includes: with optional per-agent override at `agents.list[].skillsLimits.maxSkillsPromptChars`. - Self-update instructions -- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`. +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`. - Time (UTC + user timezone) - Reply tags + heartbeat behavior - Runtime metadata (host/OS/model/thinking) diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 0588b0ade66..fff091cd5cf 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -12,6 +12,7 @@ import type { MemoryQmdMcporterConfig, MemoryQmdSearchMode, } from "../../../../src/config/types.memory.js"; +import { CANONICAL_ROOT_MEMORY_FILENAME } from "../../../../src/memory/root-memory-files.js"; import { normalizeAgentId } from "../../../../src/routing/session-key.js"; import { normalizeLowercaseStringOrEmpty } from "../../../../src/shared/string-coerce.js"; import { resolveUserPath } from "../../../../src/utils.js"; @@ -328,7 +329,7 @@ function resolveDefaultCollections( return []; } const entries: Array<{ path: string; pattern: string; base: string }> = [ - { path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" }, + { path: workspaceDir, pattern: CANONICAL_ROOT_MEMORY_FILENAME, 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.ts b/packages/memory-host-sdk/src/host/internal.ts index 2b8b5d85074..228bb3fffdc 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -3,6 +3,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { detectMime } from "../../../../src/media/mime.js"; +import { + CANONICAL_ROOT_MEMORY_FILENAME, + resolveCanonicalRootMemoryFile, + shouldSkipRootMemoryAuxiliaryPath, +} from "../../../../src/memory/root-memory-files.js"; import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../../../../src/utils/cjk-chars.js"; import { runTasksWithConcurrency } from "../../../../src/utils/run-with-concurrency.js"; import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js"; @@ -77,7 +82,7 @@ export function isMemoryPath(relPath: string): boolean { if (!normalized) { return false; } - if (normalized === "MEMORY.md" || normalized === "dreams.md") { + if (normalized === CANONICAL_ROOT_MEMORY_FILENAME || normalized === "dreams.md") { return true; } return normalized.startsWith("memory/"); @@ -124,18 +129,6 @@ async function walkDir( } } -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[], @@ -144,23 +137,8 @@ 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 shouldSkipWorkspaceMemoryPath = (absPath: string): boolean => + shouldSkipRootMemoryAuxiliaryPath({ workspaceDir, absPath }); const addMarkdownFile = async (absPath: string) => { try { @@ -175,7 +153,7 @@ export async function listMemoryFiles( } catch {} }; - const memoryFile = await resolveCanonicalMemoryRootFile(workspaceDir); + const memoryFile = await resolveCanonicalRootMemoryFile(workspaceDir); if (memoryFile) { await addMarkdownFile(memoryFile); } diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 8ff01c6961d..d9a71293181 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -4,6 +4,10 @@ import os from "node:os"; import path from "node:path"; import { openBoundaryFile } from "../infra/boundary-file-read.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; +import { + CANONICAL_ROOT_MEMORY_FILENAME, + exactWorkspaceEntryExists, +} from "../memory/root-memory-files.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../shared/string-coerce.js"; @@ -30,7 +34,7 @@ export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md"; 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_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME; const WORKSPACE_STATE_DIRNAME = ".openclaw"; const WORKSPACE_STATE_FILENAME = "workspace-state.json"; const WORKSPACE_STATE_VERSION = 1; @@ -201,15 +205,6 @@ 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); } diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index eb15a1b9abe..f3118469ec5 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -21,8 +21,8 @@ const auditDreamingArtifacts = vi.hoisted(() => 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()); +const noteWorkspaceMemoryHealth = vi.hoisted(() => vi.fn(async () => undefined)); +const maybeRepairWorkspaceMemoryHealth = vi.hoisted(() => vi.fn(async () => undefined)); vi.mock("../terminal/note.js", () => ({ note, @@ -89,8 +89,8 @@ vi.mock("./doctor-workspace.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - detectRootMemoryFiles, - migrateLegacyRootMemoryFile, + noteWorkspaceMemoryHealth, + maybeRepairWorkspaceMemoryHealth, }; }); @@ -137,22 +137,8 @@ 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, - }); + noteWorkspaceMemoryHealth.mockClear(); + maybeRepairWorkspaceMemoryHealth.mockClear(); } describe("noteMemorySearchHealth", () => { @@ -569,17 +555,10 @@ describe("noteMemorySearchHealth", () => { 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); + expect(noteWorkspaceMemoryHealth).toHaveBeenCalledWith(cfg); const workspaceNote = note.mock.calls.find(([, title]) => title === "Workspace memory"); expect(workspaceNote).toBeUndefined(); }); @@ -681,6 +660,7 @@ describe("memory recall doctor integration", () => { await maybeRepairMemoryRecallHealth({ cfg, prompter }); + expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({ cfg, prompter }); expect(prompter.confirmRuntimeRepair).toHaveBeenCalled(); expect(repairShortTermPromotionArtifacts).toHaveBeenCalledWith({ workspaceDir: "/tmp/agent-default/workspace", @@ -723,6 +703,7 @@ describe("memory recall doctor integration", () => { await maybeRepairMemoryRecallHealth({ cfg, prompter }); + expect(maybeRepairWorkspaceMemoryHealth).toHaveBeenCalledWith({ cfg, prompter }); expect(prompter.confirmRuntimeRepair).toHaveBeenCalled(); expect(repairDreamingArtifacts).toHaveBeenCalledWith({ workspaceDir: "/tmp/agent-default/workspace", @@ -732,60 +713,6 @@ 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", () => { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 6a8475776d7..ee5c7cc4ccc 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -34,11 +34,7 @@ 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 { maybeRepairWorkspaceMemoryHealth, noteWorkspaceMemoryHealth } from "./doctor-workspace.js"; import { isRecord } from "./doctor/shared/legacy-config-record-shared.js"; type RuntimeMemoryAuditContext = { @@ -229,35 +225,7 @@ 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"); - } + await maybeRepairWorkspaceMemoryHealth(params); try { const context = await resolveRuntimeMemoryAuditContext(params.cfg); @@ -347,13 +315,10 @@ export async function noteMemorySearchHealth( }; }, ): Promise { + await noteWorkspaceMemoryHealth(cfg); + 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 index 89e3a99d1f1..439a777081d 100644 --- a/src/commands/doctor-workspace.test.ts +++ b/src/commands/doctor-workspace.test.ts @@ -1,11 +1,22 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + import { detectRootMemoryFiles, formatRootMemoryFilesWarning, + maybeRepairWorkspaceMemoryHealth, migrateLegacyRootMemoryFile, + noteWorkspaceMemoryHealth, shouldSuggestMemorySystem, } from "./doctor-workspace.js"; @@ -14,6 +25,7 @@ describe("root memory repair", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-root-memory-")); + note.mockClear(); }); afterEach(async () => { @@ -62,4 +74,40 @@ describe("root memory repair", () => { expect(migration.archivedLegacyPath).toBeTruthy(); await expect(fs.access(migration.archivedLegacyPath ?? "")).resolves.toBeUndefined(); }); + + it("warns and repairs split-brain root memory through workspace doctor helpers", 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 cfg = { agents: { defaults: { workspace: tmpDir } } } as OpenClawConfig; + const prompter = { + confirmRuntimeRepair: vi.fn(async () => true), + } as unknown as DoctorPrompter; + + await noteWorkspaceMemoryHealth(cfg); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Split root durable memory"), + "Workspace memory", + ); + note.mockClear(); + + await maybeRepairWorkspaceMemoryHealth({ cfg, prompter }); + + expect(prompter.confirmRuntimeRepair).toHaveBeenCalledWith({ + message: "Merge legacy root memory.md into canonical MEMORY.md and remove the shadowed file?", + initialValue: true, + }); + const canonical = await fs.readFile(path.join(tmpDir, "MEMORY.md"), "utf8"); + expect(canonical).toContain("# Legacy"); + await expect(fs.access(path.join(tmpDir, "memory.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Workspace memory root merged:"), + "Doctor changes", + ); + }); }); diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index f961a4e4c89..9f9744d638b 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -1,7 +1,19 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { + CANONICAL_ROOT_MEMORY_FILENAME, + LEGACY_ROOT_MEMORY_FILENAME, + resolveCanonicalRootMemoryPath, + resolveLegacyRootMemoryPath, + resolveRootMemoryRepairDir, +} from "../memory/root-memory-files.js"; +import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; export const MEMORY_SYSTEM_PROMPT = [ "Memory system not found in workspace.", @@ -14,9 +26,9 @@ export const MEMORY_SYSTEM_PROMPT = [ export async function shouldSuggestMemorySystem(workspaceDir: string): Promise { const entries = await listWorkspaceEntries(workspaceDir); - if (entries.has("MEMORY.md")) { + if (entries.has(CANONICAL_ROOT_MEMORY_FILENAME)) { try { - const stat = await fs.promises.stat(path.join(workspaceDir, "MEMORY.md")); + const stat = await fs.promises.stat(resolveCanonicalRootMemoryPath(workspaceDir)); if (stat.isFile()) { return false; } @@ -28,7 +40,7 @@ export async function shouldSuggestMemorySystem(workspaceDir: string): Promise { const resolvedWorkspace = path.resolve(workspaceDir); - const canonicalPath = path.join(resolvedWorkspace, "MEMORY.md"); - const legacyPath = path.join(resolvedWorkspace, "memory.md"); + const canonicalPath = resolveCanonicalRootMemoryPath(resolvedWorkspace); + const legacyPath = resolveLegacyRootMemoryPath(resolvedWorkspace); const entries = await listWorkspaceEntries(resolvedWorkspace); const [canonical, legacy] = await Promise.all([ - entries.has("MEMORY.md") + entries.has(CANONICAL_ROOT_MEMORY_FILENAME) ? statIfExists(canonicalPath) : Promise.resolve({ exists: false }), - entries.has("memory.md") + entries.has(LEGACY_ROOT_MEMORY_FILENAME) ? statIfExists(legacyPath) : Promise.resolve({ exists: false }), ]); @@ -137,9 +149,9 @@ export function formatRootMemoryFilesWarning(detection: RootMemoryFilesDetection "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.', + `OpenClaw uses ${CANONICAL_ROOT_MEMORY_FILENAME} as the canonical durable memory file.`, + `Dreaming writes durable promotions to ${CANONICAL_ROOT_MEMORY_FILENAME}, so older facts in ${LEGACY_ROOT_MEMORY_FILENAME} can be shadowed.`, + `Run "openclaw doctor --fix" to merge the legacy file into ${CANONICAL_ROOT_MEMORY_FILENAME} with a backup.`, ].join("\n"); } return null; @@ -155,22 +167,18 @@ export type RootMemoryMigrationResult = { 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); + const repairDir = resolveRootMemoryRepairDir(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"); + const archivePath = path.join(archiveDir, LEGACY_ROOT_MEMORY_FILENAME); try { await fs.promises.rename(params.legacyPath, archivePath); } catch (err) { @@ -189,10 +197,10 @@ function buildMergedLegacyRootMemorySection(params: { }): string { return [ "", - "## Imported From Legacy Root memory.md", + `## Imported From Legacy Root ${LEGACY_ROOT_MEMORY_FILENAME}`, "", - ``, - "This content came from legacy root `memory.md`, which was shadowed by `MEMORY.md`.", + ``, + `This content came from legacy root \`${LEGACY_ROOT_MEMORY_FILENAME}\`, which was shadowed by \`${CANONICAL_ROOT_MEMORY_FILENAME}\`.`, "", params.legacyText.trim(), "", @@ -237,3 +245,55 @@ export async function migrateLegacyRootMemoryFile( ...(typeof detection.legacyBytes === "number" ? { copiedBytes: detection.legacyBytes } : {}), }; } + +export async function noteWorkspaceMemoryHealth(cfg: OpenClawConfig): Promise { + try { + const agentId = resolveDefaultAgentId(cfg); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const rootMemoryWarning = formatRootMemoryFilesWarning( + await detectRootMemoryFiles(workspaceDir), + ); + if (rootMemoryWarning) { + note(rootMemoryWarning, "Workspace memory"); + } + } catch (err) { + note(`Workspace memory audit could not be completed: ${formatErrorMessage(err)}`, "Doctor"); + } +} + +export async function maybeRepairWorkspaceMemoryHealth(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) { + return; + } + const approvedLegacyMigration = await params.prompter.confirmRuntimeRepair({ + message: `Merge legacy root ${LEGACY_ROOT_MEMORY_FILENAME} into canonical ${CANONICAL_ROOT_MEMORY_FILENAME} and remove the shadowed file?`, + initialValue: true, + }); + if (!approvedLegacyMigration) { + return; + } + const migration = await migrateLegacyRootMemoryFile(configuredWorkspaceDir); + if (!migration.changed) { + return; + } + 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"); + } +} diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index ade1d458bbd..2d5b530cc3d 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -12,6 +12,7 @@ import type { MemoryQmdSearchMode, } from "../../config/types.memory.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { CANONICAL_ROOT_MEMORY_FILENAME } from "../../memory/root-memory-files.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -335,7 +336,7 @@ function resolveDefaultCollections( return []; } const entries: Array<{ path: string; pattern: string; base: string }> = [ - { path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" }, + { path: workspaceDir, pattern: CANONICAL_ROOT_MEMORY_FILENAME, base: "memory-root" }, { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, ]; return entries.map((entry) => ({ diff --git a/src/memory-host-sdk/host/internal.ts b/src/memory-host-sdk/host/internal.ts index b2f2f817836..20e1d14aeef 100644 --- a/src/memory-host-sdk/host/internal.ts +++ b/src/memory-host-sdk/host/internal.ts @@ -3,6 +3,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { detectMime } from "../../media/mime.js"; +import { + CANONICAL_ROOT_MEMORY_FILENAME, + resolveCanonicalRootMemoryFile, + shouldSkipRootMemoryAuxiliaryPath, +} from "../../memory/root-memory-files.js"; import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../../utils/cjk-chars.js"; import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js"; import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js"; @@ -77,7 +82,7 @@ export function isMemoryPath(relPath: string): boolean { if (!normalized) { return false; } - if (normalized === "MEMORY.md" || normalized === "DREAMS.md") { + if (normalized === CANONICAL_ROOT_MEMORY_FILENAME || normalized === "DREAMS.md") { return true; } return normalized.startsWith("memory/"); @@ -124,18 +129,6 @@ async function walkDir( } } -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[], @@ -144,23 +137,8 @@ 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 shouldSkipWorkspaceMemoryPath = (absPath: string): boolean => + shouldSkipRootMemoryAuxiliaryPath({ workspaceDir, absPath }); const addMarkdownFile = async (absPath: string) => { try { @@ -175,7 +153,7 @@ export async function listMemoryFiles( } catch {} }; - const memoryFile = await resolveCanonicalMemoryRootFile(workspaceDir); + const memoryFile = await resolveCanonicalRootMemoryFile(workspaceDir); if (memoryFile) { await addMarkdownFile(memoryFile); } diff --git a/src/memory/root-memory-files.ts b/src/memory/root-memory-files.ts new file mode 100644 index 00000000000..e7719edb5d4 --- /dev/null +++ b/src/memory/root-memory-files.ts @@ -0,0 +1,63 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const CANONICAL_ROOT_MEMORY_FILENAME = "MEMORY.md"; +export const LEGACY_ROOT_MEMORY_FILENAME = "memory.md"; +export const ROOT_MEMORY_REPAIR_RELATIVE_DIR = ".openclaw-repair/root-memory"; + +export function resolveCanonicalRootMemoryPath(workspaceDir: string): string { + return path.join(workspaceDir, CANONICAL_ROOT_MEMORY_FILENAME); +} + +export function resolveLegacyRootMemoryPath(workspaceDir: string): string { + return path.join(workspaceDir, LEGACY_ROOT_MEMORY_FILENAME); +} + +export function resolveRootMemoryRepairDir(workspaceDir: string): string { + return path.join(workspaceDir, ".openclaw-repair", "root-memory"); +} + +export function normalizeWorkspaceRelativePath(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/^\.\//, ""); +} + +export async function exactWorkspaceEntryExists(dir: string, name: string): Promise { + try { + const entries = await fs.readdir(dir); + return entries.includes(name); + } catch { + return false; + } +} + +export async function resolveCanonicalRootMemoryFile(workspaceDir: string): Promise { + try { + const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); + for (const entry of entries) { + if ( + entry.name === CANONICAL_ROOT_MEMORY_FILENAME && + entry.isFile() && + !entry.isSymbolicLink() + ) { + return path.join(workspaceDir, entry.name); + } + } + } catch {} + return null; +} + +export function shouldSkipRootMemoryAuxiliaryPath(params: { + workspaceDir: string; + absPath: string; +}): boolean { + const relative = path.relative(params.workspaceDir, params.absPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return false; + } + const normalized = normalizeWorkspaceRelativePath(relative); + return ( + normalized === LEGACY_ROOT_MEMORY_FILENAME || + normalized === ROOT_MEMORY_REPAIR_RELATIVE_DIR || + normalized.startsWith(`${ROOT_MEMORY_REPAIR_RELATIVE_DIR}/`) + ); +}