import { describe, test } from 'claude-code/testing' import assert from './assert' import { memoryFs } from './io' import { CONSOLIDATE_THRESHOLD, TOP_K, access, categoryBonus, createMemory, freshness, iEff, isDanger, lastQuestion, redact, rrf, rumination, shouldArchive, weight, } from './memory' import type { Memory } from './memory' const H = 3600_000 const T0 = Date.UTC(2026, 9, 12, 10, 0) const DAY = 24 * H const mk = (o: Partial = {}): Memory => ({ id: 1, layer: 'episodic', kind: 'upgradable', status: 'active', text: '事', importance: 6, source: 'u', createdAt: T0, lastAccess: T0, accessCount: 0, seq: 0, ...o, }) function setup() { let now = T0 const fs = memoryFs() const mem = createMemory(fs, () => now, 'mem.json') return { mem, fs, advance: (ms: number) => void (now += ms), now: () => now } } describe('記憶檢索', () => { test('相關性高者排前面', async () => { const s = setup() await s.mem.add({ layer: 'semantic', text: '使用者喜歡抹茶拿鐵', importance: 5, source: 'u' }) await s.mem.add({ layer: 'semantic', text: '使用者養了一隻貓', importance: 5, source: 'u' }) const r = await s.mem.recall('抹茶') assert.equal(r[0]!.text, '使用者喜歡抹茶拿鐵') }) test('較新、較重要者在其他條件相同時勝出', async () => { // 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 0.8,所以以權重直接比較 assert.ok(weight(mk({ seq: 300 }), 300, T0) > weight(mk({ seq: 0 }), 300, T0)) const s2 = setup() await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' }) const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' }) assert.equal((await s2.mem.recall('測試'))[0]!.id, hi.id) }) test('情緒吻合度影響排序', async () => { const s = setup() await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } }) const happy = await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: 0.8, a: 0.5, d: 0.5 } }) assert.equal((await s.mem.recall('慶祝', { p: 0.7, a: 0.4, d: 0.4 }))[0]!.id, happy.id) }) test('最多取前 6 筆,並累計取用次數', async () => { const s = setup() for (let i = 0; i < 10; i++) await s.mem.add({ layer: 'semantic', text: `筆記${i}`, importance: 5, source: 'u' }) const r = await s.mem.recall('筆記') assert.equal(r.length, TOP_K) assert.ok((await s.mem.all()).filter(m => m.accessCount === 1).length === TOP_K) }) test('寫入前過濾疑似金鑰', async () => { const s = setup() const m = await s.mem.add({ layer: 'episodic', text: '金鑰是 sk-abcdefghijklmnop1234 請記住', importance: 3, source: 'u' }) assert.ok(!m.text.includes('sk-abcdef')) assert.ok(m.text.includes('[已過濾]')) assert.equal(redact('普通的一句話'), '普通的一句話') }) }) describe('記憶權重 W = I_eff × F × A', () => { test('I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成,限制在 1–5', () => { assert.equal(iEff(mk({ importance: 6 })), 3) assert.equal(iEff(mk({ importance: 6, peak: 40 })), 4) assert.equal(iEff(mk({ importance: 6, peak: 40, bonus: 1 })), 5) assert.equal(iEff(mk({ importance: 10, peak: 100, bonus: 1 })), 5) assert.equal(iEff(mk({ importance: 1 })), 1) }) test('F = 0.5^(Δseq/200),原典固定 1,被檢索到時至少回到 0.8', () => { assert.equal(freshness(mk(), 0), 1) assert.equal(freshness(mk(), 200), 0.5) assert.equal(freshness(mk({ canon: true }), 1000), 1) assert.ok(freshness(mk(), 600) < 0.2) assert.equal(freshness(mk({ hitSeq: 600 }), 600), 0.8) assert.ok(freshness(mk({ hitSeq: 600 }), 800) < 0.8) }) test('A 隨命中次數成長且有上限;W 為三者相乘', () => { assert.equal(access(mk()), 1) assert.ok(access(mk({ accessCount: 3 })) > 1) assert.equal(access(mk({ accessCount: 5 })), access(mk({ accessCount: 50 }))) const m = mk({ importance: 6, peak: 40, accessCount: 5 }) assert.equal(weight(m, 200, T0), 4 * 0.5 * 1.5) }) test('檢索實際用 W 排序:峰值高、較新者勝出', async () => { const s = setup() await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u' }) const peak = await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u', peak: 80 }) assert.equal((await s.mem.recall('旅行'))[0]!.id, peak.id) }) }) describe('反覆思量', () => { test('未解負向事件每日 +20%,上限 3 倍;已解或非負向不加', () => { const neg = mk({ negative: true }) assert.equal(rumination(neg, T0), 1) assert.ok(Math.abs(rumination(neg, T0 + 2 * DAY) - 1.4) < 1e-9) assert.equal(rumination(neg, T0 + 30 * DAY), 3) assert.equal(rumination({ ...neg, resolvedAt: T0 + DAY }, T0 + 5 * DAY), 1) assert.equal(rumination(mk(), T0 + 5 * DAY), 1) }) test('resolve 後不再被核心記憶列為未解', async () => { const s = setup() const m = await s.mem.add({ layer: 'emotional', text: '被罵了', importance: 6, source: 'u', negative: true }) assert.equal((await s.mem.core()).unresolved[0]!.id, m.id) assert.equal(await s.mem.resolve(), 1) assert.equal((await s.mem.core()).unresolved.length, 0) assert.equal(await s.mem.resolve(), 0) }) test('類別加成:忌妒或想念累積速度 ≥ 0.65 才 +1', () => { assert.equal(categoryBonus('jealous', { jealous: 65 }), 1) assert.equal(categoryBonus('jealous', { jealous: 64 }), 0) assert.equal(categoryBonus('reunion', { miss: 80 }), 1) assert.equal(categoryBonus('reunion', {}), 0) }) }) describe('衰減與封存(不刪除)', () => { test('暫時記憶滿 7 天封存,長期與鎖定記憶永不封存', () => { assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 6 * DAY), false) assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 7 * DAY), true) assert.equal(shouldArchive(mk({ kind: 'long' }), T0 + 999 * DAY), false) assert.equal(shouldArchive(mk({ kind: 'locked' }), T0 + 999 * DAY), false) }) test('可升級記憶 30 天降權、90 天未檢索封存', () => { const m = mk({ kind: 'upgradable' }) assert.equal(weight(m, 0, T0 + 29 * DAY), weight(m, 0, T0)) assert.equal(weight(m, 0, T0 + 30 * DAY), weight(m, 0, T0) / 2) assert.equal(shouldArchive(m, T0 + 89 * DAY), false) assert.equal(shouldArchive(m, T0 + 90 * DAY), true) assert.equal(shouldArchive({ ...m, lastAccess: T0 + 80 * DAY }, T0 + 120 * DAY), false) }) test('鞏固時封存過期記憶但不刪除,封存的不再被檢索', async () => { const s = setup() const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' }) const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u', kind: 'long' }) s.advance(10 * DAY) const r = await s.mem.consolidate({ force: true }) assert.equal(r.archived, 1) const all = await s.mem.all() assert.equal(all.find(m => m.id === trivial.id)!.status, 'archived') assert.equal(all.find(m => m.id === key.id)!.status, 'active') assert.equal((await s.mem.recall('隨口聊天')).some(m => m.id === trivial.id), false) }) test('場景結束時暫時記憶封存,其餘保留', async () => { const s = setup() const t = await s.mem.add({ layer: 'episodic', text: '剛剛的閒聊', importance: 2, source: 'u' }) const k = await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 6, source: 'u' }) assert.equal(await s.mem.endScene(), 1) const all = await s.mem.all() assert.equal(all.find(m => m.id === t.id)!.status, 'archived') assert.equal(all.find(m => m.id === k.id)!.status, 'active') }) test('檢索到 3 次的可升級記憶升為長期,之後不再衰減', async () => { const s = setup() const m = await s.mem.add({ layer: 'semantic', text: '常提的咖啡', importance: 5, source: 'u' }) for (let i = 0; i < 3; i++) await s.mem.recall('咖啡') assert.equal((await s.mem.all()).find(x => x.id === m.id)!.kind, 'long') s.advance(400 * DAY) await s.mem.consolidate({ force: true }) assert.equal((await s.mem.all()).find(x => x.id === m.id)!.status, 'active') }) test('舊存檔(沒有 kind、status)讀入後自動補欄位', async () => { const s = setup() await s.fs.write('mem.json', JSON.stringify({ items: [{ id: 1, layer: 'semantic', text: '舊記憶', importance: 5, source: 'u', createdAt: T0, lastAccess: T0, accessCount: 0 }], nextId: 2, pending: 0, lastConsolidated: 0 })) const m = (await s.mem.all())[0]! assert.equal(m.status, 'active') assert.equal(m.kind, 'upgradable') assert.equal((await s.mem.recall('舊記憶'))[0]!.id, 1) }) }) describe('危險內容不固化', () => { test('只留安全事件摘要,不留原文也不升級', async () => { const s = setup() assert.ok(isDanger('我想自殺')) assert.equal(isDanger('今天天氣很好'), false) const m = await s.mem.add({ layer: 'episodic', text: '我想自殺,方法是……', importance: 9, source: 'u', kind: 'long' }) assert.ok(!m.text.includes('自殺')) assert.ok(m.text.includes('安全事件摘要')) assert.equal(m.kind, 'temp') for (let i = 0; i < 4; i++) await s.mem.recall('安全事件摘要') assert.equal((await s.mem.all())[0]!.kind, 'temp') }) test('危險原文不進入反思', async () => { const s = setup() await s.mem.add({ layer: 'episodic', text: '想自殺', importance: 9, source: 'u' }) const r = await s.mem.consolidate({ force: true }) assert.equal(r.reflection, undefined) }) }) describe('開放迴路', () => { test('她的提問 2 回合沒有回應就逾時', async () => { const s = setup() await s.mem.openLoop('你今天吃飯了嗎?') await s.mem.recall('隨便') assert.equal((await s.mem.loops())[0]!.status, 'open') await s.mem.recall('隨便') assert.equal((await s.mem.loops())[0]!.status, 'timeout') }) test('有回應就收尾,不會逾時', async () => { const s = setup() await s.mem.openLoop('你今天吃飯了嗎?') assert.equal(await s.mem.respond('吃飯了,吃了麵'), 1) await s.mem.recall('隨便') await s.mem.recall('隨便') assert.equal((await s.mem.loops())[0]!.status, 'answered') }) test('未收尾的對話列入每回合必帶;逾時後移出', async () => { const s = setup() await s.mem.openLoop('週末要不要一起去?') assert.equal((await s.mem.core()).loops.length, 1) await s.mem.recall('x') await s.mem.recall('x') assert.equal((await s.mem.core()).loops.length, 0) }) test('取出回覆中最後一個問句', () => { assert.equal(lastQuestion('好喔。你吃飯了嗎?'), '你吃飯了嗎?') assert.equal(lastQuestion('沒有問題。'), null) }) }) describe('檢索:關鍵字 + RRF + W + 心情', () => { test('RRF 依 Σ 1/(60+rank) 融合多份排名', () => { const f = rrf([[1, 2], [2, 1]]) assert.equal(f.get(1), 1 / 61 + 1 / 62) assert.equal(f.get(2), 1 / 62 + 1 / 61) assert.equal(rrf([[7]]).get(7), 1 / 61) }) test('保留外部檢索器介面(語意相似度不做,但可注入)', async () => { const s = setup() const a = await s.mem.add({ layer: 'semantic', text: '喜歡貓咪', importance: 5, source: 'u' }) const b = await s.mem.add({ layer: 'semantic', text: '喜歡狗狗', importance: 5, source: 'u' }) const r = await s.mem.recall('毛孩', undefined, { extra: [() => [b.id, a.id]] }) assert.equal(r[0]!.id, b.id) }) test('釋懷速度越慢,心情一致加權越大', async () => { const mood = { p: 0.8, a: 0.4, d: 0.4 } const run = async (recover: number) => { const s = setup() await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } }) await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: mood, peak: 0 }) return (await s.mem.recall('慶祝', mood, { recover }))[0]!.id } assert.equal(await run(0), 2) assert.equal(await run(1), 2) }) test('沒有關鍵字命中時退回依 W 排序', async () => { const s = setup() await s.mem.add({ layer: 'semantic', text: '普通事', importance: 2, source: 'u' }) const big = await s.mem.add({ layer: 'semantic', text: '重要事', importance: 9, source: 'u' }) assert.equal((await s.mem.recall('完全不相干'))[0]!.id, big.id) }) }) describe('鞏固與反思', () => { test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => { const s = setup() await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' }) assert.deepEqual(await s.mem.consolidate(), { ran: false, archived: 0 }) for (let i = 0; i < 5; i++) await s.mem.add({ layer: 'episodic', text: `事件${i}`, importance: 5, source: 'u' }) assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD) const r = await s.mem.consolidate() assert.equal(r.ran, true) assert.equal(await s.mem.pending(), 0) assert.equal(r.reflection?.layer, 'reflection') }) test('手動 reflect(force)即使未達門檻也執行,並使用注入的摘要', async () => { const s = setup() await s.mem.add({ layer: 'episodic', text: '一起修好了建置', importance: 4, source: 'u' }) const r = await s.mem.consolidate({ force: true, summarize: items => `反思:${items.length} 件事` }) assert.equal(r.ran, true) assert.equal(r.reflection?.text, '反思:1 件事') assert.ok(r.reflection!.source.startsWith('reflect:')) }) test('反思不重複涵蓋舊事件,也不遞迴反思反思', async () => { const s = setup() await s.mem.add({ layer: 'episodic', text: '甲', importance: 4, source: 'u' }) await s.mem.consolidate({ force: true }) s.advance(H) await s.mem.add({ layer: 'episodic', text: '乙', importance: 4, source: 'u' }) const r = await s.mem.consolidate({ force: true, summarize: items => items.map(i => i.text).join('+') }) assert.equal(r.reflection?.text, '乙') }) test('沒有新記憶時鞏固不產生空反思', async () => { const s = setup() const r = await s.mem.consolidate({ force: true }) assert.equal(r.reflection, undefined) }) test('資料經檔案持久化,重建後可繼續', async () => { const s = setup() await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 5, source: 'u' }) const again = createMemory(s.fs, s.now, 'mem.json') assert.equal((await again.recall('貓'))[0]!.text, '喜歡貓') }) })