mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
refactor: centralize root memory file policy
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
63
src/memory/root-memory-files.ts
Normal file
63
src/memory/root-memory-files.ts
Normal 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}/`)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user