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) => 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) ?? [] 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 { 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[] } /** 疑似金鑰的字串在寫入前遮蔽 */ 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 /** 前 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 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, 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 }, } }