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