- 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除 - 未解負向事件反覆思量、開放迴路逾時、危險內容不固化 - 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面 - 版本 0.0.4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
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
|
||
},
|
||
}
|
||
}
|