feat: M4 主動關心、待辦與記憶鞏固核心模組

- care:事件式關心(冷卻、每日上限、靜音、回覆中/輸入中不彈出),toast 與注入兩種輸出,台詞依觸發 × 個性 × 好感階段
- todo:新增、完成、承諾事項與到期提醒
- memory:檢索評分、遺忘曲線、鞏固與反思、金鑰過濾
- 以假時鐘與記憶體檔案系統測試核心接縫

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:30:22 +08:00
co-authored by Claude Sonnet 5.5
parent 030c599208
commit ac33dcca3b
6 changed files with 1125 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
import type { Clock, Fs } from './io'
import { readJson, writeJson } from './io'
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection'
export interface Pad {
p: number
a: number
d: number
}
export interface Memory {
id: number
layer: Layer
text: string
/** 重要度 1–10 */
importance: number
source: string
createdAt: number
lastAccess: number
accessCount: number
emotion?: Pad
}
export interface MemoryDoc {
items: Memory[]
nextId: number
/** 自上次鞏固以來累積的重要度 */
pending: number
lastConsolidated: number
/** 已被反思涵蓋的最大記憶編號 */
reflectedUpTo?: number
}
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
/** 穩定度(小時)隨重要度與取用次數成長 */
export const stability = (m: Pick<Memory, 'importance' | 'accessCount'>) =>
BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount))
/** 保留率:自最近一次取用起算 */
export function retention(m: Memory, now: number): number {
const h = Math.max(0, now - m.lastAccess) / HOUR
return Math.exp(-h / stability(m))
}
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
}
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)
)
}
/** 疑似金鑰的字串在寫入前遮蔽 */
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 MemoryStore {
add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise<Memory>
/** 檢索前 6 筆,並更新取用紀錄 */
recall(query: string, mood?: Pad): Promise<Memory[]>
/** 累積達門檻、會話結束或手動 reflect 時鞏固 */
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }>
all(): Promise<Memory[]>
pending(): Promise<number>
}
export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
const load = () => readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 })
const save = (d: MemoryDoc) => writeJson(fs, path, d)
return {
async add(i) {
const d = await load()
const now = clock()
const m: Memory = {
id: d.nextId++,
layer: i.layer,
text: redact(i.text),
importance: Math.min(10, Math.max(1, i.importance)),
source: i.source,
createdAt: now,
lastAccess: now,
accessCount: 0,
emotion: i.emotion,
}
d.items.push(m)
d.pending += m.importance
await save(d)
return m
},
async recall(query, mood) {
const d = await load()
const now = clock()
const top = d.items
.map(m => ({ m, s: score(m, query, now, 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++
}
if (top.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 }
// 反思:把上次鞏固後新增、尚未被反思涵蓋的記憶濃縮成一筆
const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0))
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',
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,
}
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)
d.pending = 0
d.lastConsolidated = now
await save(d)
return { ran: true, forgotten: before - d.items.length, reflection }
},
async all() {
return (await load()).items
},
async pending() {
return (await load()).pending
},
}
}