Compare commits

...
2 Commits
Author SHA1 Message Date
jiantw83 eeed12741e Merge pull request 'feat: companion 記憶權重、反覆思量與檢索' (#51) from issue-45 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/51
2026-10-14 09:06:43 +00:00
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
7 changed files with 599 additions and 80 deletions
@@ -1,6 +1,6 @@
{ {
"name": "jsc-mod-companion", "name": "jsc-mod-companion",
"version": "0.0.3", "version": "0.0.4",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT", "license": "MIT",
"author": { "author": {
+14
View File
@@ -127,3 +127,17 @@
- 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。 - 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。
- 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。 - 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。
- 與第 9 章的差異:晝夜節律振幅、習慣化係數等數值為本實作自訂,尚未逐項對照第 9 章。 - 與第 9 章的差異:晝夜節律振幅、習慣化係數等數值為本實作自訂,尚未逐項對照第 9 章。
## 記憶權重與檢索
記憶依 wiki 9.5 實作於 `hooks/companion/memory.ts`,數值集中在 `rules.ts` 的 `MEMORY`。
- 權重 W = I_eff × F × A:I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成(限制在 1–5);F = 0.5^(Δ回合/200),原典記憶固定 1,被檢索到時至少回到 0.8;A 為存取加成。
- 反覆思量:未解的負向事件(心情增量 p < 0 的標籤)每日權重 +20%,上限 3 倍;`apology`、`soothe` 標籤出現時全部標為已解。
- 衰減與封存:暫時記憶在場景結束(`end`)或 7 天後封存;可升級記憶 30 天未升級降權一半、90 天未檢索封存,被檢索 3 次升為長期;長期與鎖定記憶不衰減。封存只改 `status`,任何記憶都不刪除。
- 危險內容(自傷、違法等關鍵字)不固化:只留一則安全事件摘要,不留原文、不升級、不進反思。
- 開放迴路:角色回覆末尾的問句記為開放迴路,使用者 2 回合內沒有相關回應就逾時;未收尾的對話與未解負向事件列入每回合必帶(`core()`)。
- 檢索:關鍵字排名經 RRF(Σ 1/(60+rank))融合後乘上 W,再加心情一致加權 w_m × 相似度(w_m = 0.10 + 0.05 × (1 − 釋懷速度))。`recall` 的 `extra` 保留外部檢索器介面,語意相似度不做。
- 舊存檔缺少的欄位讀檔時自動補上。
與第 9 章的差異:A 的公式(1 + 0.1 × min(命中次數, 5))為本實作自訂,wiki 只寫「被檢索到時 F 至少回到 0.8」;回合序號以使用者回合計;情緒峰值由標籤向量長度換算;原典佔比、相似記憶合併、釘選與使用者模型容量尚未實作。
@@ -362,6 +362,33 @@ describe('記憶、待辦與關心', () => {
expect(await c.prompt()).toContain('payment') expect(await c.prompt()).toContain('payment')
}) })
test('被罵的記憶是未解負向事件,道歉後解除;記憶不會被刪除', async () => {
const { c, fs } = setup()
await c.ensureActivated()
await c.onPrompt('你很煩,真的很煩人')
const path = `${ROOT}/companions/miko/memory.json`
const read = () => JSON.parse(fs.files.get(path)!) as { items: { negative?: boolean; resolvedAt?: number; status: string }[] }
expect(read().items[0]!.negative).toBe(true)
expect(read().items[0]!.resolvedAt).toBeUndefined()
await c.onPrompt('對不起,剛剛我太衝動了')
await c.onTurnComplete(false, '沒關係啦。<評估:apology>')
expect(read().items[0]!.resolvedAt !== undefined).toBe(true)
expect(read().items[0]!.status).toBe('active')
})
test('回覆中的提問成為開放迴路,2 回合沒回應就逾時', async () => {
const { c, fs } = setup()
await c.ensureActivated()
await c.onTurnComplete(false, '好喔。你今天吃飯了嗎?')
const path = `${ROOT}/companions/miko/memory.json`
const loops = () => (JSON.parse(fs.files.get(path)!) as { loops: { status: string }[] }).loops
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('幫我看看建置')
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('順便跑一下測試')
expect(loops()[0]!.status).toBe('timeout')
})
test('太短的提示不寫入記憶', async () => { test('太短的提示不寫入記憶', async () => {
const { c, fs } = setup() const { c, fs } = setup()
await c.ensureActivated() await c.ensureActivated()
@@ -8,10 +8,10 @@ import type { Emotions } from './emotion'
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
import type { MoodEvent } from './mood' import type { MoodEvent } from './mood'
import { extraversionOf, neuroticismOf } from './core/params' import { extraversionOf, neuroticismOf } from './core/params'
import { extractTag } from './tags' import { extractTag, stripTag } from './tags'
import { createCare } from './care' import { createCare } from './care'
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care' import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
import { createMemory } from './memory' import { categoryBonus, createMemory, lastQuestion } from './memory'
import type { MemoryStore } from './memory' import type { MemoryStore } from './memory'
import { buildPrompt } from './prompt' import { buildPrompt } from './prompt'
import { createTodos } from './todo' import { createTodos } from './todo'
@@ -271,9 +271,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const b = await bundleFor() const b = await bundleFor()
if (!b) return [] if (!b) return []
const v = await view() const v = await view()
recalled = (await b.mem.recall(text, v ? { p: v.mood.p, a: v.mood.a, d: v.mood.d } : undefined)).map(m => m.text) await b.mem.respond(text)
const params = v?.core.params
recalled = (await b.mem.recall(text, v ? { p: v.mood.p, a: v.mood.a, d: v.mood.d } : undefined, { recover: params ? params.recover / 100 : undefined })).map(m => m.text)
if (ev === 'apology' || ev === 'soothe') await b.mem.resolve()
if (text.trim().length >= 8) { if (text.trim().length >= 8) {
await b.mem.add({ layer: 'episodic', text: text.trim().slice(0, 200), importance: ev ? 6 : 3, source: 'user', emotion: ev ? MOOD_EVENTS[ev] : undefined }) const pad = ev ? MOOD_EVENTS[ev] : undefined
await b.mem.add({
layer: 'episodic',
text: text.trim().slice(0, 200),
importance: ev ? 6 : 3,
source: 'user',
emotion: pad,
peak: pad ? Math.round(Math.hypot(pad.p, pad.a, pad.d) * 300) : undefined,
bonus: ev === 'return' ? categoryBonus('reunion', params) : undefined,
negative: pad ? pad.p < 0 : undefined,
})
await b.mem.consolidate() await b.mem.consolidate()
} }
return b.care.onUserTurn() return b.care.onUserTurn()
@@ -295,6 +308,9 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const tag = regexHit ? null : extractTag(reply) const tag = regexHit ? null : extractTag(reply)
regexHit = true regexHit = true
if (tag) await moodEvent(tag) if (tag) await moodEvent(tag)
if (tag === 'apology' || tag === 'soothe') await b.mem.resolve()
const q = lastQuestion(stripTag(reply))
if (q) await b.mem.openLoop(q)
if (!usedTools) return if (!usedTools) return
await b.care.onTurnComplete(usedTools) await b.care.onTurnComplete(usedTools)
await moodEvent('success') await moodEvent('success')
@@ -311,7 +327,10 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function end() { async function end() {
const b = await bundleFor() const b = await bundleFor()
if (b) await b.mem.consolidate() if (b) {
await b.mem.endScene()
await b.mem.consolidate()
}
} }
const fmtTodo = (t: { id: number; title: string; promise: boolean; dueAt?: number; done: boolean }) => const fmtTodo = (t: { id: number; title: string; promise: boolean; dueAt?: number; done: boolean }) =>
@@ -2,11 +2,33 @@ import { describe, test } from 'claude-code/testing'
import assert from './assert' import assert from './assert'
import { memoryFs } from './io' import { memoryFs } from './io'
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory' 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 H = 3600_000
const T0 = Date.UTC(2026, 9, 12, 10, 0) 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() { function setup() {
let now = T0 let now = T0
const fs = memoryFs() const fs = memoryFs()
@@ -24,12 +46,8 @@ describe('記憶檢索', () => {
}) })
test('較新、較重要者在其他條件相同時勝出', async () => { test('較新、較重要者在其他條件相同時勝出', async () => {
const s = setup() // 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 0.8,所以以權重直接比較
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' }) assert.ok(weight(mk({ seq: 300 }), 300, T0) > weight(mk({ seq: 0 }), 300, T0))
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() const s2 = setup()
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' }) await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' }) const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
@@ -60,39 +78,215 @@ describe('記憶檢索', () => {
}) })
}) })
describe('遺忘曲線', () => { describe('記憶權重 W = I_eff × F × A', () => {
test('保留率隨時間下降,重要度與取用次數使其變慢', () => { test('I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成,限制在 1–5', () => {
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 } assert.equal(iEff(mk({ importance: 6 })), 3)
const weak = { ...base, importance: 2, accessCount: 0 } assert.equal(iEff(mk({ importance: 6, peak: 40 })), 4)
const strong = { ...base, importance: 8, accessCount: 0 } assert.equal(iEff(mk({ importance: 6, peak: 40, bonus: 1 })), 5)
const used = { ...weak, accessCount: 5 } assert.equal(iEff(mk({ importance: 10, peak: 100, bonus: 1 })), 5)
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0)) assert.equal(iEff(mk({ importance: 1 })), 1)
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 () => { 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 s = setup()
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' }) 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' }) const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u', kind: 'long' })
s.advance(10 * 24 * H) s.advance(10 * DAY)
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
const r = await s.mem.consolidate({ force: true }) const r = await s.mem.consolidate({ force: true })
assert.equal(r.forgotten, 1) assert.equal(r.archived, 1)
const ids = (await s.mem.all()).map(m => m.id) const all = await s.mem.all()
assert.ok(!ids.includes(trivial.id)) assert.equal(all.find(m => m.id === trivial.id)!.status, 'archived')
assert.ok(ids.includes(key.id)) 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 () => { test('場景結束時暫時記憶封存,其餘保留', async () => {
const s = setup() const s = setup()
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' }) const t = await s.mem.add({ layer: 'episodic', text: '剛剛的閒聊', importance: 2, source: 'u' })
s.advance(40 * H) const k = await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 6, source: 'u' })
await s.mem.recall('常提起') assert.equal(await s.mem.endScene(), 1)
s.advance(40 * H) 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 }) await s.mem.consolidate({ force: true })
assert.ok((await s.mem.all()).some(x => x.id === m.id)) 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)
}) })
}) })
@@ -100,7 +294,7 @@ describe('鞏固與反思', () => {
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => { test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
const s = setup() const s = setup()
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' }) await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
assert.deepEqual(await s.mem.consolidate(), { ran: false, forgotten: 0 }) 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' }) 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) assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
const r = await s.mem.consolidate() const r = await s.mem.consolidate()
@@ -1,8 +1,13 @@
import type { Clock, Fs } from './io' import type { Clock, Fs } from './io'
import { readJson, writeJson } from './io' import { readJson, writeJson } from './io'
import { MEMORY as R } from './rules'
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection' export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection'
/** 暫時:場景結束或 7 天衰減;可升級:30 天降權、90 天未檢索封存;長期與鎖定:不衰減 */
export type Kind = 'temp' | 'upgradable' | 'long' | 'locked'
export type Status = 'active' | 'archived'
export interface Pad { export interface Pad {
p: number p: number
a: number a: number
@@ -12,14 +17,40 @@ export interface Pad {
export interface Memory { export interface Memory {
id: number id: number
layer: Layer layer: Layer
kind: Kind
status: Status
text: string text: string
/** 重要度 1–10 */ /** 初始重要度 1–10(I_eff 以一半換算到 1–5 量表) */
importance: number importance: number
source: string source: string
createdAt: number createdAt: number
lastAccess: number lastAccess: number
accessCount: number accessCount: number
/** 建立當下的回合序號,新鮮度以回合數衰減 */
seq: number
/** 最近一次被檢索的回合序號 */
hitSeq?: number
emotion?: Pad emotion?: Pad
/** 編碼時的情緒峰值 0–100 */
peak?: number
/** 類別加成(忌妒、重逢),由 categoryBonus 算出 */
bonus?: number
/** 原典記憶:新鮮度固定為 1 */
canon?: boolean
/** 負向事件;未解時每日加權(反覆思量) */
negative?: boolean
resolvedAt?: number
/** 危險內容:只留安全摘要,不升級 */
danger?: boolean
archivedAt?: number
}
export interface Loop {
id: number
text: string
askedSeq: number
askedAt: number
status: 'open' | 'answered' | 'timeout'
} }
export interface MemoryDoc { export interface MemoryDoc {
@@ -30,29 +61,67 @@ export interface MemoryDoc {
lastConsolidated: number lastConsolidated: number
/** 已被反思涵蓋的最大記憶編號 */ /** 已被反思涵蓋的最大記憶編號 */
reflectedUpTo?: number reflectedUpTo?: number
/** 回合序號:每次檢索(每個使用者回合)加一 */
seq?: number
loops?: Loop[]
} }
export const HALF_LIFE_H = 72
export const TOP_K = 6 export const TOP_K = 6
export const WEIGHTS = { recency: 1, importance: 1, relevance: 2, emotion: 0.5 }
/** 累積重要度達此值就鞏固 */ /** 累積重要度達此值就鞏固 */
export const CONSOLIDATE_THRESHOLD = 30 export const CONSOLIDATE_THRESHOLD = 30
/** 保留率低於此值即遺忘 */
export const FORGET_BELOW = 0.1
/** 穩定度基準(小時) */
const BASE_STABILITY_H = 24
const HOUR = 3600_000 const HOUR = 3600_000
const DAY = 24 * HOUR
/** 穩定度(小時)隨重要度與取用次數成長 */ const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
export const stability = (m: Pick<Memory, 'importance' | 'accessCount'>) =>
BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount))
/** 保留率:自最近一次取用起算 */ /** 有效重要度 I_eff:初始重要度 + 情緒峰值 / 40 + 類別加成,限制在 1–5 */
export function retention(m: Memory, now: number): number { export const iEff = (m: Pick<Memory, 'importance' | 'peak' | 'bonus'>) =>
const h = Math.max(0, now - m.lastAccess) / HOUR clamp(m.importance / 2 + (m.peak ?? 0) / R.peakDiv + (m.bonus ?? 0), R.weightMin, R.weightMax)
return Math.exp(-h / stability(m))
const decay = (dSeq: number) => Math.pow(0.5, Math.max(0, dSeq) / R.halfSeq)
/** 新鮮度 F:原典固定 1;其餘 0.5^(Δseq/200),被檢索到時至少回到 0.8 後再繼續衰減 */
export function freshness(m: Pick<Memory, 'seq' | 'hitSeq' | 'canon'>, seq: number): number {
if (m.canon) return 1
const raw = decay(seq - m.seq)
return m.hitSeq === undefined ? raw : Math.max(raw, R.floor * decay(seq - m.hitSeq))
} }
/** 存取加成 A:命中越多越高,有上限 */
export const access = (m: Pick<Memory, 'accessCount'>) => 1 + R.accessStep * Math.min(m.accessCount, R.accessCap)
/** 反覆思量:未解的負向事件每日 +20%,上限 3 倍 */
export function rumination(m: Pick<Memory, 'negative' | 'resolvedAt' | 'createdAt'>, now: number): number {
if (!m.negative || m.resolvedAt !== undefined) return 1
return Math.min(R.ruminateCap, 1 + R.ruminatePerDay * (Math.max(0, now - m.createdAt) / DAY))
}
/** 可升級記憶滿 30 天仍未升級就降權 */
const demotion = (m: Pick<Memory, 'kind' | 'createdAt'>, now: number) =>
m.kind === 'upgradable' && now - m.createdAt >= R.demoteDays * DAY ? R.demoteFactor : 1
/** 記憶權重 W = I_eff × F × A(再乘反覆思量與降權) */
export const weight = (m: Memory, seq: number, now: number) =>
iEff(m) * freshness(m, seq) * access(m) * rumination(m, now) * demotion(m, now)
/** 此刻是否該封存:暫時記憶滿 7 天;可升級記憶 90 天未檢索。長期與鎖定記憶永不 */
export function shouldArchive(m: Memory, now: number): boolean {
if (m.status === 'archived') return false
if (m.kind === 'temp') return now - m.createdAt >= R.tempDays * DAY
if (m.kind === 'upgradable') return now - m.lastAccess >= R.archiveDays * DAY
return false
}
/** 類別加成:忌妒敏感度或想念累積速度 ≥ 0.65 的角色,對應事件 +1 */
export function categoryBonus(kind: 'jealous' | 'reunion', params?: { jealous?: number; miss?: number }): number {
const v = kind === 'jealous' ? params?.jealous : params?.miss
return v !== undefined && v / 100 >= R.bonusThreshold ? 1 : 0
}
const DANGER = /自殺|自傷|自殘|割腕|輕生|想死|結束生命|殺人|縱火|製毒|suicide|self[- ]?harm/i
export const isDanger = (text: string) => DANGER.test(text)
const SAFE_SUMMARY = '(安全事件摘要)對話中出現危險內容,已不保留原文'
const tokens = (s: string) => { const tokens = (s: string) => {
const out = new Set<string>() const out = new Set<string>()
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? [] const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
@@ -80,14 +149,20 @@ function moodFit(a?: Pad, b?: Pad): number {
return 1 - dist return 1 - dist
} }
export function score(m: Memory, query: string, now: number, mood?: Pad): number { /** RRF 融合:fused(id) = Σ 1 / (k + rank),rank 從 1 起算;每份排名由好到壞的記憶編號 */
const recency = Math.pow(0.5, Math.max(0, now - m.lastAccess) / HOUR / HALF_LIFE_H) export function rrf(rankings: number[][]): Map<number, number> {
return ( const out = new Map<number, number>()
WEIGHTS.recency * recency + for (const list of rankings) list.forEach((id, i) => out.set(id, (out.get(id) ?? 0) + 1 / (R.rrfK + i + 1)))
WEIGHTS.importance * (m.importance / 10) + return out
WEIGHTS.relevance * relevance(query, m.text) + }
WEIGHTS.emotion * moodFit(m.emotion, mood)
) /** 外部檢索器(例如語意相似度):回傳由好到壞的記憶編號;目前沒有內建,介面保留 */
export type Retriever = (query: string, items: Memory[]) => number[]
export interface RecallOpts {
/** 釋懷速度 0–1,決定心情一致加權的大小;預設 0.5 */
recover?: number
extra?: Retriever[]
} }
/** 疑似金鑰的字串在寫入前遮蔽 */ /** 疑似金鑰的字串在寫入前遮蔽 */
@@ -106,34 +181,100 @@ const defaultSummarize: Summarize = items =>
.map(i => i.text) .map(i => i.text)
.join(';') .join(';')
export interface AddInput {
layer: Layer
text: string
importance: number
source: string
emotion?: Pad
kind?: Kind
peak?: number
bonus?: number
canon?: boolean
negative?: boolean
}
export interface MemoryStore { export interface MemoryStore {
add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise<Memory> add(input: AddInput): Promise<Memory>
/** 檢索前 6 筆,並更新取用紀錄 */ /** 前 6 筆:關鍵字(可併入外部檢索器)經 RRF 融合 × W + 心情一致加權;回合序號加一並更新取用紀錄 */
recall(query: string, mood?: Pad): Promise<Memory[]> recall(query: string, mood?: Pad, opts?: RecallOpts): Promise<Memory[]>
/** 累積達門檻、會話結束或手動 reflect 時鞏固 */ /** 累積達門檻、會話結束或手動 reflect 時鞏固;過期的記憶封存而不刪除 */
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }> consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; archived: number; reflection?: Memory }>
/** 場景結束:暫時記憶封存 */
endScene(): Promise<number>
/** 未解負向事件標為已解(apology、soothe 或情緒消退時);不給編號就是全部 */
resolve(ids?: number[]): Promise<number>
/** 她提了一個問題,開始計算開放迴路 */
openLoop(text: string): Promise<Loop>
/** 使用者這句話回應了哪些開放迴路 */
respond(text: string): Promise<number>
loops(): Promise<Loop[]>
/** 每回合必帶:近 7 天權重最高的 3 筆、未解的負向事件、未收尾的對話 */
core(): Promise<{ recent: Memory[]; unresolved: Memory[]; loops: Loop[] }>
/** 全部記憶(含已封存),任何記憶都不刪除 */
all(): Promise<Memory[]> all(): Promise<Memory[]>
pending(): Promise<number> pending(): Promise<number>
} }
/** 回覆中最後一個問句;沒有就回 null */
export function lastQuestion(reply: string): string | null {
const qs = reply.match(/[^。!?!?\n]*[??]/g)
const q = qs?.[qs.length - 1]?.trim()
return q ? q.slice(0, 120) : null
}
export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore { export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
const load = () => readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }) const normalize = (d: MemoryDoc): MemoryDoc => {
d.seq ??= 0
d.loops ??= []
for (const m of d.items) {
m.kind ??= m.layer === 'episodic' && m.importance < 5 ? 'temp' : 'upgradable'
m.status ??= 'active'
m.seq ??= d.seq
}
return d
}
const load = async () => normalize(await readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }))
const save = (d: MemoryDoc) => writeJson(fs, path, d) const save = (d: MemoryDoc) => writeJson(fs, path, d)
// 升級與封存:回傳變動筆數
function lifecycle(d: MemoryDoc, now: number): number {
let n = 0
for (const m of d.items) {
if (m.status !== 'active') continue
if (m.kind === 'upgradable' && !m.danger && m.accessCount >= R.upgradeHits) m.kind = 'long'
else if (shouldArchive(m, now)) {
m.status = 'archived'
m.archivedAt = now
n++
}
}
return n
}
return { return {
async add(i) { async add(i) {
const d = await load() const d = await load()
const now = clock() const now = clock()
const danger = isDanger(i.text)
const m: Memory = { const m: Memory = {
id: d.nextId++, id: d.nextId++,
layer: i.layer, layer: i.layer,
text: redact(i.text), kind: danger ? 'temp' : (i.kind ?? (i.layer === 'episodic' && i.importance < 5 ? 'temp' : 'upgradable')),
importance: Math.min(10, Math.max(1, i.importance)), status: 'active',
text: danger ? SAFE_SUMMARY : redact(i.text),
importance: clamp(i.importance, 1, 10),
source: i.source, source: i.source,
createdAt: now, createdAt: now,
lastAccess: now, lastAccess: now,
accessCount: 0, accessCount: 0,
emotion: i.emotion, seq: d.seq!,
emotion: danger ? undefined : i.emotion,
peak: i.peak,
bonus: i.bonus,
canon: i.canon,
negative: i.negative,
danger: danger || undefined,
} }
d.items.push(m) d.items.push(m)
d.pending += m.importance d.pending += m.importance
@@ -141,54 +282,142 @@ export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
return m return m
}, },
async recall(query, mood) { async recall(query, mood, opts = {}) {
const d = await load() const d = await load()
const now = clock() const now = clock()
const top = d.items d.seq!++
.map(m => ({ m, s: score(m, query, now, mood) })) lifecycle(d, now)
for (const l of d.loops!) {
if (l.status === 'open' && d.seq! - l.askedSeq >= R.loopTurns) l.status = 'timeout'
}
const active = d.items.filter(m => m.status === 'active')
const w = new Map(active.map(m => [m.id, weight(m, d.seq!, now)]))
const hits = active
.map(m => ({ m, r: relevance(query, m.text) }))
.filter(x => x.r > 0)
.sort((a, b) => b.r - a.r || b.m.id - a.m.id)
.map(x => x.m.id)
// 查無關鍵字命中時退回依權重排序,讓核心記憶仍能帶出
const base = hits.length || opts.extra?.length ? hits : [...active].sort((a, b) => w.get(b.id)! - w.get(a.id)! || b.id - a.id).map(m => m.id)
const lists = [base, ...(opts.extra ?? []).map(f => f(query, active))]
const fused = rrf(lists)
const top1 = lists.length / (R.rrfK + 1)
const wm = R.moodBase + R.moodSlope * (1 - (opts.recover ?? 0.5))
const top = active
.filter(m => fused.has(m.id))
.map(m => ({ m, s: (fused.get(m.id)! / top1) * w.get(m.id)! + wm * moodFit(m.emotion, mood) }))
.sort((a, b) => b.s - a.s || b.m.id - a.m.id) .sort((a, b) => b.s - a.s || b.m.id - a.m.id)
.slice(0, TOP_K) .slice(0, TOP_K)
.map(x => x.m) .map(x => x.m)
for (const m of top) { for (const m of top) {
m.lastAccess = now m.lastAccess = now
m.accessCount++ m.accessCount++
m.hitSeq = d.seq
} }
if (top.length) await save(d) lifecycle(d, now)
// 空的記憶檔不為了回合序號而建立
if (d.items.length || d.loops!.length) await save(d)
return top return top
}, },
async consolidate(opts = {}) { async consolidate(opts = {}) {
const d = await load() const d = await load()
const now = clock() const now = clock()
if (!opts.force && d.pending < CONSOLIDATE_THRESHOLD) return { ran: false, forgotten: 0 } if (!opts.force && d.pending < CONSOLIDATE_THRESHOLD) return { ran: false, archived: 0 }
// 反思:把上次鞏固後新增、尚未被反思涵蓋的記憶濃縮成一筆 // 反思:把上次鞏固後新增、尚未被反思涵蓋的記憶濃縮成一筆
const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0)) const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0) && !m.danger)
let reflection: Memory | undefined let reflection: Memory | undefined
if (fresh.length) { if (fresh.length) {
const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id) const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id)
reflection = { reflection = {
id: d.nextId++, id: d.nextId++,
layer: 'reflection', layer: 'reflection',
kind: 'upgradable',
status: 'active',
text: redact((opts.summarize ?? defaultSummarize)(top)), text: redact((opts.summarize ?? defaultSummarize)(top)),
importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1), importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1),
source: `reflect:${top.map(t => t.id).join(',')}`, source: `reflect:${top.map(t => t.id).join(',')}`,
createdAt: now, createdAt: now,
lastAccess: now, lastAccess: now,
accessCount: 0, accessCount: 0,
seq: d.seq!,
} }
d.items.push(reflection) d.items.push(reflection)
} }
d.reflectedUpTo = d.nextId - 1 d.reflectedUpTo = d.nextId - 1
const archived = lifecycle(d, now)
// 遺忘:保留率過低的記憶淘汰(反思本身不在本輪淘汰)
const before = d.items.length
d.items = d.items.filter(m => m === reflection || retention(m, now) >= FORGET_BELOW)
d.pending = 0 d.pending = 0
d.lastConsolidated = now d.lastConsolidated = now
await save(d) await save(d)
return { ran: true, forgotten: before - d.items.length, reflection } return { ran: true, archived, reflection }
},
async endScene() {
const d = await load()
const now = clock()
let n = 0
for (const m of d.items) {
if (m.status === 'active' && m.kind === 'temp') {
m.status = 'archived'
m.archivedAt = now
n++
}
}
if (n) await save(d)
return n
},
async resolve(ids) {
const d = await load()
const now = clock()
let n = 0
for (const m of d.items) {
if (m.negative && m.resolvedAt === undefined && (!ids || ids.includes(m.id))) {
m.resolvedAt = now
n++
}
}
if (n) await save(d)
return n
},
async openLoop(text) {
const d = await load()
const l: Loop = { id: (d.loops!.reduce((x, y) => Math.max(x, y.id), 0)) + 1, text: redact(text), askedSeq: d.seq!, askedAt: clock(), status: 'open' }
d.loops!.push(l)
await save(d)
return l
},
async respond(text) {
const d = await load()
let n = 0
for (const l of d.loops!) {
if (l.status === 'open' && relevance(l.text, text) > 0) {
l.status = 'answered'
n++
}
}
if (n) await save(d)
return n
},
async loops() {
return (await load()).loops!
},
async core() {
const d = await load()
const now = clock()
const active = d.items.filter(m => m.status === 'active')
const recent = active
.filter(m => now - m.createdAt <= 7 * DAY)
.sort((a, b) => weight(b, d.seq!, now) - weight(a, d.seq!, now) || b.id - a.id)
.slice(0, 3)
const unresolved = active.filter(m => m.negative && m.resolvedAt === undefined)
return { recent, unresolved, loops: d.loops!.filter(l => l.status === 'open') }
}, },
async all() { async all() {
@@ -25,6 +25,42 @@ export const RULES = {
brokenPromise: { p: -0.12, a: 0.04, d: -0.08 }, brokenPromise: { p: -0.12, a: 0.04, d: -0.08 },
} as const satisfies Record<Tag, Pad> } as const satisfies Record<Tag, Pad>
// 記憶權重 W = I_eff × F × A 與生命週期的數值(第 9.5 章)
export const MEMORY = {
/** I_eff 下限與上限 */
weightMin: 1,
weightMax: 5,
/** 編碼時情緒峰值(0–100)除以此值加進 I_eff */
peakDiv: 40,
/** 新鮮度半衰的回合數:F = 0.5^(Δseq / 200) */
halfSeq: 200,
/** 被檢索到時 F 至少回到此值 */
floor: 0.8,
/** 存取加成:A = 1 + step × min(命中次數, cap) */
accessStep: 0.1,
accessCap: 5,
/** 未解負向事件:每日 +20%,上限 3 倍 */
ruminatePerDay: 0.2,
ruminateCap: 3,
/** 類別加成的角色參數門檻(0–1) */
bonusThreshold: 0.65,
/** 暫時記憶 7 天衰減 */
tempDays: 7,
/** 可升級記憶:30 天未升級就降權、90 天未檢索就封存 */
demoteDays: 30,
demoteFactor: 0.5,
archiveDays: 90,
/** 被檢索到這麼多次就升級為長期記憶 */
upgradeHits: 3,
/** 開放迴路:她的提問經過這麼多個使用者回合沒有回應就逾時 */
loopTurns: 2,
/** RRF:fused = Σ 1 / (k + rank) */
rrfK: 60,
/** 心情一致加權 w_m = base + slope × (1 − 釋懷速度) */
moodBase: 0.1,
moodSlope: 0.05,
} as const
// 正則先判明確的情況;順序即優先序 // 正則先判明確的情況;順序即優先序
const PATTERNS: [Tag, RegExp][] = [ const PATTERNS: [Tag, RegExp][] = [
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],