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 = { 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 } type WikiCfg = { url: string; cacheDir: string } type Wiki = { dir: string; url: string; auth: string[] } async function wikiCtx($: Engine, cfg: WikiCfg): Promise { 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 { return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => '')) } async function registerEntry($: Engine, known: Set, 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) { 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, args: string): Promise { 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 { 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 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() const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') }) 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: 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 Promise | 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 ( {shown.map((s, i) => ( {i > 0 ? {SEPARATOR} : null} {s.parts.map((p, j) => ( {p.text} ))} ))} ) }) }