Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/memory.test.ts
T
adminandClaude Sonnet 5.5 6f7b4d91ea feat: companion 記憶權重、反覆思量與檢索
- 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除
- 未解負向事件反覆思量、開放迴路逾時、危險內容不固化
- 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面
- 版本 0.0.4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:01:55 +00:00

338 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): 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, '喜歡貓')
})
})