refactor: statusbar、companion、herdr 外掛改名加上 jsc- 前綴

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-13 07:43:50 +00:00
co-authored by Claude Sonnet 5.5
parent f10b168c28
commit 58e2b2d26a
53 changed files with 28 additions and 25 deletions
@@ -0,0 +1,548 @@
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[]>
/** /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,
}
}