mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6c000dc696
commit
ed6010ed54
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user