- 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>
549 lines
22 KiB
TypeScript
549 lines
22 KiB
TypeScript
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<Record<Trigger, number>>
|
||
}
|
||
|
||
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
|
||
/** 曾失敗、尚未成功的驗證指令(同一指令成功才算轉通過) */
|
||
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<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,
|
||
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<Stage, number> = { 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<Attribute, { base: number; caring?: number; celebrating?: number }> = {
|
||
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<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}」到時間了,需要的話我陪你。`,
|
||
},
|
||
},
|
||
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<Trigger, Delivery> = {
|
||
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<string[]>
|
||
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
|
||
onToolResult(ok: boolean, command?: string): Promise<boolean>
|
||
/** 一輪回覆結束;有呼叫過工具才算做完事 */
|
||
onTurnComplete(usedTools: boolean): Promise<void>
|
||
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
|
||
tick(signals?: Signals): Promise<Care[]>
|
||
/** /jsc-mod-companion: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 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,
|
||
}
|
||
}
|