mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 14:51:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user