From 6f7b4d91ea378f5f25b05cdef74408bae06617e8 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 14 Oct 2026 09:01:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20companion=20=E8=A8=98=E6=86=B6=E6=AC=8A?= =?UTF-8?q?=E9=87=8D=E3=80=81=E5=8F=8D=E8=A6=86=E6=80=9D=E9=87=8F=E8=88=87?= =?UTF-8?q?=E6=AA=A2=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除 - 未解負向事件反覆思量、開放迴路逾時、危險內容不固化 - 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面 - 版本 0.0.4 Co-Authored-By: Claude Sonnet 5.5 --- .../.claude-plugin/plugin.json | 2 +- plugins/jsc-mod-companion/README.md | 14 + .../hooks/companion/companion.test.ts | 27 ++ .../jsc-mod-companion/hooks/companion/core.ts | 29 +- .../hooks/companion/memory.test.ts | 258 +++++++++++++-- .../hooks/companion/memory.ts | 313 +++++++++++++++--- .../hooks/companion/rules.ts | 36 ++ 7 files changed, 599 insertions(+), 80 deletions(-) diff --git a/plugins/jsc-mod-companion/.claude-plugin/plugin.json b/plugins/jsc-mod-companion/.claude-plugin/plugin.json index 261b6f0..d104773 100644 --- a/plugins/jsc-mod-companion/.claude-plugin/plugin.json +++ b/plugins/jsc-mod-companion/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-mod-companion", - "version": "0.0.3", + "version": "0.0.4", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", "license": "MIT", "author": { diff --git a/plugins/jsc-mod-companion/README.md b/plugins/jsc-mod-companion/README.md index ed14ae6..90f13d4 100644 --- a/plugins/jsc-mod-companion/README.md +++ b/plugins/jsc-mod-companion/README.md @@ -127,3 +127,17 @@ - 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。 - 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。 - 與第 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」;回合序號以使用者回合計;情緒峰值由標籤向量長度換算;原典佔比、相似記憶合併、釘選與使用者模型容量尚未實作。 diff --git a/plugins/jsc-mod-companion/hooks/companion/companion.test.ts b/plugins/jsc-mod-companion/hooks/companion/companion.test.ts index 34d1880..7aea2a6 100644 --- a/plugins/jsc-mod-companion/hooks/companion/companion.test.ts +++ b/plugins/jsc-mod-companion/hooks/companion/companion.test.ts @@ -362,6 +362,33 @@ describe('記憶、待辦與關心', () => { 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 () => { const { c, fs } = setup() await c.ensureActivated() diff --git a/plugins/jsc-mod-companion/hooks/companion/core.ts b/plugins/jsc-mod-companion/hooks/companion/core.ts index fad8f77..ecddfce 100644 --- a/plugins/jsc-mod-companion/hooks/companion/core.ts +++ b/plugins/jsc-mod-companion/hooks/companion/core.ts @@ -8,10 +8,10 @@ import type { Emotions } from './emotion' import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import type { MoodEvent } from './mood' import { extraversionOf, neuroticismOf } from './core/params' -import { extractTag } from './tags' +import { extractTag, stripTag } from './tags' import { createCare } 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 { buildPrompt } from './prompt' import { createTodos } from './todo' @@ -271,9 +271,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const b = await bundleFor() if (!b) return [] 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) { - 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() } return b.care.onUserTurn() @@ -295,6 +308,9 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const tag = regexHit ? null : extractTag(reply) regexHit = true 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 await b.care.onTurnComplete(usedTools) await moodEvent('success') @@ -311,7 +327,10 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan async function end() { 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 }) => diff --git a/plugins/jsc-mod-companion/hooks/companion/memory.test.ts b/plugins/jsc-mod-companion/hooks/companion/memory.test.ts index 9c3d853..fd7e82f 100644 --- a/plugins/jsc-mod-companion/hooks/companion/memory.test.ts +++ b/plugins/jsc-mod-companion/hooks/companion/memory.test.ts @@ -2,11 +2,33 @@ import { describe, test } from 'claude-code/testing' import assert from './assert' 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 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() @@ -24,12 +46,8 @@ describe('記憶檢索', () => { }) 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) + // 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 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' }) @@ -60,39 +78,215 @@ describe('記憶檢索', () => { }) }) -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)) +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('鞏固時淘汰低保留率的記憶,保留重要與常用的', 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 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 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.forgotten, 1) - const ids = (await s.mem.all()).map(m => m.id) - assert.ok(!ids.includes(trivial.id)) - assert.ok(ids.includes(key.id)) + 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 () => { + 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) + 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.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 () => { const s = setup() 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' }) assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD) const r = await s.mem.consolidate() diff --git a/plugins/jsc-mod-companion/hooks/companion/memory.ts b/plugins/jsc-mod-companion/hooks/companion/memory.ts index 3613a71..b135df2 100644 --- a/plugins/jsc-mod-companion/hooks/companion/memory.ts +++ b/plugins/jsc-mod-companion/hooks/companion/memory.ts @@ -1,8 +1,13 @@ import type { Clock, Fs } from './io' import { readJson, writeJson } from './io' +import { MEMORY as R } from './rules' 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 { p: number a: number @@ -12,14 +17,40 @@ export interface Pad { export interface Memory { id: number layer: Layer + kind: Kind + status: Status text: string - /** 重要度 1–10 */ + /** 初始重要度 1–10(I_eff 以一半換算到 1–5 量表) */ importance: number source: string createdAt: number lastAccess: number accessCount: number + /** 建立當下的回合序號,新鮮度以回合數衰減 */ + seq: number + /** 最近一次被檢索的回合序號 */ + hitSeq?: number 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 { @@ -30,29 +61,67 @@ export interface MemoryDoc { lastConsolidated: number /** 已被反思涵蓋的最大記憶編號 */ reflectedUpTo?: number + /** 回合序號:每次檢索(每個使用者回合)加一 */ + seq?: number + loops?: Loop[] } -export const HALF_LIFE_H = 72 export const TOP_K = 6 -export const WEIGHTS = { recency: 1, importance: 1, relevance: 2, emotion: 0.5 } /** 累積重要度達此值就鞏固 */ export const CONSOLIDATE_THRESHOLD = 30 -/** 保留率低於此值即遺忘 */ -export const FORGET_BELOW = 0.1 -/** 穩定度基準(小時) */ -const BASE_STABILITY_H = 24 const HOUR = 3600_000 +const DAY = 24 * HOUR -/** 穩定度(小時)隨重要度與取用次數成長 */ -export const stability = (m: Pick) => - BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount)) +const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x)) -/** 保留率:自最近一次取用起算 */ -export function retention(m: Memory, now: number): number { - const h = Math.max(0, now - m.lastAccess) / HOUR - return Math.exp(-h / stability(m)) +/** 有效重要度 I_eff:初始重要度 + 情緒峰值 / 40 + 類別加成,限制在 1–5 */ +export const iEff = (m: Pick) => + clamp(m.importance / 2 + (m.peak ?? 0) / R.peakDiv + (m.bonus ?? 0), R.weightMin, R.weightMax) + +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, 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) => 1 + R.accessStep * Math.min(m.accessCount, R.accessCap) + +/** 反覆思量:未解的負向事件每日 +20%,上限 3 倍 */ +export function rumination(m: Pick, 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, 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 out = new Set() const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? [] @@ -80,14 +149,20 @@ function moodFit(a?: Pad, b?: Pad): number { return 1 - dist } -export function score(m: Memory, query: string, now: number, mood?: Pad): number { - const recency = Math.pow(0.5, Math.max(0, now - m.lastAccess) / HOUR / HALF_LIFE_H) - return ( - WEIGHTS.recency * recency + - WEIGHTS.importance * (m.importance / 10) + - WEIGHTS.relevance * relevance(query, m.text) + - WEIGHTS.emotion * moodFit(m.emotion, mood) - ) +/** RRF 融合:fused(id) = Σ 1 / (k + rank),rank 從 1 起算;每份排名由好到壞的記憶編號 */ +export function rrf(rankings: number[][]): Map { + const out = new Map() + for (const list of rankings) list.forEach((id, i) => out.set(id, (out.get(id) ?? 0) + 1 / (R.rrfK + i + 1))) + return out +} + +/** 外部檢索器(例如語意相似度):回傳由好到壞的記憶編號;目前沒有內建,介面保留 */ +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) .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 { - add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise - /** 檢索前 6 筆,並更新取用紀錄 */ - recall(query: string, mood?: Pad): Promise - /** 累積達門檻、會話結束或手動 reflect 時鞏固 */ - consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }> + add(input: AddInput): Promise + /** 前 6 筆:關鍵字(可併入外部檢索器)經 RRF 融合 × W + 心情一致加權;回合序號加一並更新取用紀錄 */ + recall(query: string, mood?: Pad, opts?: RecallOpts): Promise + /** 累積達門檻、會話結束或手動 reflect 時鞏固;過期的記憶封存而不刪除 */ + consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; archived: number; reflection?: Memory }> + /** 場景結束:暫時記憶封存 */ + endScene(): Promise + /** 未解負向事件標為已解(apology、soothe 或情緒消退時);不給編號就是全部 */ + resolve(ids?: number[]): Promise + /** 她提了一個問題,開始計算開放迴路 */ + openLoop(text: string): Promise + /** 使用者這句話回應了哪些開放迴路 */ + respond(text: string): Promise + loops(): Promise + /** 每回合必帶:近 7 天權重最高的 3 筆、未解的負向事件、未收尾的對話 */ + core(): Promise<{ recent: Memory[]; unresolved: Memory[]; loops: Loop[] }> + /** 全部記憶(含已封存),任何記憶都不刪除 */ all(): Promise pending(): Promise } +/** 回覆中最後一個問句;沒有就回 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 { - const load = () => readJson(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(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 })) 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 { async add(i) { const d = await load() const now = clock() + const danger = isDanger(i.text) const m: Memory = { id: d.nextId++, layer: i.layer, - text: redact(i.text), - importance: Math.min(10, Math.max(1, i.importance)), + kind: danger ? 'temp' : (i.kind ?? (i.layer === 'episodic' && i.importance < 5 ? 'temp' : 'upgradable')), + status: 'active', + text: danger ? SAFE_SUMMARY : redact(i.text), + importance: clamp(i.importance, 1, 10), source: i.source, createdAt: now, lastAccess: now, 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.pending += m.importance @@ -141,54 +282,142 @@ export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore { return m }, - async recall(query, mood) { + async recall(query, mood, opts = {}) { const d = await load() const now = clock() - const top = d.items - .map(m => ({ m, s: score(m, query, now, mood) })) + d.seq!++ + 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) .slice(0, TOP_K) .map(x => x.m) for (const m of top) { m.lastAccess = now 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 }, async consolidate(opts = {}) { const d = await load() 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 if (fresh.length) { const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id) reflection = { id: d.nextId++, layer: 'reflection', + kind: 'upgradable', + status: 'active', text: redact((opts.summarize ?? defaultSummarize)(top)), importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1), source: `reflect:${top.map(t => t.id).join(',')}`, createdAt: now, lastAccess: now, accessCount: 0, + seq: d.seq!, } d.items.push(reflection) } d.reflectedUpTo = d.nextId - 1 - - // 遺忘:保留率過低的記憶淘汰(反思本身不在本輪淘汰) - const before = d.items.length - d.items = d.items.filter(m => m === reflection || retention(m, now) >= FORGET_BELOW) + const archived = lifecycle(d, now) d.pending = 0 d.lastConsolidated = now 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() { diff --git a/plugins/jsc-mod-companion/hooks/companion/rules.ts b/plugins/jsc-mod-companion/hooks/companion/rules.ts index 7c792ce..a6a1b31 100644 --- a/plugins/jsc-mod-companion/hooks/companion/rules.ts +++ b/plugins/jsc-mod-companion/hooks/companion/rules.ts @@ -25,6 +25,42 @@ export const RULES = { brokenPromise: { p: -0.12, a: 0.04, d: -0.08 }, } as const satisfies Record +// 記憶權重 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][] = [ ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],