refactor: centralize root memory file policy

This commit is contained in:
Peter Steinberger
2026-04-23 16:39:52 +01:00
parent 14808371a6
commit deb1364dfb
12 changed files with 235 additions and 218 deletions

View File

@@ -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`).

View File

@@ -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)

View File

@@ -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) => ({

View File

@@ -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<string | null> {
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);
}

View File

@@ -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<boolean> {
}
}
async function exactWorkspaceEntryExists(dir: string, name: string): Promise<boolean> {
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);
}

View File

@@ -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<typeof import("./doctor-workspace.js")>();
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", () => {

View File

@@ -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<void> {
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<void> {
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);

View File

@@ -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",
);
});
});

View File

@@ -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<boolean> {
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<b
const agentsPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
try {
const content = await fs.promises.readFile(agentsPath, "utf-8");
if (/\bMEMORY\.md\b/.test(content)) {
if (new RegExp(`\\b${CANONICAL_ROOT_MEMORY_FILENAME.replace(".", "\\.")}\\b`).test(content)) {
return false;
}
} catch {
@@ -105,14 +117,14 @@ export async function detectRootMemoryFiles(
workspaceDir: string,
): Promise<RootMemoryFilesDetection> {
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<RootMemoryStatResult>({ exists: false }),
entries.has("memory.md")
entries.has(LEGACY_ROOT_MEMORY_FILENAME)
? statIfExists(legacyPath)
: Promise.resolve<RootMemoryStatResult>({ 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<string> {
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}`,
"",
`<!-- openclaw-root-memory-merge source=memory.md archived=${params.archivedLegacyPath} -->`,
"This content came from legacy root `memory.md`, which was shadowed by `MEMORY.md`.",
`<!-- openclaw-root-memory-merge source=${LEGACY_ROOT_MEMORY_FILENAME} archived=${params.archivedLegacyPath} -->`,
`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<void> {
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<void> {
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");
}
}

View File

@@ -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) => ({

View File

@@ -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<string | null> {
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);
}

View File

@@ -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<boolean> {
try {
const entries = await fs.readdir(dir);
return entries.includes(name);
} catch {
return false;
}
}
export async function resolveCanonicalRootMemoryFile(workspaceDir: string): Promise<string | null> {
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}/`)
);
}