feat: companion 工作事件反應與個性化主動頻率 #17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "companion",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user