fix: keep root memory uppercase (#70621)

Thanks @mbelinky.
This commit is contained in:
Mariano
2026-04-23 17:10:36 +02:00
committed by GitHub
parent 645294510c
commit 10a9acbf29
26 changed files with 677 additions and 203 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -530,17 +530,12 @@ async function scanMemoryFiles(
): Promise<SourceScan> {
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;
}

View File

@@ -370,7 +370,6 @@ export abstract class MemoryManagerSyncOps {
}
const watchPaths = new Set<string>([
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);

View File

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

View File

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

View File

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

View File

@@ -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/")) {

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

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

View File

@@ -318,34 +318,6 @@ function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcport
return parsed;
}
function isRegularDefaultMemoryEntry(
entry: Pick<fs.Dirent, "name" | "isFile" | "isSymbolicLink">,
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) => ({

View File

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

View File

@@ -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<string | null> {
async function resolveCanonicalMemoryRootFile(workspaceDir: string): Promise<string | null> {
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)) {

View File

@@ -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<ReturnType<typeof loadWorkspaceBootstrapFiles>>) =>
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<ReturnType<typeof loadWorkspaceBootstrapFiles>>,
@@ -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 () => {

View File

@@ -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<string> = new Set([
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
]);
async function writeFileIfMissing(filePath: string, content: string): Promise<boolean> {
@@ -204,6 +201,15 @@ 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);
}
@@ -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<WorkspaceBootstrapFile[]> {
const resolvedDir = resolveUserPath(dir);
@@ -535,15 +514,20 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
name: DEFAULT_BOOTSTRAP_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
},
{
name: DEFAULT_MEMORY_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_MEMORY_FILENAME),
},
];
const memoryEntry = await resolveMemoryBootstrapEntry(resolvedDir);
if (memoryEntry) {
entries.push(memoryEntry);
}
const result: WorkspaceBootstrapFile[] = [];
for (const entry of entries) {
if (
entry.name === DEFAULT_MEMORY_FILENAME &&
!(await exactWorkspaceEntryExists(resolvedDir, DEFAULT_MEMORY_FILENAME))
) {
continue;
}
const loaded = await readWorkspaceFileWithGuards({
filePath: entry.filePath,
workspaceDir: resolvedDir,

View File

@@ -21,6 +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());
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<typeof import("./doctor-workspace.js")>();
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");
});
});

View File

@@ -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<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");
}
try {
const context = await resolveRuntimeMemoryAuditContext(params.cfg);
const workspaceDir = context?.workspaceDir?.trim();
@@ -314,6 +349,11 @@ export async function noteMemorySearchHealth(
): Promise<void> {
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

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

View File

@@ -13,12 +13,13 @@ export const MEMORY_SYSTEM_PROMPT = [
].join("\n");
export async function shouldSuggestMemorySystem(workspaceDir: string): Promise<boolean> {
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<b
const agentsPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
try {
const content = await fs.promises.readFile(agentsPath, "utf-8");
if (/memory\.md/i.test(content)) {
if (/\bMEMORY\.md\b/.test(content)) {
return false;
}
} catch {
@@ -58,3 +59,181 @@ export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection
"If unused, archive or move to Trash.",
].join("\n");
}
export type RootMemoryFilesDetection = {
workspaceDir: string;
canonicalPath: string;
legacyPath: string;
canonicalExists: boolean;
legacyExists: boolean;
canonicalBytes?: number;
legacyBytes?: number;
};
type RootMemoryStatResult = {
exists: boolean;
bytes?: number;
};
async function statIfExists(filePath: string): Promise<RootMemoryStatResult> {
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<Set<string>> {
try {
return new Set(await fs.promises.readdir(workspaceDir));
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
return new Set<string>();
}
throw err;
}
}
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 entries = await listWorkspaceEntries(resolvedWorkspace);
const [canonical, legacy] = await Promise.all([
entries.has("MEMORY.md")
? statIfExists(canonicalPath)
: Promise.resolve<RootMemoryStatResult>({ exists: false }),
entries.has("memory.md")
? statIfExists(legacyPath)
: Promise.resolve<RootMemoryStatResult>({ 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<string> {
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",
"",
`<!-- openclaw-root-memory-merge source=memory.md archived=${params.archivedLegacyPath} -->`,
"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<RootMemoryMigrationResult> {
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 } : {}),
};
}

View File

@@ -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<string>([...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;

View File

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

View File

@@ -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<ReturnType<typeof fs.lstat>>;
}
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<ReturnType<typeof fs.readdir>>;
}
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");

View File

@@ -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<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[],
multimodal?: MemoryMultimodalSettings,
): Promise<string[]> {
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)) {