Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

567 lines
18 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
getSearchIndex,
setIndexPersistence,
} from "../src/functions/search.js";
import { memoryToObservation } from "../src/state/memory-utils.js";
import type { Memory, SemanticMemory } from "../src/types.js";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function mockKV(
memories: Memory[] = [],
semanticMems: SemanticMemory[] = [],
) {
const store = new Map<string, Map<string, unknown>>();
const memMap = new Map<string, unknown>();
for (const m of memories) memMap.set(m.id, m);
store.set("mem:memories", memMap);
const semMap = new Map<string, unknown>();
for (const s of semanticMems) semMap.set(s.id, s);
store.set("mem:semantic", semMap);
store.set("mem:retention", new Map());
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string, fn: Function) => {
if (typeof idOrOpts !== "string") {
throw new Error("registerFunction expects string function id");
}
functions.set(idOrOpts, fn);
},
trigger: async (input: { function_id: string; payload: unknown }) => {
if (typeof input === "string") {
throw new Error("legacy trigger signature is not supported in tests");
}
const fn = functions.get(input.function_id);
if (fn) return fn(input.payload);
return null;
},
};
}
function makeMemory(
id: string,
type: Memory["type"],
daysOld: number,
): Memory {
const created = new Date(
Date.now() - daysOld * 24 * 60 * 60 * 1000,
).toISOString();
return {
id,
createdAt: created,
updatedAt: created,
type,
title: `Memory ${id}`,
content: `Content of memory ${id}`,
concepts: [],
files: [],
sessionIds: ["ses_1"],
strength: 1,
version: 1,
isLatest: true,
};
}
function makeSemanticMemory(
id: string,
daysOld: number,
accessCount = 0,
): SemanticMemory {
const created = new Date(
Date.now() - daysOld * 24 * 60 * 60 * 1000,
).toISOString();
return {
id,
fact: `Fact ${id}`,
confidence: 0.8,
sourceSessionIds: ["ses_1"],
sourceMemoryIds: [],
accessCount,
lastAccessedAt: created,
strength: 0.8,
createdAt: created,
updatedAt: created,
};
}
describe("RetentionScoring", () => {
it("imports without errors", async () => {
const mod = await import("../src/functions/retention.js");
expect(mod.registerRetentionFunctions).toBeDefined();
});
it("computes retention scores for all memories", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_recent", "architecture", 1),
makeMemory("mem_old", "fact", 365),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as {
success: boolean;
total: number;
tiers: any;
scores: any[];
};
expect(result.success).toBe(true);
expect(result.total).toBe(2);
expect(result.scores.length).toBe(2);
const recentScore = result.scores.find(
(s: any) => s.memoryId === "mem_recent",
);
const oldScore = result.scores.find(
(s: any) => s.memoryId === "mem_old",
);
expect(recentScore!.score).toBeGreaterThan(oldScore!.score);
});
it("higher-type memories get higher salience", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_arch", "architecture", 30),
makeMemory("mem_fact", "fact", 30),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
const archScore = result.scores.find(
(s: any) => s.memoryId === "mem_arch",
);
const factScore = result.scores.find(
(s: any) => s.memoryId === "mem_fact",
);
expect(archScore.salience).toBeGreaterThan(factScore.salience);
});
it("classifies memories into tiers", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("hot1", "architecture", 1),
makeMemory("hot2", "preference", 3),
makeMemory("warm1", "pattern", 60),
makeMemory("cold1", "fact", 300),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
expect(result.tiers.hot + result.tiers.warm + result.tiers.cold + result.tiers.evictable).toBe(4);
});
it("dry-run eviction shows candidates without deleting", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const memories = [
makeMemory("mem_keep", "architecture", 1),
makeMemory("mem_evict", "fact", 500),
];
const sdk = mockSdk();
const kv = mockKV(memories);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const dryResult = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: {
threshold: 0.5,
dryRun: true,
},
})) as any;
expect(dryResult.dryRun).toBe(true);
expect(dryResult.wouldEvict).toBeGreaterThanOrEqual(0);
const remaining = await kv.list("mem:memories");
expect(remaining.length).toBe(2);
});
it("includes semantic memories in scoring", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const semanticMems = [
makeSemanticMemory("sem_1", 10, 5),
makeSemanticMemory("sem_2", 200, 0),
];
const sdk = mockSdk();
const kv = mockKV([], semanticMems);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
expect(result.total).toBe(2);
const sem1 = result.scores.find((s: any) => s.memoryId === "sem_1");
const sem2 = result.scores.find((s: any) => s.memoryId === "sem_2");
expect(sem1.score).toBeGreaterThan(sem2.score);
});
it("scores tag rows with their source scope (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_ep", "fact", 10)],
[makeSemanticMemory("sem_sem", 10, 2)],
);
registerRetentionFunctions(sdk as never, kv as never);
const result = (await sdk.trigger({
function_id: "mem::retention-score",
payload: {},
})) as any;
const ep = result.scores.find((s: any) => s.memoryId === "mem_ep");
const sem = result.scores.find((s: any) => s.memoryId === "sem_sem");
expect(ep.source).toBe("episodic");
expect(sem.source).toBe("semantic");
// Also assert the source discriminator is persisted to mem:retention,
// not just present in the transient response payload — the eviction
// loop reads back from stored rows, so a regression in kv.set or
// serialization would still pass the in-memory check above.
const [epStored, semStored] = await Promise.all([
kv.get("mem:retention", "mem_ep"),
kv.get("mem:retention", "sem_sem"),
]);
expect(epStored).toMatchObject({ source: "episodic" });
expect(semStored).toMatchObject({ source: "semantic" });
});
it("mem::retention-evict deletes semantic memories from mem:semantic, not mem:memories (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// Both are 500 days old with zero access → both will score below
// the default cold threshold. Before #124 the loop silently called
// kv.delete(mem:memories, <semantic-id>) which was a no-op, leaving
// the semantic row in mem:semantic forever.
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_evict", "fact", 500)],
[makeSemanticMemory("sem_evict", 500, 0)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
})) as any;
expect(result.evicted).toBe(2);
expect(result.evictedEpisodic).toBe(1);
expect(result.evictedSemantic).toBe(1);
const remainingEp = await kv.list("mem:memories");
const remainingSem = await kv.list("mem:semantic");
expect(remainingEp).toHaveLength(0);
expect(remainingSem).toHaveLength(0);
// Retention score rows also cleaned up for both.
const remainingScores = await kv.list("mem:retention");
expect(remainingScores).toHaveLength(0);
});
it("mem::retention-evict emits a single batched audit record on success (#124, audit policy)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_a", "fact", 500), makeMemory("mem_b", "fact", 500)],
[makeSemanticMemory("sem_c", 500, 0)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
});
// Retention-score ALSO emits an audit row (one per rescore, also
// required by the repo audit-coverage policy), so filter the audit
// log down to just the retention-evict entry we're asserting on.
const allEntries = await kv.list<{
operation: string;
functionId: string;
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
const evictEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-evict",
);
expect(evictEntries).toHaveLength(1);
const [entry] = evictEntries;
expect(entry.operation).toBe("delete");
expect([...entry.targetIds].sort()).toEqual(["mem_a", "mem_b", "sem_c"]);
expect(entry.details.evicted).toBe(3);
expect(entry.details.evictedEpisodic).toBe(2);
expect(entry.details.evictedSemantic).toBe(1);
});
it("mem::retention-evict skips audit when evicted=0 (no spurious audit rows)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
// Memory is 1 day old → score will be high → nothing falls below
// the strict 0.99 threshold → evict=0 → no evict audit row.
// Retention-score itself still writes one audit row per sweep,
// which is the expected behavior (zero-eviction != zero-rescore),
// so we filter the audit log down to just the evict entries.
const kv = mockKV([makeMemory("mem_keep", "architecture", 1)]);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.0001 },
});
const allEntries = await kv.list<{ functionId: string }>("mem:audit");
const evictEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-evict",
);
expect(evictEntries).toHaveLength(0);
});
it("mem::retention-score emits a batched audit row per rescore (#124, audit policy)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const sdk = mockSdk();
const kv = mockKV(
[makeMemory("mem_a", "fact", 10), makeMemory("mem_b", "fact", 10)],
[makeSemanticMemory("sem_c", 10, 2)],
);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
const allEntries = await kv.list<{
operation: string;
functionId: string;
targetIds: string[];
details: Record<string, unknown>;
}>("mem:audit");
const scoreEntries = allEntries.filter(
(e) => e.functionId === "mem::retention-score",
);
expect(scoreEntries).toHaveLength(1);
const [entry] = scoreEntries;
expect(entry.operation).toBe("retention_score");
// targetIds is intentionally empty — a mature store can have 1000+
// memory ids per rescore and flooding the audit log would be worse
// than recording just the summary counts.
expect(entry.targetIds).toEqual([]);
expect(entry.details.total).toBe(3);
expect(entry.details.episodic).toBe(2);
expect(entry.details.semantic).toBe(1);
});
it("mem::retention-evict probes namespaces for legacy semantic rows (backwards-compat, #124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// The actual nasty case from CodeRabbit's review: a pre-0.8.10
// store that had a semantic memory scored by the old code path.
// The retention row has NO source field and the memory lives in
// mem:semantic. If the eviction path blindly defaults missing
// source to episodic, it no-ops the delete and strands the
// semantic row forever — which is the exact bug #124 is about.
const sdk = mockSdk();
const kv = mockKV([], [makeSemanticMemory("sem_legacy", 500, 0)]);
registerRetentionFunctions(sdk as never, kv as never);
await kv.set("mem:retention", "sem_legacy", {
memoryId: "sem_legacy",
// No `source` field — simulates a row written by 0.8.9 or earlier.
score: 0.01,
salience: 0,
temporalDecay: 0,
reinforcementBoost: 0,
lastAccessed: new Date().toISOString(),
accessCount: 0,
});
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.5 },
})) as any;
expect(result.evicted).toBe(1);
expect(result.evictedSemantic).toBe(1);
expect(result.evictedEpisodic).toBe(0);
// Most important assertion: the semantic row is GONE from
// mem:semantic. Before the probe fix, this assertion failed
// because the delete targeted mem:memories.
const remainingSem = await kv.list("mem:semantic");
expect(remainingSem).toHaveLength(0);
});
it("mem::retention-evict routes pre-0.8.10 episodic rows with missing source to mem:memories (#124)", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
// Simulate a store that was scored on 0.8.9 or earlier: retention
// rows exist but they have no `source` field. The new eviction
// loop must still route those to mem:memories so users don't get
// stuck with un-evictable episodic rows after upgrading.
const sdk = mockSdk();
const kv = mockKV([makeMemory("mem_old", "fact", 500)]);
registerRetentionFunctions(sdk as never, kv as never);
// Directly plant a legacy-shape retention score (no `source` key).
await kv.set("mem:retention", "mem_old", {
memoryId: "mem_old",
score: 0.01,
salience: 0,
temporalDecay: 0,
reinforcementBoost: 0,
lastAccessed: new Date().toISOString(),
accessCount: 0,
});
const result = (await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.5 },
})) as any;
expect(result.evicted).toBe(1);
expect(result.evictedEpisodic).toBe(1);
expect(result.evictedSemantic).toBe(0);
const remaining = await kv.list("mem:memories");
expect(remaining).toHaveLength(0);
});
describe("search-index cleanup on eviction", () => {
beforeEach(() => {
getSearchIndex().clear();
setIndexPersistence(null);
});
afterEach(() => {
setIndexPersistence(null);
});
it("removes evicted memories from the BM25 index and flushes persistence", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const evictee = makeMemory("mem_evict", "fact", 500);
const sdk = mockSdk();
const kv = mockKV([evictee]);
registerRetentionFunctions(sdk as never, kv as never);
getSearchIndex().add(memoryToObservation(evictee));
expect(getSearchIndex().has("mem_evict")).toBe(true);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.9 },
});
expect(getSearchIndex().has("mem_evict")).toBe(false);
expect(persistence.save).toHaveBeenCalled();
});
it("does not flush persistence when nothing is evicted", async () => {
const { registerRetentionFunctions } = await import(
"../src/functions/retention.js"
);
const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) };
setIndexPersistence(persistence);
const sdk = mockSdk();
const kv = mockKV([makeMemory("mem_fresh", "architecture", 1)]);
registerRetentionFunctions(sdk as never, kv as never);
await sdk.trigger({ function_id: "mem::retention-score", payload: {} });
await sdk.trigger({
function_id: "mem::retention-evict",
payload: { threshold: 0.01 },
});
expect(persistence.save).not.toHaveBeenCalled();
});
});
});