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
+415
View File
@@ -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,
}
}