diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 18c75bb..5caa027 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -62,6 +62,12 @@ "description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度", "default": true }, + "background": { + "type": "boolean", + "title": "背景工作線", + "description": "需要動手的要求交給背景子代理處理,對話線立刻回應、完成後由角色回報", + "default": true + }, "candidates": { "type": "string", "title": "候選模型", diff --git a/hooks/register.tsx b/hooks/register.tsx index 9f76ed1..ef9335d 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -243,9 +243,13 @@ async function quotaRemain($: Engine): Promise { } +async function pickRoute($: Engine, r: Route | undefined, o: Omit[1], 'quotaRemain'>) { + return choose(r ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }, { ...o, quotaRemain: await quotaRemain($) }) +} + // 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。 async function loadPacks($: Engine): Promise { - const dir = new URL('../characters/', import.meta.url).pathname + const dir = `${$.plugin.root}/characters/` try { if (!(await $.fs.exists(`${dir}index.json`))) return const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] } @@ -268,6 +272,8 @@ export const register: Register = (on, options) => { let active: string | undefined let toolRegistered = false let route: Route | undefined + let delegating = false + const jobs = new Map() let lastPick: { model: ModelName; effort: Effort } | undefined let prefixed = false const holder: { sys?: Sys } = {} @@ -291,6 +297,13 @@ export const register: Register = (on, options) => { return out } + const routeOpts = () => ({ + candidates: candidates(), + lockModel: lockOf('lockModel', ['haiku', 'sonnet', 'opus']), + lockEffort: lockOf('lockEffort', ['low', 'medium', 'high']), + downgradeAt: num('downgradeAt', 15), + }) + on('session.start', async ($, e, next) => { if (isEnabled) { await loadPacks($) @@ -373,6 +386,7 @@ export const register: Register = (on, options) => { } else { text += `\n${relationText(relationOf(c.timeline, 0, false))}` } + if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。' return { ...res, sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }], @@ -384,6 +398,9 @@ export const register: Register = (on, options) => { if (c && flag('todoRemind', true)) await remindDue($, c.name) if (!c || e.command || e.isMeta) return next(e) prefixed = false + delegating = false + // 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。 + if (e.origin?.kind === 'plugin') return next(e) const axes = applyOverrides(c.axes, overrides()) if (routingOn()) { const res = await $.model @@ -391,6 +408,17 @@ export const register: Register = (on, options) => { .catch(() => undefined) route = parseRoute(res && res.isAnswered ? res.text : '') if (route.ack) $.ui.log(withPrefix(c.name, route.ack)) + if (route.work && flag('background', true)) { + const pick = await pickRoute($, route, routeOpts()) + const started = await $.agent + .spawn({ prompt: e.text, description: e.text.slice(0, 30), model: pick.model }) + .catch(() => undefined) + if (started && !('deny' in started && started.deny)) { + if (started.agentId) jobs.set(started.agentId, e.text) + delegating = true + lastPick = pick + } + } } else { route = undefined } @@ -408,17 +436,28 @@ export const register: Register = (on, options) => { on('turn.step', async function* ($, e, next) { const c = isEnabled ? findCharacter(active) : undefined if (!c || e.agentId || !routingOn()) return yield* next(e) - const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, { - candidates: candidates(), - lockModel: lockOf('lockModel', ['haiku', 'sonnet', 'opus']), - lockEffort: lockOf('lockEffort', ['low', 'medium', 'high']), - quotaRemain: await quotaRemain($), - downgradeAt: num('downgradeAt', 15), - }) + // 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。 + const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts()) lastPick = { model: pick.model, effort: pick.effort } return yield* next({ ...e, model: pick.model, effort: pick.effort }) }) + on('turn.complete', async ($, e, next) => { + const res = await next(e) + const task = e.agentId ? jobs.get(e.agentId) : undefined + const c = isEnabled ? findCharacter(active) : undefined + if (e.agentId && task !== undefined) { + jobs.delete(e.agentId) + if (c) { + const body = e.reason === 'answer' ? e.answer : `(工作未完成:${e.reason})` + void $.prompt.submit({ text: `[工作線回報] 使用者的要求:${task}\n處理結果:\n${body}\n請用角色口吻向使用者說明結果,技術內容原樣保留。` }).catch(() => undefined) + } + } else if (!e.agentId) { + delegating = false + } + return res + }) + on('session.append', { door: 'response' }, async ($, e, next) => { const c = isEnabled ? findCharacter(active) : undefined if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e) diff --git a/hooks/router.ts b/hooks/router.ts index 6f446c5..3b8b3a7 100644 --- a/hooks/router.ts +++ b/hooks/router.ts @@ -1,15 +1,15 @@ export type ModelName = 'haiku' | 'sonnet' | 'opus' export type Effort = 'low' | 'medium' | 'high' export type Tone = 'neutral' | 'praise' | 'scold' -export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string } +export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string; work: boolean } export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus'] -export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' } +export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false } export const CLASSIFY_PROMPT = '你是路由器。讀下面使用者輸入,只回傳一行 JSON:' + - '{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話"}。' + + '{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話","work":true|false}。work 為 true 表示需要動手做事(讀寫檔、執行指令、搜尋、改程式);純閒聊或一句話可答的問題為 false。' + 'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n' const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus' @@ -27,6 +27,7 @@ export const parseRoute = (text: string): Route => { effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort, tone: isTone(j.tone) ? j.tone : 'neutral', ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '', + work: j.work === true, } } catch { return FALLBACK_ROUTE diff --git a/hooks/routing.test.ts b/hooks/routing.test.ts index 51aec42..79a1ae9 100644 --- a/hooks/routing.test.ts +++ b/hooks/routing.test.ts @@ -1,9 +1,9 @@ import { expect, mock, test } from 'claude-code/testing' -type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any } +type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[] } const setup = (on: any): Ctl => { - const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } } + const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] } mock.store(on) mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') }) on('ui.log', () => ({ value: undefined }) as never) @@ -11,7 +11,7 @@ const setup = (on: any): Ctl => { on('session.start', () => ({ cwd: '/tmp' }) as never) on('command.run', () => ({ text: '' })) on('prompt.compose', () => ({ sections: [] })) - on('prompt.submit', (_$: any, e: any) => ({ text: e.text }) as never) + on('prompt.submit', (_$: any, e: any) => (ctl.submitted.push(e.text), { text: e.text }) as never) on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never) on('session.usage', () => ({ value: ctl.usage }) as never) on('turn.step', async function* (_$: any, e: any) { @@ -142,3 +142,30 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依 await $.command.run({ command: 'anima-affinity', args: '100' } as never) expect((await personaText($)).includes('關係定位:友情')).toBe(true) }) + +const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` }) + +test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => { + const ctl = setup(on) + const spawned: any[] = [] + on('agent.spawn', (_$: any, e: any) => (spawned.push(e), { model: e.model, agentId: 'a1' }) as never) + ctl.complete = () => workRoute('opus', true) + await start($) + await submit($, '幫我重構 src 目錄') + expect(spawned.length).toBe(1) + expect(spawned[0].model).toBe('opus') + await step($, 'main') + expect(ctl.seen.model).toBe('haiku') + const sys = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never) + expect(sys.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text).toContain('背景工作線') +}) + +test('work 為 false 或關閉背景工作線時不派工', async ($, on) => { + const ctl = setup(on) + let n = 0 + on('agent.spawn', () => (n++, { model: 'm', agentId: 'a' }) as never) + ctl.complete = () => workRoute('sonnet', false) + await start($) + await submit($, '你好') + expect(n).toBe(0) +})