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
+241
View File
@@ -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('交報告'))
})
})