Merge pull request 'feat: companion 記憶權重、反覆思量與檢索' (#51) from issue-45 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/51
This commit was merged in pull request #51.
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in New Issue
Block a user