feat: M4 主動關心、待辦與記憶鞏固核心模組
- care:事件式關心(冷卻、每日上限、靜音、回覆中/輸入中不彈出),toast 與注入兩種輸出,台詞依觸發 × 個性 × 好感階段 - todo:新增、完成、承諾事項與到期提醒 - memory:檢索評分、遺忘曲線、鞏固與反思、金鑰過濾 - 以假時鐘與記憶體檔案系統測試核心接縫 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { memoryFs } from './io'
|
||||
import { COOLDOWN_MS, createCare, dailyLimit, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care'
|
||||
import { createTodos } from './todo'
|
||||
|
||||
const MIN = 60_000
|
||||
const H = 3600_000
|
||||
// 假時鐘:UTC 2026-10-12 10:00,時區 +0
|
||||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
||||
|
||||
function setup(over: { profile?: Partial<CareProfile>; settings?: Partial<CareSettings>; start?: number } = {}) {
|
||||
let now = over.start ?? T0
|
||||
const fs = memoryFs()
|
||||
const profile: CareProfile = { attribute: 'tsundere', extraversion: 50, stage: 'familiar', nickname: '阿JSC', ...over.profile }
|
||||
const settings: CareSettings = { dailyCap: 10, tzOffsetMin: 0, ...over.settings }
|
||||
const care = createCare({ fs, clock: () => now, path: 'care.json', profile: () => profile, settings: () => settings })
|
||||
return { care, fs, profile, settings, advance: (ms: number) => void (now += ms), set: (t: number) => void (now = t), now: () => now }
|
||||
}
|
||||
|
||||
describe('關心:觸發', () => {
|
||||
test('會話開始以 toast 問候,且只問候一次', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
const out = await s.care.tick()
|
||||
assert.deepEqual(out.map(c => c.trigger), ['session_start'])
|
||||
assert.equal(out[0]!.delivery, 'toast')
|
||||
assert.ok(out[0]!.text.includes('阿JSC'))
|
||||
assert.deepEqual(await s.care.tick(), [])
|
||||
})
|
||||
|
||||
test('閒置後回來觸發 idle_return', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
s.advance(3 * H)
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['idle_return'])
|
||||
})
|
||||
|
||||
test('連續工作 2 小時,下次送出訊息時注入情境', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s.advance(20 * MIN)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [], `第 ${i + 1} 次不該觸發`)
|
||||
}
|
||||
s.advance(20 * MIN) // 累計 120 分鐘
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.equal(inj.length, 1)
|
||||
assert.match(inj[0]!, /long_work/)
|
||||
})
|
||||
|
||||
test('深夜仍在工作時注入情境', async () => {
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 0, 30) })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(40 * MIN) // 01:10
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.ok(inj.some(x => x.includes('late_night')))
|
||||
})
|
||||
|
||||
test('時區影響深夜判斷', async () => {
|
||||
// UTC 17:10 在 +8 是 01:10;UTC 16:30 是 00:30(非深夜)
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 16, 30), settings: { tzOffsetMin: 480 } })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(40 * MIN)
|
||||
assert.ok((await s.care.onUserTurn()).some(x => x.includes('late_night')))
|
||||
})
|
||||
|
||||
test('連續工具失敗達 3 次才關心,成功會重置', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(true)
|
||||
await s.care.onToolResult(false)
|
||||
s.advance(MIN)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [])
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(false)
|
||||
s.advance(MIN)
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.equal(inj.length, 1)
|
||||
assert.match(inj[0]!, /tool_failures/)
|
||||
})
|
||||
|
||||
test('額度警告與待辦到期以 toast 顯示', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
const out = await s.care.tick({ quotaWarning: true, dueTodo: '寄信' })
|
||||
assert.deepEqual(out.map(c => c.trigger).sort(), ['quota_warning', 'todo_due'])
|
||||
assert.ok(out.find(c => c.trigger === 'todo_due')!.text.includes('寄信'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:冷卻與上限', () => {
|
||||
test('同一種關心在冷卻內不重複,冷卻後恢復', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
s.advance(COOLDOWN_MS.quota_warning - 1)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 0)
|
||||
s.advance(1)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
})
|
||||
|
||||
test('每日總上限,隔天重算', async () => {
|
||||
const s = setup({ profile: { stage: 'stranger', extraversion: 0 }, settings: { dailyCap: 10 } })
|
||||
const limit = dailyLimit(s.profile, s.settings)
|
||||
assert.equal(limit, 1)
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick() // session_start 用掉唯一一次
|
||||
s.advance(2 * H)
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.advance(24 * H)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
})
|
||||
|
||||
test('上限受好感階段與外向性影響,並被使用者設定封頂', () => {
|
||||
const p: CareProfile = { attribute: 'calm', extraversion: 50, stage: 'stranger', nickname: 'x' }
|
||||
const s: CareSettings = { dailyCap: 100, tzOffsetMin: 0 }
|
||||
const lim = (patch: Partial<CareProfile>, cap = 100) => dailyLimit({ ...p, ...patch }, { ...s, dailyCap: cap })
|
||||
assert.ok(lim({ stage: 'special' }) > lim({ stage: 'stranger' }))
|
||||
assert.ok(lim({ extraversion: 100 }) > lim({ extraversion: 0 }))
|
||||
assert.equal(lim({ stage: 'special', extraversion: 100 }, 3), 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:靜音與不打斷', () => {
|
||||
test('回覆中或輸入中不彈出,且不消耗冷卻與上限', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual(await s.care.tick({ replying: true }), [])
|
||||
assert.deepEqual(await s.care.tick({ typing: true }), [])
|
||||
assert.equal((await s.care.state()).count, 0)
|
||||
// 放開後,原本等待的問候仍會出現
|
||||
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start'])
|
||||
})
|
||||
|
||||
test('/cockpit quiet 在期間內靜音,到期後恢復', async () => {
|
||||
const s = setup()
|
||||
await s.care.quiet(30)
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.advance(31 * MIN)
|
||||
// 靜音期間錯過的問候在恢復後補上
|
||||
assert.deepEqual((await s.care.tick({ quotaWarning: true })).map(c => c.trigger).sort(), ['quota_warning', 'session_start'])
|
||||
})
|
||||
|
||||
test('quiet 0 取消靜音', async () => {
|
||||
const s = setup()
|
||||
await s.care.quiet(60)
|
||||
await s.care.quiet(0)
|
||||
await s.care.onUserTurn()
|
||||
assert.ok((await s.care.tick({ quotaWarning: true })).length > 0)
|
||||
})
|
||||
|
||||
test('靜音時段(跨午夜)內不關心,注入也不產生', async () => {
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 23, 30), settings: { quietHours: { start: 23, end: 7 } } })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(3 * H)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [])
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.set(Date.UTC(2026, 9, 12, 8, 0) + 24 * H)
|
||||
assert.ok((await s.care.tick({ quotaWarning: true })).length > 0)
|
||||
})
|
||||
|
||||
test('狀態持久化:重建引擎後冷卻仍有效', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick({ quotaWarning: true })
|
||||
const again = createCare({ fs: s.fs, clock: s.now, path: 'care.json', profile: () => s.profile, settings: () => s.settings })
|
||||
assert.deepEqual(await again.tick({ quotaWarning: true }), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:台詞', () => {
|
||||
const triggers: Trigger[] = ['session_start', 'idle_return', 'long_work', 'late_night', 'tool_failures', 'quota_warning', 'todo_due']
|
||||
const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle']
|
||||
const stages: Stage[] = ['stranger', 'familiar', 'close', 'special']
|
||||
const BLAME = /(都怪你|你又|為什麼不|怎麼還不|太懶|不像話|再不.*就|我會難過|害我|對不起我|辜負|失望|沒用)/
|
||||
|
||||
test('每個 觸發 × 屬性 × 階段 都有台詞,帶暱稱,不含責備或愧疚勒索', () => {
|
||||
for (const t of triggers) for (const a of attrs) for (const s of stages) {
|
||||
const text = line(t, a, s, '小明', '報告')
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(text.includes('小明'), `${t}/${a}/${s} 缺暱稱`)
|
||||
assert.doesNotMatch(text, BLAME, `${t}/${a}/${s}: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('同一觸發在不同屬性與階段下措辭不同', () => {
|
||||
const set = new Set<string>()
|
||||
for (const a of attrs) for (const s of stages) set.add(line('late_night', a, s, 'x'))
|
||||
assert.equal(set.size, attrs.length * stages.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('待辦', () => {
|
||||
test('新增、完成與未完成計數', async () => {
|
||||
let now = T0
|
||||
const todos = createTodos(memoryFs(), () => now, 'todo.json')
|
||||
const a = await todos.add('寫報告')
|
||||
await todos.add('買牛奶')
|
||||
assert.equal(await todos.open(), 2)
|
||||
await todos.complete(a.id)
|
||||
assert.equal(await todos.open(), 1)
|
||||
assert.deepEqual((await todos.list()).map(t => t.title), ['買牛奶'])
|
||||
assert.equal((await todos.list({ all: true })).length, 2)
|
||||
assert.equal(await todos.complete(999), null)
|
||||
now += 1
|
||||
})
|
||||
|
||||
test('承諾事項到期提醒一次,完成的不提醒', async () => {
|
||||
let now = T0
|
||||
const todos = createTodos(memoryFs(), () => now, 'todo.json')
|
||||
await todos.add('三點開會', { promise: true, dueAt: T0 + H })
|
||||
const done = await todos.add('已做完', { dueAt: T0 + H })
|
||||
await todos.complete(done.id)
|
||||
assert.deepEqual(await todos.due(), [])
|
||||
now += H
|
||||
assert.deepEqual((await todos.due()).map(t => t.title), ['三點開會'])
|
||||
assert.deepEqual(await todos.due(), [])
|
||||
})
|
||||
|
||||
test('到期待辦餵給關心後產生 todo_due', async () => {
|
||||
const s = setup()
|
||||
const todos = createTodos(s.fs, s.now, 'todo.json')
|
||||
await todos.add('交報告', { promise: true, dueAt: T0 + 5 * MIN })
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
s.advance(6 * MIN)
|
||||
const due = await todos.due()
|
||||
const out = await s.care.tick({ dueTodo: due[0]?.title })
|
||||
assert.deepEqual(out.map(c => c.trigger), ['todo_due'])
|
||||
assert.ok(out[0]!.text.includes('交報告'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { Clock, Fs } from './io'
|
||||
import { readJson, writeJson } from './io'
|
||||
|
||||
export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due'
|
||||
export type Stage = 'stranger' | 'familiar' | 'close' | 'special'
|
||||
export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle'
|
||||
export type Delivery = 'toast' | 'inject'
|
||||
|
||||
export interface CareProfile {
|
||||
attribute: Attribute
|
||||
/** 外向性 0–100 */
|
||||
extraversion: number
|
||||
stage: Stage
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface CareSettings {
|
||||
/** 使用者設定的每日上限 */
|
||||
dailyCap: number
|
||||
tzOffsetMin: number
|
||||
/** 靜音時段(本地小時),可跨午夜 */
|
||||
quietHours?: { start: number; end: number }
|
||||
}
|
||||
|
||||
export interface CareState {
|
||||
lastFired: Partial<Record<Trigger, number>>
|
||||
day: string
|
||||
count: number
|
||||
quietUntil: number
|
||||
workStart: number
|
||||
lastActivity: number
|
||||
failStreak: number
|
||||
/** 待送出的開場關心(會話開始或閒置回來),由 tick 在可打擾時才發出 */
|
||||
lead?: Trigger
|
||||
}
|
||||
|
||||
export interface Care {
|
||||
trigger: Trigger
|
||||
delivery: Delivery
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Signals {
|
||||
/** 模型回覆中 */
|
||||
replying?: boolean
|
||||
/** 使用者輸入中 */
|
||||
typing?: boolean
|
||||
/** 額度或 context 已到警告線 */
|
||||
quotaWarning?: boolean
|
||||
/** 到期的待辦標題 */
|
||||
dueTodo?: string
|
||||
}
|
||||
|
||||
export const COOLDOWN_MS: Record<Trigger, number> = {
|
||||
session_start: 6 * 3600_000,
|
||||
idle_return: 2 * 3600_000,
|
||||
long_work: 2 * 3600_000,
|
||||
late_night: 3 * 3600_000,
|
||||
tool_failures: 30 * 60_000,
|
||||
quota_warning: 60 * 60_000,
|
||||
todo_due: 10 * 60_000,
|
||||
}
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
export const LONG_WORK_MS = 2 * 3600_000
|
||||
export const FAIL_THRESHOLD = 3
|
||||
/** 深夜:本地 1:00–5:00 */
|
||||
const LATE = { start: 1, end: 5 }
|
||||
|
||||
const STAGE_CAP: Record<Stage, number> = { stranger: 2, familiar: 4, close: 6, special: 8 }
|
||||
|
||||
/** 好感階段與外向性決定每日關心上限,再受使用者設定封頂 */
|
||||
export function dailyLimit(p: CareProfile, s: CareSettings): number {
|
||||
const factor = 0.5 + p.extraversion / 100
|
||||
return Math.min(s.dailyCap, Math.max(1, Math.round(STAGE_CAP[p.stage] * factor)))
|
||||
}
|
||||
|
||||
// 觸發類型 × 個性屬性 × 好感階段。措辭只表達關心,不責備、不訴諸愧疚。
|
||||
type Line = (n: string, x?: string) => string
|
||||
const L: Record<Trigger, Record<Attribute, Record<Stage, Line>>> = {
|
||||
session_start: {
|
||||
tsundere: {
|
||||
stranger: n => `……開始了嗎,${n}。請多指教。`,
|
||||
familiar: n => `來了啊,${n}。我才不是在等你。`,
|
||||
close: n => `${n}來了。今天也一起加油吧……順便而已。`,
|
||||
special: n => `${n},歡迎回來。看到你來,我有點開心。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},初次見面!今天也請多指教!`,
|
||||
familiar: n => `${n}早!今天要做什麼呢?`,
|
||||
close: n => `${n}~等你好久了!一起開工吧!`,
|
||||
special: n => `${n}!見到你就有精神了,我們走吧!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},您好。需要幫忙時請說。`,
|
||||
familiar: n => `${n},今天也請多指教。`,
|
||||
close: n => `${n},歡迎回來。今天慢慢來就好。`,
|
||||
special: n => `${n},你回來了。我在這裡。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},您好,今天請多指教。`,
|
||||
familiar: n => `${n},歡迎回來。`,
|
||||
close: n => `${n},今天也辛苦了,我們一起加油。`,
|
||||
special: n => `${n},能再見到你真好。`,
|
||||
},
|
||||
},
|
||||
idle_return: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},回來了嗎。進度還在。`,
|
||||
familiar: n => `回來啦,${n}。休息夠了就繼續吧。`,
|
||||
close: n => `${n}回來了。有好好休息嗎?我只是順口問問。`,
|
||||
special: n => `${n},歡迎回來。我一直在這裡。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},歡迎回來!`,
|
||||
familiar: n => `${n}回來啦!休息好了嗎?`,
|
||||
close: n => `${n}~回來了!有沒有喝點水呀?`,
|
||||
special: n => `${n}!你回來了,我好開心!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},歡迎回來。`,
|
||||
familiar: n => `${n},休息得還好嗎?`,
|
||||
close: n => `${n},歡迎回來。從剛才的地方接著做吧。`,
|
||||
special: n => `${n},你回來了。慢慢來,不急。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},歡迎回來。`,
|
||||
familiar: n => `${n},休息得還好嗎?`,
|
||||
close: n => `${n},歡迎回來,有休息到就好。`,
|
||||
special: n => `${n},回來了呢。我一直在等你。`,
|
||||
},
|
||||
},
|
||||
long_work: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},已經連續工作兩小時了。要不要伸展一下?`,
|
||||
familiar: n => `${n},兩小時了喔。去喝口水再回來也不遲。`,
|
||||
close: n => `${n},工作兩小時了。起來動一動吧……我是為了進度著想。`,
|
||||
special: n => `${n},已經兩小時了。休息一下好嗎?我會在這裡等你。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},已經工作兩小時了!要不要休息一下?`,
|
||||
familiar: n => `${n},兩小時了!站起來伸個懶腰吧!`,
|
||||
close: n => `${n}~兩小時啦!我們喝點東西休息一下吧!`,
|
||||
special: n => `${n},兩小時了!一起休息一下,好不好?`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},已連續工作兩小時,可以休息一下。`,
|
||||
familiar: n => `${n},兩小時了。起身走走對身體好。`,
|
||||
close: n => `${n},工作兩小時了。休息一會兒再繼續吧。`,
|
||||
special: n => `${n},兩小時了。讓眼睛和肩膀休息一下吧。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},已經工作兩小時,記得休息。`,
|
||||
familiar: n => `${n},兩小時了,喝杯水休息一下吧。`,
|
||||
close: n => `${n},辛苦了,休息一下再繼續吧。`,
|
||||
special: n => `${n},你很努力了。休息一下,好嗎?`,
|
||||
},
|
||||
},
|
||||
late_night: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},現在很晚了。注意休息。`,
|
||||
familiar: n => `${n},這個時間了。睡眠也算進度的一部分喔。`,
|
||||
close: n => `${n},已經很晚了。早點睡……明天再做也來得及。`,
|
||||
special: n => `${n},夜深了。我會擔心的,早點休息吧。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},已經很晚了!要注意休息喔!`,
|
||||
familiar: n => `${n},好晚了!明天再繼續也可以喔!`,
|
||||
close: n => `${n}~很晚啦!我們今天先收工,好不好?`,
|
||||
special: n => `${n},夜深了,好好睡一覺,明天再一起加油!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},時間不早了,請留意休息。`,
|
||||
familiar: n => `${n},夜深了。找個好停點就休息吧。`,
|
||||
close: n => `${n},很晚了。存好進度,晚安。`,
|
||||
special: n => `${n},夜深了。今天辛苦了,好好睡吧。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},時間很晚了,請注意休息。`,
|
||||
familiar: n => `${n},夜深了,早點休息吧。`,
|
||||
close: n => `${n},很晚了,今天先到這裡吧。`,
|
||||
special: n => `${n},夜深了,我希望你能睡個好覺。`,
|
||||
},
|
||||
},
|
||||
tool_failures: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},連續失敗了幾次。換個角度看看吧。`,
|
||||
familiar: n => `${n},卡住了呢。深呼吸,我們一起看看哪裡不對。`,
|
||||
close: n => `${n},別急。失敗幾次很正常,我陪你查。`,
|
||||
special: n => `${n},沒關係的。我們一步一步來,我在這裡。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},連續失敗了幾次,我們換個方法試試!`,
|
||||
familiar: n => `${n},卡關了呢!沒問題,我們再來一次!`,
|
||||
close: n => `${n}~別灰心!我們一起找出原因吧!`,
|
||||
special: n => `${n},沒事的!有我在,我們一定可以解決!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},連續失敗數次。建議先檢查錯誤訊息。`,
|
||||
familiar: n => `${n},先停一下,確認錯誤原因再繼續。`,
|
||||
close: n => `${n},別著急。我們從錯誤訊息開始慢慢排查。`,
|
||||
special: n => `${n},沒關係。先喝口水,我陪你一起看。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},連續失敗了幾次,換個方式試試看吧。`,
|
||||
familiar: n => `${n},遇到困難了呢,一起想想辦法。`,
|
||||
close: n => `${n},不要緊,我們慢慢找原因。`,
|
||||
special: n => `${n},沒關係,一步一步來,我陪著你。`,
|
||||
},
|
||||
},
|
||||
quota_warning: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},額度快用完了。請留意。`,
|
||||
familiar: n => `${n},額度或 context 快滿了。先整理重點比較好。`,
|
||||
close: n => `${n},額度快見底了。先把重要的事做完吧。`,
|
||||
special: n => `${n},額度快滿了。我們優先處理最重要的部分。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},額度快用完了,請注意喔!`,
|
||||
familiar: n => `${n},額度快滿了!先做最重要的吧!`,
|
||||
close: n => `${n}~額度快見底啦!先挑重點做吧!`,
|
||||
special: n => `${n},額度快滿了!我們把最重要的先完成!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},額度或 context 接近上限。`,
|
||||
familiar: n => `${n},額度接近上限,建議先整理進度。`,
|
||||
close: n => `${n},額度快滿了。先收尾手上的事吧。`,
|
||||
special: n => `${n},額度接近上限。我們先把重點記下來。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},額度或 context 快滿了,請留意。`,
|
||||
familiar: n => `${n},額度快滿了,先整理一下進度吧。`,
|
||||
close: n => `${n},額度快見底了,先處理重要的事吧。`,
|
||||
special: n => `${n},額度快滿了,我們把重點先收好。`,
|
||||
},
|
||||
},
|
||||
todo_due: {
|
||||
tsundere: {
|
||||
stranger: (n, x) => `${n},「${x}」時間到了。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了喔。`,
|
||||
close: (n, x) => `${n},「${x}」該處理了。我有幫你記著。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了。需要我幫忙嗎?`,
|
||||
},
|
||||
genki: {
|
||||
stranger: (n, x) => `${n},「${x}」時間到囉!`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間啦!`,
|
||||
close: (n, x) => `${n}~「${x}」時間到!要開始了嗎?`,
|
||||
special: (n, x) => `${n},「${x}」時間到了!我們一起處理吧!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: (n, x) => `${n},「${x}」已到期。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了。`,
|
||||
close: (n, x) => `${n},「${x}」到時間了,方便時處理即可。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了,我來陪你。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: (n, x) => `${n},「${x}」到期了。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了,記得處理。`,
|
||||
close: (n, x) => `${n},「${x}」到時間了,有空時處理就好。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了,需要的話我陪你。`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 台詞查詢(供測試與內容檢查) */
|
||||
export function line(trigger: Trigger, attr: Attribute, stage: Stage, nickname: string, extra = ''): string {
|
||||
return L[trigger][attr][stage](nickname, extra)
|
||||
}
|
||||
|
||||
/** 想關心的情境:下次送出訊息時注入,不額外觸發模型回合 */
|
||||
export function injectContext(trigger: Trigger, text: string): string {
|
||||
return `[care:${trigger}] 若自然,請在這次回覆中順帶表達這份關心(勿責備、勿施加罪惡感):${text}`
|
||||
}
|
||||
|
||||
const DELIVERY: Record<Trigger, Delivery> = {
|
||||
session_start: 'toast',
|
||||
idle_return: 'toast',
|
||||
long_work: 'inject',
|
||||
late_night: 'inject',
|
||||
tool_failures: 'inject',
|
||||
quota_warning: 'toast',
|
||||
todo_due: 'toast',
|
||||
}
|
||||
|
||||
const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 })
|
||||
|
||||
export interface CareEngine {
|
||||
/** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */
|
||||
onUserTurn(): Promise<string[]>
|
||||
/** 工具結果:連續失敗計數 */
|
||||
onToolResult(ok: boolean): Promise<void>
|
||||
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
|
||||
tick(signals?: Signals): Promise<Care[]>
|
||||
/** /cockpit quiet [分鐘],0 取消靜音 */
|
||||
quiet(minutes: number): Promise<void>
|
||||
state(): Promise<CareState>
|
||||
}
|
||||
|
||||
export interface CareDeps {
|
||||
fs: Fs
|
||||
clock: Clock
|
||||
path: string
|
||||
profile: () => CareProfile
|
||||
settings: () => CareSettings
|
||||
}
|
||||
|
||||
export function createCare(d: CareDeps): CareEngine {
|
||||
let cache: CareState | null = null
|
||||
const load = async () => (cache ??= { ...initial(), ...(await readJson<Partial<CareState>>(d.fs, d.path, {})) })
|
||||
const save = (s: CareState) => writeJson(d.fs, d.path, s)
|
||||
|
||||
const local = (t: number) => new Date(t + d.settings().tzOffsetMin * 60_000)
|
||||
const dayKey = (t: number) => local(t).toISOString().slice(0, 10)
|
||||
const hourOf = (t: number) => local(t).getUTCHours()
|
||||
const inRange = (h: number, a: number, b: number) => (a <= b ? h >= a && h < b : h >= a || h < b)
|
||||
|
||||
const muted = (s: CareState, now: number) => {
|
||||
if (now < s.quietUntil) return true
|
||||
const q = d.settings().quietHours
|
||||
return !!q && inRange(hourOf(now), q.start, q.end)
|
||||
}
|
||||
|
||||
/** 冷卻與每日上限檢查;通過才算一次關心 */
|
||||
const fire = (s: CareState, trigger: Trigger, now: number, extra = ''): Care | null => {
|
||||
const key = dayKey(now)
|
||||
if (s.day !== key) {
|
||||
s.day = key
|
||||
s.count = 0
|
||||
}
|
||||
const last = s.lastFired[trigger]
|
||||
if (last !== undefined && now - last < COOLDOWN_MS[trigger]) return null
|
||||
if (s.count >= dailyLimit(d.profile(), d.settings())) return null
|
||||
const p = d.profile()
|
||||
s.lastFired[trigger] = now
|
||||
s.count++
|
||||
const text = line(trigger, p.attribute, p.stage, p.nickname, extra)
|
||||
return { trigger, delivery: DELIVERY[trigger], text }
|
||||
}
|
||||
|
||||
// 條件成立的觸發(尚未套用冷卻與上限)
|
||||
const candidates = (s: CareState, now: number, sig: Signals): { t: Trigger; extra?: string }[] => {
|
||||
const out: { t: Trigger; extra?: string }[] = []
|
||||
if (sig.dueTodo) out.push({ t: 'todo_due', extra: sig.dueTodo })
|
||||
if (sig.quotaWarning) out.push({ t: 'quota_warning' })
|
||||
if (s.failStreak >= FAIL_THRESHOLD) out.push({ t: 'tool_failures' })
|
||||
if (inRange(hourOf(now), LATE.start, LATE.end) && s.lastActivity > 0) out.push({ t: 'late_night' })
|
||||
if (s.workStart > 0 && now - s.workStart >= LONG_WORK_MS) out.push({ t: 'long_work' })
|
||||
return out
|
||||
}
|
||||
|
||||
return {
|
||||
async onUserTurn() {
|
||||
const s = await load()
|
||||
const now = d.clock()
|
||||
const first = s.lastActivity === 0
|
||||
const returned = !first && now - s.lastActivity >= IDLE_MS
|
||||
if (first || returned) s.workStart = now
|
||||
if (first) s.lead = 'session_start'
|
||||
else if (returned) s.lead = 'idle_return'
|
||||
s.lastActivity = now
|
||||
const toInject: Care[] = []
|
||||
if (!muted(s, now)) {
|
||||
for (const { t, extra } of candidates(s, now, {})) {
|
||||
if (DELIVERY[t] !== 'inject') continue
|
||||
const c = fire(s, t, now, extra)
|
||||
if (c) {
|
||||
toInject.push(c)
|
||||
if (t === 'tool_failures') s.failStreak = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
await save(s)
|
||||
return toInject.map(c => injectContext(c.trigger, c.text))
|
||||
},
|
||||
|
||||
async onToolResult(ok) {
|
||||
const s = await load()
|
||||
s.failStreak = ok ? 0 : s.failStreak + 1
|
||||
await save(s)
|
||||
},
|
||||
|
||||
async tick(sig = {}) {
|
||||
const s = await load()
|
||||
const now = d.clock()
|
||||
// 閒置時間不算連續工作
|
||||
if (s.lastActivity > 0 && now - s.lastActivity >= IDLE_MS) s.workStart = 0
|
||||
// 模型回覆中或使用者輸入中,一律不彈出(也不消耗冷卻與上限)
|
||||
if (sig.replying || sig.typing || muted(s, now)) {
|
||||
await save(s)
|
||||
return []
|
||||
}
|
||||
const out: Care[] = []
|
||||
if (s.lead) {
|
||||
const c = fire(s, s.lead, now)
|
||||
s.lead = undefined
|
||||
if (c) out.push(c)
|
||||
}
|
||||
for (const { t, extra } of candidates(s, now, sig)) {
|
||||
if (DELIVERY[t] !== 'toast') continue
|
||||
const c = fire(s, t, now, extra)
|
||||
if (c) out.push(c)
|
||||
}
|
||||
await save(s)
|
||||
return out
|
||||
},
|
||||
|
||||
async quiet(minutes) {
|
||||
const s = await load()
|
||||
s.quietUntil = minutes > 0 ? d.clock() + minutes * 60_000 : 0
|
||||
await save(s)
|
||||
},
|
||||
|
||||
state: load,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 檔案系統與時鐘由外部注入,核心不直接碰真實環境
|
||||
export interface Fs {
|
||||
read(path: string): Promise<string | null>
|
||||
/** 原子寫入:先寫暫存檔再改名 */
|
||||
write(path: string, data: string): Promise<void>
|
||||
}
|
||||
|
||||
export type Clock = () => number
|
||||
|
||||
export function memoryFs(): Fs & { files: Map<string, string> } {
|
||||
const files = new Map<string, string>()
|
||||
return {
|
||||
files,
|
||||
async read(p) {
|
||||
return files.get(p) ?? null
|
||||
},
|
||||
async write(p, d) {
|
||||
files.set(p, d)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson<T>(fs: Fs, path: string, fallback: T): Promise<T> {
|
||||
const raw = await fs.read(path)
|
||||
if (raw === null) return fallback
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writeJson = (fs: Fs, path: string, value: unknown) => fs.write(path, JSON.stringify(value, null, 2))
|
||||
|
||||
export async function nodeFs(): Promise<Fs> {
|
||||
const { readFile, writeFile, rename, mkdir } = await import('node:fs/promises')
|
||||
const { dirname } = await import('node:path')
|
||||
return {
|
||||
async read(p) {
|
||||
try {
|
||||
return await readFile(p, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
async write(p, d) {
|
||||
await mkdir(dirname(p), { recursive: true })
|
||||
const tmp = `${p}.${process.pid}.tmp`
|
||||
await writeFile(tmp, d)
|
||||
await rename(tmp, p)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { memoryFs } from './io'
|
||||
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
|
||||
|
||||
const H = 3600_000
|
||||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
||||
|
||||
function setup() {
|
||||
let now = T0
|
||||
const fs = memoryFs()
|
||||
const mem = createMemory(fs, () => now, 'mem.json')
|
||||
return { mem, fs, advance: (ms: number) => void (now += ms), now: () => now }
|
||||
}
|
||||
|
||||
describe('記憶檢索', () => {
|
||||
test('相關性高者排前面', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'semantic', text: '使用者喜歡抹茶拿鐵', importance: 5, source: 'u' })
|
||||
await s.mem.add({ layer: 'semantic', text: '使用者養了一隻貓', importance: 5, source: 'u' })
|
||||
const r = await s.mem.recall('抹茶')
|
||||
assert.equal(r[0]!.text, '使用者喜歡抹茶拿鐵')
|
||||
})
|
||||
|
||||
test('較新、較重要者在其他條件相同時勝出', async () => {
|
||||
const s = setup()
|
||||
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
s.advance(100 * H)
|
||||
const fresh = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
assert.equal((await s.mem.recall('部署'))[0]!.id, fresh.id)
|
||||
assert.notEqual(old.id, fresh.id)
|
||||
const s2 = setup()
|
||||
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
|
||||
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
|
||||
assert.equal((await s2.mem.recall('測試'))[0]!.id, hi.id)
|
||||
})
|
||||
|
||||
test('情緒吻合度影響排序', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } })
|
||||
const happy = await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: 0.8, a: 0.5, d: 0.5 } })
|
||||
assert.equal((await s.mem.recall('慶祝', { p: 0.7, a: 0.4, d: 0.4 }))[0]!.id, happy.id)
|
||||
})
|
||||
|
||||
test('最多取前 6 筆,並累計取用次數', async () => {
|
||||
const s = setup()
|
||||
for (let i = 0; i < 10; i++) await s.mem.add({ layer: 'semantic', text: `筆記${i}`, importance: 5, source: 'u' })
|
||||
const r = await s.mem.recall('筆記')
|
||||
assert.equal(r.length, TOP_K)
|
||||
assert.ok((await s.mem.all()).filter(m => m.accessCount === 1).length === TOP_K)
|
||||
})
|
||||
|
||||
test('寫入前過濾疑似金鑰', async () => {
|
||||
const s = setup()
|
||||
const m = await s.mem.add({ layer: 'episodic', text: '金鑰是 sk-abcdefghijklmnop1234 請記住', importance: 3, source: 'u' })
|
||||
assert.ok(!m.text.includes('sk-abcdef'))
|
||||
assert.ok(m.text.includes('[已過濾]'))
|
||||
assert.equal(redact('普通的一句話'), '普通的一句話')
|
||||
})
|
||||
})
|
||||
|
||||
describe('遺忘曲線', () => {
|
||||
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
|
||||
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
|
||||
const weak = { ...base, importance: 2, accessCount: 0 }
|
||||
const strong = { ...base, importance: 8, accessCount: 0 }
|
||||
const used = { ...weak, accessCount: 5 }
|
||||
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
|
||||
assert.ok(retention(strong, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(retention(used, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(stability(strong) > stability(weak))
|
||||
})
|
||||
|
||||
test('鞏固時淘汰低保留率的記憶,保留重要與常用的', async () => {
|
||||
const s = setup()
|
||||
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' })
|
||||
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u' })
|
||||
s.advance(10 * 24 * H)
|
||||
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
|
||||
const r = await s.mem.consolidate({ force: true })
|
||||
assert.equal(r.forgotten, 1)
|
||||
const ids = (await s.mem.all()).map(m => m.id)
|
||||
assert.ok(!ids.includes(trivial.id))
|
||||
assert.ok(ids.includes(key.id))
|
||||
})
|
||||
|
||||
test('取用過的記憶會重置時效而不被淘汰', async () => {
|
||||
const s = setup()
|
||||
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
|
||||
s.advance(40 * H)
|
||||
await s.mem.recall('常提起')
|
||||
s.advance(40 * H)
|
||||
await s.mem.consolidate({ force: true })
|
||||
assert.ok((await s.mem.all()).some(x => x.id === m.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('鞏固與反思', () => {
|
||||
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
|
||||
assert.deepEqual(await s.mem.consolidate(), { ran: false, forgotten: 0 })
|
||||
for (let i = 0; i < 5; i++) await s.mem.add({ layer: 'episodic', text: `事件${i}`, importance: 5, source: 'u' })
|
||||
assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
|
||||
const r = await s.mem.consolidate()
|
||||
assert.equal(r.ran, true)
|
||||
assert.equal(await s.mem.pending(), 0)
|
||||
assert.equal(r.reflection?.layer, 'reflection')
|
||||
})
|
||||
|
||||
test('手動 reflect(force)即使未達門檻也執行,並使用注入的摘要', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '一起修好了建置', importance: 4, source: 'u' })
|
||||
const r = await s.mem.consolidate({ force: true, summarize: items => `反思:${items.length} 件事` })
|
||||
assert.equal(r.ran, true)
|
||||
assert.equal(r.reflection?.text, '反思:1 件事')
|
||||
assert.ok(r.reflection!.source.startsWith('reflect:'))
|
||||
})
|
||||
|
||||
test('反思不重複涵蓋舊事件,也不遞迴反思反思', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '甲', importance: 4, source: 'u' })
|
||||
await s.mem.consolidate({ force: true })
|
||||
s.advance(H)
|
||||
await s.mem.add({ layer: 'episodic', text: '乙', importance: 4, source: 'u' })
|
||||
const r = await s.mem.consolidate({ force: true, summarize: items => items.map(i => i.text).join('+') })
|
||||
assert.equal(r.reflection?.text, '乙')
|
||||
})
|
||||
|
||||
test('沒有新記憶時鞏固不產生空反思', async () => {
|
||||
const s = setup()
|
||||
const r = await s.mem.consolidate({ force: true })
|
||||
assert.equal(r.reflection, undefined)
|
||||
})
|
||||
|
||||
test('資料經檔案持久化,重建後可繼續', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 5, source: 'u' })
|
||||
const again = createMemory(s.fs, s.now, 'mem.json')
|
||||
assert.equal((await again.recall('貓'))[0]!.text, '喜歡貓')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Clock, Fs } from './io'
|
||||
import { readJson, writeJson } from './io'
|
||||
|
||||
export interface Todo {
|
||||
id: number
|
||||
title: string
|
||||
done: boolean
|
||||
/** 承諾事項:助理答應記住的事 */
|
||||
promise: boolean
|
||||
dueAt?: number
|
||||
createdAt: number
|
||||
doneAt?: number
|
||||
/** 已提醒過,避免重複 */
|
||||
reminded?: boolean
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
add(title: string, opts?: { promise?: boolean; dueAt?: number }): Promise<Todo>
|
||||
complete(id: number): Promise<Todo | null>
|
||||
list(opts?: { all?: boolean }): Promise<Todo[]>
|
||||
/** 角色行顯示的待辦數(未完成) */
|
||||
open(): Promise<number>
|
||||
/** 取出到期且尚未提醒的項目,並標記為已提醒 */
|
||||
due(): Promise<Todo[]>
|
||||
}
|
||||
|
||||
export function createTodos(fs: Fs, clock: Clock, path: string): TodoList {
|
||||
const load = () => readJson<Todo[]>(fs, path, [])
|
||||
const save = (t: Todo[]) => writeJson(fs, path, t)
|
||||
return {
|
||||
async add(title, opts = {}) {
|
||||
const all = await load()
|
||||
const t: Todo = {
|
||||
id: all.reduce((m, x) => Math.max(m, x.id), 0) + 1,
|
||||
title: title.trim(),
|
||||
done: false,
|
||||
promise: !!opts.promise,
|
||||
dueAt: opts.dueAt,
|
||||
createdAt: clock(),
|
||||
}
|
||||
all.push(t)
|
||||
await save(all)
|
||||
return t
|
||||
},
|
||||
async complete(id) {
|
||||
const all = await load()
|
||||
const t = all.find(x => x.id === id)
|
||||
if (!t || t.done) return t ?? null
|
||||
t.done = true
|
||||
t.doneAt = clock()
|
||||
await save(all)
|
||||
return t
|
||||
},
|
||||
async list(opts = {}) {
|
||||
const all = await load()
|
||||
return opts.all ? all : all.filter(t => !t.done)
|
||||
},
|
||||
async open() {
|
||||
return (await load()).filter(t => !t.done).length
|
||||
},
|
||||
async due() {
|
||||
const all = await load()
|
||||
const now = clock()
|
||||
const hit = all.filter(t => !t.done && !t.reminded && t.dueAt !== undefined && t.dueAt <= now)
|
||||
if (hit.length) {
|
||||
for (const t of hit) t.reminded = true
|
||||
await save(all)
|
||||
}
|
||||
return hit
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user