fix(memory): keep dreaming consolidation within project scope (#115721)

* fix(memory): isolate consolidation by project

* test(memory): align dreaming status threshold
This commit is contained in:
Peter Steinberger
2026-07-29 04:27:24 -04:00
committed by GitHub
parent 6c000dc696
commit ed6010ed54
7 changed files with 552 additions and 172 deletions
@@ -205,7 +205,7 @@ describe("memory-core /dreaming command", () => {
// Dreaming is enabled by default; the fixture sets no explicit enabled flag.
expect(result.text).toContain("- enabled: on (America/Los_Angeles)");
expect(result.text).toContain("- sweep cadence: 15 */8 * * *");
expect(result.text).toContain("- promotion policy: score>=0.8, recalls>=3, uniqueQueries>=3");
expect(result.text).toContain("- promotion policy: score>=0.75, recalls>=3, uniqueQueries>=3");
expect(harness.runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
@@ -0,0 +1,107 @@
// Memory Core plugin module owns consolidation preimages and operator summaries.
import { createHash } from "node:crypto";
import { updateDreamsFile } from "./dreaming-dreams-file.js";
import {
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
DREAMING_MEMORY_BACKUP_NAMESPACE,
} from "./dreaming-state.js";
const CONSOLIDATION_BACKUP_LIMIT = 8;
type ConsolidationBackup = {
createdAt: string;
content: string;
contentHash: string;
};
export type MemoryConsolidationResult = {
content: string;
added: number;
merged: number;
superseded: number;
highlights: string[];
};
export async function storeMemoryPreimage(params: {
workspaceDir: string;
content: string;
nowMs: number;
}): Promise<void> {
const current = await readMemoryCoreWorkspaceEntries<ConsolidationBackup>({
namespace: DREAMING_MEMORY_BACKUP_NAMESPACE,
workspaceDir: params.workspaceDir,
});
const createdAt = new Date(params.nowMs).toISOString();
const contentHash = createHash("sha256").update(params.content).digest("hex");
const entries = [
...current,
{
key: `${createdAt}:${contentHash.slice(0, 12)}`,
value: { createdAt, content: params.content, contentHash },
},
]
.toSorted((left, right) => left.value.createdAt.localeCompare(right.value.createdAt))
.slice(-CONSOLIDATION_BACKUP_LIMIT);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_MEMORY_BACKUP_NAMESPACE,
workspaceDir: params.workspaceDir,
entries,
});
}
export async function appendConsolidationSummary(params: {
workspaceDir: string;
result: MemoryConsolidationResult;
nowMs: number;
}): Promise<void> {
const timestamp = new Date(params.nowMs).toISOString();
const lines = [
`### ${timestamp}`,
"",
`- Added: ${params.result.added}`,
`- Merged: ${params.result.merged}`,
`- Superseded: ${params.result.superseded}`,
...(params.result.highlights.length > 0
? [
"- Highlights:",
...params.result.highlights.map((line) => ` - \`${line.replaceAll("`", "'")}\``),
]
: []),
"",
];
await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
const heading = "## Memory Consolidation History";
const base = existing.includes(heading)
? existing.trimEnd()
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${heading}`;
return {
content: `${base}\n\n${lines.join("\n")}`,
result: dreamsPath,
};
},
});
}
export async function appendConsolidationSkippedSummary(params: {
workspaceDir: string;
nowMs: number;
reason: string;
}): Promise<void> {
const timestamp = new Date(params.nowMs).toISOString();
await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, _dreamsPath) => {
const heading = "## Memory Consolidation History";
const base = existing.includes(heading)
? existing.trimEnd()
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${heading}`;
return {
content: `${base}\n\n### ${timestamp}\n\n- Rewrite skipped: ${params.reason}.\n- Fallback: append-only promotion.\n`,
result: undefined,
};
},
});
}
@@ -0,0 +1,239 @@
// Memory Core tests cover project isolation across consolidation passes.
import { describe, expect, it, vi } from "vitest";
import { applyMemoryConsolidationPlan, consolidateMemory } from "./dreaming-consolidation.js";
import type { PromotionCandidate } from "./short-term-promotion.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
const logger = { info: vi.fn(), warn: vi.fn() };
type ConsolidationPrompt = {
currentMemory: string;
candidates: Array<{ key: string; resultEntry: string; projectKey: string | null }>;
};
function projectCandidate(key: string, snippet: string, projectKey?: string): PromotionCandidate {
return {
key,
path: "memory/2026-07-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet,
recallCount: 3,
signalCount: 3,
avgScore: 0.9,
maxScore: 0.9,
uniqueQueries: 2,
firstRecalledAt: "2026-07-01T10:00:00.000Z",
lastRecalledAt: "2026-07-01T10:00:00.000Z",
ageDays: 0,
score: 0.9,
recallDays: ["2026-07-01", "2026-07-02"],
conceptTags: ["preference"],
components: {
frequency: 1,
relevance: 0.9,
diversity: 0.5,
recency: 1,
consolidation: 0.5,
conceptual: 0.2,
},
...(projectKey ? { projectKey } : {}),
provenance: {
originClass: "agent",
sessionKind: "interactive",
observedAt: Date.parse("2026-07-01T10:00:00.000Z"),
},
};
}
function createPromptResponder(
respond: (prompt: ConsolidationPrompt) => {
memory: string;
operations: Array<{
candidateKey: string;
action: "added" | "merged" | "superseded";
resultEntry: string;
priorEntries: string[];
}>;
},
) {
const responses = new Map<string, string>();
return {
run: vi.fn(async (options: unknown) => {
const { message, sessionKey } = options as { message: string; sessionKey: string };
responses.set(
sessionKey,
JSON.stringify(respond(JSON.parse(message) as ConsolidationPrompt)),
);
return { runId: sessionKey };
}),
waitForRun: vi.fn(async () => ({ status: "ok" })),
getSessionMessages: vi.fn(async (options: unknown) => {
const { sessionKey } = options as { sessionKey: string };
return { messages: [{ role: "assistant", content: responses.get(sessionKey) ?? "" }] };
}),
deleteSession: vi.fn(async (_options: unknown) => undefined),
};
}
describe("memory consolidation project groups", () => {
it("consolidates global and project candidates in deterministic isolated passes", async () => {
const workspaceDir = await createTempWorkspace("memory-consolidation-project-groups-");
const existingMemory = "# Memory\n\n- Existing global fact.\n";
const candidates = [
projectCandidate("beta", "Beta deployment uses blue.", "github.com/acme/beta"),
projectCandidate("global", "Use metric units globally."),
projectCandidate("alpha", "Alpha deployment uses green.", "github.com/acme/alpha"),
];
const subagent = createPromptResponder((prompt) => ({
memory: `${prompt.currentMemory.trimEnd()}\n${prompt.candidates.map((item) => item.resultEntry).join("\n")}\n`,
operations: prompt.candidates.map((item) => ({
candidateKey: item.key,
action: "added",
resultEntry: item.resultEntry,
priorEntries: [],
})),
}));
const plan = await consolidateMemory({
subagent,
workspaceDir,
existingMemory,
candidates,
maxPriorEntryLossFraction: 0.25,
nowMs: Date.parse("2026-07-02T10:00:00.000Z"),
logger,
});
expect(plan).not.toBeNull();
if (!plan) {
return;
}
const promptedGroups = subagent.run.mock.calls.map(([options]) => {
const prompt = JSON.parse((options as { message: string }).message) as ConsolidationPrompt;
return prompt.candidates.map((item) => item.projectKey);
});
expect(promptedGroups).toEqual([[null], ["github.com/acme/alpha"], ["github.com/acme/beta"]]);
const result = applyMemoryConsolidationPlan({
existingMemory,
plan,
nowMs: Date.parse("2026-07-02T10:00:00.000Z"),
maxPriorEntryLossFraction: 0.25,
});
expect(result?.content).toContain(
"Alpha deployment uses green. Source: memory/2026-07-01.md#L1-L1 <!-- trigger: preference --> <!-- importance: 9 --> <!-- project: github.com/acme/alpha -->",
);
expect(result?.content).toContain(
"Beta deployment uses blue. Source: memory/2026-07-01.md#L1-L1 <!-- trigger: preference --> <!-- importance: 9 --> <!-- project: github.com/acme/beta -->",
);
const globalLine = result?.content
.split("\n")
.find((line) => line.includes("Use metric units globally."));
expect(globalLine).toBeDefined();
expect(globalLine).not.toContain("<!-- project:");
});
it("rejects a cross-project merge after completing the isolated group passes", async () => {
const workspaceDir = await createTempWorkspace("memory-consolidation-project-reject-");
const betaPrior = "- Shared deployment uses blue. <!-- project: github.com/acme/beta -->";
const existingMemory = `# Memory\n\n${betaPrior}\n- Two.\n- Three.\n- Four.\n`;
const candidates = [
projectCandidate("beta", "Beta deployment uses blue.", "github.com/acme/beta"),
projectCandidate("global", "Use metric units globally."),
projectCandidate("alpha", "Shared deployment uses blue.", "github.com/acme/alpha"),
];
const subagent = createPromptResponder((prompt) => {
const item = prompt.candidates[0]!;
const crossProject = item.projectKey === "github.com/acme/alpha";
return {
memory: crossProject
? `${prompt.currentMemory.replace(`${betaPrior}\n`, "").trimEnd()}\n${item.resultEntry}\n`
: `${prompt.currentMemory.trimEnd()}\n${item.resultEntry}\n`,
operations: [
{
candidateKey: item.key,
action: crossProject ? "merged" : "added",
resultEntry: item.resultEntry,
priorEntries: crossProject ? [betaPrior] : [],
},
],
};
});
await expect(
consolidateMemory({
subagent,
workspaceDir,
existingMemory,
candidates,
maxPriorEntryLossFraction: 0.25,
nowMs: Date.parse("2026-07-02T10:00:00.000Z"),
logger,
}),
).resolves.toBeNull();
expect(subagent.run).toHaveBeenCalledTimes(3);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("output crosses project groups for candidate alpha"),
);
});
it("rejects groups whose code-applied aggregate exceeds the memory budget", async () => {
const workspaceDir = await createTempWorkspace("memory-consolidation-project-budget-");
const existingMemory = "# Memory\n";
const candidates = [
projectCandidate("global", "Use metric units globally."),
projectCandidate("alpha", "Alpha deployment uses green.", "github.com/acme/alpha"),
];
const outputLengths: number[] = [];
const createBudgetSubagent = () =>
createPromptResponder((prompt) => {
const memory = `${prompt.currentMemory.trimEnd()}\n${prompt.candidates.map((item) => item.resultEntry).join("\n")}\n`;
outputLengths.push(memory.length);
return {
memory,
operations: prompt.candidates.map((item) => ({
candidateKey: item.key,
action: "added",
resultEntry: item.resultEntry,
priorEntries: [],
})),
};
});
const fullPlan = await consolidateMemory({
subagent: createBudgetSubagent(),
workspaceDir,
existingMemory,
candidates,
maxPriorEntryLossFraction: 0.25,
nowMs: Date.parse("2026-07-02T10:00:00.000Z"),
logger,
});
expect(fullPlan).not.toBeNull();
if (!fullPlan) {
return;
}
const aggregateLength = fullPlan.memory.length;
const perGroupBudget = Math.max(...outputLengths);
expect(aggregateLength).toBeGreaterThan(perGroupBudget);
await expect(
consolidateMemory({
subagent: createBudgetSubagent(),
workspaceDir,
existingMemory,
candidates,
maxPriorEntryLossFraction: 0.25,
memoryFileMaxChars: perGroupBudget,
nowMs: Date.parse("2026-07-02T10:00:00.000Z"),
logger,
}),
).resolves.toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
"memory-core: combined consolidation plan is invalid; using append-only fallback.",
);
});
});
@@ -5,21 +5,20 @@ import {
formatMemoryDreamingDay,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { MemoryConsolidationResult } from "./dreaming-consolidation-artifacts.js";
import { filterConsolidationCandidates } from "./dreaming-consolidation-candidates.js";
import { updateDreamsFile } from "./dreaming-dreams-file.js";
import type { SubagentSurface } from "./dreaming-narrative.js";
import {
readMemoryCoreWorkspaceEntries,
writeMemoryCoreWorkspaceEntries,
DREAMING_MEMORY_BACKUP_NAMESPACE,
} from "./dreaming-state.js";
import { DEFAULT_MEMORY_FILE_MAX_CHARS } from "./memory-budget.js";
import { buildPromotionRecallAnnotations } from "./short-term-promotion-metadata.js";
import {
buildPromotionRecallAnnotations,
groupPromotionCandidatesByProjectKey,
memoryEntryMatchesPromotionProjectGroup,
type PromotionProjectGroup,
} from "./short-term-promotion-metadata.js";
import type { PromotionCandidate } from "./short-term-promotion-types.js";
const CONSOLIDATION_TIMEOUT_MS = 60_000;
const CONSOLIDATION_MESSAGE_LIMIT = 5;
const CONSOLIDATION_BACKUP_LIMIT = 8;
const PROMOTION_MARKER_PREFIX = "openclaw-memory-promotion:";
const PROMOTED_SNIPPET_CHARS_PER_TOKEN_ESTIMATE = 4;
const CONSOLIDATION_SYSTEM_PROMPT = [
@@ -39,12 +38,6 @@ type Logger = {
warn: (message: string) => void;
};
type ConsolidationBackup = {
createdAt: string;
content: string;
contentHash: string;
};
type ConsolidationOperation = {
candidateKey: string;
action: "added" | "merged" | "superseded";
@@ -60,14 +53,6 @@ type ConsolidationOutput = {
type MemoryConsolidationPlan = ConsolidationOutput;
type MemoryConsolidationResult = {
content: string;
added: number;
merged: number;
superseded: number;
highlights: string[];
};
function candidateSourceRef(candidate: PromotionCandidate): string {
return `${candidate.path}#L${candidate.startLine}-L${candidate.endLine}`;
}
@@ -262,6 +247,7 @@ function validateConsolidatedMemory(params: {
previous: string;
output: ConsolidationOutput;
candidates: PromotionCandidate[];
projectKey?: string;
maxPriorEntryLossFraction: number;
memoryFileMaxChars: number;
maxPromotedSnippetTokens: number;
@@ -347,6 +333,13 @@ function validateConsolidatedMemory(params: {
) {
return `output has invalid prior-entry evidence for candidate ${candidate.key}`;
}
if (
operation.priorEntries.some(
(entry) => !memoryEntryMatchesPromotionProjectGroup(entry, params.projectKey),
)
) {
return `output crosses project groups for candidate ${candidate.key}`;
}
if (
operation.action === "merged" &&
operation.priorEntries.some(
@@ -539,33 +532,6 @@ export function applyMemoryConsolidationPlan(params: {
};
}
export async function storeMemoryPreimage(params: {
workspaceDir: string;
content: string;
nowMs: number;
}): Promise<void> {
const current = await readMemoryCoreWorkspaceEntries<ConsolidationBackup>({
namespace: DREAMING_MEMORY_BACKUP_NAMESPACE,
workspaceDir: params.workspaceDir,
});
const createdAt = new Date(params.nowMs).toISOString();
const contentHash = createHash("sha256").update(params.content).digest("hex");
const entries = [
...current,
{
key: `${createdAt}:${contentHash.slice(0, 12)}`,
value: { createdAt, content: params.content, contentHash },
},
]
.toSorted((left, right) => left.value.createdAt.localeCompare(right.value.createdAt))
.slice(-CONSOLIDATION_BACKUP_LIMIT);
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_MEMORY_BACKUP_NAMESPACE,
workspaceDir: params.workspaceDir,
entries,
});
}
export async function consolidateMemory(params: {
subagent: SubagentSurface;
workspaceDir: string;
@@ -582,28 +548,118 @@ export async function consolidateMemory(params: {
if (candidates.length === 0) {
return null;
}
const sessionKey = `dreaming-narrative-consolidation-${createHash("sha1")
const sessionPrefix = `dreaming-narrative-consolidation-${createHash("sha1")
.update(params.workspaceDir)
.digest("hex")
.slice(0, 12)}-${randomUUID()}`;
try {
const maxPromotedSnippetTokens = Math.max(
1,
Math.floor(
params.maxPromotedSnippetTokens ?? DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
),
const maxPromotedSnippetTokens = Math.max(
1,
Math.floor(
params.maxPromotedSnippetTokens ?? DEFAULT_MEMORY_DEEP_DREAMING_MAX_PROMOTED_SNIPPET_TOKENS,
),
);
const budget = Math.max(
1,
Math.floor(params.memoryFileMaxChars ?? DEFAULT_MEMORY_FILE_MAX_CHARS),
);
const groups = groupPromotionCandidatesByProjectKey(candidates);
const outputs: ConsolidationOutput[] = [];
let rejected = false;
for (const [groupIndex, group] of groups.entries()) {
const sessionKey = `${sessionPrefix}-${groupIndex}`;
try {
const output = await runConsolidationGroup({
...params,
group,
sessionKey,
maxPromotedSnippetTokens,
});
if (!output) {
rejected = true;
continue;
}
const rejection = validateConsolidatedMemory({
previous: params.existingMemory,
output,
candidates: group.candidates,
...(group.projectKey ? { projectKey: group.projectKey } : {}),
maxPriorEntryLossFraction: params.maxPriorEntryLossFraction,
memoryFileMaxChars: budget,
maxPromotedSnippetTokens,
});
if (rejection) {
params.logger.warn(
`memory-core: consolidation rejected because ${rejection}; using append-only fallback.`,
);
rejected = true;
continue;
}
outputs.push(output);
} catch (error) {
params.logger.warn(
`memory-core: consolidation failed (${error instanceof Error ? error.message : String(error)}); using append-only fallback.`,
);
rejected = true;
} finally {
await params.subagent.deleteSession({ sessionKey }).catch(() => undefined);
}
}
if (rejected || outputs.length !== groups.length) {
return null;
}
const candidatesByKey = new Map(candidates.map((candidate) => [candidate.key, candidate]));
const operations = outputs
.flatMap((output) => output.operations)
.map((operation) => {
const lineageKey = candidatesByKey.get(operation.candidateKey)?.provenance?.supersedesKey;
if (lineageKey) {
operation.lineageKey = lineageKey;
}
return operation;
});
const plan = { memory: params.existingMemory, operations };
const aggregate = applyMemoryConsolidationPlan({
existingMemory: params.existingMemory,
plan,
nowMs: params.nowMs,
memoryFileMaxChars: budget,
maxPriorEntryLossFraction: params.maxPriorEntryLossFraction,
});
if (!aggregate) {
params.logger.warn(
"memory-core: combined consolidation plan is invalid; using append-only fallback.",
);
return null;
}
plan.memory = aggregate.content;
return plan;
}
async function runConsolidationGroup(params: {
subagent: SubagentSurface;
existingMemory: string;
group: PromotionProjectGroup;
model?: string;
nowMs: number;
sessionKey: string;
maxPromotedSnippetTokens: number;
logger: Logger;
}): Promise<ConsolidationOutput | null> {
try {
const run = await params.subagent.run({
idempotencyKey: `${sessionKey}-${params.nowMs}`,
sessionKey,
idempotencyKey: `${params.sessionKey}-${params.nowMs}`,
sessionKey: params.sessionKey,
message: buildConsolidationPrompt(
params.existingMemory,
candidates,
maxPromotedSnippetTokens,
params.group.candidates,
params.maxPromotedSnippetTokens,
),
...(params.model ? { model: params.model } : {}),
extraSystemPrompt: CONSOLIDATION_SYSTEM_PROMPT,
lane: `dreaming-consolidation:${sessionKey}`,
lane: `dreaming-consolidation:${params.sessionKey}`,
lightContext: true,
deliver: false,
});
@@ -618,7 +674,7 @@ export async function consolidateMemory(params: {
return null;
}
const { messages } = await params.subagent.getSessionMessages({
sessionKey,
sessionKey: params.sessionKey,
limit: CONSOLIDATION_MESSAGE_LIMIT,
});
const assistantText = extractAssistantText(messages);
@@ -629,97 +685,11 @@ export async function consolidateMemory(params: {
);
return null;
}
const budget = Math.max(
1,
Math.floor(params.memoryFileMaxChars ?? DEFAULT_MEMORY_FILE_MAX_CHARS),
);
const rejection = validateConsolidatedMemory({
previous: params.existingMemory,
output,
candidates,
maxPriorEntryLossFraction: params.maxPriorEntryLossFraction,
memoryFileMaxChars: budget,
maxPromotedSnippetTokens,
});
if (rejection) {
params.logger.warn(
`memory-core: consolidation rejected because ${rejection}; using append-only fallback.`,
);
return null;
}
const candidatesByKey = new Map(candidates.map((candidate) => [candidate.key, candidate]));
return {
...output,
operations: output.operations.map((operation) => {
const lineageKey = candidatesByKey.get(operation.candidateKey)?.provenance?.supersedesKey;
if (lineageKey) {
operation.lineageKey = lineageKey;
}
return operation;
}),
};
return output;
} catch (error) {
params.logger.warn(
`memory-core: consolidation failed (${error instanceof Error ? error.message : String(error)}); using append-only fallback.`,
);
return null;
} finally {
await params.subagent.deleteSession({ sessionKey }).catch(() => undefined);
}
}
export async function appendConsolidationSummary(params: {
workspaceDir: string;
result: MemoryConsolidationResult;
nowMs: number;
}): Promise<void> {
const timestamp = new Date(params.nowMs).toISOString();
const lines = [
`### ${timestamp}`,
"",
`- Added: ${params.result.added}`,
`- Merged: ${params.result.merged}`,
`- Superseded: ${params.result.superseded}`,
...(params.result.highlights.length > 0
? [
"- Highlights:",
...params.result.highlights.map((line) => ` - \`${line.replaceAll("`", "'")}\``),
]
: []),
"",
];
await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, dreamsPath) => {
const heading = "## Memory Consolidation History";
const base = existing.includes(heading)
? existing.trimEnd()
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${heading}`;
return {
content: `${base}\n\n${lines.join("\n")}`,
result: dreamsPath,
};
},
});
}
export async function appendConsolidationSkippedSummary(params: {
workspaceDir: string;
nowMs: number;
reason: string;
}): Promise<void> {
const timestamp = new Date(params.nowMs).toISOString();
await updateDreamsFile({
workspaceDir: params.workspaceDir,
updater: (existing, _dreamsPath) => {
const heading = "## Memory Consolidation History";
const base = existing.includes(heading)
? existing.trimEnd()
: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${heading}`;
return {
content: `${base}\n\n### ${timestamp}\n\n- Rewrite skipped: ${params.reason}.\n- Fallback: append-only promotion.\n`,
result: undefined,
};
},
});
}
@@ -9,17 +9,16 @@ import {
} from "openclaw/plugin-sdk/memory-core-host-status";
import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
appendConsolidationSkippedSummary,
appendConsolidationSummary,
storeMemoryPreimage,
} from "./dreaming-consolidation-artifacts.js";
import {
isConsolidationCandidateEligible,
isPromotionOriginBlocked,
} from "./dreaming-consolidation-candidates.js";
import {
applyMemoryConsolidationPlan,
appendConsolidationSkippedSummary,
appendConsolidationSummary,
consolidateMemory,
storeMemoryPreimage,
} from "./dreaming-consolidation.js";
import { applyMemoryConsolidationPlan, consolidateMemory } from "./dreaming-consolidation.js";
import {
DREAMING_DAILY_PROVENANCE_NAMESPACE,
readMemoryCoreWorkspaceEntries,
@@ -33,7 +32,10 @@ import {
resolveMemoryWritePath,
writeMemoryContent,
} from "./short-term-promotion-memory-write.js";
import { buildPromotionRecallAnnotations } from "./short-term-promotion-metadata.js";
import {
buildPromotionRecallAnnotations,
groupPromotionCandidatesByProjectKey,
} from "./short-term-promotion-metadata.js";
import { resolveShortTermSourcePathCandidates } from "./short-term-promotion-record.js";
import { rehydratePromotionCandidate } from "./short-term-promotion-rehydrate.js";
import { readStore, withShortTermLock, writeStore } from "./short-term-promotion-store.js";
@@ -70,15 +72,10 @@ function buildPromotionSection(
): string {
const sectionDate = formatMemoryDreamingDay(nowMs, timezone);
const lines = ["", `## Promoted From Short-Term Memory (${sectionDate})`, ""];
const projectGroups = new Map<string, PromotionCandidate[]>();
for (const candidate of candidates) {
const group =
candidate.projectKey && !/[\r\n<>]/u.test(candidate.projectKey) ? candidate.projectKey : "";
projectGroups.set(group, [...(projectGroups.get(group) ?? []), candidate]);
}
const projectGroups = groupPromotionCandidatesByProjectKey(candidates);
for (const [projectKey, groupCandidates] of projectGroups) {
if (projectGroups.size > 1) {
for (const { projectKey, candidates: groupCandidates } of projectGroups) {
if (projectGroups.length > 1) {
lines.push(projectKey ? `### Project: ${projectKey}` : "### Global", "");
}
for (const candidate of groupCandidates) {
@@ -92,7 +89,7 @@ function buildPromotionSection(
`- ${formatPromotedSnippetForMemory(candidate.snippet, maxPromotedSnippetTokens)} ${metadata} ${buildPromotionRecallAnnotations(candidate)}`,
);
}
if (projectGroups.size > 1) {
if (projectGroups.length > 1) {
lines.push("");
}
}
@@ -1,8 +1,35 @@
// Memory Core tests cover deterministic recall metadata for promoted entries.
import { describe, expect, it } from "vitest";
import { buildPromotionRecallAnnotations } from "./short-term-promotion-metadata.js";
import {
buildPromotionRecallAnnotations,
groupPromotionCandidatesByProjectKey,
} from "./short-term-promotion-metadata.js";
describe("promotion recall metadata", () => {
it("orders exact project-key groups deterministically", () => {
const groups = groupPromotionCandidatesByProjectKey([
{ key: "beta", projectKey: "github.com/acme/beta" },
{ key: "global" },
{ key: "multi", projectKey: "github.com/acme/alpha; path:/srv/alpha" },
{ key: "alpha", projectKey: "github.com/acme/alpha" },
]);
expect(
groups.map((group) => ({
projectKey: group.projectKey ?? null,
candidates: group.candidates.map((candidate) => candidate.key),
})),
).toEqual([
{ projectKey: null, candidates: ["global"] },
{ projectKey: "github.com/acme/alpha", candidates: ["alpha"] },
{
projectKey: "github.com/acme/alpha; path:/srv/alpha",
candidates: ["multi"],
},
{ projectKey: "github.com/acme/beta", candidates: ["beta"] },
]);
});
it("keeps the top three concept tags and rounds importance into the supported range", () => {
expect(
buildPromotionRecallAnnotations({
@@ -1,8 +1,16 @@
// Memory Core plugin module formats deterministic recall metadata for promoted entries.
import { extractProjectKeysFromCuratedEntry } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import type { PromotionCandidate } from "./short-term-promotion-types.js";
const MAX_PROMOTION_TRIGGER_PHRASE_CHARS = 64;
export type PromotionProjectGroup<
Candidate extends Pick<PromotionCandidate, "projectKey"> = PromotionCandidate,
> = {
projectKey?: string;
candidates: Candidate[];
};
function normalizePromotionTriggerPhrase(value: string): string {
const singleLine = value
.replace(/<!--|-->/gu, " ")
@@ -12,6 +20,40 @@ function normalizePromotionTriggerPhrase(value: string): string {
return Array.from(singleLine).slice(0, MAX_PROMOTION_TRIGGER_PHRASE_CHARS).join("").trimEnd();
}
function resolvePromotionProjectKey(
candidate: Pick<PromotionCandidate, "projectKey">,
): string | undefined {
return candidate.projectKey && !/[\r\n<>]/u.test(candidate.projectKey)
? candidate.projectKey
: undefined;
}
export function groupPromotionCandidatesByProjectKey<
Candidate extends Pick<PromotionCandidate, "projectKey">,
>(candidates: readonly Candidate[]): PromotionProjectGroup<Candidate>[] {
const groups = new Map<string, Candidate[]>();
for (const candidate of candidates) {
const projectKey = resolvePromotionProjectKey(candidate) ?? "";
groups.set(projectKey, [...(groups.get(projectKey) ?? []), candidate]);
}
// Stable group order keeps both prompts and append fallback bytes cache-friendly.
return [...groups.entries()]
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([projectKey, groupedCandidates]) =>
projectKey
? { projectKey, candidates: groupedCandidates }
: { candidates: groupedCandidates },
);
}
export function memoryEntryMatchesPromotionProjectGroup(
entry: string,
projectKey: string | undefined,
): boolean {
const annotations = extractProjectKeysFromCuratedEntry(entry);
return annotations.valid && annotations.keys.join("; ") === (projectKey ?? "");
}
export function buildPromotionRecallAnnotations(
candidate: Pick<PromotionCandidate, "conceptTags" | "score" | "projectKey">,
): string {
@@ -21,9 +63,7 @@ export function buildPromotionRecallAnnotations(
.filter(Boolean)
.join(", ");
const importance = Math.min(10, Math.max(3, Math.round(candidate.score * 10)));
const project =
candidate.projectKey && !/[\r\n<>]/u.test(candidate.projectKey)
? ` <!-- project: ${candidate.projectKey} -->`
: "";
const projectKey = resolvePromotionProjectKey(candidate);
const project = projectKey ? ` <!-- project: ${projectKey} -->` : "";
return `<!-- trigger: ${triggers} --> <!-- importance: ${importance} -->${project}`;
}