Merge pull request 'feat: companion 工作事件反應與個性化主動頻率' (#17) from feat/companion-event-reactions into main

Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/17
This commit was merged in pull request #17.
This commit is contained in:
2026-10-12 08:16:39 +00:00
9 changed files with 434 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "companion",
"version": "0.0.2",
"version": "0.0.3",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT",
"author": {
+175 -1
View File
@@ -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, 'nickname'>): 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)
})
})
+142 -9
View File
@@ -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<Record<Trigger, number>>
}
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<Trigger, number> = {
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<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
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<Trigger, Record<Attribute, Record<Stage, Line>>> = {
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<Trigger, Delivery> = {
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<string[]>
/** 工具結果:連續失敗計數 */
onToolResult(ok: boolean): Promise<void>
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
onToolResult(ok: boolean, command?: string): Promise<boolean>
/** 一輪回覆結束;有呼叫過工具才算做完事 */
onTurnComplete(usedTools: boolean): Promise<void>
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
tick(signals?: Signals): Promise<Care[]>
/** /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)
@@ -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([])
})
})
+14 -5
View File
@@ -47,7 +47,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const core = await readJson<CompanionCore>(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<CompanionState>(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<typeof createCompanion>
+3 -1
View File
@@ -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<Record<Trigger, number>>
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
neuroticism: number
baseline: Pad
@@ -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)
}
})
})
@@ -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)))
}
+6 -1
View File
@@ -6,6 +6,7 @@ import { engineFs } from './companion/engine-fs'
let companion: ReturnType<typeof createCompanion> | 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
})