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

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

431 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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
d: number
}
export interface Memory {
id: number
layer: Layer
kind: Kind
status: Status
text: string
/** 初始重要度 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 {
items: Memory[]
nextId: number
/** 自上次鞏固以來累積的重要度 */
pending: number
lastConsolidated: number
/** 已被反思涵蓋的最大記憶編號 */
reflectedUpTo?: number
/** 回合序號:每次檢索(每個使用者回合)加一 */
seq?: number
loops?: Loop[]
}
export const TOP_K = 6
/** 累積重要度達此值就鞏固 */
export const CONSOLIDATE_THRESHOLD = 30
const HOUR = 3600_000
const DAY = 24 * HOUR
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
/** 有效重要度 I_eff:初始重要度 + 情緒峰值 / 40 + 類別加成,限制在 1–5 */
export const iEff = (m: Pick<Memory, 'importance' | 'peak' | 'bonus'>) =>
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<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 out = new Set<string>()
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
for (const w of latin) out.add(w)
const cjk = s.replace(/[^一-鿿]/g, '')
for (let i = 0; i < cjk.length; i++) {
out.add(cjk[i]!)
if (i + 1 < cjk.length) out.add(cjk.slice(i, i + 2))
}
return out
}
function relevance(query: string, text: string): number {
const q = tokens(query)
if (!q.size) return 0
const t = tokens(text)
let hit = 0
for (const w of q) if (t.has(w)) hit++
return hit / q.size
}
function moodFit(a?: Pad, b?: Pad): number {
if (!a || !b) return 0
const dist = Math.hypot(a.p - b.p, a.a - b.a, a.d - b.d) / Math.hypot(2, 2, 2)
return 1 - dist
}
/** RRF 融合:fused(id) = Σ 1 / (k + rank),rank 從 1 起算;每份排名由好到壞的記憶編號 */
export function rrf(rankings: number[][]): Map<number, number> {
const out = new Map<number, number>()
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[]
}
/** 疑似金鑰的字串在寫入前遮蔽 */
export function redact(s: string): string {
return s
.replace(/\b(sk|pk|ghp|gho|xox[bap]|AKIA)[-_A-Za-z0-9]{12,}/g, '[已過濾]')
.replace(/\b[A-Za-z0-9+/_-]{32,}\b/g, '[已過濾]')
}
export type Summarize = (items: Memory[]) => string
/** 預設摘要:不呼叫模型,直接串接重點;實際反思文字可由外部注入模型版 */
const defaultSummarize: Summarize = items =>
items
.slice(0, 5)
.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: AddInput): Promise<Memory>
/** 前 6 筆:關鍵字(可併入外部檢索器)經 RRF 融合 × W + 心情一致加權;回合序號加一並更新取用紀錄 */
recall(query: string, mood?: Pad, opts?: RecallOpts): Promise<Memory[]>
/** 累積達門檻、會話結束或手動 reflect 時鞏固;過期的記憶封存而不刪除 */
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[]>
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 {
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)
// 升級與封存:回傳變動筆數
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,
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,
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
await save(d)
return m
},
async recall(query, mood, opts = {}) {
const d = await load()
const now = clock()
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
}
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, archived: 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 archived = lifecycle(d, now)
d.pending = 0
d.lastConsolidated = now
await save(d)
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() {
return (await load()).items
},
async pending() {
return (await load()).pending
},
}
}