import type { Clock, Fs } from './io' import { readJson, writeJson } from './io' import { isVerifyCommand } from './verify' export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done' 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 /** 工作事件反應的疲乏:每次反應增加的量;預設由外向性推得 */ fatigueRate?: number /** 疲乏達此值就暫停反應,衰減回門檻以下才恢復;預設由外向性推得 */ fatigueCap?: number /** 各觸發的頻率倍率(>1 更常、<1 更少),疊加在屬性預設之上 */ freq?: Partial> } export interface CareSettings { /** 使用者設定的每日上限 */ dailyCap: number tzOffsetMin: number /** 靜音時段(本地小時),可跨午夜 */ quietHours?: { start: number; end: number } } export interface CareState { lastFired: Partial> day: string count: number quietUntil: number workStart: number lastActivity: number failStreak: number /** 待送出的開場關心(會話開始或閒置回來),由 tick 在可打擾時才發出 */ lead?: Trigger /** 曾失敗、尚未成功的驗證指令(同一指令成功才算轉通過) */ failedCmds?: string[] /** 工作事件排隊中的反應,tick 在可打擾時才發出;過期不補 */ pending?: { t: Trigger; at: number }[] /** 工作事件反應的疲乏值,隨時間減半衰減 */ fatigue?: { level: number; at: number } } 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 = { 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, test_recovered: 10 * 60_000, turn_done: 20 * 60_000, } /** 會讓角色疲乏的工作事件反應:越常說越容易膩 */ const TIRING: Trigger[] = ['test_recovered', 'turn_done'] export const FATIGUE_HALF_LIFE_MS = 12 * 3600_000 export const fatigueRate = (p: CareProfile) => p.fatigueRate ?? 1.5 - p.extraversion / 100 export const fatigueCap = (p: CareProfile) => p.fatigueCap ?? 1.5 + p.extraversion / 25 /** 排隊中的工作事件超過這段時間就不補發 */ export const PENDING_TTL_MS = 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 = { stranger: 2, familiar: 4, close: 6, special: 8 } /** 使用者設定與系統狀態的提醒,不因個性變得更少或更慢 */ const FIXED_FREQ: Trigger[] = ['todo_due', 'quota_warning'] const CARING: Trigger[] = ['long_work', 'late_night', 'tool_failures'] const CELEBRATING: Trigger[] = ['test_recovered', 'turn_done'] // 屬性的主動傾向:base 是整體倍率,其餘為個別觸發的倍率 const ATTR_FREQ: Record = { genki: { base: 1.3 }, tsundere: { base: 0.8, caring: 1 }, calm: { base: 0.7 }, gentle: { base: 1, caring: 1.3, celebrating: 0.6 }, } /** 這個角色對某種觸發主動的程度:1 為基準,越大越常說 */ export function frequency(p: CareProfile, trigger: Trigger): number { if (FIXED_FREQ.includes(trigger)) return 1 const a = ATTR_FREQ[p.attribute] const kind = CARING.includes(trigger) ? a.caring : CELEBRATING.includes(trigger) ? a.celebrating : undefined return (kind ?? a.base) * (0.5 + p.extraversion / 100) * (p.freq?.[trigger] ?? 1) } /** 好感階段、外向性與屬性決定每日關心上限,再受使用者設定封頂 */ export function dailyLimit(p: CareProfile, s: CareSettings): number { const factor = (0.5 + p.extraversion / 100) * ATTR_FREQ[p.attribute].base 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>> = { 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}」到時間了,需要的話我陪你。`, }, }, test_recovered: { 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},通過了呢,我真為你高興。`, }, }, turn_done: { 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},完成了呢,我們一起休息一下吧。`, }, }, } /** 台詞查詢(供測試與內容檢查) */ 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 = { session_start: 'toast', idle_return: 'toast', long_work: 'inject', late_night: 'inject', tool_failures: 'inject', quota_warning: 'toast', todo_due: 'toast', test_recovered: 'toast', turn_done: 'toast', } const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 }) export interface CareEngine { /** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */ onUserTurn(): Promise /** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */ onToolResult(ok: boolean, command?: string): Promise /** 一輪回覆結束;有呼叫過工具才算做完事 */ onTurnComplete(usedTools: boolean): Promise /** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */ tick(signals?: Signals): Promise /** /companion quiet [分鐘],0 取消靜音 */ quiet(minutes: number): Promise state(): Promise } 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>(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 p = d.profile() const tiring = TIRING.includes(trigger) const tired = tiring && s.fatigue ? s.fatigue.level * Math.pow(0.5, (now - s.fatigue.at) / FATIGUE_HALF_LIFE_MS) : 0 if (tiring && tired >= fatigueCap(p)) return null const last = s.lastFired[trigger] if (last !== undefined && now - last < (COOLDOWN_MS[trigger] * (1 + tired)) / frequency(p, trigger)) return null if (s.count >= dailyLimit(p, d.settings())) return null if (tiring) s.fatigue = { level: tired + fatigueRate(p), at: now } 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, command) { const s = await load() s.failStreak = ok ? 0 : s.failStreak + 1 const cmd = command?.trim() let recovered = false if (cmd && isVerifyCommand(cmd)) { const failed = new Set(s.failedCmds ?? []) if (!ok) failed.add(cmd) else if (failed.delete(cmd)) { recovered = true s.pending = [...(s.pending ?? []), { t: 'test_recovered', at: d.clock() }] } s.failedCmds = [...failed].slice(-10) } await save(s) return recovered }, async onTurnComplete(usedTools) { if (!usedTools) return const s = await load() s.pending = [...(s.pending ?? []), { t: 'turn_done', at: d.clock() }] 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) } // 工作事件:同一次 tick 只發一則,其餘當作已過去 const waiting = (s.pending ?? []).filter(p => now - p.at < PENDING_TTL_MS) s.pending = [] const work = waiting.find(p => p.t === 'test_recovered') ?? waiting[0] if (work) { const c = fire(s, work.t, now) 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, } }