575 lines
26 KiB
TypeScript
575 lines
26 KiB
TypeScript
import type { Engine, Register } from 'claude-code'
|
||
|
||
import { CHARACTERS, applyOverrides, findCharacter, packToCharacter, personaSection, registerCharacters, themeColor } from './characters'
|
||
import type { PackLike } from './characters'
|
||
import type { Axes } from './characters'
|
||
import { EXTRACT_COMMAND, runExtract } from './extractCommand'
|
||
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
|
||
import type { Affinity } from './affinity'
|
||
import { latestEntry, 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 { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } from './wiki'
|
||
import type { Entry } from './wiki'
|
||
import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo'
|
||
import type { Todo } from './todo'
|
||
import { NEED_ACTIVE, OFF_COMMAND, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||
|
||
const ACTIVE_KEY = 'active'
|
||
const AFFINITY_COMMAND = 'anima-affinity'
|
||
const RAW_LOG_KEY = 'rawlog'
|
||
const RAW_LOG_MAX = 200
|
||
const WIKI_COMMAND = 'anima-wiki'
|
||
const SYNCED_KEY = 'wikiSyncedAt'
|
||
|
||
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)
|
||
}
|
||
|
||
async function todoRun($: Engine, name: string, persona: string, args: string): Promise<string> {
|
||
const todos = (((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []).slice()
|
||
const types = ((await $.store.get(TYPES_KEY)) as string[] | undefined) ?? DEFAULT_TYPES
|
||
const p = parseTodo(args)
|
||
if (!p) return USAGE
|
||
const save = () => $.store.set(TODO_KEY, todos)
|
||
const find = (id: number) => todos.find(t => t.id === id)
|
||
if (p.op === 'list') return renderList(todos)
|
||
if (p.op === 'type') {
|
||
if (types.includes(p.name)) return `類型「${p.name}」已存在。`
|
||
await $.store.set(TYPES_KEY, [...types, p.name])
|
||
return `已新增類型「${p.name}」。`
|
||
}
|
||
if (p.op === 'add' || p.op === 'update') {
|
||
if (!types.includes(p.type)) return `沒有「${p.type}」這個類型,現有:${types.join('、')}。可用 type 新增。`
|
||
const project = (await $.session.cwd().catch(() => '')) || ''
|
||
if (p.op === 'add') {
|
||
const t: Todo = { id: nextId(todos), type: p.type, at: p.at, task: p.task, project, status: 'pending' }
|
||
todos.push(t)
|
||
await save()
|
||
return `已建立待辦 #${t.id}。`
|
||
}
|
||
const t = find(p.id)
|
||
if (!t) return `找不到待辦 #${p.id}。`
|
||
Object.assign(t, { type: p.type, at: p.at, task: p.task, status: 'pending', result: undefined })
|
||
await save()
|
||
return `已更新待辦 #${t.id}。`
|
||
}
|
||
const t = find(p.id)
|
||
if (!t) return `找不到待辦 #${p.id}。`
|
||
if (t.status === 'done') return `待辦 #${t.id} 已經完成。`
|
||
if (p.op === 'done') {
|
||
t.status = 'done'
|
||
await save()
|
||
return `待辦 #${t.id} 已標記完成。`
|
||
}
|
||
// 使用者輸入 run 即為確認;執行結果由角色告知。
|
||
const res = await $.model
|
||
.complete({ model: 'sonnet', prompt: `[${t.type}] ${t.task}`, system: `${RUN_SYSTEM}\n角色:${name}。${persona}`, maxTokens: 2000, timeoutMs: 120000 })
|
||
.catch(() => undefined)
|
||
if (!res || !res.isAnswered || !res.text.trim()) return `待辦 #${t.id} 執行失敗,仍保留為待辦。`
|
||
t.status = 'done'
|
||
t.result = res.text.trim()
|
||
await save()
|
||
return withPrefix(name, t.result)
|
||
}
|
||
|
||
async function remindDue($: Engine, name: string): Promise<void> {
|
||
const todos = ((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []
|
||
const due = dueNow(todos, await $.clock.now())
|
||
if (!due.length) return
|
||
for (const t of due) {
|
||
t.status = 'reminded'
|
||
$.ui.log(withPrefix(name, reminderText(t)))
|
||
}
|
||
await $.store.set(TODO_KEY, todos)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
|
||
async function pickRoute($: Engine, r: Route | undefined, o: Omit<Parameters<typeof choose>[1], 'quotaRemain'>) {
|
||
return choose(r ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }, { ...o, quotaRemain: await quotaRemain($) })
|
||
}
|
||
|
||
// 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。
|
||
async function loadPacks($: Engine): Promise<void> {
|
||
const dir = `${$.plugin.root}/characters/`
|
||
try {
|
||
if (!(await $.fs.exists(`${dir}index.json`))) return
|
||
const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] }
|
||
for (const i of index.packs) {
|
||
const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike
|
||
const work = (await $.fs.exists(`${dir}${i.work}/work.json`)) ? (JSON.parse(await $.fs.read(`${dir}${i.work}/work.json`)) as { terms?: { term: string; definition: string }[] }) : undefined
|
||
registerCharacters([packToCharacter(pack, i.workName, work?.terms ?? [])])
|
||
}
|
||
} catch {
|
||
// 角色包損壞不影響內建角色。
|
||
}
|
||
}
|
||
|
||
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 delegating = false
|
||
const jobs = new Map<string, string>()
|
||
let lastPick: { model: ModelName; effort: Effort } | undefined
|
||
let prefixed = false
|
||
const holder: { sys?: Sys } = {}
|
||
const wikiCommands = new Set<string>()
|
||
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
|
||
|
||
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
|
||
}
|
||
|
||
const routeOpts = () => ({
|
||
candidates: candidates(),
|
||
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
|
||
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
|
||
downgradeAt: num('downgradeAt', 15),
|
||
})
|
||
|
||
on('session.start', async ($, e, next) => {
|
||
if (isEnabled) {
|
||
await loadPacks($)
|
||
for (const c of CHARACTERS) {
|
||
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
|
||
}
|
||
await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
|
||
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)好感度;未啟動時需指定角色代號' })
|
||
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($)
|
||
}
|
||
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
|
||
const ch = findCharacter(active)
|
||
if (ch && flag('todoRemind', true)) await remindDue($, ch.name)
|
||
}
|
||
return next(e)
|
||
})
|
||
|
||
on('command.run', async ($, e, next) => {
|
||
if (!isEnabled) return next(e)
|
||
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
|
||
if (e.command === OFF_COMMAND) {
|
||
if (!findCharacter(active)) return { text: NEED_ACTIVE }
|
||
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 === TODO_COMMAND) {
|
||
const c = findCharacter(active)
|
||
return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE }
|
||
}
|
||
if (e.command === AFFINITY_COMMAND) {
|
||
// 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
|
||
const parts = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
|
||
const named = parts.length ? findCharacter(parts[0]) : undefined
|
||
const c = named ?? findCharacter(active)
|
||
if (!c) return { text: '未啟動角色時請指定角色代號:anima-affinity <角色代號> [reset|0-100]。' }
|
||
const arg = (named ? parts.slice(1) : parts).join(' ')
|
||
const cur = await loadAffinity($, c.slug)
|
||
if (arg === 'reset') {
|
||
await $.store.set(affinityKey(c.slug), initialAffinity())
|
||
return { text: `${c.name}的好感度已重置為 0。` }
|
||
}
|
||
const n = Number(arg)
|
||
if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `${c.name}目前好感度 ${cur.score}(${stageName(cur.score)})。用法:[角色代號] reset 或 0-100 的數字。` }
|
||
await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) })
|
||
return { text: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||
}
|
||
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
|
||
route = undefined
|
||
await $.store.set(ACTIVE_KEY, c.slug)
|
||
// 工具二到四只在啟動角色後才註冊。
|
||
if (!toolRegistered) {
|
||
toolRegistered = true
|
||
await registerTools($)
|
||
}
|
||
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
|
||
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)), latestEntry(c.timeline)?.romance)}`
|
||
} else {
|
||
text += `\n${relationText(relationOf(c.timeline, 0, false), latestEntry(c.timeline)?.romance)}`
|
||
}
|
||
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
|
||
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 && flag('todoRemind', true)) await remindDue($, c.name)
|
||
if (!c || e.command || e.isMeta) return next(e)
|
||
prefixed = false
|
||
delegating = false
|
||
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
|
||
if (e.origin?.kind === 'plugin') return next(e)
|
||
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))
|
||
if (route.work && flag('background', true)) {
|
||
const pick = await pickRoute($, route, routeOpts())
|
||
const started = await $.agent
|
||
.spawn({ prompt: e.text, description: e.text.slice(0, 30), model: pick.model })
|
||
.catch(() => undefined)
|
||
if (started && !('deny' in started && started.deny)) {
|
||
if (started.agentId) jobs.set(started.agentId, e.text)
|
||
delegating = true
|
||
lastPick = pick
|
||
}
|
||
}
|
||
} 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 = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
|
||
lastPick = { model: pick.model, effort: pick.effort }
|
||
return yield* next({ ...e, model: pick.model, effort: pick.effort })
|
||
})
|
||
|
||
on('turn.complete', async ($, e, next) => {
|
||
const res = await next(e)
|
||
const task = e.agentId ? jobs.get(e.agentId) : undefined
|
||
const c = isEnabled ? findCharacter(active) : undefined
|
||
if (e.agentId && task !== undefined) {
|
||
jobs.delete(e.agentId)
|
||
if (c) {
|
||
const body = e.reason === 'answer' ? e.answer : `(工作未完成:${e.reason})`
|
||
void $.prompt.submit({ text: `[工作線回報] 使用者的要求:${task}\n處理結果:\n${body}\n請用角色口吻向使用者說明結果,技術內容原樣保留。` }).catch(() => undefined)
|
||
}
|
||
} else if (!e.agentId) {
|
||
delegating = false
|
||
}
|
||
return res
|
||
})
|
||
|
||
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: themeColor(c) }],
|
||
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: lastPick && routingOn() ? `${model} · ${lastPick.effort}` : 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>
|
||
)
|
||
})
|
||
}
|