From c002887223ee8cb68b21716f0aeb599a6dd3cc5d Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 31 May 2026 19:08:35 -0700 Subject: [PATCH] fix(memory): rehydrate daily list promotions * fix(memory): rehydrate daily list promotions * fix(memory): preserve multi-line daily list promotions * fix(memory): preserve daily list promotion context * fix(memory): rehydrate capped daily list promotions * test(memory): cover capped daily list promotion * test(agents): update model selection mocks * ci: ignore lazy three dependency * fix(memory): skip heading-only rehydration * fix(memory): preserve list rehydration mode * fix(memory): match capped renamed heading bodies * fix(memory): avoid duplicate tail heading matches * fix(microsoft-foundry): satisfy provider lint * perf(memory): precompute promotion heading context --------- Co-authored-by: Peter Steinberger --- config/knip.config.ts | 2 + .../src/short-term-promotion.test.ts | 595 ++++++++++++++++++ .../memory-core/src/short-term-promotion.ts | 171 ++++- .../agent-command.live-model-switch.test.ts | 2 +- src/commands/agent-command.test-mocks.ts | 4 +- 5 files changed, 765 insertions(+), 9 deletions(-) diff --git a/config/knip.config.ts b/config/knip.config.ts index eaa37fe7f143..27c363c58113 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -165,6 +165,8 @@ const config = { "vite.config.ts!", "vitest*.ts!", ], + // Workboard lazy-loads Three.js at runtime; Knip's dependency pass misses it. + ignoreDependencies: ["three"], project: ["src/**/*.{ts,tsx}!"], }, "packages/sdk": { diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 2c1a2af7cc47..cea2f03c4524 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -2026,6 +2026,601 @@ describe("short-term promotion", () => { }); }); + it("rehydrates daily-ingested heading-prefixed list snippets from the live note", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## 模型切换 (16:23)", + "- **需求**: 用户想使用小米 Mimo 模型作为默认", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: "模型切换 (16:23): **需求**: 用户想使用小米 Mimo 模型作为默认", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.startLine).toBe(4); + expect(applied.appliedCandidates[0]?.endLine).toBe(4); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "模型切换 (16:23): **需求**: 用户想使用小米 Mimo 模型作为默认", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).toContain("memory/2026-05-28.md:4-4"); + expect(memoryText).toContain("模型切换 (16:23): **需求**"); + }); + }); + + it("rehydrates daily-ingested multi-line list snippets from the full live note range", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## 模型切换 (16:23)", + "- **需求**: 用户想使用小米 Mimo 模型作为默认", + "- **偏好**: 保持低成本默认路由", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 5, + score: 0.91, + snippet: + "模型切换 (16:23): **需求**: 用户想使用小米 Mimo 模型作为默认; **偏好**: 保持低成本默认路由", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.startLine).toBe(4); + expect(applied.appliedCandidates[0]?.endLine).toBe(5); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "模型切换 (16:23): **需求**: 用户想使用小米 Mimo 模型作为默认; **偏好**: 保持低成本默认路由", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).toContain("memory/2026-05-28.md:4-5"); + expect(memoryText).toContain("模型切换 (16:23): **需求**"); + expect(memoryText).toContain("**偏好**: 保持低成本默认路由"); + }); + }); + + it("rebuilds heading context from the live note during list rehydration", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## New model routing (16:23)", + "- Keep Xiaomi Mimo as the low-cost default.", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: "Old model routing: Keep Xiaomi Mimo as the low-cost default.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "New model routing (16:23): Keep Xiaomi Mimo as the low-cost default.", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).toContain("New model routing (16:23)"); + expect(memoryText).not.toContain("Old model routing"); + }); + }); + + it("does not rehydrate heading-prefixed list snippets without a live body", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Model routing", + "", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: "Model routing: Keep Xiaomi Mimo as the low-cost default.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(0); + }); + }); + + it("does not add heading context to ordinary list-item rehydration", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Model routing", + "- Keep Xiaomi Mimo as the low-cost default.", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: "Keep Xiaomi Mimo as the low-cost default.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "Keep Xiaomi Mimo as the low-cost default.", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).not.toContain("Model routing: Keep Xiaomi"); + }); + }); + + it("rehydrates capped heading-prefixed list snippets from the live note", async () => { + await withTempWorkspace(async (workspaceDir) => { + const longBody = `Keep Xiaomi Mimo as the low-cost default ${"route ".repeat(80)}`.trim(); + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Long model routing", + `- ${longBody}`, + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: `Long model routing: ${longBody}`.slice(0, 280).replace(/\s+/g, " ").trim(), + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.startLine).toBe(4); + expect(applied.appliedCandidates[0]?.endLine).toBe(4); + expect(applied.appliedCandidates[0]?.snippet).toContain( + "Long model routing: Keep Xiaomi Mimo", + ); + }); + }); + + it("rehydrates capped heading-prefixed list snippets after the heading changes", async () => { + await withTempWorkspace(async (workspaceDir) => { + const longBody = `Keep Xiaomi Mimo as the low-cost default ${"route ".repeat(80)}`.trim(); + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## New model routing", + `- ${longBody}`, + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 4, + score: 0.91, + snippet: `Old model routing: ${longBody}`.slice(0, 280).replace(/\s+/g, " ").trim(), + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.snippet).toContain( + "New model routing: Keep Xiaomi Mimo", + ); + expect(applied.appliedCandidates[0]?.snippet).not.toContain("Old model routing"); + }); + }); + + it("keeps renamed heading fallback bound to colon-prefixed list bodies", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Nearby shortcut", + "- use Mimo", + "", + "## New model routing", + "- **需求**: use Mimo", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 7, + endLine: 7, + score: 0.91, + snippet: "Old model routing: **需求**: use Mimo", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.startLine).toBe(7); + expect(applied.appliedCandidates[0]?.endLine).toBe(7); + expect(applied.appliedCandidates[0]?.snippet).toBe("New model routing: **需求**: use Mimo"); + expect(applied.appliedCandidates[0]?.snippet).not.toContain("Nearby shortcut"); + }); + }); + + it("preserves the full range for capped heading-prefixed multi-line list snippets", async () => { + await withTempWorkspace(async (workspaceDir) => { + const maxDailySnippetChars = 280; + const firstListItem = + `Keep Xiaomi Mimo as the low-cost default ${"route ".repeat(12)}`.trim(); + const secondListItem = + `Preserve the fallback routing note when the ingestion cap cuts this chunk ${"tail ".repeat( + 42, + )}`.trim(); + const fullIngestedSnippet = `Long model routing: ${firstListItem}; ${secondListItem}` + .replace(/\s+/g, " ") + .trim(); + const ingestedSnippet = fullIngestedSnippet + .slice(0, maxDailySnippetChars) + .replace(/\s+/g, " ") + .trim(); + expect(ingestedSnippet.length).toBeLessThan(fullIngestedSnippet.length); + + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Long model routing", + `- ${firstListItem}`, + `- ${secondListItem}`, + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 4, + endLine: 5, + score: 0.91, + snippet: ingestedSnippet, + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.startLine).toBe(4); + expect(applied.appliedCandidates[0]?.endLine).toBe(5); + expect(applied.appliedCandidates[0]?.snippet).toContain(firstListItem); + expect(applied.appliedCandidates[0]?.snippet).toContain(secondListItem); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).toContain("memory/2026-05-28.md:4-5"); + expect(memoryText).toContain(secondListItem); + }); + }); + + it("does not reintroduce generic daily headings during list rehydration", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Model routing", + "- Keep Xiaomi Mimo as the low-cost default.", + "", + "## Morning", + "- Reviewed travel timing before the workshop.", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 7, + endLine: 7, + score: 0.91, + snippet: "Reviewed travel timing before the workshop.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "Reviewed travel timing before the workshop.", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).not.toContain("Morning:"); + expect(memoryText).not.toContain("Model routing: Reviewed travel timing"); + }); + }); + + it("does not reintroduce managed dreaming headings during list rehydration", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-05-28", [ + "# 2026-05-28", + "", + "## Light Sleep", + "", + "- Candidate: scratch reflection", + "", + "- Reviewed travel timing before the workshop.", + ]); + await recordShortTermRecalls({ + workspaceDir, + query: "__dreaming_daily__:2026-05-28", + signalType: "daily", + dedupeByQueryPerDay: true, + dayBucket: "2026-05-28", + results: [ + { + path: "memory/2026-05-28.md", + startLine: 7, + endLine: 7, + score: 0.91, + snippet: "Reviewed travel timing before the workshop.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + const applied = await applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-05-31T00:00:00.000Z"), + }); + + expect(applied.applied).toBe(1); + expect(applied.appliedCandidates[0]?.snippet).toBe( + "Reviewed travel timing before the workshop.", + ); + const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8"); + expect(memoryText).not.toContain("Light Sleep:"); + }); + }); + it("keeps rehydrated promotion snippets capped in the recall store", async () => { await withTempWorkspace(async (workspaceDir) => { const maxSnippetChars = testing.SHORT_TERM_RECALL_MAX_SNIPPET_CHARS; diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index bba9f6f2f7c6..45b5cfdb765e 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -53,6 +53,10 @@ const PHASE_SIGNAL_HALF_LIFE_DAYS = 14; const DREAMING_TRANSCRIPT_PROMPT_LINE_RE = /\[[^\]]*dreaming-narrative[^\]]*]\s*(?:User|Assistant):\s*Write a dream diary entry from these memory fragments:?/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; +const PROMOTION_LIST_MARKER_RE = /^(?:\d+\.\s+|[-*+]\s+)/; +const MANAGED_DREAMING_HEADINGS = new Set(["light sleep", "rem sleep"]); const inProcessShortTermLocks = new Map>(); const ensuredShortTermDirs = new Map>(); @@ -1594,6 +1598,114 @@ function normalizeRangeSnippet(lines: string[], startLine: number, endLine: numb return normalizeSnippet(lines.slice(startIndex, endIndex).join(" ")); } +function normalizeListMarkerFreeRangeSnippet( + lines: string[], + startLine: number, + endLine: number, +): string { + const startIndex = Math.max(0, startLine - 1); + const endIndex = Math.min(lines.length, endLine); + if (startIndex >= endIndex) { + return ""; + } + const strippedLines = lines.slice(startIndex, endIndex).map((line) => { + const trimmed = line.trim(); + const withoutMarker = trimmed.replace(PROMOTION_LIST_MARKER_RE, ""); + return { text: withoutMarker, hadListMarker: withoutMarker !== trimmed }; + }); + const joiner = + strippedLines.length > 1 && strippedLines.every((line) => line.hadListMarker) ? "; " : " "; + return normalizeSnippet(strippedLines.map((line) => line.text).join(joiner)); +} + +function normalizeDailyHeadingForPromotion(line: string): string | null { + const match = line.trim().match(/^#{1,6}\s+(.+)$/); + const heading = match?.[1]?.replace(PROMOTION_LIST_MARKER_RE, "").trim() ?? ""; + const normalized = normalizeSnippet(heading); + if ( + !normalized || + SHORT_TERM_BASENAME_RE.test(normalized) || + isGenericDailyHeadingForPromotion(normalized) + ) { + return null; + } + return normalized; +} + +function isGenericDailyHeadingForPromotion(heading: string): boolean { + const normalized = heading.trim().replace(/\s+/g, " "); + const lower = normalized.toLowerCase(); + if (MANAGED_DREAMING_HEADINGS.has(lower)) { + return true; + } + if (lower === "today" || lower === "yesterday" || lower === "tomorrow") { + return true; + } + if (lower === "morning" || lower === "afternoon" || lower === "evening" || lower === "night") { + return true; + } + return GENERIC_DAY_HEADING_RE.test(normalized); +} + +function buildRelocatedDailyHeadingLookup(lines: string[]): (string | null)[] { + const headings: (string | null)[] = Array.from({ length: lines.length + 1 }, () => null); + let currentHeading: string | null = null; + for (let index = 0; index < lines.length; index += 1) { + headings[index + 1] = currentHeading; + const line = lines[index] ?? ""; + if (DREAMING_FENCE_START_RE.test(line) || DREAMING_FENCE_END_RE.test(line)) { + currentHeading = null; + continue; + } + if (/^#{1,6}\s+.+$/.test(line.trim())) { + currentHeading = normalizeDailyHeadingForPromotion(line); + } + } + return headings; +} + +function buildListMarkerFreeMatchSnippet( + heading: string | null, + listMarkerFreeSnippet: string, +): string { + if (!listMarkerFreeSnippet) { + return listMarkerFreeSnippet; + } + return heading ? normalizeSnippet(`${heading}: ${listMarkerFreeSnippet}`) : listMarkerFreeSnippet; +} + +function targetSnippetHasHeadingContext(targetSnippet: string, bodySnippet: string): boolean { + if (!targetSnippet || !bodySnippet || targetSnippet === bodySnippet) { + return false; + } + const bodyIndex = targetSnippet.indexOf(bodySnippet); + if (bodyIndex <= 0) { + return false; + } + return targetSnippet.slice(0, bodyIndex).trimEnd().endsWith(":"); +} + +function extractTargetHeadingBodySnippet( + targetSnippet: string, + bodySnippet: string, +): string | null { + if (!targetSnippet || !bodySnippet || targetSnippet === bodySnippet) { + return null; + } + if (bodySnippet.startsWith(targetSnippet)) { + return null; + } + const normalizedBody = normalizeSnippet(bodySnippet); + for (let separatorIndex = targetSnippet.indexOf(": "); separatorIndex > 0; ) { + const targetBody = normalizeSnippet(targetSnippet.slice(separatorIndex + 2)); + if (targetBody && normalizedBody.startsWith(targetBody)) { + return targetBody; + } + separatorIndex = targetSnippet.indexOf(": ", separatorIndex + 2); + } + return null; +} + function compareCandidateWindow( targetSnippet: string, windowSnippet: string, @@ -1641,6 +1753,7 @@ function relocateCandidateRange( } const maxSpan = Math.min(lines.length, Math.max(preferredSpan + 3, 8)); + const headingLookup = buildRelocatedDailyHeadingLookup(lines); let bestMatch: | { startLine: number; endLine: number; snippet: string; quality: number; distance: number } | undefined; @@ -1650,15 +1763,61 @@ function relocateCandidateRange( const endLine = startIndex + span; const snippet = normalizeRangeSnippet(lines, startLine, endLine); const comparison = compareCandidateWindow(targetSnippet, snippet); - if (!comparison.matched) { + const listMarkerFreeSnippet = normalizeListMarkerFreeRangeSnippet(lines, startLine, endLine); + const listMarkerFreeMatchSnippet = buildListMarkerFreeMatchSnippet( + headingLookup[startLine] ?? null, + listMarkerFreeSnippet, + ); + const listMarkerFreeComparison = + listMarkerFreeSnippet === snippet + ? { matched: false, quality: 0 } + : compareCandidateWindow(targetSnippet, listMarkerFreeSnippet); + const listMarkerFreeContextComparison = + listMarkerFreeMatchSnippet === listMarkerFreeSnippet + ? { matched: false, quality: 0 } + : compareCandidateWindow(targetSnippet, listMarkerFreeMatchSnippet); + const targetHeadingBodySnippet = extractTargetHeadingBodySnippet( + targetSnippet, + listMarkerFreeSnippet, + ); + const targetHeadingBodyComparison = + targetHeadingBodySnippet && listMarkerFreeMatchSnippet !== listMarkerFreeSnippet + ? compareCandidateWindow(targetHeadingBodySnippet, listMarkerFreeSnippet) + : { matched: false, quality: 0 }; + const useTargetHeadingBodyContext = + targetHeadingBodyComparison.matched && + targetHeadingBodyComparison.quality >= comparison.quality && + targetHeadingBodyComparison.quality >= listMarkerFreeComparison.quality; + const useListMarkerFreeContext = + !useTargetHeadingBodyContext && + listMarkerFreeContextComparison.quality > comparison.quality && + listMarkerFreeContextComparison.quality >= listMarkerFreeComparison.quality; + const useListMarkerFree = + !useListMarkerFreeContext && listMarkerFreeComparison.quality > comparison.quality; + const bestComparison = useTargetHeadingBodyContext + ? targetHeadingBodyComparison + : useListMarkerFreeContext + ? listMarkerFreeContextComparison + : useListMarkerFree + ? listMarkerFreeComparison + : comparison; + if (!bestComparison.matched) { continue; } + const matchedSnippet = + useTargetHeadingBodyContext || useListMarkerFreeContext + ? listMarkerFreeMatchSnippet + : useListMarkerFree + ? targetSnippetHasHeadingContext(targetSnippet, listMarkerFreeSnippet) + ? listMarkerFreeMatchSnippet + : listMarkerFreeSnippet + : snippet; const distance = Math.abs(startLine - candidate.startLine); if ( !bestMatch || - comparison.quality > bestMatch.quality || - (comparison.quality === bestMatch.quality && distance < bestMatch.distance) || - (comparison.quality === bestMatch.quality && + bestComparison.quality > bestMatch.quality || + (bestComparison.quality === bestMatch.quality && distance < bestMatch.distance) || + (bestComparison.quality === bestMatch.quality && distance === bestMatch.distance && Math.abs(span - preferredSpan) < Math.abs(bestMatch.endLine - bestMatch.startLine + 1 - preferredSpan)) @@ -1666,8 +1825,8 @@ function relocateCandidateRange( bestMatch = { startLine, endLine, - snippet, - quality: comparison.quality, + snippet: matchedSnippet, + quality: bestComparison.quality, distance, }; } diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 1975ecbe7701..a5a60b4503b5 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -536,7 +536,7 @@ vi.mock("./model-selection.js", () => { return fallback ? { provider: fallback.provider, model: fallback.id } : null; }, modelKey: (p: string, m: string) => `${p}/${m}`, - normalizeModelRef: (p: string, m: string) => ({ provider: p, model: m }), + normalizeModelRef: (p: string, m: string) => ({ provider: normalizeProviderId(p), model: m }), normalizeProviderId, normalizeProviderIdForAuth: normalizeProviderId, parseModelRef: (m: string, p: string) => ({ provider: p, model: m }), diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index 1af4e1377e4d..e471c90ca6e5 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -81,11 +81,11 @@ vi.mock("../agents/model-selection.js", () => { return { provider: defaultProvider, model: value }; }; const parseModelRef = vi.fn(parseModelRefImpl); + const normalizeProviderId = (provider: string) => provider.trim().toLowerCase(); const normalizeModelRef = (provider: string, model: string): ModelRef => ({ - provider: provider.trim().toLowerCase(), + provider: normalizeProviderId(provider), model: model.trim(), }); - const normalizeProviderId = (provider: string) => provider.trim().toLowerCase(); const modelKey = (provider: string, model: string) => `${normalizeProviderId(provider)}/${model.trim().toLowerCase()}`; const isModelKeyAllowedBySet = (allowedKeys: ReadonlySet, key: string) => {