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 <hi@obviy.us>
This commit is contained in:
heichl_xydigit
2026-07-10 17:27:56 +08:00
committed by GitHub
parent ecfea21eeb
commit 80139d16bc
4 changed files with 92 additions and 20 deletions

View File

@@ -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,

View File

@@ -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";

View File

@@ -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(/<!-- openclaw-memory-promotion:[^\n]+ -->/);
});
@@ -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) => {

View File

@@ -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(`<!-- ${PROMOTION_MARKER_PREFIX}${candidate.key} -->`);
// 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) {