// Pure unit tests for lib/memory.ts. These import the module directly // (Node 24 strips TypeScript types natively) and exercise the helpers // that have no database dependencies: normalisation/dedupe keys, the // token estimator, the hard-cap constants, the kind whitelist, and the // pure ranker (`rankAndPackFacts`) — including header+instruction // overhead and the per-turn token budget. // // Run with the api-server's DATABASE_URL exported (the import path // constructs a pg Pool at module load, but does not actually connect): // node --test artifacts/api-server/src/lib/__tests__/memory.unit.test.mjs import { test } from "node:test"; import assert from "node:assert/strict"; import { MEMORY_HARD_CAPS, MEMORY_HEADER, MEMORY_INSTRUCTION_USE, MEMORY_INSTRUCTION_EXTRACT, MEMORY_BOOTSTRAP_EXTRACT, VALID_KINDS, contentHash, estimateTokens, normalize, rankAndPackFacts, } from "../memory-core.ts"; // ---------- helpers function fact(overrides = {}) { const now = new Date().toISOString(); return { id: overrides.id || `memf_${Math.random().toString(36).slice(2, 10)}`, kind: "fact", content: "placeholder content", confidence: 0.7, salience: 0.6, source: "auto", source_message_id: null, conversation_id: null, use_count: 0, archived: false, created_at: now, updated_at: now, last_used_at: null, ...overrides, }; } const cfgOn = { enabled: true, auto_extract: true, max_facts: 100, max_tokens_per_turn: 2000, }; // ---------- hard caps test("hard caps are pinned to 100 facts / 2000 tokens / 5 new per turn", () => { assert.equal(MEMORY_HARD_CAPS.facts, 100); assert.equal(MEMORY_HARD_CAPS.tokensPerTurn, 2000); assert.equal(MEMORY_HARD_CAPS.newPerTurn, 5); assert.equal(MEMORY_HARD_CAPS.contentChars, 500); }); test("VALID_KINDS lists exactly the six spec kinds", () => { assert.deepEqual( [...VALID_KINDS].sort(), ["domain", "fact", "interest", "preference", "summary", "terminology"], ); }); // ---------- normalize + dedupe key test("normalize collapses case, punctuation and whitespace", () => { assert.equal( normalize(" User PREFERS concise, bullet-point answers!! "), "user prefers concise bullet point answers", ); }); test("normalize handles unicode punctuation and CJK content", () => { assert.equal(normalize("用户偏好:简洁的、要点式回答。"), "用户偏好 简洁的 要点式回答"); }); test("normalize-based dedupe: cosmetic variants share the same content hash", () => { const a = "User's lab focuses on KRAS G12C in NSCLC."; const b = "user s lab focuses on kras g12c in nsclc"; const c = "USER'S lab focuses on KRAS-G12C, in NSCLC!!!"; assert.equal(normalize(a), normalize(b)); assert.equal(normalize(a), normalize(c)); assert.equal(contentHash(a), contentHash(b)); assert.equal(contentHash(a), contentHash(c)); }); test("normalize-based dedupe: substantive differences do not collide", () => { assert.notEqual( contentHash("User likes mouse models"), contentHash("User dislikes mouse models"), ); assert.notEqual(contentHash(""), contentHash("anything")); }); // ---------- token estimator test("estimateTokens returns 0 for empty input and ceils to ~chars/4 otherwise", () => { assert.equal(estimateTokens(""), 0); assert.equal(estimateTokens("a"), 1); assert.equal(estimateTokens("abcd"), 1); assert.equal(estimateTokens("abcde"), 2); assert.equal(estimateTokens("x".repeat(400)), 100); }); // ---------- ranker scoring test("rankAndPackFacts returns nothing when memory is disabled", () => { const out = rankAndPackFacts([fact()], "anything", { ...cfgOn, enabled: false }); assert.deepEqual(out, { facts: [], fact_ids: [], injected_tokens: 0, text: null }); }); test("rankAndPackFacts returns the bootstrap extract when store is empty + auto_extract on", () => { const out = rankAndPackFacts([], "hello", cfgOn); assert.equal(out.text, MEMORY_BOOTSTRAP_EXTRACT); assert.equal(out.injected_tokens, estimateTokens(MEMORY_BOOTSTRAP_EXTRACT)); assert.deepEqual(out.facts, []); assert.deepEqual(out.fact_ids, []); }); test("rankAndPackFacts returns null text when store is empty and auto_extract is off", () => { const out = rankAndPackFacts([], "hello", { ...cfgOn, auto_extract: false }); assert.equal(out.text, null); assert.equal(out.injected_tokens, 0); }); test("rankAndPackFacts ranks higher salience first when query has no overlap", () => { const high = fact({ id: "hi", salience: 0.95, content: "alpha alpha alpha" }); const low = fact({ id: "lo", salience: 0.2, content: "beta beta beta" }); const out = rankAndPackFacts([low, high], "unrelated query gamma", cfgOn); assert.deepEqual(out.fact_ids, ["hi", "lo"]); }); test("rankAndPackFacts boosts facts whose tokens overlap the query", () => { // Without keyword overlap, baseline would tie on salience. Add an // overlap term and watch the relevant fact jump to first place. const relevant = fact({ id: "rel", salience: 0.5, content: "User is researching KRAS G12C inhibitors in lung cancer", }); const irrelevant = fact({ id: "irr", salience: 0.5, content: "User likes pour-over coffee in the morning", }); const out = rankAndPackFacts( [irrelevant, relevant], "any updates on KRAS inhibitors?", cfgOn, ); assert.equal(out.fact_ids[0], "rel", "keyword overlap must outrank irrelevant tie"); assert.ok(out.fact_ids.includes("irr")); }); test("rankAndPackFacts builds the system-prompt block with header + facts + instruction", () => { const f1 = fact({ id: "a", kind: "preference", content: "Prefers concise answers", salience: 0.9 }); const f2 = fact({ id: "b", kind: "fact", content: "PI at Stanford lab", salience: 0.8 }); const out = rankAndPackFacts([f1, f2], "hello", cfgOn); assert.ok(out.text?.startsWith(MEMORY_HEADER), "must lead with header"); assert.ok(out.text?.includes("- [preference] Prefers concise answers")); assert.ok(out.text?.includes("- [fact] PI at Stanford lab")); assert.ok( out.text?.endsWith(MEMORY_INSTRUCTION_USE + MEMORY_INSTRUCTION_EXTRACT), "must end with use+extract instruction when auto_extract is on", ); }); test("rankAndPackFacts omits the extract instruction when auto_extract is off", () => { const f1 = fact({ id: "a", content: "Anything", salience: 0.9 }); const out = rankAndPackFacts([f1], "hello", { ...cfgOn, auto_extract: false }); assert.ok(out.text?.endsWith(MEMORY_INSTRUCTION_USE)); assert.ok(!out.text?.includes(MEMORY_INSTRUCTION_EXTRACT)); }); // ---------- token-budget cap (header + instruction overhead included) test("rankAndPackFacts injected_tokens accounts for header + instruction overhead", () => { const overheadOn = estimateTokens(MEMORY_HEADER) + estimateTokens(MEMORY_INSTRUCTION_USE + MEMORY_INSTRUCTION_EXTRACT); const overheadOff = estimateTokens(MEMORY_HEADER) + estimateTokens(MEMORY_INSTRUCTION_USE); assert.ok(overheadOn > overheadOff, "extract instruction must add overhead"); const f = fact({ id: "x", kind: "fact", content: "hello world", salience: 0.9 }); const lineCost = estimateTokens(`- [${f.kind}] ${f.content}\n`); const onOut = rankAndPackFacts([f], "q", cfgOn); assert.equal( onOut.injected_tokens, overheadOn + lineCost, "auto_extract on: tokens = header + use+extract instruction + line", ); const offOut = rankAndPackFacts([f], "q", { ...cfgOn, auto_extract: false }); assert.equal( offOut.injected_tokens, overheadOff + lineCost, "auto_extract off: tokens = header + use instruction + line", ); }); test("rankAndPackFacts respects the token budget — drops facts that don't fit", () => { // Build many fat facts whose combined size obviously exceeds the cap, // then confirm only as many as fit are included and injected_tokens // never exceeds the budget. const many = []; for (let i = 0; i < 60; i++) { many.push(fact({ id: `f${i}`, kind: "fact", content: `Fact #${i}: ${"x".repeat(180)}`, salience: 0.5 + (i % 5) * 0.05, })); } const tightCfg = { ...cfgOn, max_tokens_per_turn: 400 }; const out = rankAndPackFacts(many, "anything", tightCfg); assert.ok(out.facts.length > 0, "should fit at least one fact"); assert.ok(out.facts.length < many.length, "must not include them all under tight cap"); assert.ok(out.injected_tokens <= 400, `tokens ${out.injected_tokens} must respect budget`); }); test("rankAndPackFacts caps the budget at MEMORY_HARD_CAPS.tokensPerTurn even if cfg asks more", () => { // Even if a misconfigured caller passes a huge budget, the packer // must clamp to the hard cap of 2000. const many = []; for (let i = 0; i < 200; i++) { many.push(fact({ id: `f${i}`, content: `Fact #${i}: ${"y".repeat(400)}`, salience: 0.5, })); } const out = rankAndPackFacts(many, "anything", { ...cfgOn, max_tokens_per_turn: 999_999, }); assert.ok( out.injected_tokens <= MEMORY_HARD_CAPS.tokensPerTurn, `tokens ${out.injected_tokens} must stay <= 2000 even when cfg over-asks`, ); }); test("rankAndPackFacts caps picked count at 50 even if budget allows more", () => { // Tiny content so all 200 would fit token-wise; the loop's hard cap // of 50 picked rows must still trigger. const many = []; for (let i = 0; i < 200; i++) { many.push(fact({ id: `f${i}`, content: `t${i}`, salience: 0.5 })); } const out = rankAndPackFacts(many, "q", cfgOn); assert.equal(out.facts.length, 50); assert.equal(out.fact_ids.length, 50); });