mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix: store memory-core dreams state in sqlite (#91056)
This commit is contained in:
parent
1222f7a6bc
commit
3f5e001844
24 changed files with 2118 additions and 1018 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
261
extensions/memory-core/doctor-contract-api.test.ts
Normal file
261
extensions/memory-core/doctor-contract-api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
270
extensions/memory-core/doctor-contract-api.ts
Normal file
270
extensions/memory-core/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
170
extensions/memory-core/src/dreaming-state.ts
Normal file
170
extensions/memory-core/src/dreaming-state.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
export {
|
||||
dedupeDreamDiaryEntries,
|
||||
loadShortTermPromotionDreamingStats,
|
||||
previewGroundedRemMarkdown,
|
||||
previewRemHarness,
|
||||
removeBackfillDiaryEntries,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"] = ((
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue