From 80139d16bcbca3fb6d71be1ed08a25b70c8b7eea Mon Sep 17 00:00:00 2001 From: heichl_xydigit Date: Fri, 10 Jul 2026 17:27:56 +0800 Subject: [PATCH] fix(memory-core): surface aggregate signal count in promotion audit surfaces (#87590) Memory promotion audit surfaces (MEMORY.md annotations, dreaming ranked/applied logs, memory promote listing, and promote-explain text/JSON) now show the aggregate signal count the promotion engine actually gates on, instead of recall-only numbers that misread daily/grounded promotions as zero-signal. The candidate carries a required signalCount owned by totalSignalCountForEntry (inline recomputation removed), and the promotion-annotation contamination regex accepts both legacy and signals= annotation generations. Fixes #87588 Thanks to @bladin for the contribution. Co-authored-by: heichl_xydigit <1740879+bladin@users.noreply.github.com> Co-authored-by: Ayaan Zaidi --- extensions/memory-core/src/cli.runtime.ts | 9 ++- extensions/memory-core/src/dreaming.ts | 4 +- .../src/short-term-promotion.test.ts | 81 ++++++++++++++++++- .../memory-core/src/short-term-promotion.ts | 18 ++--- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index f5bb27cec0fd..1ab95fb0c763 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -3,6 +3,7 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import type { MemoryEmbeddingProbeResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { resolveMemoryDreamingConfig, @@ -10,7 +11,6 @@ import { resolveMemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; -import { isUsageCountedSessionTranscriptFileName } from "openclaw/plugin-sdk/memory-core-host-engine-qmd"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { colorize, @@ -1465,7 +1465,7 @@ export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) { colorize( rich, theme.muted, - `recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} queries=${candidate.uniqueQueries} age=${candidate.ageDays.toFixed(1)}d consolidate=${candidate.components.consolidation.toFixed(2)} conceptual=${candidate.components.conceptual.toFixed(2)}`, + `signals=${candidate.signalCount} recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} queries=${candidate.uniqueQueries} age=${candidate.ageDays.toFixed(1)}d consolidate=${candidate.components.consolidation.toFixed(2)} conceptual=${candidate.components.conceptual.toFixed(2)}`, ), ); if (candidate.conceptTags.length > 0) { @@ -1580,7 +1580,8 @@ export async function runMemoryPromoteExplain( candidate, passes: { score: candidate.score >= thresholds.minScore, - recallCount: candidate.recallCount >= thresholds.minRecallCount, + // Engine gate is aggregate signalCount vs minRecallCount (config name unchanged). + recallCount: candidate.signalCount >= thresholds.minRecallCount, uniqueQueries: candidate.uniqueQueries >= thresholds.minUniqueQueries, maxAge: thresholds.maxAgeDays === null ? true : candidate.ageDays <= thresholds.maxAgeDays, @@ -1606,7 +1607,7 @@ export async function runMemoryPromoteExplain( colorize( rich, theme.muted, - `score=${candidate.score.toFixed(3)} recallCount=${candidate.recallCount} uniqueQueries=${candidate.uniqueQueries} ageDays=${candidate.ageDays.toFixed(1)}`, + `score=${candidate.score.toFixed(3)} signals=${candidate.signalCount} recalls=${candidate.recallCount} uniqueQueries=${candidate.uniqueQueries} ageDays=${candidate.ageDays.toFixed(1)}`, ), colorize( rich, diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index ef43370d9877..85424547839d 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -617,7 +617,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { ? candidates .map( (candidate) => - `${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`, + `${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} signals=${candidate.signalCount} recalls=${candidate.recallCount} queries=${candidate.uniqueQueries} components={freq=${candidate.components.frequency.toFixed(3)},rel=${candidate.components.relevance.toFixed(3)},div=${candidate.components.diversity.toFixed(3)},rec=${candidate.components.recency.toFixed(3)},cons=${candidate.components.consolidation.toFixed(3)},concept=${candidate.components.conceptual.toFixed(3)}}`, ) .join(" | ") : "none"; @@ -645,7 +645,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: { ? applied.appliedCandidates .map( (candidate) => - `${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount}`, + `${candidate.path}:${candidate.startLine}-${candidate.endLine} score=${candidate.score.toFixed(3)} signals=${candidate.signalCount} recalls=${candidate.recallCount}`, ) .join(" | ") : "none"; diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index c481781c21ad..6941b8b00a64 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -1548,6 +1548,7 @@ describe("short-term promotion", () => { source: "memory", snippet: "Move backups to S3 Glacier.", recallCount: 1, + signalCount: 1, avgScore: 0.95, maxScore: 0.95, uniqueQueries: 1, @@ -1692,6 +1693,19 @@ describe("short-term promotion", () => { ).toBe(true); }); + it("treats legacy and signals= promotion annotations as contaminated", () => { + expect( + testing.isContaminatedDreamingSnippet( + "Legacy annotation [score=0.837 recalls=0 avg=0.620 source=memory/2026-06-13.md:10-12]", + ), + ).toBe(true); + expect( + testing.isContaminatedDreamingSnippet( + "New annotation [score=0.837 signals=7 recalls=0 avg=0.620 source=memory/2026-06-13.md:10-12]", + ), + ).toBe(true); + }); + it("does not treat prose that mentions the word Candidate as contaminated", () => { expect( testing.isContaminatedDreamingSnippet( @@ -1905,6 +1919,7 @@ describe("short-term promotion", () => { source: "memory", snippet: "- Candidate: staged dream scratchwork", recallCount: 3, + signalCount: 3, avgScore: 0.9, maxScore: 0.9, uniqueQueries: 2, @@ -1952,6 +1967,7 @@ describe("short-term promotion", () => { source: "memory", snippet: "Expired short-term note.", recallCount: 3, + signalCount: 3, avgScore: 0.95, maxScore: 0.95, uniqueQueries: 2, @@ -1995,6 +2011,7 @@ describe("short-term promotion", () => { snippet: "Candidate: Default to action. confidence: 0.76 evidence: memory/.dreams/session-corpus/2026-04-08.txt:1-1 recalls: 3 status: staged", recallCount: 4, + signalCount: 4, avgScore: 0.97, maxScore: 0.97, uniqueQueries: 2, @@ -2183,7 +2200,7 @@ describe("short-term promotion", () => { expect(promotedLine?.length).toBeLessThan(340); expect(promotedLine).toContain("..."); expect(promotedLine).toMatch( - /\[score=0\.\d{3} recalls=1 avg=0\.\d{3} source=memory\/2026-04-01\.md:1-1\]/, + /\[score=0\.\d{3} signals=1 recalls=1 avg=0\.\d{3} source=memory\/2026-04-01\.md:1-1\]/, ); expect(memoryText).toMatch(//); }); @@ -2997,6 +3014,7 @@ describe("short-term promotion", () => { source: "memory", snippet: "Legacy basename path note.", recallCount: 2, + signalCount: 2, avgScore: 0.9, maxScore: 0.95, uniqueQueries: 2, @@ -3588,6 +3606,67 @@ describe("short-term promotion", () => { }); }); }); + it("shows signalCount instead of just recallCount in promotion annotations", async () => { + await withTempWorkspace(async (workspaceDir) => { + const nowMs = Date.parse("2026-05-28T10:00:00.000Z"); + const snippet = "Entry with dailyCount signals but zero recallCount."; + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [snippet]); + + await recordShortTermRecalls({ + workspaceDir, + query: "test signal count display", + nowMs, + results: [ + { + path: "memory/2026-05-28.md", + startLine: 1, + endLine: 1, + score: 0.85, + snippet, + source: "memory", + }, + ], + }); + + const store = await testing.readRecallStore(workspaceDir, new Date(nowMs).toISOString()); + const entryKey = Object.keys(store.entries)[0]; + store.entries[entryKey].dailyCount = 6; + store.entries[entryKey].recallCount = 0; + store.entries[entryKey].groundedCount = 1; + await testing.writeRawRecallStore(workspaceDir, store); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs, + }); + + expect(ranked.length).toBe(1); + expect(ranked[0].recallCount).toBe(0); + expect(ranked[0].dailyCount).toBe(6); + expect(ranked[0].groundedCount).toBe(1); + expect(ranked[0].signalCount).toBe(7); + + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs, + }); + + expect(applied.applied).toBe(1); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + + expect(memoryText).toContain("signals=7"); + expect(memoryText).toContain("recalls=0"); + expect(memoryText).not.toMatch(/recalls=7/); + }); + }); + describe("UTF-16 snippet bounds", () => { it("stores a complete-code-point short-term recall snippet", async () => { await withTempWorkspace(async (workspaceDir) => { diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 2a1cf5b3a48d..c4518e20eb80 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -83,8 +83,9 @@ const RAW_CONVERSATION_SUMMARY_RE = /^(?:[-*+]\s*)?Conversation Summary:/i; const RAW_TRANSCRIPT_TURN_RE = /^(?:[-*+]\s*)?(?:user|assistant):\s/i; const MEMORY_FLUSH_PROMPT_RE = /Save important context from this session to the daily memory file\.\s*STRICT RULES:/i; +// Optional signals=N accepts both legacy annotations and the current format. const PROMOTION_SCORE_METADATA_RE = - /\[\s*score=\d+(?:\.\d+)?\s+recalls=\d+\s+avg=\d+(?:\.\d+)?\s+source=memory\//i; + /\[\s*score=\d+(?:\.\d+)?\s+(?:signals=\d+\s+)?recalls=\d+\s+avg=\d+(?:\.\d+)?\s+source=memory\//i; const DREAMING_DIFF_PREFIX_RE = /@@\s*-\d+(?:,\d+)?\s+[-*+]\s+/iy; const GENERIC_DAY_HEADING_RE = /^(?:(?:mon|monday|tue|tues|tuesday|wed|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday|sun|sunday)(?:,\s+)?)?(?:(?:jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+\d{1,2}(?:st|nd|rd|th)?(?:,\s*\d{4})?|\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|\d{4}[/-]\d{2}[/-]\d{2})$/i; @@ -180,7 +181,7 @@ export type PromotionCandidate = { recallCount: number; dailyCount?: number; groundedCount?: number; - signalCount?: number; + signalCount: number; avgScore: number; maxScore: number; uniqueQueries: number; @@ -2324,7 +2325,7 @@ function buildPromotionSection( for (const candidate of candidates) { const source = `${candidate.path}:${candidate.startLine}-${candidate.endLine}`; - const metadata = `[score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} source=${source}]`; + const metadata = `[score=${candidate.score.toFixed(3)} signals=${candidate.signalCount} recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} source=${source}]`; lines.push(``); // Cap only the visible MEMORY.md text. The recall store keeps the full // rehydrated snippet so ranking, provenance, and dream narratives remain @@ -2431,16 +2432,7 @@ export async function applyShortTermPromotions( if (candidate.score < minScore) { return false; } - const candidateSignalCount = Math.max( - 0, - candidate.signalCount ?? - totalSignalCountForEntry({ - recallCount: candidate.recallCount, - dailyCount: candidate.dailyCount, - groundedCount: candidate.groundedCount, - }), - ); - if (candidateSignalCount < minRecallCount) { + if (candidate.signalCount < minRecallCount) { return false; } if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {