diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index 65aad3058882..ac3f0cc6c864 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -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(); }); diff --git a/extensions/memory-core/src/dreaming-consolidation-artifacts.ts b/extensions/memory-core/src/dreaming-consolidation-artifacts.ts new file mode 100644 index 000000000000..842d04c0a5ec --- /dev/null +++ b/extensions/memory-core/src/dreaming-consolidation-artifacts.ts @@ -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 { + const current = await readMemoryCoreWorkspaceEntries({ + 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 { + 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 { + 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, + }; + }, + }); +} diff --git a/extensions/memory-core/src/dreaming-consolidation-projects.test.ts b/extensions/memory-core/src/dreaming-consolidation-projects.test.ts new file mode 100644 index 000000000000..135873fc599d --- /dev/null +++ b/extensions/memory-core/src/dreaming-consolidation-projects.test.ts @@ -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(); + 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 ", + ); + expect(result?.content).toContain( + "Beta deployment uses blue. Source: memory/2026-07-01.md#L1-L1 ", + ); + const globalLine = result?.content + .split("\n") + .find((line) => line.includes("Use metric units globally.")); + expect(globalLine).toBeDefined(); + expect(globalLine).not.toContain(""; + 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.", + ); + }); +}); diff --git a/extensions/memory-core/src/dreaming-consolidation.ts b/extensions/memory-core/src/dreaming-consolidation.ts index 924b5a56cb91..58cc680ba79e 100644 --- a/extensions/memory-core/src/dreaming-consolidation.ts +++ b/extensions/memory-core/src/dreaming-consolidation.ts @@ -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 { - const current = await readMemoryCoreWorkspaceEntries({ - 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 { + 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 { - 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 { - 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, - }; - }, - }); -} diff --git a/extensions/memory-core/src/short-term-promotion-apply.ts b/extensions/memory-core/src/short-term-promotion-apply.ts index 1a75ccb735e2..826d63a9d7a3 100644 --- a/extensions/memory-core/src/short-term-promotion-apply.ts +++ b/extensions/memory-core/src/short-term-promotion-apply.ts @@ -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(); - 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(""); } } diff --git a/extensions/memory-core/src/short-term-promotion-metadata.test.ts b/extensions/memory-core/src/short-term-promotion-metadata.test.ts index 650ab427fca2..2a2b9dbee64e 100644 --- a/extensions/memory-core/src/short-term-promotion-metadata.test.ts +++ b/extensions/memory-core/src/short-term-promotion-metadata.test.ts @@ -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({ diff --git a/extensions/memory-core/src/short-term-promotion-metadata.ts b/extensions/memory-core/src/short-term-promotion-metadata.ts index 265925e5df21..257218c67cb5 100644 --- a/extensions/memory-core/src/short-term-promotion-metadata.ts +++ b/extensions/memory-core/src/short-term-promotion-metadata.ts @@ -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?: 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, +): string | undefined { + return candidate.projectKey && !/[\r\n<>]/u.test(candidate.projectKey) + ? candidate.projectKey + : undefined; +} + +export function groupPromotionCandidatesByProjectKey< + Candidate extends Pick, +>(candidates: readonly Candidate[]): PromotionProjectGroup[] { + const groups = new Map(); + 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, ): 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) - ? ` ` - : ""; + const projectKey = resolvePromotionProjectKey(candidate); + const project = projectKey ? ` ` : ""; return ` ${project}`; }