Files
jsc-anima/hooks/register.tsx
T
2026-10-15 06:26:44 +00:00

330 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<keyof Axes, string> = {
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<Sys | undefined> {
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<Affinity> {
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
return v && typeof v.score === 'number' ? v : initialAffinity()
}
async function quotaRemain($: Engine): Promise<number | undefined> {
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<string, unknown>
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 = <T extends string>(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<Axes> => {
const out: Partial<Axes> = {}
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<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
lockEffort: lockOf<Effort>('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<string, () => Promise<Segment['parts'] | undefined> | 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 (
<Box flexDirection="column">
<Text> </Text>
<Text wrap="truncate-end">
{shown.map((s, i) => (
<Text key={s.id}>
{i > 0 ? <Text dimColor>{SEPARATOR}</Text> : null}
{s.parts.map((p, j) => (
<Text key={j} color={p.color}>{p.text}</Text>
))}
</Text>
))}
</Text>
<Text> </Text>
</Box>
)
})
}