feat: 階段 2 路由、改寫與好感度
- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應 - turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定 - 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log - 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity - 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+133
-1
@@ -2,11 +2,20 @@ 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'
|
||||
|
||||
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',
|
||||
@@ -54,6 +63,19 @@ async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Pro
|
||||
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)
|
||||
@@ -62,8 +84,18 @@ export const register: Register = (on, options) => {
|
||||
|
||||
const isEnabled = flag('enabled', true)
|
||||
let active: string | undefined
|
||||
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)[]) {
|
||||
@@ -78,6 +110,7 @@ export const register: Register = (on, options) => {
|
||||
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
|
||||
@@ -92,9 +125,24 @@ export const register: Register = (on, options) => {
|
||||
await $.store.delete(ACTIVE_KEY)
|
||||
return { text: '已離開角色,回到一般模式。' }
|
||||
}
|
||||
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)
|
||||
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
||||
})
|
||||
@@ -103,13 +151,92 @@ export const register: Register = (on, options) => {
|
||||
const res = await next(e)
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
if (!c) return res
|
||||
const text = personaSection(c, applyOverrides(c.axes, overrides()))
|
||||
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)
|
||||
@@ -134,6 +261,11 @@ export const register: Register = (on, options) => {
|
||||
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user