import type { Engine, Register } from 'claude-code' import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters' import type { Axes } from './characters' import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity' import type { Affinity } from './affinity' import { relationOf, relationText } from './relation' import { CLASSIFY_PROMPT, choose, parseRoute } from './router' import type { Effort, ModelName, Route } from './router' import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite' import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline' import type { Segment } from './statusline' import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool' const ACTIVE_KEY = 'active' const OFF_COMMAND = 'anima-off' const AFFINITY_COMMAND = 'anima-affinity' const RAW_LOG_KEY = 'rawlog' const RAW_LOG_MAX = 200 const AXIS_OPTION: Record = { gentle: 'axisGentle', tsundere: 'axisTsundere', lively: 'axisLively', formal: 'axisFormal', talkative: 'axisTalkative', humor: 'axisHumor', proactive: 'axisProactive', expressive: 'axisExpressive', } const SYS_SCRIPT = 'a=$(awk \'{print $1}\' /proc/loadavg); n=$(nproc); ' + 'echo "$a $n"; free | awk \'/Mem:/{printf "%d\\n", $3*100/$2}\'; df / | awk \'NR==2{gsub("%","",$5); print $5}\'' const resetText = (iso: string | undefined, long: boolean): string => { if (!iso) return '' const d = new Date(iso) if (Number.isNaN(d.getTime())) return '' const p = (n: number) => String(n).padStart(2, '0') return long ? ` ${d.getMonth() + 1}/${d.getDate()}` : ` ${p(d.getHours())}:${p(d.getMinutes())}` } const fmtTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)) type Sys = { at: number; cpu: number; ram: number; dsk: number } async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Promise { const now = await $.clock.now() if (holder.sys && now - holder.sys.at < refreshMs) return holder.sys try { const r = await $.process.run(['sh', '-c', SYS_SCRIPT], { timeoutMs: 3000 }) const [l1, l2, l3] = String(r.stdout).trim().split('\n') const [load, cores] = (l1 ?? '').split(' ').map(Number) holder.sys = { at: now, cpu: Math.min(100, Math.round(((load || 0) / (cores || 1)) * 100)), ram: Number(l2) || 0, dsk: Number(l3) || 0, } } catch { holder.sys = undefined } return holder.sys } const affinityKey = (slug: string): string => `affinity:${slug}` async function loadAffinity($: Engine, slug: string): Promise { const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined return v && typeof v.score === 'number' ? v : initialAffinity() } async function quotaRemain($: Engine): Promise { const usage = await $.session.usage().catch(() => undefined) const used = usage?.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day').map(r => r.percentUsed) return used && used.length ? 100 - Math.max(...used) : undefined } export const register: Register = (on, options) => { const opt = options as Record const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d) const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d) const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d) const isEnabled = flag('enabled', true) let active: string | undefined let toolRegistered = false let route: Route | undefined let prefixed = false const holder: { sys?: Sys } = {} const routingOn = () => flag('routing', true) const candidates = (): ModelName[] => str('candidates', 'haiku,sonnet,opus').split(',').map(x => x.trim()).filter((x): x is ModelName => x === 'haiku' || x === 'sonnet' || x === 'opus') const lockOf = (k: string, ok: readonly T[]): T | undefined => { const v = str(k, 'auto') return (ok as readonly string[]).includes(v) ? (v as T) : undefined } const overrides = (): Partial => { const out: Partial = {} for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) { const v = num(AXIS_OPTION[k], -1) if (v >= 0) out[k] = v } return out } on('session.start', async ($, e, next) => { if (isEnabled) { for (const c of CHARACTERS) { await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) } await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' }) await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' }) const saved = await $.store.get(ACTIVE_KEY) active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined if (active && !toolRegistered) { toolRegistered = true await registerTools($) } } return next(e) }) on('command.run', async ($, e, next) => { if (!isEnabled) return next(e) if (e.command === OFF_COMMAND) { active = undefined await $.store.delete(ACTIVE_KEY) return { text: '已離開角色,回到一般模式。' } } if (e.command === TOOL_COMMAND) { return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE } } if (e.command === AFFINITY_COMMAND) { const c = findCharacter(active) if (!c) return { text: '請先啟動角色。' } const arg = String(e.args ?? '').trim() const cur = await loadAffinity($, c.slug) if (arg === 'reset') { await $.store.set(affinityKey(c.slug), initialAffinity()) return { text: '好感度已重置為 0。' } } const n = Number(arg) if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `目前好感度 ${cur.score}(${stageName(cur.score)})。用法:reset 或 0-100 的數字。` } await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) }) return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` } } const c = findCharacter(e.command) if (!c) return next(e) active = c.slug route = undefined await $.store.set(ACTIVE_KEY, c.slug) // 工具二到四只在啟動角色後才註冊。 if (!toolRegistered) { toolRegistered = true await registerTools($) } return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` } }) on('prompt.compose', async ($, e, next) => { const res = await next(e) const c = isEnabled ? findCharacter(active) : undefined if (!c) return res let text = personaSection(c, applyOverrides(c.axes, overrides())) if (flag('affinity', true)) { const a = await loadAffinity($, c.slug) text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}` } else { text += `\n${relationText(relationOf(c.timeline, 0, false))}` } return { ...res, sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }], } }) on('prompt.submit', async ($, e, next) => { const c = isEnabled ? findCharacter(active) : undefined if (!c || e.command || e.isMeta) return next(e) prefixed = false const axes = applyOverrides(c.axes, overrides()) if (routingOn()) { const res = await $.model .complete({ model: 'haiku', prompt: CLASSIFY_PROMPT + e.text, system: `角色:${c.name}。${c.persona}`, effort: 'low', maxTokens: 300, timeoutMs: 8000 }) .catch(() => undefined) route = parseRoute(res && res.isAnswered ? res.text : '') if (route.ack) $.ui.log(withPrefix(c.name, route.ack)) } else { route = undefined } if (flag('affinity', true)) { const today = dayKey(await $.clock.now()) let a = await loadAffinity($, c.slug) a = applyEvent(a, 'daily', axes, today) const tone = route?.tone if (tone === 'praise' || tone === 'scold') a = applyEvent(a, tone, axes, today) await $.store.set(affinityKey(c.slug), a) } return next(e) }) 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), }) return yield* next({ ...e, model: pick.model, effort: pick.effort }) }) 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) const blocks = e.message.content const out = [] for (const b of blocks) { if (b.type !== 'text' || typeof b.text !== 'string' || !b.text.trim()) { out.push(b) continue } const orig = b.text let text = orig const { text: masked, tokens } = protect(orig) const model = needsSonnet(orig, num('longReply', 1200)) ? 'sonnet' : 'haiku' const res = await $.model .complete({ model, prompt: masked, system: `${REWRITE_SYSTEM}\n角色:${c.name}。${c.persona}`, maxTokens: 4000, timeoutMs: num('rewriteTimeoutSec', 20) * 1000 }) .catch(() => undefined) const done = res && res.isAnswered ? restore(res.text.trim(), tokens) : undefined if (done) { text = done if (flag('rawLog', true)) { const log = ((await $.store.get(RAW_LOG_KEY)) as { at: number; raw: string }[] | undefined) ?? [] log.push({ at: await $.clock.now(), raw: orig }) await $.store.set(RAW_LOG_KEY, log.slice(-RAW_LOG_MAX)) } } if (!prefixed) { text = withPrefix(c.name, text) prefixed = true } out.push({ ...b, text }) } return next({ ...e, message: { ...e.message, content: out } }) }) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const c = isEnabled ? findCharacter(active) : undefined if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e) const warn = num('warnAt', 60) const danger = num('dangerAt', 85) const style = str('barStyle', 'block') === 'dot' ? 'dot' : 'block' const usage = await $.session.usage().catch(() => undefined) const cwd = await $.session.cwd().catch(() => '') const branch = await $.process .run(['git', 'branch', '--show-current'], { cwd: cwd || undefined, timeoutMs: 2000 }) .then(r => String(r.stdout).trim()) .catch(() => '') const model = await $.session.model().catch(() => '') const quota = (kind: string, label: string, long: boolean): Segment['parts'] | undefined => { const rl = usage?.rateLimits.find(r => r.kind === kind) if (!rl) return undefined const remain = Math.max(0, 100 - rl.percentUsed) return meter(label, remain, remainColor(remain, warn, danger), style, resetText(rl.resetsAt, long)) } const builders: Record Promise | Segment['parts'] | undefined> = { character: () => [{ text: c.name, color: 'cyan' }], love: async () => { if (!flag('affinity', true) || !flag('showHearts', true)) return undefined const a = await loadAffinity($, c.slug) return [{ text: hearts(a.score), color: 'red' as const }] }, model: () => (model ? [{ text: model }] : undefined), dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined), branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined), '5h': () => quota('five_hour', '5h', false), '7d': () => quota('seven_day', '7d', true), ctx: () => { const cx = usage?.context if (!cx || cx.percent === undefined) return undefined return meter('ctx', cx.percent, usedColor(cx.percent, warn, danger), style, ` ${fmtTokens(cx.tokens ?? 0)}/${fmtTokens(cx.window)}`) }, sys: async () => { const s = await readSys($, holder, num('refreshSec', 10) * 1000) if (!s) return undefined return [ { text: 'CPU ' }, { text: `${s.cpu}%`, color: usedColor(s.cpu, warn, danger) }, { text: ' RAM ' }, { text: `${s.ram}%`, color: usedColor(s.ram, warn, danger) }, { text: ' DSK ' }, { text: `${s.dsk}%`, color: usedColor(s.dsk, warn, danger) }, ] }, } const segments: Segment[] = [] for (const id of parseFields(str('fields', ''))) { const parts = await builders[id]?.() if (parts) segments.push({ id, parts }) } const shown = layout(segments, e.props.bodyColumns) const { Box, Text } = $.ui.resolve(e) return ( {shown.map((s, i) => ( {i > 0 ? {SEPARATOR} : null} {s.parts.map((p, j) => ( {p.text} ))} ))} ) }) }