import type { Register } from 'claude-code' import { fitCompanion } from './companion/band' import { createCompanion } from './companion/core' import { engineFs } from './companion/engine-fs' let companion: ReturnType | undefined let replying = false let usedTools = false let typedAt = 0 let timer: { cancel(): void } | undefined const TICK_MS = 60_000 const TYPING_MS = 5_000 const COMMANDS: Record = { info: { description: '查看目前角色、心情、好感與待辦數量' }, switch: { description: '切換角色;不給角色會列出可用角色', argumentHint: '<角色>' }, disable: { description: '關閉陪伴,所有資料保留' }, setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' }, todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' }, quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' }, materials: { description: '外部素材(天氣、新聞)的快取狀態;list 列出新聞', argumentHint: '[list]' }, } // 註冊名稱與內部子指令不同者(status 與內建 /status 撞名;disable 對應 off) const INTERNAL: Record = { info: 'status', disable: 'off' } export const register: Register = on => { on('session.start', async ($, e, next) => { // 單一指令撞名被拒絕時不能擋下後面的初始化 for (const [name, c] of Object.entries(COMMANDS)) await $.command.register({ name, ...c }).catch(() => {}) const home = await $.env.get('HOME') companion = createCompanion({ fs: engineFs({ read: p => $.fs.read(p), write: (p, t) => $.fs.write(p, t), list: p => $.fs.list(p) }), clock: { now: Date.now }, root: `${home ?? ''}/.companion` }) const first = await companion.ensureActivated() if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /jsc-mod-companion:disable 可關閉`) // 定時檢查 toast 類關心(到期待辦、作息);失敗不影響主流程 timer?.cancel() timer = $.clock.every(TICK_MS, () => { companion ?.tick({ replying, typing: Date.now() - typedAt < TYPING_MS }) .then(cares => cares.forEach(c => $.ui.toast(c.text))) .catch(() => {}) }) return next(e) }) for (const command of Object.keys(COMMANDS)) on('command.run', { command }, async ($, e) => ({ text: companion ? await companion.command(`${INTERNAL[command] ?? command} ${e.args}`.trim()) : '尚未就緒。', })) // 角色卡附加在 system prompt 尾端(session 區段),語氣才會進到模型 on('prompt.compose', async ($, e, next) => { const result = await next(e) const text = await companion?.prompt() if (!text) return result return { sections: [...result.sections, { id: 'companion:persona', text, scope: 'session' as const }] } }) on('prompt.submit', async ($, e, next) => { // 更新心情失敗不能擋下使用者的提示 const care = (await companion?.onPrompt(e.text).catch(() => [] as string[])) ?? [] return next(care.length ? { ...e, context: [...(e.context ?? []), ...care] } : e) }) on('prompt.edit', async ($, e, next) => { typedAt = Date.now() return next(e) }) 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, e.answer).catch(() => {}) return next(e) }) // 連續工具失敗會觸發關心,並影響心情 on('tool.call', async ($, e, next) => { const r = await next(e) if (!e.agentId) usedTools = true companion?.onToolResult(!r.isError, { command: e.tool === 'Bash' ? e.command : undefined }).catch(() => {}) return r }) on('session.end', async ($, e, next) => { timer?.cancel() timer = undefined await companion?.end().catch(() => {}) return next(e) }) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const rest = await next(e) const view = await companion?.view() if (e.props.hasSurvey || !view) return rest const line = fitCompanion(view, e.props.bodyColumns) const { Box, Text } = $.ui.resolve(e) return ( {rest} {line.map(s => ( {s.text} ))} ) }) }