- jsc-mod-statusline:/jsc-mod-statusline:enable、compact、disable - jsc-mod-companion:/jsc-mod-companion:status、switch、disable、setup、todo、quiet - 同步更新 plugin.json、marketplace.json 與各 README - 補上 statusline band 測試缺少的 ui.render 底層處理 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
202 lines
6.1 KiB
TypeScript
202 lines
6.1 KiB
TypeScript
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
|
||
},
|
||
}
|
||
}
|