diff --git a/plugins/companion/.claude-plugin/plugin.json b/plugins/companion/.claude-plugin/plugin.json index b3432a4..e79579f 100644 --- a/plugins/companion/.claude-plugin/plugin.json +++ b/plugins/companion/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "companion", - "version": "0.0.2", + "version": "0.0.3", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", "license": "MIT", "author": { diff --git a/plugins/companion/hooks/companion/care.test.ts b/plugins/companion/hooks/companion/care.test.ts index 3699b3f..8656e05 100644 --- a/plugins/companion/hooks/companion/care.test.ts +++ b/plugins/companion/hooks/companion/care.test.ts @@ -2,7 +2,7 @@ import { describe, test } from 'claude-code/testing' import assert from './assert' import { memoryFs } from './io' -import { COOLDOWN_MS, createCare, dailyLimit, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care' +import { COOLDOWN_MS, createCare, dailyLimit, frequency, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care' import { createTodos } from './todo' const MIN = 60_000 @@ -239,3 +239,177 @@ describe('待辦', () => { assert.ok(out[0]!.text.includes('交報告')) }) }) + +describe('關心:工作事件反應', () => { + async function started() { + const s = setup() + await s.care.onUserTurn() + await s.care.tick() // 清掉開場問候 + s.advance(10 * MIN) + return s + } + + test('同一個驗證指令先失敗後成功,tick 以 toast 反應', async () => { + const s = await started() + await s.care.onToolResult(false, 'bun test') + await s.care.onToolResult(true, 'bun test') + const out = await s.care.tick() + assert.deepEqual(out.map(c => c.trigger), ['test_recovered']) + assert.equal(out[0]!.delivery, 'toast') + assert.ok(out[0]!.text.includes('阿JSC')) + }) + + test('不同指令的失敗與成功不算轉通過', async () => { + const s = await started() + await s.care.onToolResult(false, 'bun test') + await s.care.onToolResult(true, 'tsc -p .') + assert.deepEqual(await s.care.tick(), []) + }) + + test('非驗證指令的失敗後成功不反應', async () => { + const s = await started() + await s.care.onToolResult(false, 'grep foo src') + await s.care.onToolResult(true, 'grep foo src') + assert.deepEqual(await s.care.tick(), []) + }) + + test('模型回覆中不彈出,之後仍會補上', async () => { + const s = await started() + await s.care.onToolResult(false, 'npm test') + await s.care.onToolResult(true, 'npm test') + assert.deepEqual(await s.care.tick({ replying: true }), []) + assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['test_recovered']) + }) +}) + +describe('關心:一輪完成與疲乏', () => { + // special 階段每日上限高,讓測試只觀察疲乏而非每日上限 + // 抵銷屬性與外向性的頻率倍率,只留下疲乏的效果 + const neutral = (p: Omit): CareProfile['freq'] => ({ turn_done: 1 / frequency({ ...p, nickname: '' }, 'turn_done') }) + async function started(extraversion: number) { + const base = { extraversion, stage: 'special', attribute: 'genki' } as const + const s = setup({ profile: { ...base, freq: neutral(base) } }) + await s.care.onUserTurn() + await s.care.tick() + s.advance(10 * MIN) + const attempt = async () => { + await s.care.onTurnComplete(true) + return (await s.care.tick()).filter(c => c.trigger === 'turn_done').length + } + return { s, attempt } + } + + test('沒有呼叫過工具的回合不算做完事', async () => { + const { s } = await started(50) + await s.care.onTurnComplete(false) + assert.deepEqual(await s.care.tick(), []) + }) + + test('有呼叫工具的回合結束,以 toast 反應', async () => { + const { s } = await started(50) + await s.care.onTurnComplete(true) + const out = await s.care.tick() + assert.deepEqual(out.map(c => c.trigger), ['turn_done']) + assert.equal(out[0]!.delivery, 'toast') + }) + + test('每反應一次,下次的冷卻就更長', async () => { + const { s, attempt } = await started(10) // rate 1.4 → 第二次冷卻 20×2.4=48 分 + assert.equal(await attempt(), 1) + s.advance(25 * MIN) + assert.equal(await attempt(), 0, '基礎冷卻 20 分已過,但疲乏使冷卻拉長') + s.advance(25 * MIN) + assert.equal(await attempt(), 1, '累計 50 分,過了拉長後的冷卻') + }) + + test('內向角色疲乏到門檻就暫停,休息夠久後恢復', async () => { + const { s, attempt } = await started(10) + let fired = 0 + for (let i = 0; i < 12; i++) { + s.advance(30 * MIN) + fired += await attempt() + } + assert.ok(fired <= 3, `6 小時內最多反應 3 次,實際 ${fired}`) + assert.equal(await attempt(), 0, '仍在暫停中') + s.advance(24 * H) + assert.equal(await attempt(), 1, '休息一天後恢復') + }) + + test('外向角色比內向角色更常反應', async () => { + const count = async (ext: number) => { + const { s, attempt } = await started(ext) + let fired = 0 + for (let i = 0; i < 16; i++) { + s.advance(30 * MIN) + fired += await attempt() + } + return fired + } + assert.ok((await count(100)) > (await count(10))) + }) + + test('角色資料可覆寫疲乏參數', async () => { + const base = { extraversion: 50, stage: 'special', attribute: 'genki' } as const + const s = setup({ profile: { ...base, freq: neutral(base), fatigueRate: 0.1, fatigueCap: 99 } }) + await s.care.onUserTurn() + await s.care.tick() + let fired = 0 + for (let i = 0; i < 6; i++) { + s.advance(30 * MIN) + await s.care.onTurnComplete(true) + fired += (await s.care.tick()).filter(c => c.trigger === 'turn_done').length + } + assert.ok(fired >= 5, `幾乎不疲乏,實際 ${fired}`) + }) +}) + +describe('關心:個性決定主動頻率', () => { + const prof = (attribute: Attribute, extraversion = 50, freq?: CareProfile['freq']): CareProfile => ({ attribute, extraversion, stage: 'close', nickname: 'n', freq }) + + test('待辦到期與額度警告不受個性影響', () => { + for (const t of ['todo_due', 'quota_warning'] as const) { + for (const a of ['tsundere', 'genki', 'calm', 'gentle'] as const) assert.equal(frequency(prof(a, 10), t), 1, `${a}/${t}`) + } + }) + + test('genki 比 calm 更常主動', () => { + for (const t of ['turn_done', 'long_work', 'session_start'] as const) assert.ok(frequency(prof('genki'), t) > frequency(prof('calm'), t), t) + }) + + test('gentle 關心多、慶祝少', () => { + const g = prof('gentle') + assert.ok(frequency(g, 'late_night') > frequency(g, 'turn_done')) + assert.ok(frequency(g, 'tool_failures') > frequency(g, 'test_recovered')) + }) + + test('外向性放大頻率,內向縮小', () => { + assert.ok(frequency(prof('calm', 90), 'long_work') > frequency(prof('calm', 10), 'long_work')) + }) + + test('角色資料的倍率覆寫預設', () => { + assert.equal(frequency(prof('calm', 50, { turn_done: 2 }), 'turn_done'), frequency(prof('calm'), 'turn_done') * 2) + }) + + test('頻率倍率縮放冷卻:gentle 的連續失敗關心比 calm 更快再來', async () => { + const run = async (attribute: Attribute) => { + const s = setup({ profile: { attribute, stage: 'special' } }) + await s.care.onUserTurn() + const fail3 = async () => { + for (let i = 0; i < 3; i++) await s.care.onToolResult(false) + return (await s.care.onUserTurn()).length + } + s.advance(MIN) + assert.equal(await fail3(), 1) + s.advance(30 * MIN) // 基礎冷卻 30 分:gentle 約 23 分已過,calm 約 43 分未過 + return fail3() + } + assert.equal(await run('gentle'), 1) + assert.equal(await run('calm'), 0) + }) + + test('每日上限隨屬性調整,且仍受使用者設定封頂', () => { + const set = (dailyCap: number): CareSettings => ({ dailyCap, tzOffsetMin: 0 }) + assert.ok(dailyLimit(prof('genki'), set(99)) > dailyLimit(prof('calm'), set(99))) + assert.equal(dailyLimit(prof('genki', 100), set(2)), 2) + }) +}) diff --git a/plugins/companion/hooks/companion/care.ts b/plugins/companion/hooks/companion/care.ts index 7591ed9..0d5ca94 100644 --- a/plugins/companion/hooks/companion/care.ts +++ b/plugins/companion/hooks/companion/care.ts @@ -1,7 +1,8 @@ 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' +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' @@ -12,6 +13,12 @@ export interface CareProfile { extraversion: number stage: Stage nickname: string + /** 工作事件反應的疲乏:每次反應增加的量;預設由外向性推得 */ + fatigueRate?: number + /** 疲乏達此值就暫停反應,衰減回門檻以下才恢復;預設由外向性推得 */ + fatigueCap?: number + /** 各觸發的頻率倍率(>1 更常、<1 更少),疊加在屬性預設之上 */ + freq?: Partial> } export interface CareSettings { @@ -32,6 +39,12 @@ export interface CareState { failStreak: number /** 待送出的開場關心(會話開始或閒置回來),由 tick 在可打擾時才發出 */ lead?: Trigger + /** 曾失敗、尚未成功的驗證指令(同一指令成功才算轉通過) */ + failedCmds?: string[] + /** 工作事件排隊中的反應,tick 在可打擾時才發出;過期不補 */ + pending?: { t: Trigger; at: number }[] + /** 工作事件反應的疲乏值,隨時間減半衰減 */ + fatigue?: { level: number; at: number } } export interface Care { @@ -59,8 +72,20 @@ export const COOLDOWN_MS: Record = { 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 @@ -69,9 +94,30 @@ 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 + 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))) } @@ -260,6 +306,58 @@ const L: Record>> = { 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},完成了呢,我們一起休息一下吧。`, + }, + }, } /** 台詞查詢(供測試與內容檢查) */ @@ -280,6 +378,8 @@ const DELIVERY: Record = { 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 }) @@ -287,8 +387,10 @@ const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil export interface CareEngine { /** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */ onUserTurn(): Promise - /** 工具結果:連續失敗計數 */ - onToolResult(ok: boolean): Promise + /** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */ + onToolResult(ok: boolean, command?: string): Promise + /** 一輪回覆結束;有呼叫過工具才算做完事 */ + onTurnComplete(usedTools: boolean): Promise /** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */ tick(signals?: Signals): Promise /** /companion quiet [分鐘],0 取消靜音 */ @@ -327,10 +429,14 @@ export function createCare(d: CareDeps): CareEngine { 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() + 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) @@ -373,9 +479,28 @@ export function createCare(d: CareDeps): CareEngine { return toInject.map(c => injectContext(c.trigger, c.text)) }, - async onToolResult(ok) { + 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) }, @@ -395,6 +520,14 @@ export function createCare(d: CareDeps): CareEngine { 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) diff --git a/plugins/companion/hooks/companion/companion.test.ts b/plugins/companion/hooks/companion/companion.test.ts index 0ee2ff9..8f96bfe 100644 --- a/plugins/companion/hooks/companion/companion.test.ts +++ b/plugins/companion/hooks/companion/companion.test.ts @@ -358,3 +358,63 @@ describe('預設角色語氣', () => { expect(p).not.toContain('狐狸') }) }) + +describe('工作事件反應', () => { + test('同一個測試指令失敗後成功:toast 反應,心情變好', async () => { + const { c, clock } = setup() + await c.ensureActivated() + await c.onPrompt('開始跑測試修 bug') + await c.tick() + clock.advance(10 * 60_000) + await c.onToolResult(false, { command: 'bun test' }) + const before = (await c.view())!.mood.p + await c.onToolResult(true, { command: 'bun test' }) + expect((await c.view())!.mood.p).toBeGreaterThan(before) + expect((await c.tick()).map(x => x.trigger)).toEqual(['test_recovered']) + }) + + test('沒有轉通過的成功不改變心情', async () => { + const { c } = setup() + await c.ensureActivated() + const before = (await c.view())!.mood + await c.onToolResult(true, { command: 'bun test' }) + expect((await c.view())!.mood).toEqual(before) + }) + + test('有用過工具的回合結束:toast 反應並提升心情;純聊天不反應', async () => { + const { c, clock } = setup() + await c.ensureActivated() + await c.onPrompt('幫我整理一下目錄結構') + await c.tick() + clock.advance(10 * 60_000) + await c.onTurnComplete(false) + expect(await c.tick()).toEqual([]) + const before = (await c.view())!.mood.p + await c.onTurnComplete(true) + expect((await c.view())!.mood.p).toBeGreaterThan(before) + expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done']) + }) + + test('沒有啟用角色時事件被忽略', async () => { + const { c } = setup() + await c.ensureActivated() + await c.command('off') + await c.onToolResult(true, { command: 'bun test' }) + await c.onTurnComplete(true) + expect(await c.tick()).toEqual([]) + }) + + test('角色資料的 freq 會調整主動頻率', async () => { + const quiet = { ...MIKO, freq: { turn_done: 0.001 } } + const { c, clock } = setup([quiet]) + await c.ensureActivated() + await c.onPrompt('幫我整理一下目錄結構') + await c.tick() + clock.advance(10 * 60_000) + await c.onTurnComplete(true) + expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done']) + clock.advance(2 * 3600_000) + await c.onTurnComplete(true) + expect(await c.tick()).toEqual([]) + }) +}) diff --git a/plugins/companion/hooks/companion/core.ts b/plugins/companion/hooks/companion/core.ts index 3b796ae..c30a5c4 100644 --- a/plugins/companion/hooks/companion/core.ts +++ b/plugins/companion/hooks/companion/core.ts @@ -47,7 +47,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const core = await readJson(fs, `${dir(id)}/core.json`) const b = builtins.find(x => x.id === id) if (!core || !b) return core - return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion } + return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion, freq: core.freq ?? b.freq } } const getState = (id: string) => readJson(fs, `${dir(id)}/state.json`) const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s) @@ -201,7 +201,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const qs = hourOf(u.quiet.start) const qe = hourOf(u.quiet.end) ctx = { - profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname }, + profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq }, settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined }, } if (bundle?.id !== v.core.id) { @@ -245,11 +245,20 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan return b.care.onUserTurn() } - async function onToolResult(ok: boolean) { + async function onToolResult(ok: boolean, info: { command?: string } = {}) { const b = await bundleFor() if (!b) return - await b.care.onToolResult(ok) + const recovered = await b.care.onToolResult(ok, info.command) if (!ok) await moodEvent('toolFail') + else if (recovered) await moodEvent('success') + } + + // 一輪回覆結束;有用過工具才算做完事,心情變好並排隊一則反應 + async function onTurnComplete(usedTools: boolean) { + const b = await bundleFor() + if (!b || !usedTools) return + await b.care.onTurnComplete(usedTools) + await moodEvent('success') } // 定時檢查:到期待辦與額度警告等 toast 類關心;回覆中或輸入中不彈出 @@ -310,7 +319,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan return n > 0 ? `已靜音 ${n} 分鐘。` : '已取消靜音。' } - return { ensureActivated, view, moodEvent, affinityEvent, command, getUser, prompt, onPrompt, onToolResult, tick, end } + return { ensureActivated, view, moodEvent, affinityEvent, command, getUser, prompt, onPrompt, onToolResult, onTurnComplete, tick, end } } export type Companion = ReturnType diff --git a/plugins/companion/hooks/companion/types.ts b/plugins/companion/hooks/companion/types.ts index 06b565f..4d727cd 100644 --- a/plugins/companion/hooks/companion/types.ts +++ b/plugins/companion/hooks/companion/types.ts @@ -1,4 +1,4 @@ -import type { Attribute } from './care' +import type { Attribute, Trigger } from './care' export type Pad = { p: number; a: number; d: number } @@ -19,6 +19,8 @@ export type CompanionCore = { // 主動關心的語氣屬性與外向性(0–100),影響關心頻率與措辭 attribute?: Attribute extraversion?: number + // 各種主動關心的頻率倍率(>1 更常、<1 更少),疊加在屬性預設之上 + freq?: Partial> // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 neuroticism: number baseline: Pad diff --git a/plugins/companion/hooks/companion/verify.test.ts b/plugins/companion/hooks/companion/verify.test.ts new file mode 100644 index 0000000..1aaf020 --- /dev/null +++ b/plugins/companion/hooks/companion/verify.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { isVerifyCommand } from './verify' + +describe('isVerifyCommand', () => { + test('常見的測試與 build 指令算驗證指令', () => { + for (const cmd of ['bun test', 'npm test', 'npm run test -- --watch', 'pnpm build', 'pytest -q', 'tsc -p .', 'cargo build --release', 'go test ./...', 'cd app && npm run build']) { + expect(isVerifyCommand(cmd), cmd).toBe(true) + } + }) + + test('其他指令不算', () => { + for (const cmd of ['grep foo src', 'ls -la', 'git status', 'cat test.txt', 'echo build', '']) { + expect(isVerifyCommand(cmd), cmd).toBe(false) + } + }) +}) diff --git a/plugins/companion/hooks/companion/verify.ts b/plugins/companion/hooks/companion/verify.ts new file mode 100644 index 0000000..ac7f4f1 --- /dev/null +++ b/plugins/companion/hooks/companion/verify.ts @@ -0,0 +1,16 @@ +// 辨識「驗證類」指令(測試、型別檢查、build),失敗後轉成功才值得角色反應 +const VERIFY: RegExp[] = [ + /^(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build|typecheck|lint)\b/, + /^(?:bun|npm|pnpm|yarn)\s+test\b/, + /^(?:pytest|tsc|vitest|jest|mypy|ruff)\b/, + /^cargo\s+(?:test|build|check)\b/, + /^go\s+(?:test|build|vet)\b/, +] + +// 以 && ; | 拆開複合指令,任何一段是驗證指令就算 +export function isVerifyCommand(command: string): boolean { + return command + .split(/&&|\|\||;|\|/) + .map(s => s.trim()) + .some(s => VERIFY.some(re => re.test(s))) +} diff --git a/plugins/companion/hooks/register.tsx b/plugins/companion/hooks/register.tsx index 619ef51..05ada52 100644 --- a/plugins/companion/hooks/register.tsx +++ b/plugins/companion/hooks/register.tsx @@ -6,6 +6,7 @@ import { engineFs } from './companion/engine-fs' let companion: ReturnType | undefined let replying = false +let usedTools = false let typedAt = 0 let timer: { cancel(): void } | undefined @@ -61,18 +62,22 @@ export const register: Register = on => { on('turn.start', async ($, e, next) => { replying = true + usedTools = false return next(e) }) on('turn.complete', async ($, e, next) => { replying = false + // 只有主迴圈正常答完、且這輪用過工具,才算做完一件事 + if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools).catch(() => {}) return next(e) }) // 連續工具失敗會觸發關心,並影響心情 on('tool.call', async ($, e, next) => { const r = await next(e) - companion?.onToolResult(!r.isError).catch(() => {}) + if (!e.agentId) usedTools = true + companion?.onToolResult(!r.isError, { command: e.tool === 'Bash' ? e.command : undefined }).catch(() => {}) return r })