mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 16:12:13 +00:00
* feat(memory): add recall audit and doctor repair flow * refactor(memory): rename symbolic scoring and harden dreaming * feat(memory): add multilingual concept vocabulary * docs(changelog): note dreaming memory follow-up * docs(changelog): shorten dreaming follow-up entry * fix(memory): address review follow-ups * chore(skills): tighten security triage trust model * Update CHANGELOG.md
627 lines
20 KiB
TypeScript
627 lines
20 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
applyShortTermPromotions,
|
|
auditShortTermPromotionArtifacts,
|
|
isShortTermMemoryPath,
|
|
rankShortTermPromotionCandidates,
|
|
recordShortTermRecalls,
|
|
repairShortTermPromotionArtifacts,
|
|
resolveShortTermRecallLockPath,
|
|
resolveShortTermRecallStorePath,
|
|
__testing,
|
|
} from "./short-term-promotion.js";
|
|
|
|
describe("short-term promotion", () => {
|
|
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-promote-"));
|
|
try {
|
|
await run(workspaceDir);
|
|
} finally {
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
it("detects short-term daily memory paths", () => {
|
|
expect(isShortTermMemoryPath("memory/2026-04-03.md")).toBe(true);
|
|
expect(isShortTermMemoryPath("2026-04-03.md")).toBe(true);
|
|
expect(isShortTermMemoryPath("notes/2026-04-03.md")).toBe(false);
|
|
expect(isShortTermMemoryPath("MEMORY.md")).toBe(false);
|
|
expect(isShortTermMemoryPath("memory/network.md")).toBe(false);
|
|
});
|
|
|
|
it("records recalls and ranks candidates with weighted scores", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "router",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-02.md",
|
|
startLine: 3,
|
|
endLine: 5,
|
|
score: 0.9,
|
|
snippet: "Configured VLAN 10 on Omada router",
|
|
source: "memory",
|
|
},
|
|
{
|
|
path: "MEMORY.md",
|
|
startLine: 1,
|
|
endLine: 1,
|
|
score: 0.99,
|
|
snippet: "Long-term note",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "iot vlan",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-02.md",
|
|
startLine: 3,
|
|
endLine: 5,
|
|
score: 0.8,
|
|
snippet: "Configured VLAN 10 on Omada router",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
|
|
expect(ranked).toHaveLength(1);
|
|
expect(ranked[0]?.path).toBe("memory/2026-04-02.md");
|
|
expect(ranked[0]?.recallCount).toBe(2);
|
|
expect(ranked[0]?.uniqueQueries).toBe(2);
|
|
expect(ranked[0]?.score).toBeGreaterThan(0);
|
|
expect(ranked[0]?.conceptTags).toContain("router");
|
|
expect(ranked[0]?.components.conceptual).toBeGreaterThan(0);
|
|
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
const raw = await fs.readFile(storePath, "utf-8");
|
|
expect(raw).toContain("memory/2026-04-02.md");
|
|
expect(raw).not.toContain("Long-term note");
|
|
});
|
|
});
|
|
|
|
it("serializes concurrent recall writes so counts are not lost", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await Promise.all(
|
|
Array.from({ length: 20 }, (_, index) =>
|
|
recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: `backup-${index % 4}`,
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.9,
|
|
snippet: "Move backups to S3 Glacier.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
expect(ranked).toHaveLength(1);
|
|
expect(ranked[0]?.recallCount).toBe(20);
|
|
expect(ranked[0]?.uniqueQueries).toBe(4);
|
|
});
|
|
});
|
|
|
|
it("uses default thresholds for promotion", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "glacier",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.96,
|
|
snippet: "Move backups to S3 Glacier.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({ workspaceDir });
|
|
expect(ranked).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
it("rewards spaced recalls as consolidation instead of only raw count", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "router",
|
|
nowMs: Date.parse("2026-04-01T10:00:00.000Z"),
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.9,
|
|
snippet: "Configured router VLAN 10 and IoT segment.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "iot segment",
|
|
nowMs: Date.parse("2026-04-04T10:00:00.000Z"),
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.88,
|
|
snippet: "Configured router VLAN 10 and IoT segment.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
|
|
});
|
|
|
|
expect(ranked).toHaveLength(1);
|
|
expect(ranked[0]?.recallDays).toEqual(["2026-04-01", "2026-04-04"]);
|
|
expect(ranked[0]?.components.consolidation).toBeGreaterThan(0.4);
|
|
});
|
|
});
|
|
|
|
it("treats negative threshold overrides as invalid and keeps defaults", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "glacier",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.96,
|
|
snippet: "Move backups to S3 Glacier.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: -1,
|
|
minRecallCount: -1,
|
|
minUniqueQueries: -1,
|
|
});
|
|
expect(ranked).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
it("enforces default thresholds during apply even when candidates are passed directly", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const applied = await applyShortTermPromotions({
|
|
workspaceDir,
|
|
candidates: [
|
|
{
|
|
key: "memory:memory/2026-04-03.md:1:2",
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
source: "memory",
|
|
snippet: "Move backups to S3 Glacier.",
|
|
recallCount: 1,
|
|
avgScore: 0.95,
|
|
maxScore: 0.95,
|
|
uniqueQueries: 1,
|
|
firstRecalledAt: new Date().toISOString(),
|
|
lastRecalledAt: new Date().toISOString(),
|
|
ageDays: 0,
|
|
score: 0.95,
|
|
recallDays: [new Date().toISOString().slice(0, 10)],
|
|
conceptTags: ["glacier", "backups"],
|
|
components: {
|
|
frequency: 0.2,
|
|
relevance: 0.95,
|
|
diversity: 0.2,
|
|
recency: 1,
|
|
consolidation: 0.2,
|
|
conceptual: 0.4,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(applied.applied).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("applies promotion candidates to MEMORY.md and marks them promoted", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "gateway host",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 10,
|
|
endLine: 12,
|
|
score: 0.92,
|
|
snippet: "Gateway binds loopback and port 18789",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
const applied = await applyShortTermPromotions({
|
|
workspaceDir,
|
|
candidates: ranked,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
expect(applied.applied).toBe(1);
|
|
|
|
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
|
expect(memoryText).toContain("Promoted From Short-Term Memory");
|
|
expect(memoryText).toContain("memory/2026-04-01.md:10-12");
|
|
|
|
const rankedAfter = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
expect(rankedAfter).toHaveLength(0);
|
|
|
|
const rankedIncludingPromoted = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
includePromoted: true,
|
|
});
|
|
expect(rankedIncludingPromoted).toHaveLength(1);
|
|
expect(rankedIncludingPromoted[0]?.promotedAt).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("does not re-append candidates that were promoted in a prior run", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "gateway host",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 10,
|
|
endLine: 12,
|
|
score: 0.92,
|
|
snippet: "Gateway binds loopback and port 18789",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const ranked = await rankShortTermPromotionCandidates({
|
|
workspaceDir,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
const first = await applyShortTermPromotions({
|
|
workspaceDir,
|
|
candidates: ranked,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
expect(first.applied).toBe(1);
|
|
|
|
const second = await applyShortTermPromotions({
|
|
workspaceDir,
|
|
candidates: ranked,
|
|
minScore: 0,
|
|
minRecallCount: 0,
|
|
minUniqueQueries: 0,
|
|
});
|
|
expect(second.applied).toBe(0);
|
|
|
|
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
|
|
const sectionCount = memoryText.match(/Promoted From Short-Term Memory/g)?.length ?? 0;
|
|
expect(sectionCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("audits and repairs invalid store metadata plus stale locks", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
updatedAt: "2026-04-04T00:00:00.000Z",
|
|
entries: {
|
|
good: {
|
|
key: "good",
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
source: "memory",
|
|
snippet: "Gateway host uses qmd vector search for router notes.",
|
|
recallCount: 2,
|
|
totalScore: 1.8,
|
|
maxScore: 0.95,
|
|
firstRecalledAt: "2026-04-01T00:00:00.000Z",
|
|
lastRecalledAt: "2026-04-04T00:00:00.000Z",
|
|
queryHashes: ["a", "b"],
|
|
},
|
|
bad: {
|
|
path: "",
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
|
|
await fs.writeFile(lockPath, "999999:0\n", "utf-8");
|
|
const staleMtime = new Date(Date.now() - 120_000);
|
|
await fs.utimes(lockPath, staleMtime, staleMtime);
|
|
|
|
const auditBefore = await auditShortTermPromotionArtifacts({ workspaceDir });
|
|
expect(auditBefore.invalidEntryCount).toBe(1);
|
|
expect(auditBefore.issues.map((issue) => issue.code)).toEqual(
|
|
expect.arrayContaining(["recall-store-invalid", "recall-lock-stale"]),
|
|
);
|
|
|
|
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
|
expect(repair.changed).toBe(true);
|
|
expect(repair.rewroteStore).toBe(true);
|
|
expect(repair.removedStaleLock).toBe(true);
|
|
|
|
const auditAfter = await auditShortTermPromotionArtifacts({ workspaceDir });
|
|
expect(auditAfter.invalidEntryCount).toBe(0);
|
|
expect(auditAfter.issues.map((issue) => issue.code)).not.toContain("recall-lock-stale");
|
|
|
|
const repairedRaw = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
|
|
entries: Record<string, { conceptTags?: string[]; recallDays?: string[] }>;
|
|
};
|
|
expect(repairedRaw.entries.good?.conceptTags).toContain("router");
|
|
expect(repairedRaw.entries.good?.recallDays).toEqual(["2026-04-04"]);
|
|
});
|
|
});
|
|
|
|
it("repairs empty recall-store files without throwing", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(storePath, " \n", "utf-8");
|
|
|
|
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
|
|
|
expect(repair.changed).toBe(true);
|
|
expect(repair.rewroteStore).toBe(true);
|
|
expect(JSON.parse(await fs.readFile(storePath, "utf-8"))).toMatchObject({
|
|
version: 1,
|
|
entries: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not rewrite an already normalized healthy recall store", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
const snippet = "Gateway host uses qmd vector search for router notes.";
|
|
const raw = `${JSON.stringify(
|
|
{
|
|
version: 1,
|
|
updatedAt: "2026-04-04T00:00:00.000Z",
|
|
entries: {
|
|
good: {
|
|
key: "good",
|
|
path: "memory/2026-04-01.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
source: "memory",
|
|
snippet,
|
|
recallCount: 2,
|
|
totalScore: 1.8,
|
|
maxScore: 0.95,
|
|
firstRecalledAt: "2026-04-01T00:00:00.000Z",
|
|
lastRecalledAt: "2026-04-04T00:00:00.000Z",
|
|
queryHashes: ["a", "b"],
|
|
recallDays: ["2026-04-04"],
|
|
conceptTags: __testing.deriveConceptTags({
|
|
path: "memory/2026-04-01.md",
|
|
snippet,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
await fs.writeFile(storePath, raw, "utf-8");
|
|
|
|
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
|
|
|
expect(repair.changed).toBe(false);
|
|
expect(repair.rewroteStore).toBe(false);
|
|
expect(await fs.readFile(storePath, "utf-8")).toBe(raw);
|
|
});
|
|
});
|
|
|
|
it("waits for an active short-term lock before repairing", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const storePath = resolveShortTermRecallStorePath(workspaceDir);
|
|
const lockPath = resolveShortTermRecallLockPath(workspaceDir);
|
|
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
await fs.writeFile(
|
|
storePath,
|
|
JSON.stringify(
|
|
{
|
|
version: 1,
|
|
updatedAt: "2026-04-04T00:00:00.000Z",
|
|
entries: {
|
|
bad: {
|
|
path: "",
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
await fs.writeFile(lockPath, `${process.pid}:${Date.now()}\n`, "utf-8");
|
|
|
|
let settled = false;
|
|
const repairPromise = repairShortTermPromotionArtifacts({ workspaceDir }).then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
expect(settled).toBe(false);
|
|
|
|
await fs.unlink(lockPath);
|
|
const repair = await repairPromise;
|
|
|
|
expect(repair.changed).toBe(true);
|
|
expect(repair.rewroteStore).toBe(true);
|
|
expect(repair.removedInvalidEntries).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("downgrades lock inspection failures into audit issues", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
const lockPath = path.join(workspaceDir, "memory", ".dreams", "short-term-promotion.lock");
|
|
const stat = vi.spyOn(fs, "stat").mockImplementation(async (target) => {
|
|
if (String(target) === lockPath) {
|
|
const error = Object.assign(new Error("no access"), { code: "EACCES" });
|
|
throw error;
|
|
}
|
|
return await vi
|
|
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
|
|
.then((actual) => actual.stat(target));
|
|
});
|
|
try {
|
|
const audit = await auditShortTermPromotionArtifacts({ workspaceDir });
|
|
expect(audit.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
code: "recall-lock-unreadable",
|
|
fixable: false,
|
|
}),
|
|
]),
|
|
);
|
|
} finally {
|
|
stat.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("reports concept tag script coverage for multilingual recalls", async () => {
|
|
await withTempWorkspace(async (workspaceDir) => {
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "routeur glacier",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.93,
|
|
snippet: "Configuration du routeur et sauvegarde Glacier.",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
await recordShortTermRecalls({
|
|
workspaceDir,
|
|
query: "router cjk",
|
|
results: [
|
|
{
|
|
path: "memory/2026-04-04.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.95,
|
|
snippet: "障害対応ルーター設定とバックアップ確認。",
|
|
source: "memory",
|
|
},
|
|
],
|
|
});
|
|
|
|
const audit = await auditShortTermPromotionArtifacts({ workspaceDir });
|
|
expect(audit.conceptTaggedEntryCount).toBe(2);
|
|
expect(audit.conceptTagScripts).toEqual({
|
|
latinEntryCount: 1,
|
|
cjkEntryCount: 1,
|
|
mixedEntryCount: 0,
|
|
otherEntryCount: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("extracts stable concept tags from snippets and paths", () => {
|
|
expect(
|
|
__testing.deriveConceptTags({
|
|
path: "memory/2026-04-03.md",
|
|
snippet: "Move backups to S3 Glacier and sync QMD router notes.",
|
|
}),
|
|
).toEqual(expect.arrayContaining(["glacier", "router", "backups"]));
|
|
});
|
|
|
|
it("extracts multilingual concept tags across latin and cjk snippets", () => {
|
|
expect(
|
|
__testing.deriveConceptTags({
|
|
path: "memory/2026-04-03.md",
|
|
snippet: "Configuración du routeur et sauvegarde Glacier.",
|
|
}),
|
|
).toEqual(expect.arrayContaining(["configuración", "routeur", "sauvegarde", "glacier"]));
|
|
expect(
|
|
__testing.deriveConceptTags({
|
|
path: "memory/2026-04-03.md",
|
|
snippet: "障害対応ルーター設定とバックアップ確認。路由器备份与网关同步。",
|
|
}),
|
|
).toEqual(expect.arrayContaining(["障害対応", "ルーター", "バックアップ", "路由器", "备份"]));
|
|
});
|
|
});
|