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 <steipete@gmail.com>
This commit is contained in:
Ted Li
2026-05-31 19:08:35 -07:00
committed by GitHub
parent 912ea4897f
commit c002887223
5 changed files with 765 additions and 9 deletions

View File

@@ -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": {

View File

@@ -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",
"<!-- openclaw:dreaming:light:start -->",
"- Candidate: scratch reflection",
"<!-- openclaw:dreaming:light:end -->",
"- 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;

View File

@@ -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<string, Promise<void>>();
const ensuredShortTermDirs = new Map<string, Promise<void>>();
@@ -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,
};
}

View File

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

View File

@@ -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<string>, key: string) => {