Files
jsc-anima/hooks/register.tsx
T

299 lines
13 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 { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
import type { Entry } from './wiki'
const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const WIKI_COMMAND = 'anima-wiki'
const SYNCED_KEY = 'wikiSyncedAt'
const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
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
}
type WikiCfg = { url: string; cacheDir: string }
type Wiki = { dir: string; url: string; auth: string[] }
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
let url = cfg.url
if (!url) {
const cwd = await $.session.cwd().catch(() => '')
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
}
if (!url) return undefined
const home = (await $.env.get('HOME')) ?? ''
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
}
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r : undefined
}
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
const now = await $.clock.now()
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
if (ok) {
await $.store.set(SYNCED_KEY, now)
return 'fresh'
}
return cloned ? 'cached' : 'none'
}
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
}
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
const name = commandName(e)
if (known.has(name)) return
known.add(name)
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
}
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
if (!known.has(WIKI_COMMAND)) {
known.add(WIKI_COMMAND)
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
}
try {
const w = await wikiCtx($, cfg)
if (!w) return
await sync($, w, false)
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
} catch {}
}
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
const parsed = parseWriteArgs(args)
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
const w = await wikiCtx($, cfg)
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
const { entry, source, body } = parsed
const file = `${w.dir}/${pageName(entry)}.md`
const existed = await $.fs.exists(file).catch(() => false)
const catalog = addEntry(await readCatalog($, w), entry)
await $.fs.write(file, renderPage(entry, body, source))
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
return '寫入失敗:無法建立 commit。'
}
if (!(await git($, w, ['push']))) {
await git($, w, ['reset', '--hard', 'HEAD~1'])
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
}
await $.store.set(SYNCED_KEY, await $.clock.now())
await registerEntry($, known, entry)
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
}
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
const w = await wikiCtx($, cfg)
if (!w) return undefined
await sync($, w, false)
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
if (!e) return undefined
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => 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
const holder: { sys?: Sys } = {}
const wikiCommands = new Set<string>()
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
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: 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) await registerWikiTools($, wikiCfg(), wikiCommands)
}
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: '已離開角色,回到一般模式。' }
}
const c = findCharacter(e.command)
if (!c) {
if (!wikiCommands.has(e.command)) return next(e)
if (!findCharacter(active)) return { text: NEED_ACTIVE }
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
const page = await readWiki($, wikiCfg(), e.command)
return { text: page ?? `找不到知識「${e.command}」。` }
}
active = c.slug
await $.store.set(ACTIVE_KEY, c.slug)
await registerWikiTools($, wikiCfg(), wikiCommands)
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
const text = personaSection(c, applyOverrides(c.axes, overrides()))
return {
...res,
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
}
})
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' }],
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>
)
})
}