Files
jsc-marketplace/plugins/hud/hooks/companion/memory.test.ts
T
jiantw83andClaude Sonnet 5.5 ac33dcca3b feat: M4 主動關心、待辦與記憶鞏固核心模組
- care:事件式關心(冷卻、每日上限、靜音、回覆中/輸入中不彈出),toast 與注入兩種輸出,台詞依觸發 × 個性 × 好感階段
- todo:新增、完成、承諾事項與到期提醒
- memory:檢索評分、遺忘曲線、鞏固與反思、金鑰過濾
- 以假時鐘與記憶體檔案系統測試核心接縫

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:30:22 +08:00

144 lines
6.5 KiB
TypeScript

import { describe, test } from 'node:test'
import assert from 'node:assert/strict'
import { memoryFs } from './io'
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
const H = 3600_000
const T0 = Date.UTC(2026, 9, 12, 10, 0)
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 () => {
const s = setup()
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
s.advance(100 * H)
const fresh = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
assert.equal((await s.mem.recall('部署'))[0]!.id, fresh.id)
assert.notEqual(old.id, fresh.id)
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('遺忘曲線', () => {
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
const weak = { ...base, importance: 2, accessCount: 0 }
const strong = { ...base, importance: 8, accessCount: 0 }
const used = { ...weak, accessCount: 5 }
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
assert.ok(retention(strong, T0 + 100 * H) > retention(weak, T0 + 100 * H))
assert.ok(retention(used, T0 + 100 * H) > retention(weak, T0 + 100 * H))
assert.ok(stability(strong) > stability(weak))
})
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' })
s.advance(10 * 24 * H)
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
const r = await s.mem.consolidate({ force: true })
assert.equal(r.forgotten, 1)
const ids = (await s.mem.all()).map(m => m.id)
assert.ok(!ids.includes(trivial.id))
assert.ok(ids.includes(key.id))
})
test('取用過的記憶會重置時效而不被淘汰', async () => {
const s = setup()
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
s.advance(40 * H)
await s.mem.recall('常提起')
s.advance(40 * H)
await s.mem.consolidate({ force: true })
assert.ok((await s.mem.all()).some(x => x.id === m.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, forgotten: 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, '喜歡貓')
})
})