fix: store memory-core dreams state in sqlite (#91056)

This commit is contained in:
Peter Steinberger 2026-06-06 18:38:45 -07:00 committed by GitHub
parent 1222f7a6bc
commit 3f5e001844
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2118 additions and 1018 deletions

View file

@ -124,7 +124,7 @@ export const stateMigrations: PluginDoctorStateMigration[] = [
.map((entry) => normalizeAcpxProcessLease(entry.value))
.filter(
(lease): lease is AcpxProcessLease =>
Boolean(lease) && (lease.state === "open" || lease.state === "closing"),
lease !== undefined && (lease.state === "open" || lease.state === "closing"),
);
const leaseGatewayIds = new Set(openLeases.map((lease) => lease.gatewayInstanceId));
const onlyLeaseGatewayId = leaseGatewayIds.size === 1 ? [...leaseGatewayIds][0] : null;

View file

@ -14,6 +14,7 @@ export { previewGroundedRemMarkdown } from "./src/rem-evidence.js";
export { filterRecallEntriesWithinLookback } from "./src/dreaming-phases.js";
export { previewRemHarness } from "./src/rem-harness.js";
export type { PreviewRemHarnessOptions, PreviewRemHarnessResult } from "./src/rem-harness.js";
export { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
export {
buildDreamingShadowTrialReport,
defaultDreamingShadowTrialReportPath,

View file

@ -0,0 +1,261 @@
// Memory Core tests cover doctor migration of legacy dreaming state.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import { testing as dreamingTesting } from "./src/dreaming-phases.js";
import {
configureMemoryCoreDreamingState,
resetMemoryCoreDreamingStateForTests,
} from "./src/dreaming-state.js";
import { testing as shortTermTesting } from "./src/short-term-promotion.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("memory-core", {
...options,
env: options.env ?? env,
});
},
};
}
describe("memory-core doctor dreaming migration", () => {
let rootDir = "";
let workspaceDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-core-doctor-"));
workspaceDir = path.join(rootDir, "workspace");
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams"), { recursive: true });
env = { ...process.env, OPENCLAW_STATE_DIR: path.join(rootDir, "state") };
});
afterEach(async () => {
resetMemoryCoreDreamingStateForTests();
await fs.rm(rootDir, { recursive: true, force: true });
});
function context(): PluginDoctorStateMigrationContext {
return createDoctorContext(env);
}
function migrationParams(
config: unknown = {
agents: {
list: [{ id: "main", workspace: workspaceDir }],
},
},
) {
return {
config,
env,
stateDir: path.join(rootDir, "state"),
oauthDir: path.join(rootDir, "oauth"),
context: context(),
};
}
it("imports persistent legacy dreaming state and ignores transient locks", async () => {
const dreamsDir = path.join(workspaceDir, "memory", ".dreams");
const dailyPath = path.join(dreamsDir, "daily-ingestion.json");
const sessionPath = path.join(dreamsDir, "session-ingestion.json");
const recallPath = path.join(dreamsDir, "short-term-recall.json");
const phasePath = path.join(dreamsDir, "phase-signals.json");
const lockPath = path.join(dreamsDir, "short-term-promotion.lock");
await fs.writeFile(
dailyPath,
JSON.stringify({
version: 1,
files: {
"memory/2026-04-05.md": {
size: 42,
mtimeMs: 1,
contentHash: "daily-hash",
ingestedAt: "2026-04-05T10:00:00.000Z",
},
},
}),
"utf8",
);
await fs.writeFile(
sessionPath,
JSON.stringify({
version: 1,
files: {
"main/session.jsonl": {
size: 91,
mtimeMs: 2,
lineCount: 3,
lastContentLine: 3,
contentHash: "session-hash",
ingestedAt: "2026-04-05T11:00:00.000Z",
},
},
seenMessages: {
"main/session.jsonl": ["seen-a", "seen-b"],
},
}),
"utf8",
);
await fs.writeFile(
recallPath,
JSON.stringify({
version: 1,
updatedAt: "2026-04-05T12:00:00.000Z",
entries: {
"memory:memory/2026-04-05.md:1:1": {
key: "memory:memory/2026-04-05.md:1:1",
path: "memory/2026-04-05.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "Move backups to S3 Glacier.",
recallCount: 1,
totalScore: 0.9,
maxScore: 0.9,
firstRecalledAt: "2026-04-05T12:00:00.000Z",
lastRecalledAt: "2026-04-05T12:00:00.000Z",
queryHashes: ["hash-a"],
},
},
}),
"utf8",
);
await fs.writeFile(
phasePath,
JSON.stringify({
version: 1,
updatedAt: "2026-04-05T13:00:00.000Z",
entries: {
"memory:memory/2026-04-05.md:1:1": {
key: "memory:memory/2026-04-05.md:1:1",
lightHits: 1,
remHits: 2,
lastLightAt: "2026-04-05T12:00:00.000Z",
lastRemAt: "2026-04-05T13:00:00.000Z",
},
},
}),
"utf8",
);
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf8");
const migration = stateMigrations[0];
const preview = await migration.detectLegacyState(migrationParams());
expect(preview?.preview).toEqual([
expect.stringContaining("Memory Core daily ingestion"),
expect.stringContaining("Memory Core session ingestion"),
expect.stringContaining("Memory Core short-term recall"),
expect.stringContaining("Memory Core phase signals"),
]);
expect(preview?.preview.join("\n")).not.toContain("short-term-promotion.lock");
const result = await migration.migrateLegacyState(migrationParams());
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Memory Core daily ingestion -> SQLite plugin state (1 row(s))",
expect.stringContaining("Archived Memory Core daily ingestion legacy source"),
"Migrated Memory Core session ingestion -> SQLite plugin state (2 row(s))",
expect.stringContaining("Archived Memory Core session ingestion legacy source"),
"Migrated Memory Core short-term recall -> SQLite plugin state (1 row(s))",
expect.stringContaining("Archived Memory Core short-term recall legacy source"),
"Migrated Memory Core phase signals -> SQLite plugin state (1 row(s))",
expect.stringContaining("Archived Memory Core phase signals legacy source"),
]);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
await expect(fs.access(`${dailyPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${sessionPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${recallPath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(`${phasePath}.migrated`)).resolves.toBeUndefined();
await expect(fs.access(lockPath)).resolves.toBeUndefined();
const daily = await dreamingTesting.readDailyIngestionState(workspaceDir);
expect(daily.files["memory/2026-04-05.md"]?.mtimeMs).toBe(1);
const session = await dreamingTesting.readSessionIngestionState(workspaceDir);
expect(session.files["main/session.jsonl"]?.contentHash).toBe("session-hash");
expect(session.seenMessages["main/session.jsonl"]).toEqual(["seen-a", "seen-b"]);
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
const phase = await shortTermTesting.readPhaseSignalStore(
workspaceDir,
"2026-04-05T13:00:00.000Z",
);
expect(phase.entries["memory:memory/2026-04-05.md:1:1"]?.remHits).toBe(2);
});
it("leaves invalid legacy JSON in place", async () => {
const recallPath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.writeFile(recallPath, "{", "utf8");
const result = await stateMigrations[0].migrateLegacyState(migrationParams());
expect(result.changes).toEqual([]);
expect(result.warnings).toEqual([
expect.stringContaining("Skipped Memory Core short-term recall import"),
]);
await expect(fs.access(recallPath)).resolves.toBeUndefined();
await expect(fs.access(`${recallPath}.migrated`)).rejects.toThrow();
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
const recall = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
expect(recall.entries).toEqual({});
});
it("uses migration env when resolving default workspaces", async () => {
env = { ...env, OPENCLAW_WORKSPACE_DIR: workspaceDir };
const recallPath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.writeFile(
recallPath,
JSON.stringify({
version: 1,
updatedAt: "2026-04-05T12:00:00.000Z",
entries: {
"memory:memory/2026-04-05.md:1:1": {
key: "memory:memory/2026-04-05.md:1:1",
path: "memory/2026-04-05.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "Move backups to S3 Glacier.",
recallCount: 1,
totalScore: 0.9,
maxScore: 0.9,
firstRecalledAt: "2026-04-05T12:00:00.000Z",
lastRecalledAt: "2026-04-05T12:00:00.000Z",
queryHashes: ["hash-a"],
},
},
}),
"utf8",
);
const config = { agents: { list: [{ id: "main", default: true }] } };
const preview = await stateMigrations[0].detectLegacyState(migrationParams(config));
expect(preview?.preview).toEqual([expect.stringContaining("Memory Core short-term recall")]);
const result = await stateMigrations[0].migrateLegacyState(migrationParams(config));
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Memory Core short-term recall -> SQLite plugin state (1 row(s))",
expect.stringContaining("Archived Memory Core short-term recall legacy source"),
]);
configureMemoryCoreDreamingState(context().openPluginStateKeyedStore);
const recall = await shortTermTesting.readRecallStore(workspaceDir, "2026-04-05T12:00:00.000Z");
expect(recall.entries["memory:memory/2026-04-05.md:1:1"]?.conceptTags).toContain("glacier");
});
});

View file

@ -0,0 +1,270 @@
// Memory Core doctor contract migrates shipped workspace dreaming state.
import fs from "node:fs/promises";
import path from "node:path";
import { resolveMemoryDreamingWorkspaces } from "openclaw/plugin-sdk/memory-core-host-status";
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
import {
DAILY_INGESTION_STATE_RELATIVE_PATH,
SESSION_INGESTION_STATE_RELATIVE_PATH,
normalizeDailyIngestionState,
normalizeSessionIngestionState,
} from "./src/dreaming-phases.js";
import {
DREAMING_DAILY_INGESTION_NAMESPACE,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
SESSION_SEEN_HASHES_PER_CHUNK,
SHORT_TERM_META_NAMESPACE,
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
SHORT_TERM_RECALL_NAMESPACE,
configureMemoryCoreDreamingState,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntry,
} from "./src/dreaming-state.js";
import {
SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH,
SHORT_TERM_STORE_RELATIVE_PATH,
normalizeShortTermPhaseSignalStore,
normalizeShortTermRecallStore,
} from "./src/short-term-promotion.js";
type LegacySource = {
workspaceDir: string;
label: string;
filePath: string;
};
function resolveConfiguredWorkspaces(config: unknown, env: NodeJS.ProcessEnv): string[] {
return resolveMemoryDreamingWorkspaces(
config as Parameters<typeof resolveMemoryDreamingWorkspaces>[0],
{ env },
).map((entry) => entry.workspaceDir);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
async function readJsonFile(filePath: string): Promise<unknown | null> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
}
async function archiveLegacySource(params: {
filePath: string;
label: string;
changes: string[];
warnings: string[];
}): Promise<void> {
const archivedPath = `${params.filePath}.migrated`;
if (await fileExists(archivedPath)) {
params.warnings.push(
`Left migrated Memory Core ${params.label} source in place because ${archivedPath} already exists`,
);
return;
}
try {
await fs.rename(params.filePath, archivedPath);
params.changes.push(`Archived Memory Core ${params.label} legacy source -> ${archivedPath}`);
} catch (err) {
params.warnings.push(
`Failed archiving Memory Core ${params.label} legacy source: ${String(err)}`,
);
}
}
async function collectLegacySources(
config: unknown,
env: NodeJS.ProcessEnv,
): Promise<LegacySource[]> {
const sources: LegacySource[] = [];
for (const workspaceDir of resolveConfiguredWorkspaces(config, env)) {
const candidates = [
{ label: "daily ingestion", relativePath: DAILY_INGESTION_STATE_RELATIVE_PATH },
{ label: "session ingestion", relativePath: SESSION_INGESTION_STATE_RELATIVE_PATH },
{ label: "short-term recall", relativePath: SHORT_TERM_STORE_RELATIVE_PATH },
{ label: "phase signals", relativePath: SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH },
];
for (const candidate of candidates) {
const filePath = path.join(workspaceDir, candidate.relativePath);
if (await fileExists(filePath)) {
sources.push({ workspaceDir, label: candidate.label, filePath });
}
}
}
return sources;
}
async function workspaceHasRows(namespace: string, workspaceDir: string): Promise<boolean> {
return (await readMemoryCoreWorkspaceEntries({ namespace, workspaceDir })).length > 0;
}
async function migrateDailyIngestion(source: LegacySource): Promise<number> {
const state = normalizeDailyIngestionState(await readJsonFile(source.filePath));
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
});
return Object.keys(state.files).length;
}
async function migrateSessionIngestion(source: LegacySource): Promise<number> {
const state = normalizeSessionIngestionState(await readJsonFile(source.filePath));
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
Array.from(
{ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) },
(_, index) => ({
key: `${scope}:${index}`,
value: {
scope,
index,
hashes: hashes.slice(
index * SESSION_SEEN_HASHES_PER_CHUNK,
(index + 1) * SESSION_SEEN_HASHES_PER_CHUNK,
),
},
}),
),
);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir: source.workspaceDir,
entries: seenEntries,
}),
]);
return Object.keys(state.files).length + Object.keys(state.seenMessages).length;
}
async function migrateShortTermRecall(source: LegacySource): Promise<number> {
const nowIso = new Date().toISOString();
const state = normalizeShortTermRecallStore(await readJsonFile(source.filePath), nowIso);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.workspaceDir,
key: "recall",
value: { updatedAt: state.updatedAt },
}),
]);
return Object.keys(state.entries).length;
}
async function migratePhaseSignals(source: LegacySource): Promise<number> {
const nowIso = new Date().toISOString();
const state = normalizeShortTermPhaseSignalStore(await readJsonFile(source.filePath), nowIso);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir: source.workspaceDir,
entries: Object.entries(state.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir: source.workspaceDir,
key: "phase",
value: { updatedAt: state.updatedAt },
}),
]);
return Object.keys(state.entries).length;
}
function targetNamespacesForSource(label: string): string[] {
if (label === "daily ingestion") {
return [DREAMING_DAILY_INGESTION_NAMESPACE];
}
if (label === "session ingestion") {
return [DREAMING_SESSION_INGESTION_FILES_NAMESPACE, DREAMING_SESSION_INGESTION_SEEN_NAMESPACE];
}
if (label === "short-term recall") {
return [SHORT_TERM_RECALL_NAMESPACE];
}
return [SHORT_TERM_PHASE_SIGNAL_NAMESPACE];
}
async function migrateSource(source: LegacySource): Promise<number> {
if (source.label === "daily ingestion") {
return await migrateDailyIngestion(source);
}
if (source.label === "session ingestion") {
return await migrateSessionIngestion(source);
}
if (source.label === "short-term recall") {
return await migrateShortTermRecall(source);
}
return await migratePhaseSignals(source);
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "memory-core-dreams-json-to-sqlite",
label: "Memory Core dreaming state",
async detectLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const sources = await collectLegacySources(params.config, params.env);
if (sources.length === 0) {
return null;
}
return {
preview: sources.map(
(source) => `- Memory Core ${source.label}: ${source.filePath} -> SQLite plugin state`,
),
};
},
async migrateLegacyState(params) {
configureMemoryCoreDreamingState(params.context.openPluginStateKeyedStore);
const changes: string[] = [];
const warnings: string[] = [];
for (const source of await collectLegacySources(params.config, params.env)) {
const targetHasRows = (
await Promise.all(
targetNamespacesForSource(source.label).map((namespace) =>
workspaceHasRows(namespace, source.workspaceDir),
),
)
).some(Boolean);
if (targetHasRows) {
warnings.push(
`Skipped Memory Core ${source.label} import for ${source.workspaceDir} because SQLite rows already exist; left legacy source in place`,
);
continue;
}
let imported: number;
try {
imported = await migrateSource(source);
} catch (err) {
warnings.push(
`Skipped Memory Core ${source.label} import for ${source.workspaceDir} because the legacy source could not be imported: ${String(err)}`,
);
continue;
}
changes.push(
`Migrated Memory Core ${source.label} -> SQLite plugin state (${imported} row(s))`,
);
await archiveLegacySource({
filePath: source.filePath,
label: source.label,
changes,
warnings,
});
}
return { changes, warnings };
},
},
];

View file

@ -12,7 +12,9 @@ import {
type AnyAgentTool,
type OpenClawPluginToolContext,
} from "openclaw/plugin-sdk/plugin-entry";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import type { TSchema } from "typebox";
import { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
import { registerShortTermPromotionDreaming } from "./src/dreaming.js";
import { buildMemoryFlushPlan } from "./src/flush-plan.js";
import { registerBuiltInMemoryEmbeddingProviders } from "./src/memory/provider-adapters.js";
@ -178,6 +180,9 @@ export default definePluginEntry({
description: "File-backed memory search tools and CLI",
kind: "memory",
register(api) {
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
api.runtime.state.openKeyedStore<T>(options),
);
registerBuiltInMemoryEmbeddingProviders(api);
registerShortTermPromotionDreaming(api);
api.registerMemoryCapability({

View file

@ -17,8 +17,10 @@ export {
export { checkQmdBinaryAvailability } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
export { hasConfiguredMemorySecretInput } from "openclaw/plugin-sdk/memory-core-host-secret";
export { auditDreamingArtifacts, repairDreamingArtifacts } from "./src/dreaming-repair.js";
export { configureMemoryCoreDreamingState } from "./src/dreaming-state.js";
export {
auditShortTermPromotionArtifacts,
loadShortTermPromotionDreamingStats,
removeGroundedShortTermCandidates,
repairShortTermPromotionArtifacts,
} from "./src/short-term-promotion.js";
@ -29,5 +31,7 @@ export type {
} from "./src/dreaming-repair.js";
export type {
RepairShortTermPromotionArtifactsResult,
ShortTermDreamingStats,
ShortTermDreamingStatsEntry,
ShortTermAuditSummary,
} from "./src/short-term-promotion.js";

View file

@ -10,7 +10,15 @@ import {
spyRuntimeLogs,
} from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { readShortTermRecallEntries, recordShortTermRecalls } from "./short-term-promotion.js";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "./dreaming-state.js";
import {
readShortTermRecallEntries,
recordShortTermRecalls,
testing as shortTermTesting,
} from "./short-term-promotion.js";
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const getRuntimeConfig = vi.hoisted(() => vi.fn(() => ({})));
@ -73,6 +81,7 @@ let workspaceCaseId = 0;
let qmdCaseId = 0;
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
({ registerMemoryCli } = await import("./cli.js"));
({ defaultRuntime, isVerbose, setVerbose } =
await import("openclaw/plugin-sdk/memory-core-host-runtime-cli"));
@ -104,6 +113,7 @@ afterAll(async () => {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
resetMemoryCoreDreamingStateForTests();
});
describe("memory cli", () => {
@ -702,42 +712,33 @@ describe("memory cli", () => {
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "QMD router cache note",
recallCount: 1,
totalScore: 0.8,
maxScore: 0.8,
firstRecalledAt: "2026-04-04T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a"],
},
bad: {
path: "",
},
},
await shortTermTesting.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "QMD router cache note",
recallCount: 1,
totalScore: 0.8,
maxScore: 0.8,
firstRecalledAt: "2026-04-04T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a"],
},
null,
2,
),
"utf-8",
);
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
await fs.writeFile(lockPath, "999999:0\n", "utf-8");
const staleMtime = new Date(Date.now() - 120_000);
await fs.utimes(lockPath, staleMtime, staleMtime);
bad: {
path: "",
},
},
});
await shortTermTesting.writeShortTermLock(workspaceDir, {
owner: "999999:0",
acquiredAt: Date.now() - 120_000,
});
const close = vi.fn(async () => {});
mockManager({
@ -750,8 +751,8 @@ describe("memory cli", () => {
await runMemoryCli(["status", "--fix"]);
expectLogged(log, "Repair: rewrote store");
await expectPathMissing(lockPath);
const repaired = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
const audit = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
const repaired = audit as {
entries: Record<string, { conceptTags?: string[] }>;
};
expect(repaired.entries.good?.conceptTags).toContain("router");
@ -761,8 +762,15 @@ describe("memory cli", () => {
it("shows the fix hint only before --fix has been run", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.writeFile(storePath, " \n", "utf-8");
await shortTermTesting.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
bad: {
path: "",
},
},
});
const close = vi.fn(async () => {});
mockManager({
@ -2054,32 +2062,11 @@ describe("memory cli", () => {
await runMemoryCli(["search", "glacier", "--json"]);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<
string,
{
key: string;
path: string;
startLine: number;
endLine: number;
source: string;
snippet: string;
recallCount: number;
dailyCount: number;
groundedCount: number;
totalScore: number;
maxScore: number;
firstRecalledAt: string;
lastRecalledAt: string;
queryHashes: string[];
recallDays: string[];
conceptTags: string[];
}
>;
};
const entries = Object.values(store.entries ?? {});
const entries = await waitFor(async () => {
const recalled = await readShortTermRecallEntries({ workspaceDir });
expect(recalled).toHaveLength(1);
return recalled;
});
expect(entries).toHaveLength(1);
const entry = entries[0];
if (!entry) {
@ -2160,9 +2147,7 @@ describe("memory cli", () => {
}
expect(payload.results).toHaveLength(1);
expect(payload.results[0]?.path).toBe("memory/2026-04-03.md");
await expectPathMissing(
path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json"),
);
expect(await readShortTermRecallEntries({ workspaceDir })).toHaveLength(0);
expect(close).toHaveBeenCalled();
});
});

View file

@ -21,7 +21,7 @@ import { previewRemHarness } from "./rem-harness.js";
import {
rankShortTermPromotionCandidates,
recordShortTermRecalls,
resolveShortTermPhaseSignalStorePath,
testing as shortTermTesting,
type ShortTermRecallEntry,
} from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
@ -572,9 +572,8 @@ describe("memory-core dreaming phases", () => {
([target]) => typeof target === "string" && target === dailyPath,
).length;
expect(dailyReadCount).toBeLessThanOrEqual(1);
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json")),
).resolves.toBeUndefined();
const dailyIngestion = await testing.readDailyIngestionState(workspaceDir);
expect(Object.keys(dailyIngestion.files)).toHaveLength(1);
});
it("ingests recent daily memory files even before recall traffic exists", async () => {
@ -902,9 +901,8 @@ describe("memory-core dreaming phases", () => {
expect(transcriptReadCount).toBeLessThanOrEqual(1);
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json")),
).resolves.toBeUndefined();
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
expect(Object.keys(sessionIngestion.files)).toContain("main:sessions/main/dreaming-main.jsonl");
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")),
).resolves.toBeUndefined();
@ -1174,21 +1172,7 @@ describe("memory-core dreaming phases", () => {
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
);
const sessionIngestion = JSON.parse(
await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
"utf-8",
),
) as {
files: Record<
string,
{
lineCount: number;
lastContentLine: number;
contentHash: string;
}
>;
};
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
expect(Object.keys(sessionIngestion.files)).toHaveLength(1);
const ingestionEntry = requireFirstIngestionEntry(sessionIngestion);
expect(ingestionEntry.lineCount).toBe(0);
@ -1284,21 +1268,7 @@ describe("memory-core dreaming phases", () => {
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
);
const sessionIngestion = JSON.parse(
await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
"utf-8",
),
) as {
files: Record<
string,
{
lineCount: number;
lastContentLine: number;
contentHash: string;
}
>;
};
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
expect(Object.keys(sessionIngestion.files)).toHaveLength(1);
const ingestionEntry = requireFirstIngestionEntry(sessionIngestion);
expect(ingestionEntry.lineCount).toBe(0);
@ -1391,21 +1361,7 @@ describe("memory-core dreaming phases", () => {
path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt"),
);
const sessionIngestion = JSON.parse(
await fs.readFile(
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
"utf-8",
),
) as {
files: Record<
string,
{
lineCount: number;
lastContentLine: number;
contentHash: string;
}
>;
};
const sessionIngestion = await testing.readSessionIngestionState(workspaceDir);
const ingestionEntry = requireFirstIngestionEntry(sessionIngestion);
expect(ingestionEntry.lineCount).toBe(0);
expect(ingestionEntry.lastContentLine).toBe(0);
@ -2529,10 +2485,10 @@ describe("memory-core dreaming phases", () => {
const reinforcedCandidate = requireCandidateByKey(reinforced, baseline[0].key);
expect(reinforcedCandidate.score).toBeGreaterThan(baselineScore);
const phaseSignalPath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const phaseSignalStore = JSON.parse(await fs.readFile(phaseSignalPath, "utf-8")) as {
entries: Record<string, { lightHits: number; remHits: number }>;
};
const phaseSignalStore = await shortTermTesting.readPhaseSignalStore(
workspaceDir,
new Date().toISOString(),
);
const baselineSignals = phaseSignalStore.entries[baseline[0].key];
expect(baselineSignals?.lightHits).toBe(1);
expect(baselineSignals?.remHits).toBe(1);
@ -2614,10 +2570,10 @@ describe("memory-core dreaming phases", () => {
});
});
const phaseSignalPath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const phaseSignalStore = JSON.parse(await fs.readFile(phaseSignalPath, "utf-8")) as {
entries: Record<string, { remHits: number }>;
};
const phaseSignalStore = await shortTermTesting.readPhaseSignalStore(
workspaceDir,
new Date().toISOString(),
);
expect(phaseSignalStore.entries[liveKey]?.remHits).toBe(1);
expect(phaseSignalStore.entries[staleKey]).toBeUndefined();

View file

@ -19,7 +19,7 @@ import {
resolveMemoryRemDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { appendRegularFile, privateFileStore } from "openclaw/plugin-sdk/security-runtime";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import {
@ -28,6 +28,14 @@ import {
runDetachedDreamNarrative,
} from "./dreaming-narrative.js";
import { asRecord, formatErrorMessage, normalizeTrimmedString } from "./dreaming-shared.js";
import {
DREAMING_DAILY_INGESTION_NAMESPACE,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
SESSION_SEEN_HASHES_PER_CHUNK,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
} from "./dreaming-state.js";
import { textSimilarity as snippetSimilarity } from "./memory/tokenize.js";
import {
filterLiveShortTermRecallEntries,
@ -80,12 +88,16 @@ const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAILY_MEMORY_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})(?:-[^/]+)?\.md$/i;
const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join("memory", ".dreams", "daily-ingestion.json");
export const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join(
"memory",
".dreams",
"daily-ingestion.json",
);
const DAILY_INGESTION_SCORE = 0.62;
const DAILY_INGESTION_MAX_SNIPPET_CHARS = 280;
const DAILY_INGESTION_MIN_SNIPPET_CHARS = 8;
const DAILY_INGESTION_MAX_CHUNK_LINES = 4;
const SESSION_INGESTION_STATE_RELATIVE_PATH = path.join(
export const SESSION_INGESTION_STATE_RELATIVE_PATH = path.join(
"memory",
".dreams",
"session-ingestion.json",
@ -438,7 +450,7 @@ type DailyIngestionState = {
files: Record<string, DailyIngestionFileState>;
};
function normalizeDailyIngestionState(raw: unknown): DailyIngestionState {
export function normalizeDailyIngestionState(raw: unknown): DailyIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
if (!filesRaw) {
@ -480,24 +492,24 @@ function normalizeMemoryDay(value: unknown): string | undefined {
}
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
try {
return normalizeDailyIngestionState(
await privateFileStore(workspaceDir).readJsonIfExists(DAILY_INGESTION_STATE_RELATIVE_PATH),
);
} catch (err) {
if (err instanceof SyntaxError) {
return { version: 1, files: {} };
}
throw err;
}
const entries = await readMemoryCoreWorkspaceEntries<DailyIngestionFileState>({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
});
return normalizeDailyIngestionState({
version: 1,
files: Object.fromEntries(entries.map((entry) => [entry.key, entry.value])),
});
}
async function writeDailyIngestionState(
workspaceDir: string,
state: DailyIngestionState,
): Promise<void> {
await privateFileStore(workspaceDir).writeJson(DAILY_INGESTION_STATE_RELATIVE_PATH, state, {
trailingNewline: true,
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
});
}
@ -532,7 +544,7 @@ function normalizeWorkspaceKey(workspaceDir: string): string {
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
export function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
const files: Record<string, SessionIngestionFileState> = {};
@ -583,25 +595,67 @@ function normalizeSessionIngestionState(raw: unknown): SessionIngestionState {
}
async function readSessionIngestionState(workspaceDir: string): Promise<SessionIngestionState> {
try {
return normalizeSessionIngestionState(
await privateFileStore(workspaceDir).readJsonIfExists(SESSION_INGESTION_STATE_RELATIVE_PATH),
);
} catch (err) {
if (err instanceof SyntaxError) {
return { version: 3, files: {}, seenMessages: {} };
const [fileEntries, seenChunks] = await Promise.all([
readMemoryCoreWorkspaceEntries<SessionIngestionFileState>({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
}),
readMemoryCoreWorkspaceEntries<{ scope: string; index: number; hashes: string[] }>({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
}),
]);
const seenMessages: Record<string, string[]> = {};
const chunksByScope = new Map<string, Array<{ index: number; hashes: string[] }>>();
for (const chunk of seenChunks) {
const scope = chunk.value.scope.trim();
if (!scope) {
continue;
}
throw err;
const chunks = chunksByScope.get(scope) ?? [];
chunks.push({ index: chunk.value.index, hashes: chunk.value.hashes });
chunksByScope.set(scope, chunks);
}
for (const [scope, chunks] of chunksByScope) {
seenMessages[scope] = chunks
.toSorted((a, b) => a.index - b.index)
.flatMap((chunk) => chunk.hashes);
}
return normalizeSessionIngestionState({
version: 3,
files: Object.fromEntries(fileEntries.map((entry) => [entry.key, entry.value])),
seenMessages,
});
}
async function writeSessionIngestionState(
workspaceDir: string,
state: SessionIngestionState,
): Promise<void> {
await privateFileStore(workspaceDir).writeJson(SESSION_INGESTION_STATE_RELATIVE_PATH, state, {
trailingNewline: true,
});
const seenEntries = Object.entries(state.seenMessages).flatMap(([scope, hashes]) =>
Array.from({ length: Math.ceil(hashes.length / SESSION_SEEN_HASHES_PER_CHUNK) }, (_, index) => {
const chunkHashes = hashes.slice(
index * SESSION_SEEN_HASHES_PER_CHUNK,
(index + 1) * SESSION_SEEN_HASHES_PER_CHUNK,
);
return {
key: `${scope}:${index}`,
value: { scope, index, hashes: chunkHashes },
};
}),
);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
entries: Object.entries(state.files).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
entries: seenEntries,
}),
]);
}
function trimTrackedSessionScopes(
@ -1889,6 +1943,8 @@ async function runPhaseIfTriggered(
export const testing = {
runPhaseIfTriggered,
previewRemDreaming,
readDailyIngestionState,
readSessionIngestionState,
// Exposed for the #80613 regression test that exercises CJK-aware dedupe.
dedupeEntries,
constants: {

View file

@ -2,11 +2,27 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { auditDreamingArtifacts, repairDreamingArtifacts } from "./dreaming-repair.js";
import {
configureMemoryCoreDreamingStateForTests,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
readMemoryCoreWorkspaceEntries,
resetMemoryCoreDreamingStateForTests,
writeMemoryCoreWorkspaceEntries,
} from "./dreaming-state.js";
const tempDirs: string[] = [];
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
});
afterAll(() => {
resetMemoryCoreDreamingStateForTests();
});
async function createWorkspace(): Promise<string> {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "dreaming-repair-test-"));
tempDirs.push(workspaceDir);
@ -148,4 +164,54 @@ describe("dreaming artifact repair", () => {
archivedEntries.filter((entry) => entry.startsWith("session-ingestion.json.")),
).not.toEqual([]);
});
it("clears sqlite session ingestion state when archiving session corpus", async () => {
const workspaceDir = await createWorkspace();
const sessionCorpusDir = path.join(workspaceDir, "memory", ".dreams", "session-corpus");
await fs.mkdir(sessionCorpusDir, { recursive: true });
await fs.writeFile(path.join(sessionCorpusDir, "2026-04-11.txt"), "corpus\n", "utf-8");
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
entries: [
{
key: "main/session.jsonl",
value: {
lastSize: 120,
lastMtimeMs: 1_000,
lastContentHash: "hash",
cursorLine: 42,
},
},
],
}),
writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
entries: [
{
key: "main:0",
value: { scope: "main", index: 0, hashes: ["message-hash"] },
},
],
}),
]);
const repair = await repairDreamingArtifacts({ workspaceDir });
expect(repair.archivedSessionCorpus).toBe(true);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
}),
).resolves.toEqual([]);
await expect(
readMemoryCoreWorkspaceEntries({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
}),
).resolves.toEqual([]);
});
});

View file

@ -3,6 +3,11 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
import {
clearMemoryCoreWorkspaceNamespace,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
} from "./dreaming-state.js";
type DreamingArtifactsAuditIssue = {
severity: "warn" | "error";
@ -125,6 +130,19 @@ async function moveToArchive(params: {
return destination;
}
async function clearSessionIngestionState(workspaceDir: string): Promise<void> {
await Promise.all([
clearMemoryCoreWorkspaceNamespace({
namespace: DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
workspaceDir,
}),
clearMemoryCoreWorkspaceNamespace({
namespace: DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
workspaceDir,
}),
]);
}
export async function auditDreamingArtifacts(params: {
workspaceDir: string;
}): Promise<DreamingArtifactsAuditSummary> {
@ -258,6 +276,18 @@ export async function repairDreamingArtifacts(params: {
archivedPaths.push(sessionIngestionDestination);
}
if (sessionCorpusDestination || sessionIngestionDestination) {
try {
await clearSessionIngestionState(workspaceDir);
} catch (err) {
warnings.push(
`Failed clearing dreaming session-ingestion SQLite state: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
if (params.archiveDiary) {
const dreamsPath = await resolveExistingDreamsPath(workspaceDir);
if (dreamsPath) {

View file

@ -0,0 +1,170 @@
// Memory Core dreaming state lives in SQLite-backed plugin state.
import { createHash } from "node:crypto";
import path from "node:path";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
export const MEMORY_CORE_PLUGIN_ID = "memory-core";
export const DREAMING_DAILY_INGESTION_NAMESPACE = "dreaming-daily-ingestion";
export const DREAMING_SESSION_INGESTION_FILES_NAMESPACE = "dreaming-session-ingestion-files";
export const DREAMING_SESSION_INGESTION_SEEN_NAMESPACE = "dreaming-session-ingestion-seen";
export const SHORT_TERM_RECALL_NAMESPACE = "short-term-recall";
export const SHORT_TERM_PHASE_SIGNAL_NAMESPACE = "short-term-phase-signals";
export const SHORT_TERM_META_NAMESPACE = "short-term-meta";
export const SHORT_TERM_LOCK_NAMESPACE = "short-term-locks";
export const DREAMING_WORKSPACE_STATE_MAX_ENTRIES = 50_000;
export const SHORT_TERM_LOCK_MAX_ENTRIES = 4_096;
export const SESSION_SEEN_HASHES_PER_CHUNK = 512;
export type MemoryCoreOpenKeyedStore = <T>(
options: OpenKeyedStoreOptions,
) => PluginStateKeyedStore<T>;
type WorkspaceValue<T> = {
version: 1;
workspaceKey: string;
workspaceDir: string;
key: string;
value: T;
};
let configuredOpenKeyedStore: MemoryCoreOpenKeyedStore | undefined;
export function configureMemoryCoreDreamingState(openKeyedStore: MemoryCoreOpenKeyedStore): void {
configuredOpenKeyedStore = openKeyedStore;
}
export async function configureMemoryCoreDreamingStateForTests(
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
const { createPluginStateKeyedStoreForTests } =
await import("openclaw/plugin-sdk/plugin-state-test-runtime");
const testEnv = { ...env };
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests<T>(MEMORY_CORE_PLUGIN_ID, { ...options, env: testEnv }),
);
}
export function resetMemoryCoreDreamingStateForTests(): void {
configuredOpenKeyedStore = undefined;
}
export function openMemoryCoreStateStore<T>(
options: OpenKeyedStoreOptions,
): PluginStateKeyedStore<T> {
if (!configuredOpenKeyedStore) {
throw new Error("memory-core dreaming SQLite state store is not configured");
}
return configuredOpenKeyedStore<T>(options);
}
export function normalizeMemoryCoreWorkspaceKey(workspaceDir: string): string {
const resolved = path.resolve(workspaceDir).replace(/\\/g, "/");
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
export function memoryCoreWorkspaceStateKey(workspaceDir: string): string {
return createHash("sha256").update(normalizeMemoryCoreWorkspaceKey(workspaceDir)).digest("hex");
}
export function memoryCoreWorkspaceEntryKey(workspaceDir: string, logicalKey: string): string {
const workspaceKey = memoryCoreWorkspaceStateKey(workspaceDir);
const itemKey = createHash("sha256").update(logicalKey).digest("hex");
return `${workspaceKey}:${itemKey}`;
}
export function memoryCoreStateReference(namespace: string, workspaceDir: string): string {
return `plugin-state:${MEMORY_CORE_PLUGIN_ID}/${namespace}/${memoryCoreWorkspaceStateKey(workspaceDir)}`;
}
function openWorkspaceStore<T>(namespace: string): PluginStateKeyedStore<WorkspaceValue<T>> {
return openMemoryCoreStateStore<WorkspaceValue<T>>({
namespace,
maxEntries: DREAMING_WORKSPACE_STATE_MAX_ENTRIES,
});
}
export async function readMemoryCoreWorkspaceEntries<T>(params: {
namespace: string;
workspaceDir: string;
}): Promise<Array<{ key: string; value: T }>> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const entries = await openWorkspaceStore<T>(params.namespace).entries();
return entries
.filter((entry) => entry.key.startsWith(prefix) && entry.value.workspaceKey === workspaceKey)
.map((entry) => ({ key: entry.value.key, value: entry.value.value }));
}
export async function writeMemoryCoreWorkspaceEntries<T>(params: {
namespace: string;
workspaceDir: string;
entries: Array<{ key: string; value: T }>;
}): Promise<void> {
const store = openWorkspaceStore<T>(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
const replacementKeys = new Set<string>();
for (const entry of params.entries) {
const stateKey = memoryCoreWorkspaceEntryKey(params.workspaceDir, entry.key);
replacementKeys.add(stateKey);
await store.register(stateKey, {
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
key: entry.key,
value: entry.value,
});
}
for (const entry of await store.entries()) {
if (entry.key.startsWith(prefix) && !replacementKeys.has(entry.key)) {
await store.delete(entry.key);
}
}
}
export async function writeMemoryCoreWorkspaceEntry<T>(params: {
namespace: string;
workspaceDir: string;
key: string;
value: T;
}): Promise<void> {
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
await openWorkspaceStore<T>(params.namespace).register(
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
{
version: 1,
workspaceKey,
workspaceDir: path.resolve(params.workspaceDir),
key: params.key,
value: params.value,
},
);
}
export async function deleteMemoryCoreWorkspaceEntry(params: {
namespace: string;
workspaceDir: string;
key: string;
}): Promise<boolean> {
return await openWorkspaceStore(params.namespace).delete(
memoryCoreWorkspaceEntryKey(params.workspaceDir, params.key),
);
}
export async function clearMemoryCoreWorkspaceNamespace(params: {
namespace: string;
workspaceDir: string;
}): Promise<void> {
const store = openWorkspaceStore(params.namespace);
const workspaceKey = memoryCoreWorkspaceStateKey(params.workspaceDir);
const prefix = `${workspaceKey}:`;
for (const entry of await store.entries()) {
if (entry.key.startsWith(prefix)) {
await store.delete(entry.key);
}
}
}

View file

@ -14,7 +14,7 @@ import {
resolveShortTermPromotionDreamingConfig,
runShortTermDreamingPromotionIfTriggered,
} from "./dreaming.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import { recordShortTermRecalls, testing as shortTermTesting } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
const constants = testing.constants;
@ -2562,38 +2562,28 @@ describe("short-term dreaming trigger", () => {
"Move backups to S3 Glacier and sync router failover notes.",
"Keep router recovery docs current.",
]);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-01T00:00:00.000Z",
entries: {
"memory:memory/2026-04-03.md:1:2": {
key: "memory:memory/2026-04-03.md:1:2",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Move backups to S3 Glacier and sync router failover notes.",
recallCount: 3,
totalScore: 2.7,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-03T00:00:00.000Z",
queryHashes: ["abc", "abc", "def"],
recallDays: ["2026-04-01", "2026-04-01", "2026-04-03"],
conceptTags: [],
},
},
await shortTermTesting.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-01T00:00:00.000Z",
entries: {
"memory:memory/2026-04-03.md:1:2": {
key: "memory:memory/2026-04-03.md:1:2",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Move backups to S3 Glacier and sync router failover notes.",
recallCount: 3,
totalScore: 2.7,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-03T00:00:00.000Z",
queryHashes: ["abc", "abc", "def"],
recallDays: ["2026-04-01", "2026-04-01", "2026-04-03"],
conceptTags: [],
},
null,
2,
)}\n`,
"utf-8",
);
},
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
@ -2614,12 +2604,7 @@ describe("short-term dreaming trigger", () => {
expect(result?.handled).toBe(true);
expectLogContains(logger.info, "normalized recall artifacts before dreaming");
const repaired = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<
string,
{ queryHashes?: string[]; recallDays?: string[]; conceptTags?: string[] }
>;
};
const repaired = await shortTermTesting.readRecallStore(workspaceDir, new Date().toISOString());
expect(repaired.entries["memory:memory/2026-04-03.md:1:2"]?.queryHashes).toEqual([
"abc",
"def",

View file

@ -2,27 +2,25 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk/memory-host-events", () => ({
appendMemoryHostEvent: vi.fn(async () => {}),
}));
vi.mock("openclaw/plugin-sdk/security-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/security-runtime")>(
"openclaw/plugin-sdk/security-runtime",
);
return {
...actual,
privateFileStore: vi.fn((rootDir: string) => actual.privateFileStore(rootDir)),
};
});
import { privateFileStore } from "openclaw/plugin-sdk/security-runtime";
import {
configureMemoryCoreDreamingState,
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
} from "./dreaming-state.js";
import {
applyShortTermPromotions,
auditShortTermPromotionArtifacts,
isShortTermMemoryPath,
loadShortTermPromotionDreamingStats,
recordGroundedShortTermCandidates,
rankShortTermPromotionCandidates,
recordDreamingPhaseSignals,
@ -31,9 +29,6 @@ import {
readLightStagedKeys,
removeGroundedShortTermCandidates,
repairShortTermPromotionArtifacts,
resolveShortTermRecallLockPath,
resolveShortTermPhaseSignalStorePath,
resolveShortTermRecallStorePath,
testing,
} from "./short-term-promotion.js";
@ -42,6 +37,7 @@ describe("short-term promotion", () => {
let caseId = 0;
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-promote-"));
});
@ -50,6 +46,7 @@ describe("short-term promotion", () => {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
resetMemoryCoreDreamingStateForTests();
});
afterEach(() => {
@ -118,21 +115,9 @@ describe("short-term promotion", () => {
}
>
> {
const raw = await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8");
const store = JSON.parse(raw) as {
entries?: Record<
string,
{
claimHash?: unknown;
firstRecalledAt?: unknown;
lastRecalledAt?: unknown;
recallCount?: unknown;
snippet?: unknown;
totalScore?: unknown;
}
>;
};
return store.entries ?? {};
return await testing
.readRecallStore(workspaceDir, new Date().toISOString())
.then((store) => store.entries);
}
function readEntrySnippet(entry: { snippet?: unknown }): string {
@ -181,10 +166,8 @@ describe("short-term promotion", () => {
},
],
});
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const raw = await fs.readFile(storePath, "utf-8");
const store = JSON.parse(raw) as Record<string, unknown>;
expect(Object.keys(store).length).toBeGreaterThan(0);
const store = await testing.readRecallStore(workspaceDir, new Date().toISOString());
expect(Object.keys(store.entries).length).toBeGreaterThan(0);
});
});
@ -252,7 +235,9 @@ describe("short-term promotion", () => {
],
});
const raw = await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8");
const raw = JSON.stringify(
await testing.readRecallStore(workspaceDir, new Date().toISOString()),
);
expect(raw).toContain("memory/daily notes/2026-04-03.md");
expect(raw).toContain("memory/日记/2026-04-04.md");
});
@ -358,7 +343,7 @@ describe("short-term promotion", () => {
],
});
await expectEnoent(fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8"));
expect(await readRecallStoreEntries(workspaceDir)).toEqual({});
});
});
@ -379,7 +364,7 @@ describe("short-term promotion", () => {
],
});
await expectEnoent(fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8"));
expect(await readRecallStoreEntries(workspaceDir)).toEqual({});
});
});
@ -401,9 +386,7 @@ describe("short-term promotion", () => {
],
});
const store = JSON.parse(
await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8"),
) as { version?: number; entries?: unknown };
const store = await testing.readRecallStore(workspaceDir, new Date().toISOString());
expect(store.version).toBe(1);
expect(store.entries).toEqual({});
});
@ -432,9 +415,7 @@ describe("short-term promotion", () => {
],
});
const store = JSON.parse(
await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8"),
) as { version?: number; entries?: unknown };
const store = await testing.readRecallStore(workspaceDir, new Date().toISOString());
expect(store.version).toBe(1);
expect(store.entries).toEqual({});
});
@ -458,9 +439,7 @@ describe("short-term promotion", () => {
],
});
const store = JSON.parse(
await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8"),
) as { entries: Record<string, { snippet: string }> };
const store = await testing.readRecallStore(workspaceDir, new Date().toISOString());
const entries = Object.values(store.entries);
expect(entries).toHaveLength(1);
expect(entries[0]?.snippet).toBe(
@ -523,8 +502,9 @@ describe("short-term promotion", () => {
expect(ranked[0]?.conceptTags).toContain("router");
expect(ranked[0]?.components.conceptual).toBeGreaterThan(0);
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const raw = await fs.readFile(storePath, "utf-8");
const raw = JSON.stringify(
await testing.readRecallStore(workspaceDir, new Date().toISOString()),
);
expect(raw).toContain("memory/2026-04-02.md");
expect(raw).not.toContain("Long-term note");
});
@ -1032,10 +1012,10 @@ describe("short-term promotion", () => {
expect(ranked[0]?.path).toBe("memory/2026-04-02.md");
expect(ranked[0].score).toBeGreaterThan(ranked[1].score);
const phaseStorePath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const phaseStore = JSON.parse(await fs.readFile(phaseStorePath, "utf-8")) as {
entries: Record<string, { lightHits: number; remHits: number }>;
};
const phaseStore = await testing.readPhaseSignalStore(
workspaceDir,
new Date(nowMs).toISOString(),
);
expect(phaseStore.entries[boostedKey]?.lightHits).toBe(1);
expect(phaseStore.entries[boostedKey]?.remHits).toBe(1);
});
@ -1116,7 +1096,7 @@ describe("short-term promotion", () => {
});
});
it("propagates unreadable phase-signal store errors without overwriting existing signals", async () => {
it("updates existing phase-signal rows without dropping prior signal counts", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
@ -1147,66 +1127,71 @@ describe("short-term promotion", () => {
throw new Error("expected ranked candidate key");
}
const phaseStorePath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const existingRaw = `${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-01T10:00:00.000Z",
entries: {
[key]: {
key,
lightHits: 2,
remHits: 1,
lastLightAt: "2026-04-01T10:00:00.000Z",
lastRemAt: "2026-04-02T10:00:00.000Z",
},
await testing.writeRawPhaseSignalStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-01T10:00:00.000Z",
entries: {
[key]: {
key,
lightHits: 2,
remHits: 1,
lastLightAt: "2026-04-01T10:00:00.000Z",
lastRemAt: "2026-04-02T10:00:00.000Z",
},
},
null,
2,
)}\n`;
await fs.writeFile(phaseStorePath, existingRaw, "utf-8");
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/security-runtime")>(
"openclaw/plugin-sdk/security-runtime",
);
const mockedPrivateFileStore = vi.mocked(privateFileStore);
mockedPrivateFileStore.mockImplementation((rootDir: string) => {
const store = actual.privateFileStore(rootDir);
return {
...store,
readJsonIfExists: (async <T = unknown>(
relativePath: string,
options?: Parameters<typeof store.readJsonIfExists>[1],
): Promise<T | null> => {
if (rootDir === workspaceDir && store.path(relativePath) === phaseStorePath) {
const err = new Error("permission denied") as NodeJS.ErrnoException;
err.code = "EACCES";
throw err;
}
return store.readJsonIfExists<T>(relativePath, options);
}) as typeof store.readJsonIfExists,
};
});
try {
await expect(
recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [key],
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
}),
).rejects.toMatchObject({
code: "EACCES",
});
} finally {
mockedPrivateFileStore.mockImplementation((rootDir: string) =>
actual.privateFileStore(rootDir),
);
}
await recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [key],
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
expect(await fs.readFile(phaseStorePath, "utf-8")).toBe(existingRaw);
const phaseStore = await testing.readPhaseSignalStore(
workspaceDir,
"2026-04-05T10:00:00.000Z",
);
expect(phaseStore.entries[key]?.lightHits).toBe(2);
expect(phaseStore.entries[key]?.remHits).toBe(2);
});
});
it("keeps recall stats when phase-signal state cannot be read", async () => {
await withTempWorkspace(async (workspaceDir) => {
const nowMs = Date.parse("2026-04-05T10:00:00.000Z");
await recordShortTermRecalls({
workspaceDir,
query: "glacier cadence",
nowMs,
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const env = { ...process.env };
configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) => {
if (options.namespace === SHORT_TERM_PHASE_SIGNAL_NAMESPACE) {
throw new Error("phase state unavailable");
}
return createPluginStateKeyedStoreForTests<T>("memory-core", { ...options, env });
});
try {
const stats = await loadShortTermPromotionDreamingStats({ workspaceDir, nowMs });
expect(stats.shortTermCount).toBe(1);
expect(stats.recallSignalCount).toBe(1);
expect(stats.phaseSignalCount).toBe(0);
expect(stats.phaseSignalError).toContain("phase state unavailable");
} finally {
await configureMemoryCoreDreamingStateForTests();
}
});
});
@ -1249,14 +1234,11 @@ describe("short-term promotion", () => {
expect(firstApply.appended).toBe(1);
expect(firstApply.reconciledExisting).toBe(0);
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const rawStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<string, { promotedAt?: string }>;
};
const rawStore = await testing.readRecallStore(workspaceDir, new Date().toISOString());
for (const entry of Object.values(rawStore.entries)) {
delete entry.promotedAt;
}
await fs.writeFile(storePath, `${JSON.stringify(rawStore, null, 2)}\n`, "utf-8");
await testing.writeRawRecallStore(workspaceDir, rawStore);
const secondApply = await applyShortTermPromotions({
workspaceDir,
@ -1318,14 +1300,11 @@ describe("short-term promotion", () => {
expect(firstApply.applied).toBe(1);
expect(firstApply.appended).toBe(1);
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const rawStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<string, { promotedAt?: string }>;
};
const rawStore = await testing.readRecallStore(workspaceDir, new Date().toISOString());
for (const entry of Object.values(rawStore.entries)) {
delete entry.promotedAt;
}
await fs.writeFile(storePath, `${JSON.stringify(rawStore, null, 2)}\n`, "utf-8");
await testing.writeRawRecallStore(workspaceDir, rawStore);
const secondApply = await applyShortTermPromotions({
workspaceDir,
@ -1448,40 +1427,31 @@ describe("short-term promotion", () => {
it("does not rank contaminated dreaming snippets from an existing short-term store", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
contaminated: {
key: "contaminated",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet:
"Reflections: Theme: assistant. confidence: 1.00 evidence: memory/.dreams/session-corpus/2026-04-08.txt:2-2 recalls: 4 status: staged",
recallCount: 4,
dailyCount: 0,
groundedCount: 0,
totalScore: 3.6,
maxScore: 0.95,
firstRecalledAt: "2026-04-03T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
recallDays: ["2026-04-03", "2026-04-04"],
conceptTags: ["assistant"],
},
},
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
contaminated: {
key: "contaminated",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet:
"Reflections: Theme: assistant. confidence: 1.00 evidence: memory/.dreams/session-corpus/2026-04-08.txt:2-2 recalls: 4 status: staged",
recallCount: 4,
dailyCount: 0,
groundedCount: 0,
totalScore: 3.6,
maxScore: 0.95,
firstRecalledAt: "2026-04-03T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
recallDays: ["2026-04-03", "2026-04-04"],
conceptTags: ["assistant"],
},
null,
2,
),
"utf-8",
);
},
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
@ -2844,43 +2814,33 @@ describe("short-term promotion", () => {
it("audits and repairs invalid store metadata plus stale locks", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Gateway host uses qmd vector search for router notes.",
recallCount: 2,
totalScore: 1.8,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
},
bad: {
path: "",
},
},
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Gateway host uses qmd vector search for router notes.",
recallCount: 2,
totalScore: 1.8,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
},
null,
2,
),
"utf-8",
);
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
await fs.writeFile(lockPath, "999999:0\n", "utf-8");
const staleMtime = new Date(Date.now() - 120_000);
await fs.utimes(lockPath, staleMtime, staleMtime);
bad: {
path: "",
},
},
});
await testing.writeShortTermLock(workspaceDir, {
owner: "999999:0",
acquiredAt: Date.now() - 120_000,
});
const auditBefore = await auditShortTermPromotionArtifacts({ workspaceDir });
expect(auditBefore.invalidEntryCount).toBe(1);
@ -2898,9 +2858,7 @@ describe("short-term promotion", () => {
expect(auditAfter.invalidEntryCount).toBe(0);
expect(auditAfter.issues.map((issue) => issue.code)).not.toContain("recall-lock-stale");
const repairedRaw = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
entries: Record<string, { conceptTags?: string[]; recallDays?: string[] }>;
};
const repairedRaw = await testing.readRecallStore(workspaceDir, new Date().toISOString());
expect(repairedRaw.entries.good?.conceptTags).toContain("router");
expect(repairedRaw.entries.good?.recallDays).toEqual(["2026-04-04"]);
});
@ -2910,44 +2868,35 @@ describe("short-term promotion", () => {
await withTempWorkspace(async (workspaceDir) => {
const maxEntries = testing.SHORT_TERM_RECALL_MAX_ENTRIES;
const maxSnippetChars = testing.SHORT_TERM_RECALL_MAX_SNIPPET_CHARS;
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: Object.fromEntries(
Array.from({ length: maxEntries + 3 }, (_, index) => [
`entry-${index}`,
{
key: `entry-${index}`,
path: "memory/2026-04-01.md",
startLine: index + 1,
endLine: index + 1,
source: "memory",
snippet: `Oversized recall ${index} ${"x".repeat(maxSnippetChars + 100)}`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: index,
maxScore: 0.75,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: new Date(
Date.parse("2026-04-01T00:00:00.000Z") + index,
).toISOString(),
queryHashes: [`q-${index}`],
recallDays: ["2026-04-01"],
conceptTags: [],
},
]),
),
},
null,
2,
)}\n`,
"utf-8",
);
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: Object.fromEntries(
Array.from({ length: maxEntries + 3 }, (_, index) => [
`entry-${index}`,
{
key: `entry-${index}`,
path: "memory/2026-04-01.md",
startLine: index + 1,
endLine: index + 1,
source: "memory",
snippet: `Oversized recall ${index} ${"x".repeat(maxSnippetChars + 100)}`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: index,
maxScore: 0.75,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: new Date(
Date.parse("2026-04-01T00:00:00.000Z") + index,
).toISOString(),
queryHashes: [`q-${index}`],
recallDays: ["2026-04-01"],
conceptTags: [],
},
]),
),
});
const auditBefore = await auditShortTermPromotionArtifacts({ workspaceDir });
expect(auditBefore.entryCount).toBe(maxEntries + 3);
@ -2973,42 +2922,33 @@ describe("short-term promotion", () => {
it("uses score tie-breakers when capping stores with invalid timestamps", async () => {
await withTempWorkspace(async (workspaceDir) => {
const maxEntries = testing.SHORT_TERM_RECALL_MAX_ENTRIES;
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: Object.fromEntries(
Array.from({ length: maxEntries + 3 }, (_, index) => [
`entry-${index}`,
{
key: `entry-${index}`,
path: "memory/2026-04-01.md",
startLine: index + 1,
endLine: index + 1,
source: "memory",
snippet: `Invalid timestamp recall ${index}`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: index,
maxScore: 0.75,
firstRecalledAt: "not-a-date",
lastRecalledAt: "not-a-date",
queryHashes: [`q-${index}`],
recallDays: ["2026-04-01"],
conceptTags: [],
},
]),
),
},
null,
2,
)}\n`,
"utf-8",
);
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: Object.fromEntries(
Array.from({ length: maxEntries + 3 }, (_, index) => [
`entry-${index}`,
{
key: `entry-${index}`,
path: "memory/2026-04-01.md",
startLine: index + 1,
endLine: index + 1,
source: "memory",
snippet: `Invalid timestamp recall ${index}`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: index,
maxScore: 0.75,
firstRecalledAt: "not-a-date",
lastRecalledAt: "not-a-date",
queryHashes: [`q-${index}`],
recallDays: ["2026-04-01"],
conceptTags: [],
},
]),
),
});
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
@ -3023,39 +2963,30 @@ describe("short-term promotion", () => {
it("rejects long contaminated legacy recall entries before truncating snippets", async () => {
await withTempWorkspace(async (workspaceDir) => {
const maxSnippetChars = testing.SHORT_TERM_RECALL_MAX_SNIPPET_CHARS;
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
contaminated: {
key: "contaminated",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: `Candidate: ${"x".repeat(maxSnippetChars + 100)} confidence: 9 evidence: memory/.dreams/session-corpus/2026-04-01.txt status: staged recalls: 1`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: 1,
maxScore: 0.75,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-01T00:00:00.000Z",
queryHashes: ["q"],
recallDays: ["2026-04-01"],
conceptTags: [],
},
},
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
contaminated: {
key: "contaminated",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: `Candidate: ${"x".repeat(maxSnippetChars + 100)} confidence: 9 evidence: memory/.dreams/session-corpus/2026-04-01.txt status: staged recalls: 1`,
recallCount: 1,
dailyCount: 0,
groundedCount: 0,
totalScore: 1,
maxScore: 0.75,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-01T00:00:00.000Z",
queryHashes: ["q"],
recallDays: ["2026-04-01"],
conceptTags: [],
},
null,
2,
)}\n`,
"utf-8",
);
},
});
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
@ -3065,19 +2996,13 @@ describe("short-term promotion", () => {
});
});
it("repairs empty recall-store files without throwing", async () => {
it("leaves empty recall stores normalized without rewriting", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);
await fs.writeFile(storePath, " \n", "utf-8");
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
expect(repair.changed).toBe(true);
expect(repair.rewroteStore).toBe(true);
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
version?: number;
entries?: unknown;
};
expect(repair.changed).toBe(false);
expect(repair.rewroteStore).toBe(false);
const store = await testing.readRecallStore(workspaceDir, new Date().toISOString());
expect(store.version).toBe(1);
expect(store.entries).toEqual({});
});
@ -3085,72 +3010,59 @@ describe("short-term promotion", () => {
it("does not rewrite an already normalized healthy recall store", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const snippet = "Gateway host uses qmd vector search for router notes.";
const raw = `${JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
const raw = {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
good: {
key: "good",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet,
recallCount: 2,
dailyCount: 0,
groundedCount: 0,
totalScore: 1.8,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
recallDays: ["2026-04-04"],
conceptTags: testing.deriveConceptTags({
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet,
recallCount: 2,
dailyCount: 0,
groundedCount: 0,
totalScore: 1.8,
maxScore: 0.95,
firstRecalledAt: "2026-04-01T00:00:00.000Z",
lastRecalledAt: "2026-04-04T00:00:00.000Z",
queryHashes: ["a", "b"],
recallDays: ["2026-04-04"],
conceptTags: testing.deriveConceptTags({
path: "memory/2026-04-01.md",
snippet,
}),
},
}),
},
},
null,
2,
)}\n`;
await fs.writeFile(storePath, raw, "utf-8");
};
await testing.writeRawRecallStore(workspaceDir, raw);
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
expect(repair.changed).toBe(false);
expect(repair.rewroteStore).toBe(false);
const nextRaw = await fs.readFile(storePath, "utf-8");
expect(nextRaw).toBe(raw);
expect(await testing.readRecallStore(workspaceDir, new Date().toISOString())).toEqual(raw);
});
});
it("waits for an active short-term lock before repairing", async () => {
await withTempWorkspace(async (workspaceDir) => {
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
bad: {
path: "",
},
},
await testing.writeRawRecallStore(workspaceDir, {
version: 1,
updatedAt: "2026-04-04T00:00:00.000Z",
entries: {
bad: {
path: "",
},
null,
2,
),
"utf-8",
);
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf-8");
},
});
await testing.writeShortTermLock(workspaceDir, {
owner: `${process.pid}:${Date.now()}`,
acquiredAt: Date.now(),
});
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
@ -3163,7 +3075,7 @@ describe("short-term promotion", () => {
await vi.advanceTimersByTimeAsync(41);
expect(settled).toBe(false);
await fs.unlink(lockPath);
await testing.deleteShortTermLock(workspaceDir);
await vi.advanceTimersByTimeAsync(40);
const repair = await repairPromise;
@ -3176,30 +3088,19 @@ describe("short-term promotion", () => {
});
});
it("downgrades lock inspection failures into audit issues", async () => {
it("reports stale sqlite locks as repairable audit issues", async () => {
await withTempWorkspace(async (workspaceDir) => {
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
const stat = vi.spyOn(fs, "stat").mockImplementation(async (target) => {
if (String(target) === lockPath) {
const error = Object.assign(new Error("no access"), { code: "EACCES" });
throw error;
}
return await vi
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
.then((actual) => actual.stat(target));
await testing.writeShortTermLock(workspaceDir, {
owner: "999999:0",
acquiredAt: Date.now() - 120_000,
});
const audit = await auditShortTermPromotionArtifacts({ workspaceDir });
expect(audit.issues.find((issue) => issue.code === "recall-lock-stale")).toStrictEqual({
severity: "warn",
code: "recall-lock-stale",
message: "Short-term promotion lock appears stale.",
fixable: true,
});
try {
const audit = await auditShortTermPromotionArtifacts({ workspaceDir });
const lockIssue = audit.issues.find((issue) => issue.code === "recall-lock-unreadable");
expect(lockIssue).toStrictEqual({
severity: "warn",
code: "recall-lock-unreadable",
message: "Short-term promotion lock could not be inspected: EACCES.",
fixable: false,
});
} finally {
stat.mockRestore();
}
});
});

View file

@ -6,9 +6,9 @@ import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-ru
import {
DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
formatMemoryDreamingDay,
isSameMemoryDreamingDay,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
import { privateFileStore } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
@ -20,7 +20,20 @@ import {
summarizeConceptTagScriptCoverage,
type ConceptTagScriptCoverage,
} from "./concept-vocabulary.js";
import { asRecord } from "./dreaming-shared.js";
import { asRecord, formatErrorMessage } from "./dreaming-shared.js";
import {
SHORT_TERM_LOCK_MAX_ENTRIES,
SHORT_TERM_LOCK_NAMESPACE,
SHORT_TERM_META_NAMESPACE,
SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
SHORT_TERM_RECALL_NAMESPACE,
memoryCoreStateReference,
memoryCoreWorkspaceStateKey,
openMemoryCoreStateStore,
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntry,
} from "./dreaming-state.js";
import { compactMemoryForBudget, DEFAULT_MEMORY_FILE_MAX_CHARS } from "./memory-budget.js";
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
@ -40,12 +53,25 @@ const MAX_QUERY_HASHES = 32;
const MAX_RECALL_DAYS = 16;
const SHORT_TERM_RECALL_MAX_ENTRIES = 512;
const SHORT_TERM_RECALL_MAX_SNIPPET_CHARS = 800;
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH = path.join("memory", ".dreams", "phase-signals.json");
const SHORT_TERM_LOCK_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-promotion.lock");
export const SHORT_TERM_STORE_RELATIVE_PATH = path.join(
"memory",
".dreams",
"short-term-recall.json",
);
export const SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH = path.join(
"memory",
".dreams",
"phase-signals.json",
);
export const SHORT_TERM_LOCK_RELATIVE_PATH = path.join(
"memory",
".dreams",
"short-term-promotion.lock",
);
const SHORT_TERM_LOCK_WAIT_TIMEOUT_MS = 10_000;
const SHORT_TERM_LOCK_STALE_MS = 60_000;
const SHORT_TERM_LOCK_RETRY_DELAY_MS = 40;
const DREAMING_ENTRY_LIST_LIMIT = 8;
// Repeated dreaming revisits should be able to clear the default promotion gate
// without requiring separate organic recall traffic for the same snippet.
const PHASE_SIGNAL_LIGHT_BOOST_MAX = 0.06;
@ -59,7 +85,6 @@ const GENERIC_DAY_HEADING_RE =
const PROMOTION_LIST_MARKER_RE = /^(?:\d+\.\s+|[-*+]\s+)/;
const MANAGED_DREAMING_HEADINGS = new Set(["light sleep", "rem sleep"]);
const inProcessShortTermLocks = new Map<string, Promise<void>>();
const ensuredShortTermDirs = new Map<string, Promise<void>>();
type PromotionWeights = {
frequency: number;
@ -100,7 +125,7 @@ export type ShortTermRecallEntry = {
promotedAt?: string;
};
type ShortTermRecallStore = {
export type ShortTermRecallStore = {
version: 1;
updatedAt: string;
entries: Record<string, ShortTermRecallEntry>;
@ -115,12 +140,21 @@ type ShortTermPhaseSignalEntry = {
lastRemConsideredAt?: string;
};
type ShortTermPhaseSignalStore = {
export type ShortTermPhaseSignalStore = {
version: 1;
updatedAt: string;
entries: Record<string, ShortTermPhaseSignalEntry>;
};
type ShortTermStoreMeta = {
updatedAt: string;
};
type ShortTermLockEntry = {
owner: string;
acquiredAt: number;
};
type PromotionComponents = {
frequency: number;
relevance: number;
@ -253,6 +287,43 @@ type ApplyShortTermPromotionsResult = {
compactedDates: string[];
};
export type ShortTermDreamingStatsEntry = {
key: string;
path: string;
startLine: number;
endLine: number;
snippet: string;
recallCount: number;
dailyCount: number;
groundedCount: number;
totalSignalCount: number;
lightHits: number;
remHits: number;
phaseHitCount: number;
promotedAt?: string;
lastRecalledAt?: string;
};
export type ShortTermDreamingStats = {
shortTermCount: number;
recallSignalCount: number;
dailySignalCount: number;
groundedSignalCount: number;
totalSignalCount: number;
phaseSignalCount: number;
lightPhaseHitCount: number;
remPhaseHitCount: number;
promotedTotal: number;
promotedToday: number;
storePath: string;
phaseSignalPath: string;
phaseSignalError?: string;
lastPromotedAt?: string;
shortTermEntries: ShortTermDreamingStatsEntry[];
signalEntries: ShortTermDreamingStatsEntry[];
promotedEntries: ShortTermDreamingStatsEntry[];
};
function clampScore(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@ -495,7 +566,7 @@ function emptyStore(nowIso: string): ShortTermRecallStore {
};
}
function normalizeStore(raw: unknown, nowIso: string): ShortTermRecallStore {
export function normalizeShortTermRecallStore(raw: unknown, nowIso: string): ShortTermRecallStore {
if (!raw || typeof raw !== "object") {
return emptyStore(nowIso);
}
@ -731,37 +802,15 @@ function calculatePhaseSignalBoost(
}
function resolveStorePath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
return memoryCoreStateReference(SHORT_TERM_RECALL_NAMESPACE, workspaceDir);
}
function resolvePhaseSignalPath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH);
return memoryCoreStateReference(SHORT_TERM_PHASE_SIGNAL_NAMESPACE, workspaceDir);
}
function resolveLockPath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_LOCK_RELATIVE_PATH);
}
function resolveShortTermArtifactsDir(workspaceDir: string): string {
return path.dirname(resolveLockPath(workspaceDir));
}
async function ensureShortTermArtifactsDir(workspaceDir: string): Promise<void> {
const artifactsDir = resolveShortTermArtifactsDir(workspaceDir);
const existing = ensuredShortTermDirs.get(artifactsDir);
if (existing) {
await existing;
return;
}
const ensuring = fs
.mkdir(artifactsDir, { recursive: true })
.then(() => undefined)
.catch((err: unknown) => {
ensuredShortTermDirs.delete(artifactsDir);
throw err;
});
ensuredShortTermDirs.set(artifactsDir, ensuring);
await ensuring;
return memoryCoreStateReference(SHORT_TERM_LOCK_NAMESPACE, workspaceDir);
}
function parseLockOwnerPid(raw: string): number | null {
@ -828,65 +877,72 @@ async function withInProcessShortTermLock<T>(lockPath: string, task: () => Promi
}
async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>): Promise<T> {
const lockPath = resolveLockPath(workspaceDir);
return withInProcessShortTermLock(lockPath, async () => {
await ensureShortTermArtifactsDir(workspaceDir);
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockRef = resolveLockPath(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
});
return withInProcessShortTermLock(lockKey, async () => {
const startedAt = Date.now();
while (true) {
try {
const lockHandle = await fs.open(lockPath, "wx");
await lockHandle
.writeFile(`${process.pid}:${Date.now()}\n`, "utf-8")
.catch(() => undefined);
const owner = `${process.pid}:${Date.now()}`;
const acquired = await lockStore.registerIfAbsent(lockKey, {
owner,
acquiredAt: Date.now(),
});
if (acquired) {
try {
return await task();
} finally {
await lockHandle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
}
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
throw err;
}
const ageMs = await fs
.stat(lockPath)
.then((stats) => Date.now() - stats.mtimeMs)
.catch(() => 0);
if (ageMs > SHORT_TERM_LOCK_STALE_MS) {
if (await canStealStaleLock(lockPath)) {
await fs.unlink(lockPath).catch(() => undefined);
continue;
const current = await lockStore.lookup(lockKey).catch(() => undefined);
if (current?.owner === owner) {
await lockStore.delete(lockKey).catch(() => false);
}
}
if (Date.now() - startedAt >= SHORT_TERM_LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for short-term promotion lock at ${lockPath}`, {
cause: err,
});
}
await sleep(SHORT_TERM_LOCK_RETRY_DELAY_MS);
}
const existing = await lockStore.lookup(lockKey);
if (existing && Date.now() - existing.acquiredAt > SHORT_TERM_LOCK_STALE_MS) {
const ownerPid = parseLockOwnerPid(existing.owner);
if (ownerPid === null || !isProcessLikelyAlive(ownerPid)) {
await lockStore.delete(lockKey);
continue;
}
}
if (Date.now() - startedAt >= SHORT_TERM_LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for short-term promotion lock at ${lockRef}`);
}
await sleep(SHORT_TERM_LOCK_RETRY_DELAY_MS);
}
});
}
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
try {
const store = normalizeStore(
await privateFileStore(workspaceDir).readJsonIfExists(SHORT_TERM_STORE_RELATIVE_PATH),
nowIso,
);
enforceShortTermRecallStoreRetention(store);
return store;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return emptyStore(nowIso);
}
throw err;
}
const [entryRows, metaRows] = await Promise.all([
readMemoryCoreWorkspaceEntries<ShortTermRecallEntry>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
}),
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
}),
]);
const meta = metaRows.find((entry) => entry.key === "recall")?.value;
const store = normalizeShortTermRecallStore(
{
version: 1,
updatedAt: meta?.updatedAt ?? nowIso,
entries: Object.fromEntries(entryRows.map((entry) => [entry.key, entry.value])),
},
nowIso,
);
enforceShortTermRecallStoreRetention(store);
return store;
}
function emptyPhaseSignalStore(nowIso: string): ShortTermPhaseSignalStore {
@ -897,7 +953,10 @@ function emptyPhaseSignalStore(nowIso: string): ShortTermPhaseSignalStore {
};
}
function normalizePhaseSignalStore(raw: unknown, nowIso: string): ShortTermPhaseSignalStore {
export function normalizeShortTermPhaseSignalStore(
raw: unknown,
nowIso: string,
): ShortTermPhaseSignalStore {
const record = asRecord(raw);
if (!record) {
return emptyPhaseSignalStore(nowIso);
@ -953,36 +1012,62 @@ async function readPhaseSignalStore(
workspaceDir: string,
nowIso: string,
): Promise<ShortTermPhaseSignalStore> {
try {
return normalizePhaseSignalStore(
await privateFileStore(workspaceDir).readJsonIfExists(SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH),
nowIso,
);
} catch (err) {
if (err instanceof SyntaxError) {
return emptyPhaseSignalStore(nowIso);
}
throw err;
}
const [entryRows, metaRows] = await Promise.all([
readMemoryCoreWorkspaceEntries<ShortTermPhaseSignalEntry>({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
}),
readMemoryCoreWorkspaceEntries<ShortTermStoreMeta>({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
}),
]);
const meta = metaRows.find((entry) => entry.key === "phase")?.value;
return normalizeShortTermPhaseSignalStore(
{
version: 1,
updatedAt: meta?.updatedAt ?? nowIso,
entries: Object.fromEntries(entryRows.map((entry) => [entry.key, entry.value])),
},
nowIso,
);
}
async function writePhaseSignalStore(
workspaceDir: string,
store: ShortTermPhaseSignalStore,
): Promise<void> {
await ensureShortTermArtifactsDir(workspaceDir);
await privateFileStore(workspaceDir).writeJson(SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH, store, {
trailingNewline: true,
});
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
key: "phase",
value: { updatedAt: store.updatedAt },
}),
]);
}
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
enforceShortTermRecallSnippetCap(store);
enforceShortTermRecallStoreRetention(store);
await ensureShortTermArtifactsDir(workspaceDir);
await privateFileStore(workspaceDir).writeJson(SHORT_TERM_STORE_RELATIVE_PATH, store, {
trailingNewline: true,
});
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
entries: Object.entries(store.entries).map(([key, value]) => ({ key, value })),
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
key: "recall",
value: { updatedAt: store.updatedAt },
}),
]);
}
export function isShortTermMemoryPath(filePath: string): boolean {
@ -999,6 +1084,240 @@ export function isShortTermMemoryPath(filePath: string): boolean {
return SHORT_TERM_BASENAME_RE.test(normalized);
}
function normalizeMemoryPathForWorkspace(workspaceDir: string, rawPath: string): string {
const normalized = normalizeMemoryPath(rawPath);
const workspaceNormalized = normalizeMemoryPath(workspaceDir);
if (path.isAbsolute(rawPath) && normalized.startsWith(`${workspaceNormalized}/`)) {
return normalized.slice(workspaceNormalized.length + 1);
}
return normalized;
}
function toNonNegativeInt(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) {
return 0;
}
return Math.max(0, Math.floor(num));
}
function parseEntryRangeFromKey(
key: string,
fallbackStartLine: unknown,
fallbackEndLine: unknown,
): { startLine: number; endLine: number } {
const startLine = toNonNegativeInt(fallbackStartLine);
const endLine = toNonNegativeInt(fallbackEndLine);
if (startLine > 0 && endLine > 0) {
return { startLine, endLine };
}
const match = key.match(/:(\d+):(\d+)$/);
if (match) {
return {
startLine: Math.max(1, toNonNegativeInt(match[1])),
endLine: Math.max(1, toNonNegativeInt(match[2])),
};
}
return { startLine: 1, endLine: 1 };
}
function compareDreamingStatsEntryByRecency(
a: ShortTermDreamingStatsEntry,
b: ShortTermDreamingStatsEntry,
): number {
const aMs = a.lastRecalledAt ? Date.parse(a.lastRecalledAt) : Number.NEGATIVE_INFINITY;
const bMs = b.lastRecalledAt ? Date.parse(b.lastRecalledAt) : Number.NEGATIVE_INFINITY;
if (Number.isFinite(aMs) || Number.isFinite(bMs)) {
if (bMs !== aMs) {
return bMs - aMs;
}
}
if (b.totalSignalCount !== a.totalSignalCount) {
return b.totalSignalCount - a.totalSignalCount;
}
return a.path.localeCompare(b.path);
}
function compareDreamingStatsEntryBySignals(
a: ShortTermDreamingStatsEntry,
b: ShortTermDreamingStatsEntry,
): number {
if (b.totalSignalCount !== a.totalSignalCount) {
return b.totalSignalCount - a.totalSignalCount;
}
if (b.phaseHitCount !== a.phaseHitCount) {
return b.phaseHitCount - a.phaseHitCount;
}
return compareDreamingStatsEntryByRecency(a, b);
}
function compareDreamingStatsEntryByPromotion(
a: ShortTermDreamingStatsEntry,
b: ShortTermDreamingStatsEntry,
): number {
const aMs = a.promotedAt ? Date.parse(a.promotedAt) : Number.NEGATIVE_INFINITY;
const bMs = b.promotedAt ? Date.parse(b.promotedAt) : Number.NEGATIVE_INFINITY;
if (Number.isFinite(aMs) || Number.isFinite(bMs)) {
if (bMs !== aMs) {
return bMs - aMs;
}
}
return compareDreamingStatsEntryBySignals(a, b);
}
function trimDreamingStatsEntries(
entries: ShortTermDreamingStatsEntry[],
compare: (a: ShortTermDreamingStatsEntry, b: ShortTermDreamingStatsEntry) => number,
): ShortTermDreamingStatsEntry[] {
const selected: ShortTermDreamingStatsEntry[] = [];
for (const entry of entries) {
let insertAt = selected.length;
for (let index = 0; index < selected.length; index += 1) {
if (compare(entry, selected[index]) < 0) {
insertAt = index;
break;
}
}
if (insertAt < DREAMING_ENTRY_LIST_LIMIT) {
selected.splice(insertAt, 0, entry);
if (selected.length > DREAMING_ENTRY_LIST_LIMIT) {
selected.pop();
}
} else if (selected.length < DREAMING_ENTRY_LIST_LIMIT) {
selected.push(entry);
}
}
return selected;
}
export async function loadShortTermPromotionDreamingStats(params: {
workspaceDir: string;
nowMs: number;
timezone?: string;
}): Promise<ShortTermDreamingStats> {
const workspaceDir = params.workspaceDir.trim();
const nowIso = new Date(params.nowMs).toISOString();
const store = await readStore(workspaceDir, nowIso);
let phaseSignalError: string | undefined;
let phaseStore: ShortTermPhaseSignalStore;
try {
phaseStore = await readPhaseSignalStore(workspaceDir, nowIso);
} catch (err) {
phaseSignalError = formatErrorMessage(err);
phaseStore = emptyPhaseSignalStore(nowIso);
}
let shortTermCount = 0;
let recallSignalCount = 0;
let dailySignalCount = 0;
let groundedSignalCount = 0;
let totalSignalCount = 0;
let phaseSignalCount = 0;
let lightPhaseHitCount = 0;
let remPhaseHitCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let latestPromotedAt: string | undefined;
const activeKeys = new Set<string>();
const activeEntries = new Map<string, ShortTermDreamingStatsEntry>();
const shortTermEntries: ShortTermDreamingStatsEntry[] = [];
const promotedEntries: ShortTermDreamingStatsEntry[] = [];
for (const [entryKey, entry] of Object.entries(store.entries)) {
if (entry.source !== "memory" || !entry.path || !isShortTermMemoryPath(entry.path)) {
continue;
}
const range = parseEntryRangeFromKey(entryKey, entry.startLine, entry.endLine);
const recallCount = toNonNegativeInt(entry.recallCount);
const dailyCount = toNonNegativeInt(entry.dailyCount);
const groundedCount = toNonNegativeInt(entry.groundedCount);
const totalEntrySignalCount = recallCount + dailyCount + groundedCount;
const normalizedEntryPath = normalizeMemoryPathForWorkspace(workspaceDir, entry.path);
const detail: ShortTermDreamingStatsEntry = {
key: entryKey,
path: normalizedEntryPath,
startLine: range.startLine,
endLine: Math.max(range.startLine, range.endLine),
snippet: normalizeSnippet(entry.snippet) || normalizedEntryPath,
recallCount,
dailyCount,
groundedCount,
totalSignalCount: totalEntrySignalCount,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
...(entry.lastRecalledAt ? { lastRecalledAt: entry.lastRecalledAt } : {}),
};
if (!entry.promotedAt) {
shortTermCount += 1;
activeKeys.add(entryKey);
recallSignalCount += recallCount;
dailySignalCount += dailyCount;
groundedSignalCount += groundedCount;
totalSignalCount += totalEntrySignalCount;
shortTermEntries.push(detail);
activeEntries.set(entryKey, detail);
continue;
}
promotedTotal += 1;
promotedEntries.push({ ...detail, promotedAt: entry.promotedAt });
const promotedAtMs = Date.parse(entry.promotedAt);
if (
Number.isFinite(promotedAtMs) &&
isSameMemoryDreamingDay(promotedAtMs, params.nowMs, params.timezone)
) {
promotedToday += 1;
}
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
latestPromotedAtMs = promotedAtMs;
latestPromotedAt = entry.promotedAt;
}
}
for (const [key, phaseEntry] of Object.entries(phaseStore.entries)) {
if (!activeKeys.has(key)) {
continue;
}
const lightHits = toNonNegativeInt(phaseEntry.lightHits);
const remHits = toNonNegativeInt(phaseEntry.remHits);
lightPhaseHitCount += lightHits;
remPhaseHitCount += remHits;
phaseSignalCount += lightHits + remHits;
const detail = activeEntries.get(key);
if (detail) {
detail.lightHits = lightHits;
detail.remHits = remHits;
detail.phaseHitCount = lightHits + remHits;
}
}
return {
shortTermCount,
recallSignalCount,
dailySignalCount,
groundedSignalCount,
totalSignalCount,
phaseSignalCount,
lightPhaseHitCount,
remPhaseHitCount,
promotedTotal,
promotedToday,
storePath: resolveStorePath(workspaceDir),
phaseSignalPath: resolvePhaseSignalPath(workspaceDir),
shortTermEntries: trimDreamingStatsEntries(
shortTermEntries,
compareDreamingStatsEntryByRecency,
),
signalEntries: trimDreamingStatsEntries(shortTermEntries, compareDreamingStatsEntryBySignals),
promotedEntries: trimDreamingStatsEntries(
promotedEntries,
compareDreamingStatsEntryByPromotion,
),
...(phaseSignalError ? { phaseSignalError } : {}),
...(latestPromotedAt ? { lastPromotedAt: latestPromotedAt } : {}),
};
}
async function shortTermRecallSourceExists(params: {
workspaceDir: string;
entry: Pick<ShortTermRecallEntry, "path">;
@ -2189,71 +2508,68 @@ export async function auditShortTermPromotionArtifacts(params: {
let invalidEntryCount = 0;
let updatedAt: string | undefined;
try {
const raw = await fs.readFile(storePath, "utf-8");
exists = true;
if (raw.trim().length === 0) {
const nowIso = new Date().toISOString();
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
});
exists = rawEntries.length > 0;
if (exists) {
const parsed = {
version: 1,
updatedAt: nowIso,
entries: Object.fromEntries(rawEntries.map((entry) => [entry.key, entry.value])),
};
const store = normalizeShortTermRecallStore(parsed, nowIso);
const normalizedEntryCount = Object.keys(store.entries).length;
updatedAt = store.updatedAt;
entryCount = normalizedEntryCount;
promotedCount = Object.values(store.entries).filter((entry) =>
Boolean(entry.promotedAt),
).length;
spacedEntryCount = Object.values(store.entries).filter(
(entry) => (entry.recallDays?.length ?? 0) > 1,
).length;
conceptTaggedEntryCount = Object.values(store.entries).filter(
(entry) => (entry.conceptTags?.length ?? 0) > 0,
).length;
conceptTagScripts = summarizeConceptTagScriptCoverage(
Object.values(store.entries)
.filter((entry) => (entry.conceptTags?.length ?? 0) > 0)
.map((entry) => entry.conceptTags ?? []),
);
invalidEntryCount = rawEntries.length - entryCount;
if (invalidEntryCount > 0) {
issues.push({
severity: "warn",
code: "recall-store-empty",
message: "Short-term recall store is empty.",
code: "recall-store-invalid",
message: `Short-term recall store contains ${invalidEntryCount} invalid entr${invalidEntryCount === 1 ? "y" : "ies"}.`,
fixable: true,
});
} else {
const nowIso = new Date().toISOString();
const parsed = JSON.parse(raw) as unknown;
const store = normalizeStore(parsed, nowIso);
const normalizedEntryCount = Object.keys(store.entries).length;
updatedAt = store.updatedAt;
entryCount = normalizedEntryCount;
promotedCount = Object.values(store.entries).filter((entry) =>
Boolean(entry.promotedAt),
).length;
spacedEntryCount = Object.values(store.entries).filter(
(entry) => (entry.recallDays?.length ?? 0) > 1,
).length;
conceptTaggedEntryCount = Object.values(store.entries).filter(
(entry) => (entry.conceptTags?.length ?? 0) > 0,
).length;
conceptTagScripts = summarizeConceptTagScriptCoverage(
Object.values(store.entries)
.filter((entry) => (entry.conceptTags?.length ?? 0) > 0)
.map((entry) => entry.conceptTags ?? []),
);
invalidEntryCount = Object.keys(asRecord(parsed)?.entries ?? {}).length - entryCount;
if (invalidEntryCount > 0) {
issues.push({
severity: "warn",
code: "recall-store-invalid",
message: `Short-term recall store contains ${invalidEntryCount} invalid entr${invalidEntryCount === 1 ? "y" : "ies"}.`,
fixable: true,
});
}
if (normalizedEntryCount > SHORT_TERM_RECALL_MAX_ENTRIES) {
issues.push({
severity: "warn",
code: "recall-store-over-limit",
message: `Short-term recall store contains ${normalizedEntryCount} entries; only the newest ${SHORT_TERM_RECALL_MAX_ENTRIES} are kept at runtime.`,
fixable: true,
});
}
}
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
if (normalizedEntryCount > SHORT_TERM_RECALL_MAX_ENTRIES) {
issues.push({
severity: "error",
code: "recall-store-unreadable",
message: `Short-term recall store is unreadable: ${code ?? "error"}.`,
fixable: false,
severity: "warn",
code: "recall-store-over-limit",
message: `Short-term recall store contains ${normalizedEntryCount} entries; only the newest ${SHORT_TERM_RECALL_MAX_ENTRIES} are kept at runtime.`,
fixable: true,
});
}
}
try {
const stat = await fs.stat(lockPath);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > SHORT_TERM_LOCK_STALE_MS && (await canStealStaleLock(lockPath))) {
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
});
const lockEntry = await lockStore.lookup(lockKey);
if (lockEntry) {
const ageMs = Date.now() - lockEntry.acquiredAt;
const ownerPid = parseLockOwnerPid(lockEntry.owner);
if (
ageMs > SHORT_TERM_LOCK_STALE_MS &&
(ownerPid === null || !isProcessLikelyAlive(ownerPid))
) {
issues.push({
severity: "warn",
code: "recall-lock-stale",
@ -2261,16 +2577,6 @@ export async function auditShortTermPromotionArtifacts(params: {
fixable: true,
});
}
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
issues.push({
severity: "warn",
code: "recall-lock-unreadable",
message: `Short-term promotion lock could not be inspected: ${code ?? "error"}.`,
fixable: false,
});
}
}
let qmd: ShortTermAuditSummary["qmd"];
@ -2342,28 +2648,37 @@ export async function repairShortTermPromotionArtifacts(params: {
let removedOverflowEntries = 0;
let removedStaleLock = false;
try {
const lockPath = resolveLockPath(workspaceDir);
const stat = await fs.stat(lockPath);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > SHORT_TERM_LOCK_STALE_MS && (await canStealStaleLock(lockPath))) {
await fs.unlink(lockPath).catch(() => undefined);
removedStaleLock = true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
const lockKey = memoryCoreWorkspaceStateKey(workspaceDir);
const lockStore = openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
});
const lockEntry = await lockStore.lookup(lockKey);
if (lockEntry && Date.now() - lockEntry.acquiredAt > SHORT_TERM_LOCK_STALE_MS) {
const ownerPid = parseLockOwnerPid(lockEntry.owner);
if (ownerPid === null || !isProcessLikelyAlive(ownerPid)) {
removedStaleLock = await lockStore.delete(lockKey);
}
}
await withShortTermLock(workspaceDir, async () => {
const storePath = resolveStorePath(workspaceDir);
try {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = raw.trim().length > 0 ? (JSON.parse(raw) as unknown) : emptyStore(nowIso);
const rawEntries = Object.keys(asRecord(parsed)?.entries ?? {}).length;
const normalized = normalizeStore(parsed, nowIso);
removedInvalidEntries = Math.max(0, rawEntries - Object.keys(normalized.entries).length);
const rawEntries = await readMemoryCoreWorkspaceEntries<unknown>({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
});
if (rawEntries.length > 0) {
const normalized = normalizeShortTermRecallStore(
{
version: 1,
updatedAt: nowIso,
entries: Object.fromEntries(rawEntries.map((entry) => [entry.key, entry.value])),
},
nowIso,
);
removedInvalidEntries = Math.max(
0,
rawEntries.length - Object.keys(normalized.entries).length,
);
const nextEntries = Object.fromEntries(
Object.entries(normalized.entries).map(([key, entry]) => {
const conceptTags = deriveConceptTags({ path: entry.path, snippet: entry.snippet });
@ -2393,18 +2708,17 @@ export async function repairShortTermPromotionArtifacts(params: {
entries: nextEntries,
};
removedOverflowEntries = enforceShortTermRecallStoreRetention(comparableStore);
const comparableRaw = `${JSON.stringify(comparableStore, null, 2)}\n`;
if (comparableRaw !== `${raw.trimEnd()}\n`) {
const needsRewrite =
removedInvalidEntries > 0 ||
removedOverflowEntries > 0 ||
JSON.stringify(normalized.entries) !== JSON.stringify(comparableStore.entries);
if (needsRewrite) {
await writeStore(workspaceDir, {
...comparableStore,
updatedAt: nowIso,
});
rewroteStore = true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
});
@ -2465,6 +2779,68 @@ export const testing = {
parseLockOwnerPid,
canStealStaleLock,
isProcessLikelyAlive,
readRecallStore: readStore,
readPhaseSignalStore,
writeRawRecallStore: async (workspaceDir: string, raw: unknown) => {
const record = asRecord(raw);
const entries = asRecord(record?.entries);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_RECALL_NAMESPACE,
workspaceDir,
entries: entries
? Object.entries(entries).map(([key, value]) => ({ key, value: value as unknown }))
: [],
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
key: "recall",
value: {
updatedAt:
typeof record?.updatedAt === "string" && record.updatedAt.trim()
? record.updatedAt
: new Date().toISOString(),
},
}),
]);
},
writeRawPhaseSignalStore: async (workspaceDir: string, raw: unknown) => {
const record = asRecord(raw);
const entries = asRecord(record?.entries);
await Promise.all([
writeMemoryCoreWorkspaceEntries({
namespace: SHORT_TERM_PHASE_SIGNAL_NAMESPACE,
workspaceDir,
entries: entries
? Object.entries(entries).map(([key, value]) => ({ key, value: value as unknown }))
: [],
}),
writeMemoryCoreWorkspaceEntry({
namespace: SHORT_TERM_META_NAMESPACE,
workspaceDir,
key: "phase",
value: {
updatedAt:
typeof record?.updatedAt === "string" && record.updatedAt.trim()
? record.updatedAt
: new Date().toISOString(),
},
}),
]);
},
writeShortTermLock: async (workspaceDir: string, entry: ShortTermLockEntry) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).register(memoryCoreWorkspaceStateKey(workspaceDir), entry);
},
deleteShortTermLock: async (workspaceDir: string) => {
await openMemoryCoreStateStore<ShortTermLockEntry>({
namespace: SHORT_TERM_LOCK_NAMESPACE,
maxEntries: SHORT_TERM_LOCK_MAX_ENTRIES,
}).delete(memoryCoreWorkspaceStateKey(workspaceDir));
},
deriveConceptTags,
calculateConsolidationComponent,
calculatePhaseSignalBoost,

View file

@ -3,12 +3,17 @@ import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterAll, beforeAll } from "vitest";
import {
configureMemoryCoreDreamingStateForTests,
resetMemoryCoreDreamingStateForTests,
} from "./dreaming-state.js";
export function createMemoryCoreTestHarness() {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
await configureMemoryCoreDreamingStateForTests();
fixtureRoot = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "memory-core-test-fixtures-"),
);
@ -19,6 +24,7 @@ export function createMemoryCoreTestHarness() {
return;
}
await fs.rm(fixtureRoot, { recursive: true, force: true });
resetMemoryCoreDreamingStateForTests();
});
async function createTempWorkspace(prefix: string): Promise<string> {

View file

@ -1,6 +1,5 @@
// Memory Core tests cover tools.citations plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import {
clearMemoryPluginState,
registerMemoryCorpusSupplement,
@ -17,6 +16,7 @@ import {
setMemoryWorkspaceDir,
type MemoryReadParams,
} from "./memory-tool-manager-mock.js";
import { testing as shortTermPromotionTesting } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
import { testing as memoryToolsTesting } from "./tools.js";
import {
@ -283,13 +283,15 @@ describe("memory tools", () => {
});
await tool.execute("call_recall_persist", { query: "glacier backup" });
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<string, { path: string; recallCount: number }>;
};
const entries = Object.values(store.entries ?? {});
expect(entries).toHaveLength(1);
const entries = await waitFor(async () => {
const store = await shortTermPromotionTesting.readRecallStore(
workspaceDir,
new Date().toISOString(),
);
const values = Object.values(store.entries);
expect(values).toHaveLength(1);
return values;
});
const entry = entries[0];
expect(entry?.path).toBe("memory/2026-04-03.md");
expect(entry?.recallCount).toBe(1);

View file

@ -6,6 +6,7 @@
*/
export {
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,

View file

@ -25,6 +25,7 @@ const writeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntries = vi.hoisted(() => vi.fn());
const removeGroundedShortTermCandidates = vi.hoisted(() => vi.fn());
const repairDreamingArtifacts = vi.hoisted(() => vi.fn());
const loadShortTermPromotionDreamingStats = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
getRuntimeConfig,
@ -45,6 +46,7 @@ vi.mock("../../plugins/memory-runtime.js", () => ({
vi.mock("./doctor.memory-core-runtime.js", () => ({
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
writeBackfillDiaryEntries,
@ -198,6 +200,27 @@ function findRecordByField(items: unknown, key: string, value: unknown) {
return (items as Array<Record<string, unknown>>).find((item) => item[key] === value);
}
function makeDreamingStats(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
shortTermCount: 0,
recallSignalCount: 0,
dailySignalCount: 0,
groundedSignalCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath: "plugin-state:memory-core/short-term-recall/test",
phaseSignalPath: "plugin-state:memory-core/short-term-phase-signals/test",
shortTermEntries: [],
signalEntries: [],
promotedEntries: [],
...overrides,
};
}
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
const payload = respondPayload(respond);
expectRecordFields(payload, {
@ -222,6 +245,9 @@ describe("doctor.memory.status", () => {
removeBackfillDiaryEntries.mockReset();
removeGroundedShortTermCandidates.mockReset();
repairDreamingArtifacts.mockReset();
loadShortTermPromotionDreamingStats
.mockReset()
.mockImplementation(async () => makeDreamingStats());
});
it("returns gateway embedding probe status for the default agent", async () => {
@ -552,6 +578,111 @@ describe("doctor.memory.status", () => {
}
return mainWorkspaceDir;
});
loadShortTermPromotionDreamingStats.mockImplementation(
async ({ workspaceDir }: { workspaceDir: string }) =>
workspaceDir === alphaWorkspaceDir
? makeDreamingStats({
shortTermCount: 0,
promotedTotal: 2,
promotedToday: 1,
promotedEntries: [
{
key: "memory:memory/2026-04-01.md:1:2",
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 2,
snippet: "Bunji lives in London.",
recallCount: 7,
dailyCount: 4,
groundedCount: 0,
totalSignalCount: 11,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
promotedAt: olderIso,
},
{
key: "memory:memory/notes/2026-04-04-0800.md:1:2",
path: "memory/notes/2026-04-04-0800.md",
startLine: 1,
endLine: 2,
snippet: "Always book the covered valet option at Park & Greet BCN.",
recallCount: 8,
dailyCount: 3,
groundedCount: 0,
totalSignalCount: 11,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
promotedAt: recentIso,
},
],
lastPromotedAt: recentIso,
})
: makeDreamingStats({
shortTermCount: 1,
recallSignalCount: 2,
dailySignalCount: 1,
totalSignalCount: 3,
phaseSignalCount: 5,
lightPhaseHitCount: 2,
remPhaseHitCount: 3,
promotedTotal: 1,
promotedToday: 1,
shortTermEntries: [
{
key: "memory:memory/2026-04-03-1503.md:1:2",
path: "memory/2026-04-03-1503.md",
startLine: 1,
endLine: 2,
snippet: "Emma prefers shorter, lower-pressure check-ins.",
recallCount: 2,
dailyCount: 1,
groundedCount: 0,
totalSignalCount: 3,
lightHits: 2,
remHits: 3,
phaseHitCount: 5,
lastRecalledAt: recentIso,
},
],
signalEntries: [
{
key: "memory:memory/2026-04-03-1503.md:1:2",
path: "memory/2026-04-03-1503.md",
startLine: 1,
endLine: 2,
snippet: "Emma prefers shorter, lower-pressure check-ins.",
recallCount: 2,
dailyCount: 1,
groundedCount: 0,
totalSignalCount: 3,
lightHits: 2,
remHits: 3,
phaseHitCount: 5,
lastRecalledAt: recentIso,
},
],
promotedEntries: [
{
key: "memory:memory/daily/2026-04-02-1015.md:1:2",
path: "memory/daily/2026-04-02-1015.md",
startLine: 1,
endLine: 2,
snippet: "Use the Happy Together calendar for flights.",
recallCount: 9,
dailyCount: 5,
groundedCount: 0,
totalSignalCount: 14,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
promotedAt: recentIso,
},
],
lastPromotedAt: recentIso,
}),
);
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
@ -667,6 +798,31 @@ describe("doctor.memory.status", () => {
};
await writeStore(mainWorkspaceDir, "main agent memory");
await writeStore(alphaWorkspaceDir, "alpha agent memory");
loadShortTermPromotionDreamingStats.mockImplementation(
async ({ workspaceDir }: { workspaceDir: string }) =>
makeDreamingStats({
promotedTotal: 1,
promotedEntries: [
{
key: "memory:memory/2026-04-04.md:1:2",
path: "memory/2026-04-04.md",
startLine: 1,
endLine: 2,
snippet:
workspaceDir === alphaWorkspaceDir ? "alpha agent memory" : "main agent memory",
recallCount: 0,
dailyCount: 0,
groundedCount: 0,
totalSignalCount: 0,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
promotedAt: "2026-04-04T00:00:00.000Z",
},
],
lastPromotedAt: "2026-04-04T00:00:00.000Z",
}),
);
getRuntimeConfig.mockReturnValue({
agents: {
list: [{ id: "alpha", workspace: alphaWorkspaceDir }],
@ -740,6 +896,29 @@ describe("doctor.memory.status", () => {
"utf-8",
);
resolveMemorySearchConfig.mockReturnValue(null);
loadShortTermPromotionDreamingStats.mockResolvedValueOnce(
makeDreamingStats({
promotedTotal: 1,
promotedEntries: [
{
key: "memory:memory/2026-04-03.md:1:2",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 1,
snippet: "memory/2026-04-03.md",
recallCount: 0,
dailyCount: 0,
groundedCount: 0,
totalSignalCount: 0,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
promotedAt: "2026-04-04T00:00:00.000Z",
},
],
lastPromotedAt: "2026-04-04T00:00:00.000Z",
}),
);
getRuntimeConfig.mockReturnValue({
plugins: {
entries: {
@ -879,26 +1058,7 @@ describe("doctor.memory.status", () => {
agentId === "alpha" ? alphaWorkspaceDir : mainWorkspaceDir,
);
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => {
const targetPath =
typeof target === "string"
? target
: Buffer.isBuffer(target)
? target.toString("utf-8")
: target instanceof URL
? target.pathname
: "";
if (
targetPath === path.join(mainWorkspaceDir, "memory", ".dreams", "short-term-recall.json") ||
targetPath === alphaStorePath
) {
const error = Object.assign(new Error("denied"), { code: "EACCES" });
throw error;
}
return await vi
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
.then((actual) => actual.readFile(target, options as never));
});
loadShortTermPromotionDreamingStats.mockRejectedValue(new Error("denied"));
const close = vi.fn().mockResolvedValue(undefined);
getMemorySearchManager.mockResolvedValue({
@ -919,7 +1079,6 @@ describe("doctor.memory.status", () => {
storeError: "2 dreaming stores had read errors.",
});
} finally {
readFileSpy.mockRestore();
await fs.rm(workspaceRoot, { recursive: true, force: true });
}
});

View file

@ -5,7 +5,6 @@ import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
isSameMemoryDreamingDay,
resolveMemoryDeepDreamingConfig,
resolveMemoryLightDreamingConfig,
resolveMemoryDreamingPluginConfig,
@ -18,6 +17,7 @@ import { normalizeAgentId } from "../../routing/session-key.js";
import { formatError } from "../server-utils.js";
import {
dedupeDreamDiaryEntries,
loadShortTermPromotionDreamingStats,
previewGroundedRemMarkdown,
previewRemHarness,
removeBackfillDiaryEntries,
@ -28,8 +28,6 @@ import {
import { asRecord, normalizeTrimmedString } from "./record-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH = path.join("memory", ".dreams", "phase-signals.json");
const MANAGED_DEEP_SLEEP_CRON_NAME = "Memory Dreaming Promotion";
const MANAGED_DEEP_SLEEP_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
const DEEP_SLEEP_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
@ -334,38 +332,6 @@ function resolveDreamingConfig(
};
}
function normalizeMemoryPath(rawPath: string): string {
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function normalizeMemoryPathForWorkspace(workspaceDir: string, rawPath: string): string {
const normalized = normalizeMemoryPath(rawPath);
const workspaceNormalized = normalizeMemoryPath(workspaceDir);
if (path.isAbsolute(rawPath) && normalized.startsWith(`${workspaceNormalized}/`)) {
return normalized.slice(workspaceNormalized.length + 1);
}
return normalized;
}
function isShortTermMemoryPath(filePath: string): boolean {
const normalized = normalizeMemoryPath(filePath);
// Status only counts short-term source shapes; promoted diary/report files stay out.
if (/(?:^|\/)memory\/dreaming\//.test(normalized)) {
return false;
}
if (/(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/.test(normalized)) {
return true;
}
if (
/(?:^|\/)memory\/\.dreams\/session-corpus\/(\d{4})-(\d{2})-(\d{2})\.(?:md|txt)$/.test(
normalized,
)
) {
return true;
}
return /^(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/.test(normalized);
}
type DreamingStoreStats = Pick<
DoctorMemoryDreamingPayload,
| "shortTermCount"
@ -390,34 +356,6 @@ type DreamingStoreStats = Pick<
const DREAMING_ENTRY_LIST_LIMIT = 8;
function toNonNegativeInt(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) {
return 0;
}
return Math.max(0, Math.floor(num));
}
function parseEntryRangeFromKey(
key: string,
fallbackStartLine: unknown,
fallbackEndLine: unknown,
): { startLine: number; endLine: number } {
const startLine = toNonNegativeInt(fallbackStartLine);
const endLine = toNonNegativeInt(fallbackEndLine);
if (startLine > 0 && endLine > 0) {
return { startLine, endLine };
}
const match = key.match(/:(\d+):(\d+)$/);
if (match) {
return {
startLine: Math.max(1, toNonNegativeInt(match[1])),
endLine: Math.max(1, toNonNegativeInt(match[2])),
};
}
return { startLine: 1, endLine: 1 };
}
function compareDreamingEntryByRecency(
a: DoctorMemoryDreamingEntryPayload,
b: DoctorMemoryDreamingEntryPayload,
@ -493,164 +431,9 @@ async function loadDreamingStoreStats(
nowMs: number,
timezone?: string,
): Promise<DreamingStoreStats> {
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
const phaseSignalPath = path.join(workspaceDir, SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH);
try {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
const store = asRecord(parsed);
const entries = asRecord(store?.entries) ?? {};
let shortTermCount = 0;
let recallSignalCount = 0;
let dailySignalCount = 0;
let groundedSignalCount = 0;
let totalSignalCount = 0;
let phaseSignalCount = 0;
let lightPhaseHitCount = 0;
let remPhaseHitCount = 0;
let promotedTotal = 0;
let promotedToday = 0;
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
let latestPromotedAt: string | undefined;
const activeKeys = new Set<string>();
const activeEntries = new Map<string, DoctorMemoryDreamingEntryPayload>();
const shortTermEntries: DoctorMemoryDreamingEntryPayload[] = [];
const promotedEntries: DoctorMemoryDreamingEntryPayload[] = [];
for (const [entryKey, value] of Object.entries(entries)) {
const entry = asRecord(value);
if (!entry) {
continue;
}
const source = normalizeTrimmedString(entry.source);
const entryPath = normalizeTrimmedString(entry.path);
if (source !== "memory" || !entryPath || !isShortTermMemoryPath(entryPath)) {
continue;
}
const range = parseEntryRangeFromKey(entryKey, entry.startLine, entry.endLine);
const recallCount = toNonNegativeInt(entry.recallCount);
const dailyCount = toNonNegativeInt(entry.dailyCount);
const groundedCount = toNonNegativeInt(entry.groundedCount);
const totalEntrySignalCount = recallCount + dailyCount + groundedCount;
const normalizedEntryPath = normalizeMemoryPathForWorkspace(workspaceDir, entryPath);
const snippet =
normalizeTrimmedString(entry.snippet) ??
normalizeTrimmedString(entry.summary) ??
normalizedEntryPath;
const lastRecalledAt = normalizeTrimmedString(entry.lastRecalledAt);
const detail: DoctorMemoryDreamingEntryPayload = {
key: entryKey,
path: normalizedEntryPath,
startLine: range.startLine,
endLine: Math.max(range.startLine, range.endLine),
snippet,
recallCount,
dailyCount,
groundedCount,
totalSignalCount: totalEntrySignalCount,
lightHits: 0,
remHits: 0,
phaseHitCount: 0,
...(lastRecalledAt ? { lastRecalledAt } : {}),
};
const promotedAt = normalizeTrimmedString(entry.promotedAt);
if (!promotedAt) {
shortTermCount += 1;
activeKeys.add(entryKey);
recallSignalCount += recallCount;
dailySignalCount += dailyCount;
groundedSignalCount += groundedCount;
totalSignalCount += totalEntrySignalCount;
shortTermEntries.push(detail);
activeEntries.set(entryKey, detail);
continue;
}
promotedTotal += 1;
promotedEntries.push({
...detail,
promotedAt,
});
const promotedAtMs = Date.parse(promotedAt);
if (Number.isFinite(promotedAtMs) && isSameMemoryDreamingDay(promotedAtMs, nowMs, timezone)) {
promotedToday += 1;
}
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
latestPromotedAtMs = promotedAtMs;
latestPromotedAt = promotedAt;
}
}
let phaseSignalError: string | undefined;
try {
const phaseRaw = await fs.readFile(phaseSignalPath, "utf-8");
const parsedPhase = JSON.parse(phaseRaw) as unknown;
const phaseStore = asRecord(parsedPhase);
const phaseEntries = asRecord(phaseStore?.entries) ?? {};
for (const [key, value] of Object.entries(phaseEntries)) {
// Phase signals are joined only to active short-term entries, not archived promotions.
if (!activeKeys.has(key)) {
continue;
}
const phaseEntry = asRecord(value);
const lightHits = toNonNegativeInt(phaseEntry?.lightHits);
const remHits = toNonNegativeInt(phaseEntry?.remHits);
lightPhaseHitCount += lightHits;
remPhaseHitCount += remHits;
phaseSignalCount += lightHits + remHits;
const detail = activeEntries.get(key);
if (detail) {
detail.lightHits = lightHits;
detail.remHits = remHits;
detail.phaseHitCount = lightHits + remHits;
}
}
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
phaseSignalError = formatError(err);
}
}
return {
shortTermCount,
recallSignalCount,
dailySignalCount,
groundedSignalCount,
totalSignalCount,
phaseSignalCount,
lightPhaseHitCount,
remPhaseHitCount,
promotedTotal,
promotedToday,
storePath,
phaseSignalPath,
shortTermEntries: trimDreamingEntries(shortTermEntries, compareDreamingEntryByRecency),
signalEntries: trimDreamingEntries(shortTermEntries, compareDreamingEntryBySignals),
promotedEntries: trimDreamingEntries(promotedEntries, compareDreamingEntryByPromotion),
...(latestPromotedAt ? { lastPromotedAt: latestPromotedAt } : {}),
...(phaseSignalError ? { phaseSignalError } : {}),
};
return await loadShortTermPromotionDreamingStats({ workspaceDir, nowMs, timezone });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT") {
return {
shortTermCount: 0,
recallSignalCount: 0,
dailySignalCount: 0,
groundedSignalCount: 0,
totalSignalCount: 0,
phaseSignalCount: 0,
lightPhaseHitCount: 0,
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
phaseSignalPath,
shortTermEntries: [],
signalEntries: [],
promotedEntries: [],
};
}
return {
shortTermCount: 0,
recallSignalCount: 0,
@ -662,8 +445,6 @@ async function loadDreamingStoreStats(
remPhaseHitCount: 0,
promotedTotal: 0,
promotedToday: 0,
storePath,
phaseSignalPath,
shortTermEntries: [],
signalEntries: [],
promotedEntries: [],

View file

@ -152,6 +152,7 @@ export type MemoryDreamingWorkspace = {
export type MemoryDreamingWorkspaceOptions = {
primaryWorkspaceDir?: string | null;
primaryAgentId?: string | null;
env?: NodeJS.ProcessEnv;
};
const DEFAULT_MEMORY_LIGHT_DREAMING_SOURCES: MemoryLightDreamingSource[] = [
@ -655,7 +656,7 @@ export function resolveMemoryDreamingWorkspaces(
};
for (const agentId of agentIds) {
addWorkspace(resolveAgentWorkspaceDir(cfg, agentId), agentId);
addWorkspace(resolveAgentWorkspaceDir(cfg, agentId, options.env), agentId);
}
addWorkspace(
options.primaryWorkspaceDir ?? undefined,

View file

@ -4,9 +4,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
const configureMemoryCoreDreamingStateImpl = vi.hoisted(() => vi.fn());
const createEmbeddingProviderImpl = vi.hoisted(() => vi.fn());
const registerBuiltInMemoryEmbeddingProvidersImpl = vi.hoisted(() => vi.fn());
const removeGroundedShortTermCandidatesImpl = vi.hoisted(() => vi.fn());
const loadShortTermPromotionDreamingStatsImpl = vi.hoisted(() => vi.fn());
const previewGroundedRemMarkdownImpl = vi.hoisted(() => vi.fn());
const writeBackfillDiaryEntriesImpl = vi.hoisted(() => vi.fn());
const removeBackfillDiaryEntriesImpl = vi.hoisted(() => vi.fn());
@ -23,9 +25,11 @@ vi.mock("./facade-loader.js", async () => {
describe("plugin-sdk memory-core bundled runtime", () => {
beforeEach(() => {
configureMemoryCoreDreamingStateImpl.mockReset();
createEmbeddingProviderImpl.mockReset().mockResolvedValue({ provider: { id: "openai" } });
registerBuiltInMemoryEmbeddingProvidersImpl.mockReset();
removeGroundedShortTermCandidatesImpl.mockReset().mockResolvedValue({ removed: 1 });
loadShortTermPromotionDreamingStatsImpl.mockReset().mockResolvedValue({ shortTermCount: 0 });
previewGroundedRemMarkdownImpl.mockReset().mockResolvedValue({ files: [] });
writeBackfillDiaryEntriesImpl.mockReset().mockResolvedValue({ writtenCount: 1 });
removeBackfillDiaryEntriesImpl.mockReset().mockResolvedValue({ removedCount: 1 });
@ -36,13 +40,16 @@ describe("plugin-sdk memory-core bundled runtime", () => {
.mockImplementation(({ artifactBasename }) => {
if (artifactBasename === "runtime-api.js") {
return {
configureMemoryCoreDreamingState: configureMemoryCoreDreamingStateImpl,
createEmbeddingProvider: createEmbeddingProviderImpl,
registerBuiltInMemoryEmbeddingProviders: registerBuiltInMemoryEmbeddingProvidersImpl,
removeGroundedShortTermCandidates: removeGroundedShortTermCandidatesImpl,
loadShortTermPromotionDreamingStats: loadShortTermPromotionDreamingStatsImpl,
};
}
if (artifactBasename === "api.js") {
return {
configureMemoryCoreDreamingState: configureMemoryCoreDreamingStateImpl,
previewGroundedRemMarkdown: previewGroundedRemMarkdownImpl,
writeBackfillDiaryEntries: writeBackfillDiaryEntriesImpl,
removeBackfillDiaryEntries: removeBackfillDiaryEntriesImpl,
@ -63,6 +70,7 @@ describe("plugin-sdk memory-core bundled runtime", () => {
dirName: "memory-core",
artifactBasename: "runtime-api.js",
});
expect(configureMemoryCoreDreamingStateImpl).toHaveBeenCalledWith(expect.any(Function));
});
it("delegates doctor and embedding helpers through the bundled public surfaces", async () => {
@ -70,10 +78,12 @@ describe("plugin-sdk memory-core bundled runtime", () => {
await module.previewGroundedRemMarkdown({} as never);
await module.removeGroundedShortTermCandidates({} as never);
await module.loadShortTermPromotionDreamingStats({} as never);
module.registerBuiltInMemoryEmbeddingProviders({} as never);
expect(previewGroundedRemMarkdownImpl).toHaveBeenCalledWith({} as never);
expect(removeGroundedShortTermCandidatesImpl).toHaveBeenCalledWith({} as never);
expect(loadShortTermPromotionDreamingStatsImpl).toHaveBeenCalledWith({} as never);
expect(registerBuiltInMemoryEmbeddingProvidersImpl).toHaveBeenCalledWith({} as never);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "memory-core",
@ -111,6 +121,7 @@ describe("plugin-sdk memory-core bundled runtime", () => {
expect(result).toBe(preview);
expect(previewRemHarnessImpl).toHaveBeenCalledWith(params);
expect(configureMemoryCoreDreamingStateImpl).toHaveBeenCalledWith(expect.any(Function));
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "memory-core",
artifactBasename: "api.js",

View file

@ -1,12 +1,14 @@
// Manual facade. Keep loader boundary explicit.
import { createPluginStateKeyedStore } from "../plugin-state/plugin-state-store.js";
// Memory core bundled runtime helpers load the internal memory plugin through SDK facades.
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
// Manual facade. Keep loader boundary explicit.
import type {
MemoryEmbeddingProvider,
MemoryEmbeddingProviderAdapter,
MemoryEmbeddingProviderCreateOptions,
MemoryEmbeddingProviderRuntime,
} from "./memory-core-host-engine-embeddings.js";
import type { OpenKeyedStoreOptions, PluginStateKeyedStore } from "./plugin-state-runtime.js";
type EmbeddingProviderResult = {
provider: MemoryEmbeddingProvider | null;
@ -18,6 +20,9 @@ type EmbeddingProviderResult = {
};
type RuntimeFacadeModule = {
configureMemoryCoreDreamingState: (
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
) => void;
createEmbeddingProvider: (
options: MemoryEmbeddingProviderCreateOptions & {
provider: string;
@ -30,6 +35,11 @@ type RuntimeFacadeModule = {
removeGroundedShortTermCandidates: (params: {
workspaceDir: string;
}) => Promise<{ removed: number; storePath: string }>;
loadShortTermPromotionDreamingStats: (params: {
workspaceDir: string;
nowMs: number;
timezone?: string;
}) => Promise<ShortTermDreamingStats>;
repairDreamingArtifacts: (params: {
workspaceDir: string;
archiveDiary?: boolean;
@ -89,6 +99,43 @@ type PromotionCandidate = {
promotedAt?: string;
};
export type ShortTermDreamingStatsEntry = {
key: string;
path: string;
startLine: number;
endLine: number;
snippet: string;
recallCount: number;
dailyCount: number;
groundedCount: number;
totalSignalCount: number;
lightHits: number;
remHits: number;
phaseHitCount: number;
promotedAt?: string;
lastRecalledAt?: string;
};
export type ShortTermDreamingStats = {
shortTermCount: number;
recallSignalCount: number;
dailySignalCount: number;
groundedSignalCount: number;
totalSignalCount: number;
phaseSignalCount: number;
lightPhaseHitCount: number;
remPhaseHitCount: number;
promotedTotal: number;
promotedToday: number;
storePath: string;
phaseSignalPath: string;
phaseSignalError?: string;
lastPromotedAt?: string;
shortTermEntries: ShortTermDreamingStatsEntry[];
signalEntries: ShortTermDreamingStatsEntry[];
promotedEntries: ShortTermDreamingStatsEntry[];
};
type RemHarnessPreviewResult = {
workspaceDir: string;
nowMs: number;
@ -119,6 +166,9 @@ type RemHarnessPreviewResult = {
};
type ApiFacadeModule = {
configureMemoryCoreDreamingState: (
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
) => void;
previewGroundedRemMarkdown: (params: {
workspaceDir: string;
inputPaths: string[];
@ -168,17 +218,25 @@ type RepairDreamingArtifactsResult = {
};
function loadApiFacadeModule(): ApiFacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<ApiFacadeModule>({
const module = loadBundledPluginPublicSurfaceModuleSync<ApiFacadeModule>({
dirName: "memory-core",
artifactBasename: "api.js",
});
module.configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStore<T>("memory-core", options),
);
return module;
}
function loadRuntimeFacadeModule(): RuntimeFacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<RuntimeFacadeModule>({
const module = loadBundledPluginPublicSurfaceModuleSync<RuntimeFacadeModule>({
dirName: "memory-core",
artifactBasename: "runtime-api.js",
});
module.configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStore<T>("memory-core", options),
);
return module;
}
/** Create a memory embedding provider with built-in fallback metadata. */
@ -200,6 +258,12 @@ export const removeGroundedShortTermCandidates: RuntimeFacadeModule["removeGroun
loadRuntimeFacadeModule().removeGroundedShortTermCandidates(
...args,
)) as RuntimeFacadeModule["removeGroundedShortTermCandidates"];
/** Load short-term dreaming stats for doctor/control status. */
export const loadShortTermPromotionDreamingStats: RuntimeFacadeModule["loadShortTermPromotionDreamingStats"] =
((...args) =>
loadRuntimeFacadeModule().loadShortTermPromotionDreamingStats(
...args,
)) as RuntimeFacadeModule["loadShortTermPromotionDreamingStats"];
/** Repair or archive problematic dreaming artifacts through the bundled runtime facade. */
export const repairDreamingArtifacts: RuntimeFacadeModule["repairDreamingArtifacts"] = ((...args) =>
loadRuntimeFacadeModule().repairDreamingArtifacts(

View file

@ -3,11 +3,13 @@
* Prefer vendor-neutral memory-host SDK subpaths for new plugin code.
*/
import type { OpenClawConfig } from "../config/types.js";
import { createPluginStateKeyedStore } from "../plugin-state/plugin-state-store.js";
import {
createLazyFacadeObjectValue,
loadActivatedBundledPluginPublicSurfaceModuleSync,
} from "./facade-runtime.js";
import type { MemorySearchManager } from "./memory-core-host-engine-storage.js";
import type { OpenKeyedStoreOptions, PluginStateKeyedStore } from "./plugin-state-runtime.js";
/** Doctor metadata for a built-in memory embedding provider. */
export type BuiltinMemoryEmbeddingProviderDoctorMetadata = {
@ -110,6 +112,9 @@ type MemoryIndexManagerFacade = {
};
type FacadeModule = {
configureMemoryCoreDreamingState: (
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>,
) => void;
auditShortTermPromotionArtifacts: (params: {
workspaceDir: string;
qmd?: {
@ -144,10 +149,14 @@ type FacadeModule = {
};
function loadFacadeModule(): FacadeModule {
return loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
const module = loadActivatedBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "memory-core",
artifactBasename: "runtime-api.js",
});
module.configureMemoryCoreDreamingState(<T>(options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStore<T>("memory-core", options),
);
return module;
}
/** Audit short-term promotion artifacts in an agent workspace. */
export const auditShortTermPromotionArtifacts: FacadeModule["auditShortTermPromotionArtifacts"] = ((