Skip to content

Commit 3243ba7

Browse files
committed
fix(memory-core): skip markdown placeholder snippets in short-term promotion (#80582)
Bare list markers (-, *, +), heading-only lines, and heading + empty-bullet ranges carry no durable content but were recorded as short-term recall candidates and could be promoted into MEMORY.md as empty blocks. Broad substring matching in compareCandidateWindow also anchored meaningful candidates onto bare-marker windows. Add isMarkdownPlaceholderLine / isPlaceholderShortTermSnippet / isUnpromotableShortTermSnippet and filter placeholders at recall recording, grounded-candidate recording, store load/normalization, and ranking. Guard compareCandidateWindow substring branches so neither side may be a placeholder (exact match unchanged). The relocation guard is intentionally narrower than a token count: an ASCII-only token check would split CJK text into separators and reject legitimate non-English relocations.
1 parent d29c3a5 commit 3243ba7

2 files changed

Lines changed: 232 additions & 5 deletions

File tree

extensions/memory-core/src/short-term-promotion.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,184 @@ describe("short-term promotion", () => {
448448
});
449449
});
450450

451+
it("does not record bare markdown placeholder markers as short-term recalls", async () => {
452+
await withTempWorkspace(async (workspaceDir) => {
453+
await recordShortTermRecalls({
454+
workspaceDir,
455+
query: "notes",
456+
results: [
457+
{
458+
path: "memory/2026-04-03.md",
459+
source: "memory",
460+
startLine: 1,
461+
endLine: 1,
462+
score: 0.9,
463+
snippet: "-",
464+
},
465+
{
466+
path: "memory/2026-04-03.md",
467+
source: "memory",
468+
startLine: 2,
469+
endLine: 2,
470+
score: 0.9,
471+
snippet: "*",
472+
},
473+
{
474+
path: "memory/2026-04-03.md",
475+
source: "memory",
476+
startLine: 3,
477+
endLine: 3,
478+
score: 0.9,
479+
snippet: "Real actionable note about the deploy plan.",
480+
},
481+
],
482+
});
483+
484+
const entries = Object.values(await readRecallStoreEntries(workspaceDir));
485+
expect(entries).toHaveLength(1);
486+
expect(entries[0]?.snippet).toBe("Real actionable note about the deploy plan.");
487+
});
488+
});
489+
490+
it("does not record markdown skeleton ranges as short-term recalls", async () => {
491+
await withTempWorkspace(async (workspaceDir) => {
492+
await recordShortTermRecalls({
493+
workspaceDir,
494+
query: "notes",
495+
results: [
496+
{
497+
path: "memory/2026-04-03.md",
498+
source: "memory",
499+
startLine: 1,
500+
endLine: 4,
501+
score: 0.9,
502+
snippet: ["## Tagesnotizen", "-", "## Entscheidungen", "-"].join("\n"),
503+
},
504+
{
505+
path: "memory/2026-04-03.md",
506+
source: "memory",
507+
startLine: 5,
508+
endLine: 5,
509+
score: 0.9,
510+
snippet: "Heading with body - kept because it carries real content.",
511+
},
512+
],
513+
});
514+
515+
const entries = Object.values(await readRecallStoreEntries(workspaceDir));
516+
expect(entries).toHaveLength(1);
517+
expect(entries[0]?.snippet).toBe("Heading with body - kept because it carries real content.");
518+
});
519+
});
520+
521+
it("does not record bare markdown placeholders as grounded short-term candidates", async () => {
522+
await withTempWorkspace(async (workspaceDir) => {
523+
await recordGroundedShortTermCandidates({
524+
workspaceDir,
525+
query: "notes",
526+
items: [
527+
{
528+
path: "memory/2026-04-03.md",
529+
startLine: 1,
530+
endLine: 1,
531+
snippet: "-",
532+
score: 0.9,
533+
},
534+
{
535+
path: "memory/2026-04-03.md",
536+
startLine: 2,
537+
endLine: 2,
538+
snippet: "Grounded note about the rollback runbook.",
539+
score: 0.9,
540+
},
541+
],
542+
});
543+
544+
const entries = Object.values(await readRecallStoreEntries(workspaceDir));
545+
expect(entries).toHaveLength(1);
546+
expect(entries[0]?.snippet).toBe("Grounded note about the rollback runbook.");
547+
});
548+
});
549+
550+
it("drops markdown placeholder entries when loading the recall store", async () => {
551+
await withTempWorkspace(async (workspaceDir) => {
552+
await testing.writeRawRecallStore(workspaceDir, {
553+
version: 1,
554+
updatedAt: "2026-04-03T00:00:00.000Z",
555+
entries: {
556+
"memory:memory/2026-04-03.md:1:1": {
557+
key: "memory:memory/2026-04-03.md:1:1",
558+
path: "memory/2026-04-03.md",
559+
startLine: 1,
560+
endLine: 1,
561+
source: "memory",
562+
snippet: "-",
563+
recallCount: 3,
564+
dailyCount: 0,
565+
groundedCount: 0,
566+
totalScore: 2.7,
567+
maxScore: 0.9,
568+
firstRecalledAt: "2026-04-01T00:00:00.000Z",
569+
lastRecalledAt: "2026-04-03T00:00:00.000Z",
570+
queryHashes: ["a", "b", "c"],
571+
recallDays: ["2026-04-01", "2026-04-02", "2026-04-03"],
572+
conceptTags: [],
573+
},
574+
},
575+
});
576+
577+
const store = await testing.readRecallStore(workspaceDir, "2026-04-03T00:00:00.000Z");
578+
expect(Object.values(store.entries)).toHaveLength(0);
579+
580+
const ranked = await rankShortTermPromotionCandidates({
581+
workspaceDir,
582+
minScore: 0,
583+
minRecallCount: 0,
584+
minUniqueQueries: 0,
585+
});
586+
expect(ranked).toEqual([]);
587+
});
588+
});
589+
590+
it("does not relocate a candidate onto a bare markdown placeholder line", async () => {
591+
await withTempWorkspace(async (workspaceDir) => {
592+
await writeDailyMemoryNote(workspaceDir, "2026-04-01", ["unrelated shipping note", "-"]);
593+
// The recorded snippet does not match its original line, forcing
594+
// relocation to search the live note. Without the placeholder guard the
595+
// bare "-" on line 2 substring-matches "deploy - done" (quality 1) and
596+
// the candidate relocates onto the empty marker line.
597+
await recordShortTermRecalls({
598+
workspaceDir,
599+
query: "deploy",
600+
results: [
601+
{
602+
path: "memory/2026-04-01.md",
603+
source: "memory",
604+
startLine: 1,
605+
endLine: 1,
606+
score: 0.94,
607+
snippet: "deploy - done",
608+
},
609+
],
610+
});
611+
612+
const ranked = await rankShortTermPromotionCandidates({
613+
workspaceDir,
614+
minScore: 0,
615+
minRecallCount: 0,
616+
minUniqueQueries: 0,
617+
});
618+
const applied = await applyShortTermPromotions({
619+
workspaceDir,
620+
candidates: ranked,
621+
minScore: 0,
622+
minRecallCount: 0,
623+
minUniqueQueries: 0,
624+
});
625+
expect(applied.applied).toBe(0);
626+
});
627+
});
628+
451629
it("records recalls and ranks candidates with weighted scores", async () => {
452630
await withTempWorkspace(async (workspaceDir) => {
453631
await recordShortTermRecalls({

extensions/memory-core/src/short-term-promotion.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,39 @@ function isContaminatedDreamingSnippet(raw: string): boolean {
424424
return hasNarrativeLead && hasConfidence && hasEvidence && hasStatus && hasRecalls;
425425
}
426426

427+
// Markdown skeleton placeholders (bare list markers, heading-only lines, and
428+
// heading + empty-bullet ranges) carry no durable content. Without filtering
429+
// they get recorded as short-term recall candidates and can later be promoted
430+
// into MEMORY.md as empty blocks, or anchor relocation substring matches onto
431+
// structurally meaningless ranges. See issue #80582.
432+
function isMarkdownPlaceholderLine(raw: string): boolean {
433+
const line = raw.trim();
434+
if (!line || /^[-*+]$/.test(line)) {
435+
return true;
436+
}
437+
// A heading line is a placeholder unless it also carries a list item with
438+
// real body text on the same line (e.g. "## Notes - real content").
439+
if (/^#{1,6}\s+\S/.test(line)) {
440+
return !/\s[-*+]\s+\S/.test(line);
441+
}
442+
return false;
443+
}
444+
445+
function isPlaceholderShortTermSnippet(raw: string): boolean {
446+
const snippet = normalizeSnippet(raw);
447+
if (!snippet || /^[-*+]$/.test(snippet)) {
448+
return true;
449+
}
450+
const lines = raw.split(/\r?\n/);
451+
const nonEmpty = lines.map((line) => line.trim()).filter(Boolean);
452+
return nonEmpty.length > 0 && lines.every(isMarkdownPlaceholderLine);
453+
}
454+
455+
function isUnpromotableShortTermSnippet(raw: string): boolean {
456+
const snippet = normalizeSnippet(raw);
457+
return !snippet || isPlaceholderShortTermSnippet(raw) || isContaminatedDreamingSnippet(snippet);
458+
}
459+
427460
function normalizeMemoryPath(rawPath: string): string {
428461
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
429462
}
@@ -597,8 +630,9 @@ export function normalizeShortTermRecallStore(raw: unknown, nowIso: string): Sho
597630
typeof entry.claimHash === "string" && entry.claimHash.trim().length > 0
598631
? entry.claimHash.trim()
599632
: undefined;
600-
const fullSnippet = typeof entry.snippet === "string" ? normalizeSnippet(entry.snippet) : "";
601-
if (fullSnippet && isContaminatedDreamingSnippet(fullSnippet)) {
633+
const rawEntrySnippet = typeof entry.snippet === "string" ? entry.snippet : "";
634+
const fullSnippet = normalizeSnippet(rawEntrySnippet);
635+
if (fullSnippet && isUnpromotableShortTermSnippet(rawEntrySnippet)) {
602636
continue;
603637
}
604638
const snippet = truncateShortTermSnippet(fullSnippet);
@@ -1423,7 +1457,7 @@ export async function recordShortTermRecalls(params: {
14231457
const normalizedPath = normalizeMemoryPath(result.path);
14241458
const rawSnippet = normalizeSnippet(result.snippet);
14251459
const snippet = truncateShortTermSnippet(rawSnippet);
1426-
if (!rawSnippet || isContaminatedDreamingSnippet(rawSnippet)) {
1460+
if (!rawSnippet || isUnpromotableShortTermSnippet(result.snippet)) {
14271461
continue;
14281462
}
14291463
const claimHash = buildClaimHash(rawSnippet);
@@ -1543,7 +1577,7 @@ export async function recordGroundedShortTermCandidates(params: {
15431577
const normalizedPath = normalizeMemoryPath(item.path);
15441578
if (
15451579
!rawSnippet ||
1546-
isContaminatedDreamingSnippet(rawSnippet) ||
1580+
isUnpromotableShortTermSnippet(item.snippet) ||
15471581
!normalizedPath ||
15481582
!isShortTermMemoryPath(normalizedPath) ||
15491583
!Number.isFinite(item.startLine) ||
@@ -1807,7 +1841,7 @@ export async function rankShortTermPromotionCandidates(
18071841
if (!entry || entry.source !== "memory" || !isShortTermMemoryPath(entry.path)) {
18081842
continue;
18091843
}
1810-
if (isContaminatedDreamingSnippet(entry.snippet)) {
1844+
if (isUnpromotableShortTermSnippet(entry.snippet)) {
18111845
continue;
18121846
}
18131847
if (!includePromoted && entry.promotedAt) {
@@ -2077,6 +2111,21 @@ function compareCandidateWindow(
20772111
if (windowSnippet === targetSnippet) {
20782112
return { matched: true, quality: 3 };
20792113
}
2114+
// Substring matching can anchor onto markdown skeleton placeholders (a bare
2115+
// list marker window matches any target that contains "-", or a placeholder
2116+
// target matches a meaningful window). Require both sides to carry real
2117+
// content before accepting a partial match so candidates do not relocate
2118+
// onto empty bullet/heading ranges. The exact-match branch above is
2119+
// unaffected because equality already rules out a placeholder-vs-content
2120+
// pair. This placeholder-only guard is intentionally narrower than a token
2121+
// count: an ASCII-only token check would split CJK text into separators and
2122+
// reject legitimate non-English relocations.
2123+
if (
2124+
isPlaceholderShortTermSnippet(targetSnippet) ||
2125+
isPlaceholderShortTermSnippet(windowSnippet)
2126+
) {
2127+
return { matched: false, quality: 0 };
2128+
}
20802129
if (windowSnippet.includes(targetSnippet)) {
20812130
return { matched: true, quality: 2 };
20822131
}

0 commit comments

Comments
 (0)