| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"; |
|
|
| |
|
|
| 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, |
| }; |
|
|
| |
|
|
| 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"], |
| ); |
| }); |
|
|
| |
|
|
| 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")); |
| }); |
|
|
| |
|
|
| 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); |
| }); |
|
|
| |
|
|
| 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", () => { |
| |
| |
| 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)); |
| }); |
|
|
| |
|
|
| 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", () => { |
| |
| |
| |
| 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", () => { |
| |
| |
| 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", () => { |
| |
| |
| 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); |
| }); |
|
|