Files
jsc-marketplace/plugins/jsc-mod-companion/hooks/companion/care.ts
T
rootandClaude Sonnet 5.5 ea17f1cb2d refactor: jsc-mod 改名 jsc-mod-statusline、jsc-companion 改名 jsc-mod-companion,指令拆成獨立指令
- 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>
2026-10-14 05:45:52 +00:00

549 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}