Voiced pixel anime girls who roam the Claude Code transcript: they dance while Claude works and serve tea when it is done, and they do chores too: usage…

Claude Code 的 mod 合集。目前有一个 mod 和一个最小示例:

仓库里的动图和示意图都不是录屏:角色由 mod 自己的绘制代码渲染,对话内容是摆拍的。
| 功能 | 怎么触发 | 语音和动作 |
|---|---|---|
| 流量汇报 | 干了活的一轮结束后,报这一轮新用的 token(不算缓存命中)和上下文占比,最多 45 秒报一次;/waifu token 看完整报告;上下文和额度窗口越过 50%、80%、95% 时主动提醒 | 做欢呼动作再播报 |
| 花费预算 | /waifu budget 5 设 5 美元的会话预算,用到 50%、80%、100% 时提醒 | 超预算时出招 |
| 安全检查 | 危险的 Bash 命令先拦下,你输入 /waifu allow 才放行一次;git commit 前看一遍要提交的内容里有没有密钥;Claude 写入的文件里出现密钥时提醒;/waifu scan 扫描当前改动 | 拦截时出招并喊“危险”,扫描干净时欢呼 |
| 代码 review | /waifu review 把工作区相对上次提交的改动交给模型审查,列出问题和修改建议 | 审查时来回踱步,通过欢呼,有问题出招 |
| 任务回顾 | 干了活的每一轮自动记一笔,按项目目录分开存;新会话开场提一句上次做到哪;/waifu recap 列出最近的任务 | 开场播报 |
| 结果播报 | 测试、推送命令跑完时说结果,构建只在失败时说;只有能确定结果时才开口。Claude 停下来等你时提醒;超过一分钟的一轮结束时弹系统通知(macOS) | 通过欢呼,失败出招 |
| 陪伴 | 跑任务时跳舞、走动,思考完和完成后端茶、捶背;每隔两到四分钟换人,台上一到两位;/waifu <名字> <话> 聊天;/waifu fortune 抽今日编码运势 | 每位角色有自己的声线和动作 |


claude plugin test 也跑不了。/waifu pane 可以把她们放进面板。pip install edge-tts只在 macOS 上用过。系统通知用的是 osascript,实时合成的台词(聊天、用量数字)用 afplay 播放,这两样在别的系统上不起作用。
仓库里不带角色图片。请自己下载下面几张精灵图,放进同一个文件夹(默认是 ~/Downloads),文件名保持原样:
| 角色 | 去哪下载 | 文件名 |
|---|---|---|
| 不知火舞 | The Spriters Resource → Mobile → Metal Slug Defense → Units → Mai Shiranui | Mobile - Metal Slug Defense - Units_ The King of Fighters - Mai Shiranui.png |
| 小舞(Q 版) | The Spriters Resource → Mobile → Senran no Samurai Kingdom → Mai Shiranui | Mobile - Senran no Samurai Kingdom (JPN) - Characters - The King of Fighters - Mai Shiranui.png |
| 塞拉菲娜(兔女郎) | The Spriters Resource → Nintendo Switch → Disgaea 5 Complete → Seraphina (Bunny Girl) | Nintendo Switch - Disgaea 5 Complete - Characters - Seraphina (Bunny Girl).png |
| 卡蜜拉(吸血鬼伯爵夫人) | CraftPix Free Vampire Pixel Art Sprite Sheets | 解压后的 Countess_Vampire/ 文件夹 |
缺哪张就少哪位。一张都没导入时,会由内置的手绘小人顶上(画得很粗糙,说中文)。
git clone https://github.com/a252937166/cc-mod.git
cd cc-mod/anime-cheer
sh tools/import-packs.sh ~/Downloads # 把精灵图切帧、缩放,生成 packs/<角色>/pack.json
bun tools/make-voices.ts # 录好所有预设台词:开着 VOICEVOX 就用动漫声线,否则用 edge-tts
claude --plugin-dir /path/to/cc-mod/anime-cheer
按 Claude Code 的 mod 文档,想每次启动都加载,可以在 ~/.claude/settings.json 的 env 里写上绝对路径:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cc-mod/anime-cheer" } }
| 命令 | 作用 |
|---|---|
/waifu | 显示或隐藏应援团。隐藏后她们不出声,也不调用模型;安全检查照常工作 |
/waifu help | 查看全部命令 |
/waifu token | 用量播报:上下文、额度窗口、会话花费、token |
/waifu today | 今天聊了几轮、干了多久、用了多少 token、拦下几条命令 |
/waifu budget <美元> | 设花费预算;/waifu budget off 取消 |
/waifu recap | 这个项目目录里最近做过的任务 |
/waifu review [目录] | 快速 review 仓库里还没提交的改动 |
/waifu scan [目录] | 扫描还没提交的改动里的密钥和敏感文件 |
/waifu guard on / off | 开关安全检查(默认开) |
/waifu allow | 放行刚被拦下的那条命令一次 |
/waifu notify on / off | 超过一分钟的一轮结束时弹系统通知(默认开) |
/waifu fortune | 抽今日编码运势 |
/waifu list / call <名字> / bye <名字> / shuffle | 看名单、叫人上台、请人退下、换一批 |
/waifu roam / pane | 在对话区里走动,或待在面板里 |
/waifu mute / less / voice | 配音全关、只留关键时刻、全开 |
/waifu ai on / off | 开关 AI 即兴聊天 |
/waifu <名字> <话> | 和某一位聊天,比如 /waifu mai 今天好累 |
角色的名字可以用 mai、maiq、bunny、countess,也可以用中文名。原来的中文命令(名单、日报、抽签 等)还能用。
review 和 scan 看的是工作区相对上次提交的改动,git 还没跟踪的新文件也算。不写目录时用会话所在的仓库;会话目录本身不是仓库、下面正好有一个仓库时,就用那一个。

开着的时候(默认),下面这些 Bash 命令会被拦下,Claude 会收到原因并请你确认。你在 10 分钟内输入 /waifu allow,同一条命令就可以在 5 分钟内原样执行一次。
rm -r 指向大范围的路径:根目录、主目录和它们下面的第一层(比如 ~/Desktop、/usr/local)、当前目录、上级目录、*、.git,还有 $变量/* 这种变量为空就会删到根目录的写法find <大范围目录> -delete 或 -exec rm,并且没有 -name 之类的筛选条件chmod -R、chown -R 作用在根目录、主目录或它们下面的第一层git push --force(--force-with-lease 不拦)、git reset --hard、git clean -f、git checkout .、git restore .、git checkout -f、git branch -Dcurl … | sh、bash <(curl …)、sh -c "$(curl …)"DROP、TRUNCATE TABLE、不带 WHERE 的 DELETE,以及 redis-cli FLUSHALLdd 写磁盘设备、mkfs、diskutil eraseDiskterraform destroy、kubectl delete 命名空间或 --all、docker system prune -a、gh repo deletegit commit:要提交的内容里有密钥,或者有 .env、私钥这类敏感文件。同一条命令里 git add 将要暂存的内容也会看这些命令包在 bash -c "…"、eval、ssh 主机 "…"、$(…) 里也认得。提交说明、grep 的搜索词这类引号里的文字,以及写文件用的 heredoc,一般不当成命令。
能认出的密钥有:私钥文件头、AWS 访问密钥、GitHub 令牌、Anthropic 和 OpenAI 风格的 API key、Slack 令牌、Google API key、JWT,以及写死在代码里的 password = "…" 这类赋值。
这是按规则匹配的提醒,不是沙箱:换一种写法的危险命令它可能认不出来,别把它当成唯一的防线。/waifu allow 只认你在输入框里亲手输入的。
/waifu <话> 聊天、每轮结束后的一句点评(最多 90 秒一次)、/waifu recap 在任务满 3 条时附的要点总结,各调用一次 haiku。/waifu ai off 可以把这三样都关掉。/waifu review 调用一次 sonnet(调不通时改用 haiku),把改动的 diff(最多约 6 万字符)发给它。锁文件、二进制文件和太大的文件不发。最小的 mod:数工具调用、拦一条危险命令、一轮结束时弹提示。
claude plugin validate examples/hello-mod # 检查清单,列出它挂了哪些钩子
claude --plugin-dir examples/hello-mod # 带着它启动
claude plugin test anime-cheer
anime-cheer/tests 里有三份测试:cheer.test.ts 用引擎自带的测试套件驱动整个 mod(30 个用例),guard.test.ts 和 journal.test.ts 测安全规则、任务记录和 review 的纯逻辑(12 个用例)。claude plugin test 同样需要 mod 的开关是开着的。
tools/import-pack.ts 把精灵图切成帧。它支持三种素材排布: # 帧之间有透明空隙的普通精灵图
bun tools/import-pack.ts <id> sheet.png --list
# 每帧有底色框、框外是另一种底色(比如绿底粉框)
bun tools/import-pack.ts <id> sheet.png --cells 00ff00 --key ff00ff --list
# 每个动作一张横条图、帧大小固定(比如 128×128)
bun tools/import-pack.ts <id> Idle.png Walk.png --grid 128x128 --list
先加 --list 看帧编号,再用 --anims 指定每个动作用哪些帧,动作包括 idle、walk、dance、cheer、attack、sleep。常用参数:
--scale:缩放比例。像素画能不缩就不缩--sample mode:缩小时保留主色,边缘更锐利--wide:配合舞台的四分格渲染,横向采样加倍,基本都要加--facing left:原图朝左的素材要加hooks/troupe.ts 里照现有角色加一项(名字、人设、声线、日语台词加中文字幕)。她值班时的台词写在 hooks/duty.ts 里,不写就用通用的中文台词。bun tools/make-voices.ts。想让人物清楚,优先找游戏里的像素精灵图(有分格的动作表)。高清立绘和插画缩到终端尺寸只会糊成马赛克。
Raster 色块元素,每个字符格画 2×2 个像素(四分格字符)。人物要 18 到 20 行高才看得清。hooks/register.tsx 1898 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type {
3 Elements,
4 EngineInterface,
5 Register,
6 RenderElement,
7 SessionContextUsage,
8 SessionCost,
9 SessionRateLimit,
10 TurnCompleteInput,
11 TurnUsage,
12} from 'claude-code'
13
14import type { StageBubble, StageView } from '../types'
15import { addPack, BLANK_CELL, canDraw, encode, heightOf, spans } from './art'
16import { layout, textWidth } from './bubble'
17import { dutyLine, withDetail, type Duty } from './duty'
18import {
19 commitOf,
20 isOpaqueFile,
21 isSensitiveFile,
22 mayStop,
23 newFileDiff,
24 outcomeOf,
25 riskOf,
26 scanDiff,
27 scanReport,
28 secretsIn,
29 watchesOf,
30 type Commit,
31 type Finding,
32} from './guard'
33import { addTask, isTask, lastTaskLine, makeTask, projectKey, recapPrompt, recapReport, type Task } from './journal'
34import { parseReview, REVIEW_SYSTEM, reviewPrompt, reviewReport, trimDiff } from './review'
35import {
36 briefLine,
37 budgetLine,
38 contextAlert,
39 costLine,
40 dayComment,
41 dayReport,
42 emptyDay,
43 emptyUsage,
44 fortune,
45 freshOf,
46 limitAlert,
47 totalOf,
48 turnBrief,
49 usageReport,
50 zhCount,
51 type Day,
52 type Limit,
53 type Usage,
54} from './report'
55import {
56 BANTER,
57 CAST,
58 clipPath,
59 findGirl,
60 GIRLS,
61 spoken,
62 subtitle,
63 TOOL_TALK,
64 voiceFor,
65 type Girl,
66 type Line,
67 type Moment,
68 type VoiceEngine,
69} from './cast'
70import {
71 createWorld,
72 draw,
73 drawActor,
74 enter,
75 leave,
76 moveLength,
77 onStage,
78 pace,
79 perform,
80 resize,
81 setMode,
82 settle,
83 tick,
84 wake,
85 type Actor,
86 type Placed,
87} from './world'
88
89const PANE = 'anime-cheer'
90const TICK_MS = 150
91const TEA_AFTER_TURN_MS = 22_000
92const TEA_AFTER_ABORT_MS = 8_000
93const TEA_AFTER_THINKING_MS = 6_000
94const THINKING_TEA_GAP_MS = 45_000
95const CHAT_MODEL = 'haiku'
96const REVIEW_MODEL = 'sonnet'
97// The figures of a turn are read out at most this often.
98const BRIEF_GAP_MS = 45_000
99
100type Loudness = 'key' | 'event' | 'chatter' | 'silent'
101type VoiceMode = 'on' | 'less' | 'off'
102type Talk = StageBubble & { until: number }
103type Pending = { at: number; id: string; text: Line; loudness: Loudness; move?: 'attack' | 'cheer' }
104type Place = 'roam' | 'pane'
105type OnScreen = { first: number; last: number; of: number }
106type Site = {
107 seq: number
108 onScreen: OnScreen | null
109 columns: number
110 rows: number
111 // The line that runs while Claude works: always the transcript's last row.
112 isSpinner: boolean
113 // When its render hook last ran. A row that reads `frame` is drawn every
114 // tick, so a long pause means it has left the transcript.
115 drawnAt: number
116}
117type LimitMark = { level: number; resetMs: number }
118
119// A transcript row the stage can hang from, as its render hook sees it.
120type SiteEvent = {
121 component: string
122 requestId: string
123 viewport?: { columns: number; rows: number; isFullscreen?: boolean }
124 props: { onScreen?: OnScreen | null }
125}
126
127const stage = atom({ plugin: 'anime-cheer', key: 'stage' } as const, { caption: '', bubbles: [] } as StageView)
128// One mark per transcript row, written when the row becomes the stage's anchor
129// and when it stops being it: those two rows draw again, no other does.
130const anchorMark = atom({ plugin: 'anime-cheer', key: 'anchorMark' } as const, 0)
131const frame = atom({ plugin: 'anime-cheer', key: 'frame' } as const, 0)
132
133let world = createWorld(48, 26, 0)
134let isOpen = false
135let isDismissed = false
136let isRunning = false
137let startedAt = 0
138let tools = 0
139let teaUntil = 0
140let lastTurn = { seconds: 0, tools: 0 }
141let lastThinkingTea = 0
142let voiceMode: VoiceMode = 'on'
143let isChatty = true
144let isSpeaking = false
145let tts: string | undefined
146let voiceEngine: VoiceEngine = 'edge'
147let vox: { run?: string; url?: string; speakers?: Record<string, number> } = {}
148let talk: Talk[] = []
149let pending: Pending[] = []
150let shown = ''
151let lastCells = ''
152let nextChatAt = 0
153let nextWorkTalkAt = 0
154let nextRotateAt = 0
155let nextClockCheck = 0
156let lastToolTalk = 0
157let lastErrorTalk = 0
158let lastNightTalk = 0
159let lastAiComment = 0
160let longNotices = 0
161let hour = 12
162let liveClip = 0
163let place: Place = 'roam'
164let isHidden = false
165let isMainScreen = false
166let sites = new Map<string, Site>()
167let siteSeq = 0
168let anchorShown = ''
169let today = ''
170let day: Day = emptyDay('')
171let sessionUsage: Usage = emptyUsage()
172let turnsDone = 0
173let lastUsage: Usage | undefined
174let meter: { percent?: number; tokens?: number; window: number; limits: Limit[]; costUsd?: number } = { window: 0, limits: [] }
175let contextLevel = 0
176let limitMarks = new Map<string, LimitMark>()
177let costLevel = 0
178let lastCostUsd: number | undefined
179let anchoredAt = 0
180let lastStatus = ''
181let isStepping = false
182let hasPrompt = false
183let lastOnStage = new Map<string, number>()
184let turnDueUntil = 0
185let hasStepFailed = false
186let hasMainScreenHint = false
187let lastVoxLaunch = 0
188let isGuardOn = true
189let isNotifyOn = true
190let budgetUsd: number | undefined
191let budgetMark = 0
192let blocked: { script: string; why: string; at: number } | undefined
193let allowed: { script: string; until: number } | undefined
194let turnPrompt = ''
195let turnFiles: string[] = []
196let tasks: Task[] = []
197let tasksKey = 'tasks:none'
198let lastSecretTalk = 0
199let lastResultTalk = 0
200let lastNeedYou = 0
201let lastBrief = 0
202let isReviewing = false
203
204const rand = (lo: number, hi: number) => lo + Math.random() * (hi - lo)
205const pick = <T,>(list: readonly T[]): T | undefined => list[Math.floor(Math.random() * list.length)]
206
207const HELP = [
208 '/waifu 显示或隐藏应援团',
209 '/waifu token 用量播报:上下文、额度、花费、token',
210 '/waifu today 今天聊了几轮、干了多久、用了多少 token',
211 '/waifu budget <usd> 设花费预算,用到 50%、80%、100% 时提醒(budget off 取消)',
212 '/waifu recap 这个项目里最近做过的任务',
213 '/waifu review [dir] 快速 review 工作区的改动(dir:仓库所在的目录)',
214 '/waifu scan [dir] 扫描改动里的密钥和敏感文件',
215 '/waifu guard on|off 安全检查:危险命令先拦下',
216 '/waifu allow 放行刚被拦下的那条命令一次',
217 '/waifu notify on|off 长任务结束时弹系统通知',
218 '/waifu fortune 抽今日编码运势',
219 '/waifu list | call <name> | bye <name> | shuffle 名单、叫人、退下、换一批',
220 '/waifu roam | pane 在对话区里走动,或待在面板里',
221 '/waifu mute | less | voice 配音全关、只留关键、全开',
222 '/waifu ai on|off AI 即兴聊天',
223 '/waifu <name> <话> 和某一位聊天,例如 /waifu mai 今天好累',
224].join('\n')
225
226const LEVELS = [95, 80, 50]
227const COST_STEPS = [1, 5, 10, 20, 50, 100]
228const CAST_MAX = 2
229// The terminal charges for every color pair a new Raster shows (a few
230// thousand to start with, 160 more a second), and a stage hung from another
231// row is all new Rasters. So an anchor that holds is kept, and left for a
232// better one at most this often.
233const ANCHOR_HOLD_MS = 10_000
234const ANCHOR_STALE_MS = 1200
235// After a prompt, how long a turn (and its working line) is waited for.
236const TURN_WAIT_MS = 2500
237// Cell rows per Raster of a girl: fewer Rasters admit fewer color pairs, at
238// the price of a little more text hidden beside her.
239const BAND_ROWS = 3
240const SPINNER_LINE: OnScreen = { first: 0, last: 0, of: 1 }
241const VOX_RELAUNCH_MS = 60_000
242const SAME_WINDOW_MS = 30 * 60_000
243const VOICE_WORDS = new Map<string, VoiceMode>([
244 ['静音', 'off'],
245 ['mute', 'off'],
246 ['少说', 'less'],
247 ['less', 'less'],
248 ['声音', 'on'],
249 ['voice', 'on'],
250 ['unmute', 'on'],
251])
252
253function lineOf(girl: Girl, moment: Moment): Line | undefined {
254 return pick(girl.lines[moment] ?? [])
255}
256
257function free(): Actor[] {
258 return onStage(world).filter(actor => actor.act !== 'enter' && actor.act !== 'sleep')
259}
260
261function actorOf(id: string): Actor | undefined {
262 return onStage(world).find(actor => actor.id === id)
263}
264
265// Whether anyone can see the troupe. Out of sight they keep quiet too: no
266// bubbles, no voices, no chat model calls.
267function isShown(): boolean {
268 return place === 'roam' ? !isHidden && !isMainScreen : isOpen
269}
270
271// Work left running on purpose: a failure there is nobody's to handle.
272function quiet(work: Promise<unknown>) {
273 void work.catch(() => undefined)
274}
275
276function isLoud(loudness: Loudness): boolean {
277 if (loudness === 'silent' || voiceMode === 'off') {
278 return false
279 }
280 if (voiceMode === 'less') {
281 return loudness === 'key'
282 }
283 return loudness !== 'chatter' || Math.random() < 0.6
284}
285
286function caption(now: number): string {
287 const names = onStage(world)
288 .map(actor => GIRLS.get(actor.id)?.name)
289 .join('、')
290 const mute = voiceMode === 'off' ? ' · 🔇' : ''
291 if (world.mode === 'work') {
292 return `♪ 应援中 · ${Math.round((now - startedAt) / 1000)}s · ${tools} 次工具 · ${names}${mute}`
293 }
294 const context = meter.percent === undefined ? '' : ` · 上下文 ${meter.percent}%`
295 if (world.mode === 'tea') {
296 const eaten = lastUsage === undefined ? '' : ` · ${zhCount(totalOf(lastUsage))} token`
297 return `🍵 茶歇中 · 本轮 ${lastTurn.seconds}s · ${lastTurn.tools} 次工具${eaten}${context}${mute}`
298 }
299 return `🌸 ${names}${context} · /waifu help${mute}`
300}
301
302async function publish($: EngineInterface, now: number) {
303 const next: StageView = {
304 caption: caption(now),
305 bubbles: talk.map(({ until, ...bubble }) => bubble),
306 }
307 const key = JSON.stringify(next)
308 if (key !== shown) {
309 shown = key
310 await update($, stage, () => next)
311 }
312}
313
314async function open($: EngineInterface) {
315 try {
316 const opened = await $.ui.open({ id: PANE, title: '应援团', rows: 26, columns: 64 })
317 isOpen = opened.isPlaced
318 } catch {
319 isOpen = false
320 }
321}
322
323// The person asked for them: awake and back in view.
324async function attend($: EngineInterface, now: number) {
325 wake(world, now)
326 if (place === 'roam') {
327 isHidden = false
328 } else if (!isOpen) {
329 isDismissed = false
330 await open($)
331 }
332}
333
334// Where they live and how loud they are; who is on stage is drawn afresh
335// every session.
336async function savePrefs($: EngineInterface) {
337 await $.store.set('prefs', { place, voice: voiceMode, ai: isChatty, guard: isGuardOn, notify: isNotifyOn, budget: budgetUsd })
338}
339
340// The girls who can take the stage: imported packs, or the stand-ins.
341function available(): Girl[] {
342 return CAST.filter(girl => canDraw(girl))
343}
344
345function shuffled(): string[] {
346 return available()
347 .map(girl => ({ id: girl.id, order: Math.random() }))
348 .sort((a, b) => a.order - b.order)
349 .map(({ id }) => id)
350}
351
352const castSize = () => (Math.random() < 0.6 ? 2 : 1)
353
354async function loadPacks($: EngineInterface) {
355 let entries: { name: string; kind: string }[] = []
356 try {
357 entries = await $.fs.list(`${$.plugin.root}/packs`)
358 } catch {
359 return
360 }
361 for (const entry of entries.filter(one => one.kind === 'dir')) {
362 try {
363 addPack(await $.fs.read(`${$.plugin.root}/packs/${entry.name}/pack.json`))
364 } catch {
365 // A pack that does not read is left out.
366 }
367 }
368}
369
370async function loadPrefs($: EngineInterface, now: number) {
371 const prefs = ((await $.store.get('prefs')) ?? {}) as {
372 place?: Place
373 voice?: VoiceMode
374 ai?: boolean
375 guard?: boolean
376 notify?: boolean
377 budget?: number
378 }
379 place = prefs.place ?? 'roam'
380 voiceMode = prefs.voice ?? 'on'
381 isChatty = prefs.ai ?? true
382 isGuardOn = prefs.guard ?? true
383 isNotifyOn = prefs.notify ?? true
384 budgetUsd = typeof prefs.budget === 'number' && prefs.budget > 0 ? prefs.budget : undefined
385 world = createWorld(world.w, world.h, now)
386 world.isRoaming = place === 'roam'
387 for (const id of shuffled().slice(0, castSize())) {
388 enter(world, id, now, true)
389 }
390}
391
392async function findTts($: EngineInterface) {
393 try {
394 const engine = JSON.parse(await $.fs.read(`${$.plugin.root}/voices/engine.json`)) as {
395 tts?: string
396 engine?: VoiceEngine
397 voicevox?: { run?: string; url?: string; speakers?: Record<string, number> }
398 }
399 tts = engine.tts
400 voiceEngine = engine.engine ?? 'edge'
401 vox = engine.voicevox ?? {}
402 } catch {
403 tts = undefined
404 }
405}
406
407async function voxLive($: EngineInterface, girl: Girl, text: string, file: string): Promise<boolean> {
408 const url = vox.url ?? 'http://127.0.0.1:50021'
409 const id = girl.vox === undefined ? undefined : vox.speakers?.[`${girl.vox.speaker}|${girl.vox.style}`]
410 if (id === undefined) {
411 return false
412 }
413 const up = await $.process.run(['curl', '-s', '-m', '2', `${url}/version`], { timeoutMs: 4000 })
414 if (up.exitCode !== 0) {
415 // The engine is down: start it for the next line, not once per line.
416 const now = await $.clock.now()
417 if (vox.run !== undefined && now - lastVoxLaunch >= VOX_RELAUNCH_MS) {
418 lastVoxLaunch = now
419 await $.process.run(['/bin/sh', '-c', 'nohup "$1" --host 127.0.0.1 --port 50021 >/tmp/anime-cheer-voicevox.log 2>&1 &', 'sh', vox.run])
420 }
421 return false
422 }
423 const made = await $.process.run(
424 [
425 '/bin/sh',
426 '-c',
427 'rm -f "$3"; curl -sf -X POST "$1" | curl -sf -X POST -H "Content-Type: application/json" --data-binary @- "$2" -o "$3" && test -s "$3"',
428 'sh',
429 `${url}/audio_query?speaker=${id}&text=${encodeURIComponent(text)}`,
430 `${url}/synthesis?speaker=${id}`,
431 file,
432 ],
433 { timeoutMs: 30_000 },
434 )
435 return made.exitCode === 0
436}
437
438// Speech that is not in the catalog: synthesized now, then played.
439async function speakLive($: EngineInterface, girl: Girl, line: Line) {
440 const text = spoken(line)
441 liveClip = (liveClip + 1) % 4
442 try {
443 if (voiceEngine === 'voicevox' && girl.vox !== undefined) {
444 const file = `/tmp/anime-cheer-live-${liveClip}.wav`
445 if (await voxLive($, girl, text, file)) {
446 await $.process.run(['afplay', file], { timeoutMs: 30_000 })
447 return
448 }
449 }
450 if (tts === undefined) {
451 return
452 }
453 const voice = voiceFor(girl, text)
454 const file = `/tmp/anime-cheer-live-${liveClip}.mp3`
455 await $.process.run(['rm', '-f', file], { timeoutMs: 3000 })
456 const made = await $.process.run(
457 [tts, '--voice', voice.name, `--rate=${voice.rate}`, `--pitch=${voice.pitch}`, '--text', text, '--write-media', file],
458 { timeoutMs: 20_000 },
459 )
460 if (made.exitCode === 0) {
461 await $.process.run(['afplay', file], { timeoutMs: 30_000 })
462 }
463 } catch {
464 // No voice this time; the bubble still shows.
465 }
466}
467
468async function playVoice($: EngineInterface, girl: Girl, line: Line) {
469 if (isSpeaking) {
470 return
471 }
472 isSpeaking = true
473 try {
474 await $.audio.play({ asset: clipPath(girl, line, voiceEngine) })
475 } catch {
476 await speakLive($, girl, line)
477 } finally {
478 isSpeaking = false
479 }
480}
481
482async function say($: EngineInterface, actor: Actor | undefined, line: Line | undefined, loudness: Loudness) {
483 const girl = actor === undefined ? undefined : GIRLS.get(actor.id)
484 if (actor === undefined || girl === undefined || line === undefined || subtitle(line) === '' || !isShown()) {
485 return
486 }
487 const now = await $.clock.now()
488 const text = subtitle(line)
489 const ms = Math.min(9000, 2500 + Math.max(text.length, spoken(line).length) * 200)
490 actor.talkUntil = now + ms
491 talk = [
492 ...talk.filter(one => one.key !== actor.id),
493 { key: actor.id, name: girl.name, color: girl.color, text, x: actor.x / 2, head: actor.y - heightOf(girl), until: now + ms },
494 ]
495 await publish($, now)
496 if (isLoud(loudness)) {
497 void playVoice($, girl, line)
498 }
499}
500
501// One line from the chat model in a girl's own voice: Japanese with its
502// Chinese for the troupe, Chinese for the stand-ins; undefined when off.
503async function improvise($: EngineInterface, girl: Girl, prompt: string): Promise<Line | undefined> {
504 const isJapanese = girl.pack !== undefined
505 try {
506 const reply = await $.model.complete({
507 model: CHAT_MODEL,
508 system:
509 `你在一个程序员的终端里扮演动漫角色${girl.name}(${girl.kind}):${girl.persona}。` +
510 (isJapanese
511 ? '用日语说一句口语化的台词(不超过30个字),再给出中文翻译,只输出“日语|中文”这一行。'
512 : '只回一句口语化的话,不超过30个字。') +
513 '不加引号、不用表情符号、不提自己是AI,内容健康友善。',
514 prompt,
515 maxTokens: 160,
516 timeoutMs: 15_000,
517 })
518 if (!reply.isAnswered) {
519 return undefined
520 }
521 const clean = (text: string) => text.replace(/^["“「『]|["”」』]$/g, '').replace(/\s+/g, ' ').trim()
522 if (isJapanese) {
523 const [ja = '', zh = ''] = reply.text.split(/[||]/).map(clean)
524 return ja === '' || zh === '' ? undefined : { ja: ja.slice(0, 60), zh: zh.slice(0, 48) }
525 }
526 const text = clean(reply.text)
527 return text === '' ? undefined : text.slice(0, 48)
528 } catch {
529 // The model is not to be had (blocked, offline): the catalog speaks.
530 return undefined
531 }
532}
533
534async function comment($: EngineInterface, answer: string, notBefore: number) {
535 const now = await $.clock.now()
536 const actor = pick(free())
537 const girl = actor === undefined ? undefined : GIRLS.get(actor.id)
538 if (girl === undefined || actor === undefined || !isShown()) {
539 return
540 }
541 lastAiComment = now
542 const line = await improvise(
543 $,
544 girl,
545 `AI 助手刚帮用户完成了一轮工作,它的回复开头是:「${answer.slice(0, 400)}」。请用你的口吻对用户说一句评价或鼓励。`,
546 )
547 if (line !== undefined) {
548 pending.push({ at: Math.max((await $.clock.now()) + 500, notBefore), id: actor.id, text: line, loudness: 'key' })
549 }
550}
551
552// A girl called in takes the place of whoever has been on the stage longest.
553function bring(girl: Girl, now: number) {
554 if (actorOf(girl.id) !== undefined) {
555 return
556 }
557 const cast = onStage(world)
558 for (const extra of cast.slice(0, Math.max(0, cast.length - (CAST_MAX - 1)))) {
559 leave(world, extra.id)
560 }
561 enter(world, girl.id, now)
562}
563
564async function chatWith($: EngineInterface, girl: Girl, words: string) {
565 const now = await $.clock.now()
566 bring(girl, now)
567 wake(world, now)
568 const line = (isChatty ? await improvise($, girl, `用户对你说:「${words}」`) : undefined) ?? lineOf(girl, 'idle') ?? '……'
569 if (isShown()) {
570 pending.push({ at: (await $.clock.now()) + 300, id: girl.id, text: line, loudness: 'key' })
571 } else {
572 // Nowhere to draw her (the main screen, a pane that would not open).
573 $.ui.toast(`${girl.name}:${subtitle(line)}`)
574 }
575}
576
577async function chatter($: EngineInterface, now: number) {
578 const cast = free()
579 nextChatAt = now + rand(35_000, 80_000)
580 if (cast.length === 0) {
581 return
582 }
583 const here = new Set(cast.map(actor => actor.id))
584 const pairs = BANTER.filter(([a, , b]) => here.has(a) && here.has(b))
585 const roll = Math.random()
586 if (roll < 0.3 && pairs.length > 0) {
587 const [a, first, b, second] = pick(pairs)!
588 await say($, actorOf(a), first, 'chatter')
589 pending.push({ at: now + 4000, id: b, text: second, loudness: 'chatter' })
590 return
591 }
592 const actor = pick(cast)!
593 const girl = GIRLS.get(actor.id)!
594 const isNight = hour >= 0 && hour < 5
595 if (isNight && now - lastNightTalk > 30 * 60_000) {
596 lastNightTalk = now
597 await say($, actor, lineOf(girl, 'night'), 'event')
598 return
599 }
600 await say($, actor, lineOf(girl, roll < 0.5 ? 'tip' : 'idle'), 'chatter')
601}
602
603// Every few minutes the cast changes, working or idle: one or two on the
604// stage, sleepers and girls still walking in counted. Over Claude's work the
605// change is silent; at rest they say goodbye and hello.
606async function rotate($: EngineInterface, now: number) {
607 nextRotateAt = now + rand(2 * 60_000, 4 * 60_000)
608 const cast = onStage(world)
609 for (const actor of cast) {
610 lastOnStage.set(actor.id, now)
611 }
612 // Whoever has been away longest comes next: everyone gets her turn.
613 const waiting = available().filter(girl => actorOf(girl.id) === undefined)
614 const longest = Math.min(...waiting.map(girl => lastOnStage.get(girl.id) ?? 0))
615 const incoming = pick(waiting.filter(girl => (lastOnStage.get(girl.id) ?? 0) === longest))
616 if (incoming === undefined) {
617 return
618 }
619 // Two on the stage: one makes way, and mostly another comes. One alone:
620 // mostly she gets company, sometimes she is relieved.
621 const isFull = cast.length >= CAST_MAX
622 const outgoing = isFull || (cast.length === 1 && Math.random() < 0.3) ? pick(cast.filter(actor => actor.act !== 'enter')) : undefined
623 if (isFull && (outgoing === undefined || Math.random() >= 0.6)) {
624 if (outgoing !== undefined && cast.length > 1) {
625 await sendOff($, outgoing, now)
626 }
627 return
628 }
629 const wait = outgoing === undefined ? 0 : await sendOff($, outgoing, now)
630 pending.push({ at: now + wait, id: `enter:${incoming.id}`, text: '', loudness: 'silent' })
631 if (world.mode !== 'work') {
632 pending.push({ at: now + wait + 6000, id: incoming.id, text: lineOf(incoming, 'hello') ?? '', loudness: 'event' })
633 }
634}
635
636// A girl leaves the stage: at once over Claude's work, after her goodbye at
637// rest. Resolves how long until she has turned to go.
638async function sendOff($: EngineInterface, actor: Actor, now: number): Promise<number> {
639 if (world.mode === 'work') {
640 leave(world, actor.id)
641 return 0
642 }
643 if (actor.act === 'sleep') {
644 actor.act = 'stand'
645 }
646 await say($, actor, lineOf(GIRLS.get(actor.id)!, 'bye'), 'event')
647 pending.push({ at: now + 3000, id: `leave:${actor.id}`, text: '', loudness: 'silent' })
648 return 3200
649}
650
651async function checkClock($: EngineInterface, now: number) {
652 nextClockCheck = now + 20 * 60_000
653 try {
654 const out = await $.process.run(['date', '+%H %F'], { timeoutMs: 3000 })
655 const [h = '', date = ''] = out.stdout.trim().split(' ')
656 // No answer is noon, not midnight: nobody is told to go to bed over it.
657 hour = out.exitCode === 0 && /^\d{1,2}$/.test(h) ? Number(h) : 12
658 if (date !== '' && date !== today) {
659 today = date
660 const saved = (await $.store.get(`day:${date}`)) as Partial<Day> | undefined
661 day = { ...emptyDay(date), ...(saved?.date === date ? saved : {}) }
662 if (saved?.measure !== 'fresh') {
663 // Kept by a version that counted cache hits into the biggest turn.
664 day.biggestTurn = 0
665 day.measure = 'fresh'
666 }
667 }
668 } catch {
669 hour = 12
670 }
671}
672
673async function saveDay($: EngineInterface) {
674 if (today !== '') {
675 await $.store.set(`day:${today}`, day)
676 }
677}
678
679function addUsage(usage: TurnUsage): Usage {
680 const turn: Usage = {
681 input: usage.input_tokens,
682 output: usage.output_tokens,
683 cacheRead: usage.cache_read_input_tokens,
684 cacheWrite: usage.cache_creation_input_tokens,
685 }
686 for (const total of [sessionUsage, day]) {
687 total.input += turn.input
688 total.output += turn.output
689 total.cacheRead += turn.cacheRead
690 total.cacheWrite += turn.cacheWrite
691 }
692 return turn
693}
694
695// New figures for the context, the rate-limit windows and the cost: crossing
696// a step is read out once (unless `isQuiet`, which only takes the readings).
697async function measured(
698 $: EngineInterface,
699 context: SessionContextUsage,
700 limits: readonly SessionRateLimit[],
701 cost: SessionCost | undefined,
702 isQuiet: boolean,
703) {
704 const now = await $.clock.now()
705 meter = { percent: context.percent, tokens: context.tokens, window: context.window, limits: [...limits], costUsd: cost?.usd }
706 const alerts: Line[] = []
707 const percent = context.percent ?? 0
708 const level = LEVELS.find(mark => percent >= mark) ?? 0
709 if (level > contextLevel) {
710 alerts.push(contextAlert(level, percent))
711 }
712 contextLevel = percent < 40 ? 0 : Math.max(contextLevel, level)
713 for (const limit of limits) {
714 const at = LEVELS.find(mark => limit.percentUsed >= mark) ?? 0
715 const parsed = limit.resetsAt === undefined ? 0 : Date.parse(limit.resetsAt)
716 const resetMs = Number.isFinite(parsed) ? parsed : 0
717 const before = limitMarks.get(limit.kind)
718 // A window first seen this session is the baseline, not news; a new
719 // window (its reset moved on) starts over.
720 const isSameWindow = before !== undefined && Math.abs(resetMs - before.resetMs) < SAME_WINDOW_MS
721 const base = before === undefined ? at : isSameWindow ? before.level : 0
722 if (at > base) {
723 alerts.push(limitAlert(limit, now))
724 }
725 limitMarks.set(limit.kind, { level: Math.max(at, base), resetMs })
726 }
727 if (cost !== undefined) {
728 const reached = COST_STEPS.filter(usd => cost.usd >= usd).length
729 if (lastCostUsd === undefined) {
730 // The first reading: what a resumed session had already spent.
731 costLevel = reached
732 } else if (cost.usd > lastCostUsd) {
733 day.costUsd += cost.usd - lastCostUsd
734 }
735 lastCostUsd = cost.usd
736 if (reached > costLevel) {
737 alerts.push(costLine(COST_STEPS[reached - 1]!))
738 costLevel = reached
739 }
740 }
741 let isOverBudget = false
742 if (budgetUsd !== undefined && cost !== undefined) {
743 const used = (cost.usd / budgetUsd) * 100
744 const mark = [100, 80, 50].find(floor => used >= floor) ?? 0
745 if (mark > budgetMark) {
746 if (mark >= 100) {
747 isOverBudget = true
748 } else {
749 alerts.push(budgetLine(mark, cost.usd, budgetUsd))
750 }
751 }
752 budgetMark = Math.max(budgetMark, mark)
753 }
754 if (isQuiet || !isShown()) {
755 return
756 }
757 if (isOverBudget && cost !== undefined) {
758 quiet(onDuty($, 'budget', `已花 $${cost.usd.toFixed(2)},预算 $${budgetUsd}`, 'attack', 'key'))
759 }
760 alerts.forEach((line, i) => {
761 const actor = pick(free())
762 if (actor !== undefined) {
763 pending.push({ at: now + 500 + (i + (isOverBudget ? 1 : 0)) * 6000, id: actor.id, text: line, loudness: 'key' })
764 } else {
765 // Nobody free to say it (asleep, walking in): it is still worth knowing.
766 $.ui.toast(subtitle(line))
767 }
768 })
769}
770
771// One frame of the stage, unless the last one is still under way.
772async function step($: EngineInterface) {
773 if (isStepping) {
774 return
775 }
776 isStepping = true
777 try {
778 await advance($)
779 } catch (error) {
780 if (!hasStepFailed) {
781 hasStepFailed = true
782 $.ui.log(`anime-cheer: a frame failed: ${String(error)}`, { to: 'debug' })
783 }
784 } finally {
785 isStepping = false
786 }
787}
788
789// The world moves, people talk, the stage redraws.
790async function advance($: EngineInterface) {
791 const now = await $.clock.now()
792 if (world.mode === 'tea' && now >= teaUntil) {
793 setMode(world, isRunning ? 'work' : 'idle', now)
794 }
795 tick(world, now)
796
797 const due = pending.filter(one => one.at <= now)
798 pending = pending.filter(one => one.at > now)
799 for (const one of due) {
800 if (one.id.startsWith('leave:')) {
801 leave(world, one.id.slice('leave:'.length))
802 } else if (one.id.startsWith('enter:')) {
803 if (onStage(world).length < CAST_MAX) {
804 enter(world, one.id.slice('enter:'.length), now)
805 }
806 } else if (subtitle(one.text) !== '') {
807 const speaker = actorOf(one.id)
808 if (one.move !== undefined && speaker !== undefined && isShown()) {
809 perform(world, speaker.id, one.move, now, moveLength(speaker.id, one.move) * TICK_MS + 400)
810 }
811 await say($, speaker, one.text, one.loudness)
812 }
813 }
814 talk = talk.filter(one => one.until > now)
815
816 const isSeen = isShown()
817 if (isSeen && world.mode === 'idle' && now >= nextChatAt && talk.length === 0) {
818 await chatter($, now)
819 }
820 if (isSeen && world.mode === 'work' && now >= nextWorkTalkAt) {
821 nextWorkTalkAt = now + rand(15_000, 30_000)
822 const actor = pick(free())
823 await say($, actor, actor === undefined ? undefined : lineOf(GIRLS.get(actor.id)!, 'work'), 'chatter')
824 }
825 if (isRunning && longNotices < 2 && now - startedAt > (longNotices === 0 ? 120_000 : 300_000)) {
826 longNotices += 1
827 const actor = pick(free())
828 await say($, actor, actor === undefined ? undefined : lineOf(GIRLS.get(actor.id)!, 'long'), 'event')
829 }
830 if (world.mode !== 'tea' && now >= nextRotateAt) {
831 await rotate($, now)
832 }
833 if (now >= nextClockCheck) {
834 quiet(checkClock($, now))
835 }
836
837 if (place === 'pane' && isOpen) {
838 const cells = encode(draw(world, now))
839 if (cells !== lastCells) {
840 lastCells = cells
841 quiet($.ui.blit({ requestId: PANE, key: 'stage', cells }))
842 }
843 }
844 if (place === 'roam') {
845 await roam($)
846 }
847 await publish($, now)
848}
849
850// The row the stage would best hang from. Rows after the anchor are drawn
851// over the stage, so the girls stand in front of everything only on the
852// transcript's last row: the line that runs while Claude works, for as long
853// as the turn lasts; at rest, the newest row on screen.
854function newestRow(now: number): string {
855 let best = ''
856 let bestSeq = -1
857 for (const [key, site] of sites) {
858 if (site.isSpinner) {
859 if (isRunning && now - site.drawnAt < ANCHOR_STALE_MS) {
860 return key
861 }
862 } else if (site.onScreen !== null && site.seq > bestSeq) {
863 best = key
864 bestSeq = site.seq
865 }
866 }
867 return best
868}
869
870// Whether the stage can stay where it hangs: its row is on screen from its
871// first line, and still there (it reads `frame`, so it is drawn every tick).
872function holds(site: Site | undefined, now: number): boolean {
873 if (site === undefined || site.onScreen === null || site.onScreen.first > 0) {
874 return false
875 }
876 if (site.isSpinner && !isRunning) {
877 return false
878 }
879 return now - Math.max(site.drawnAt, anchoredAt) < ANCHOR_STALE_MS
880}
881
882// The row the stage hung from stops being its anchor, and draws without it.
883async function dropAnchor($: EngineInterface) {
884 const before = anchorShown
885 anchorShown = ''
886 if (before !== '') {
887 await update($, memberOf(anchorMark, { requestId: before }), () => 0)
888 }
889}
890
891async function roam($: EngineInterface) {
892 if (isMainScreen) {
893 // The main screen docks nothing, and a pane there is the person's to open.
894 if (!hasMainScreenHint) {
895 hasMainScreenHint = true
896 $.ui.toast('对话区漫游需要全屏布局;/waifu pane 可以打开面板')
897 }
898 if (lastStatus !== '') {
899 lastStatus = ''
900 $.ui.status(undefined)
901 }
902 return
903 }
904 const now = await $.clock.now()
905 if (hasPrompt) {
906 hasPrompt = false
907 turnDueUntil = now + TURN_WAIT_MS
908 }
909 const current = sites.get(anchorShown)
910 const isHeld = !isHidden && holds(current, now)
911 if (current !== undefined && !isHeld && now - Math.max(current.drawnAt, anchoredAt) >= ANCHOR_STALE_MS) {
912 // It stopped drawing: gone from the transcript, and no anchor again.
913 current.onScreen = null
914 }
915 const newest = isHidden ? '' : newestRow(now)
916 // An anchor that holds is left only for a better one, and not too often.
917 // While a turn runs or is about to, the better one is its working line
918 // alone: the rows of a turn come and go beneath the girls' feet, and each
919 // would be a move.
920 const isBetter = newest !== anchorShown && (sites.get(newest)?.isSpinner === true || (!isRunning && now >= turnDueUntil))
921 const id = isHeld && !(isBetter && now - anchoredAt >= ANCHOR_HOLD_MS) ? anchorShown : newest
922 if (id !== anchorShown) {
923 await dropAnchor($)
924 anchorShown = id
925 anchoredAt = now
926 if (id !== '') {
927 await update($, memberOf(anchorMark, { requestId: id }), () => now)
928 }
929 }
930 const site = sites.get(id)
931 if (site !== undefined) {
932 const rows = Math.max(10, Math.min(30, site.rows - 14))
933 const columns = Math.max(30, site.columns - 2)
934 if (columns * 2 !== world.w || rows * 2 !== world.h) {
935 resize(world, columns * 2, rows * 2)
936 }
937 }
938 // The anchor draws the next frame; the working line shows it is still there.
939 await update($, frame, n => (n + 1) % 1_000_000)
940 const status = isHidden ? '' : caption(now)
941 if (status !== lastStatus) {
942 lastStatus = status
943 $.ui.status(status === '' ? undefined : status)
944 }
945}
946
947// A transcript row's drawing, with the stage laid over the rows above it
948// when it is the stage's anchor.
949async function decorate(
950 $: EngineInterface,
951 e: SiteEvent,
952 drawn: RenderElement,
953 ui: Elements['terminal'],
954 isSpinner = false,
955): Promise<RenderElement> {
956 // A tool's call row and its result row share one requestId: the component
957 // tells them apart.
958 const key = `${e.component}:${e.requestId}`
959 const now = await $.clock.now()
960 let site = sites.get(key)
961 if (site === undefined) {
962 siteSeq += 1
963 site = { seq: siteSeq, onScreen: null, columns: 80, rows: 40, isSpinner, drawnAt: now }
964 sites.set(key, site)
965 if (sites.size > 400) {
966 const oldest = [...sites].sort((a, b) => a[1].seq - b[1].seq)[0]![0]
967 sites.delete(oldest)
968 }
969 }
970 site.onScreen = isSpinner ? SPINNER_LINE : (e.props.onScreen ?? null)
971 site.drawnAt = now
972 site.columns = e.viewport?.columns ?? site.columns
973 site.rows = e.viewport?.rows ?? site.rows
974 if (e.viewport?.isFullscreen === false) {
975 isMainScreen = true
976 }
977 // Reading its own mark is what draws this row again when it becomes the
978 // anchor or stops being it.
979 await read($, memberOf(anchorMark, { requestId: key }))
980 if (isSpinner) {
981 // The working line never says when it leaves: drawn every tick, it shows
982 // by `drawnAt` that it is still there.
983 await read($, frame)
984 }
985 const onScreen = site.onScreen
986 if (anchorShown !== key || onScreen === null) {
987 return drawn
988 }
989 await read($, frame)
990 const shownView = await read($, stage)
991 const { Box, Text, Raster } = ui
992 const rows = world.h / 2
993 const top = onScreen.last - 2 - rows + 1
994 return (
995 <Box flexDirection="column">
996 {drawn}
997 {[...world.actors]
998 .sort((x, y) => (x.id < y.id ? -1 : 1))
999 .map(actor => [actor.id, drawActor(world, actor, now)] as const)
1000 .filter((placed): placed is readonly [string, Placed] => placed[1] !== undefined)
1001 .flatMap(([id, placed]) =>
1002 // A few Rasters per girl, band by band, the same ones every frame,
1003 // so the colors they admitted stay; an empty band is hidden.
1004 spans(placed.c, BAND_ROWS).map(span =>
1005 span.cells === undefined ? (
1006 <Box key={`at-${id}-${span.band}`} display="none">
1007 <Raster key={`${id}-${span.band}`} columns={1} rows={1} cells={BLANK_CELL} />
1008 </Box>
1009 ) : (
1010 <Box key={`at-${id}-${span.band}`} position="absolute" top={top + placed.row + span.row} left={placed.left + span.left}>
1011 <Raster key={`${id}-${span.band}`} columns={span.width} rows={span.height} cells={span.cells} />
1012 </Box>
1013 ),
1014 ),
1015 )}
1016 {(shownView.bubbles ?? []).map(bubble => {
1017 const at = layout(bubble, world.w / 2)
1018 const width = Math.max(...at.lines.map(textWidth))
1019 return (
1020 <Box
1021 position="absolute"
1022 top={top + at.top}
1023 left={at.left}
1024 flexDirection="column"
1025 borderStyle="round"
1026 borderColor={bubble.color}
1027 >
1028 {at.lines.map(line => (
1029 <Text color={bubble.color}>{line + ' '.repeat(width - textWidth(line))}</Text>
1030 ))}
1031 </Box>
1032 )
1033 })}
1034 </Box>
1035 )
1036}
1037
1038// A passing remark on the tool Claude is using.
1039async function toolTalk($: EngineInterface, tool: string) {
1040 const now = await $.clock.now()
1041 if (now - lastToolTalk <= 12_000 || Math.random() >= 0.4) {
1042 return
1043 }
1044 lastToolTalk = now
1045 const kind = /Edit|Write/.test(tool)
1046 ? 'edit'
1047 : tool === 'Bash'
1048 ? 'bash'
1049 : /Read|Grep|Glob|LS/.test(tool)
1050 ? 'read'
1051 : /Web/.test(tool)
1052 ? 'web'
1053 : /Agent|Task/.test(tool)
1054 ? 'agent'
1055 : 'other'
1056 await say($, pick(free()), pick(TOOL_TALK[kind] ?? []), 'silent')
1057}
1058
1059// A tool came back with an error: someone lets fly at the bug.
1060async function toolFailed($: EngineInterface) {
1061 const now = await $.clock.now()
1062 const actor = pick(free())
1063 if (now - lastErrorTalk <= 20_000 || actor === undefined || !isShown()) {
1064 return
1065 }
1066 lastErrorTalk = now
1067 perform(world, actor.id, 'attack', now, moveLength(actor.id, 'attack') * TICK_MS + 400)
1068 await say($, actor, lineOf(GIRLS.get(actor.id)!, 'error'), 'event')
1069}
1070
1071async function thoughtDone($: EngineInterface) {
1072 const now = await $.clock.now()
1073 if (!isRunning || now - lastThinkingTea < THINKING_TEA_GAP_MS) {
1074 return
1075 }
1076 lastThinkingTea = now
1077 teaUntil = now + TEA_AFTER_THINKING_MS
1078 const { server } = setMode(world, 'tea', now)
1079 await say($, server, server === undefined ? undefined : lineOf(GIRLS.get(server.id)!, 'think'), 'event')
1080}
1081
1082async function finished($: EngineInterface, e: TurnCompleteInput) {
1083 const now = await $.clock.now()
1084 isRunning = false
1085 lastTurn = { seconds: Math.round(e.durationMs / 1000), tools }
1086 turnsDone += 1
1087 day.turns += 1
1088 day.workMs += e.durationMs
1089 if (e.usage !== undefined) {
1090 lastUsage = addUsage(e.usage)
1091 day.biggestTurn = Math.max(day.biggestTurn, freshOf(lastUsage))
1092 }
1093 quiet(saveDay($))
1094 // A turn that did something goes into the journal the recap reads.
1095 const isWork = isTask(turnPrompt, lastTurn.seconds, tools)
1096 if (isWork) {
1097 const eaten = e.usage === undefined || lastUsage === undefined ? 0 : freshOf(lastUsage)
1098 tasks = addTask(tasks, makeTask(now, turnPrompt, e.answer, lastTurn.seconds, tools, eaten, turnFiles, e.reason === 'answer'))
1099 quiet($.store.set(tasksKey, tasks))
1100 }
1101 if (e.durationMs >= 60_000 && e.reason !== 'aborted') {
1102 quiet(notify($, '应援团 · Claude Code', `干完了(${lastTurn.seconds} 秒):${turnPrompt.replace(/\s+/g, ' ').slice(0, 40)}`))
1103 }
1104 if (e.reason === 'aborted') {
1105 teaUntil = now + TEA_AFTER_ABORT_MS
1106 setMode(world, 'tea', now)
1107 const actor = pick(free())
1108 await say($, actor, actor === undefined ? undefined : lineOf(GIRLS.get(actor.id)!, 'abort'), 'event')
1109 return
1110 }
1111 teaUntil = now + TEA_AFTER_TURN_MS
1112 const { server, masseuse } = setMode(world, 'tea', now)
1113 const serverGirl = server === undefined ? undefined : GIRLS.get(server.id)
1114 const moment: Moment = e.reason === 'answer' ? 'tea' : 'error'
1115 await say($, server, serverGirl === undefined ? undefined : lineOf(serverGirl, moment), 'key')
1116 if (masseuse !== undefined) {
1117 pending.push({ at: now + 4000, id: masseuse.id, text: lineOf(GIRLS.get(masseuse.id)!, 'massage') ?? '', loudness: 'event' })
1118 }
1119 // The turn's figures follow the tea and the shoulder rub; the chat model's
1120 // remark waits until they have been read out.
1121 let quietUntil = now
1122 if (isWork && server !== undefined && e.usage !== undefined && lastUsage !== undefined && e.reason === 'answer' && now - lastBrief >= BRIEF_GAP_MS) {
1123 lastBrief = now
1124 const at = now + (masseuse === undefined ? 7000 : 9000)
1125 quiet(reportTurn($, server.id, freshOf(lastUsage), at))
1126 quietUntil = at + 6000
1127 }
1128 if (isChatty && isShown() && e.reason === 'answer' && e.answer.length > 30 && now - lastAiComment > 90_000) {
1129 quiet(comment($, e.answer, quietUntil))
1130 }
1131}
1132
1133// ── On duty: guarding commands, scanning, reviewing, recalling, reporting ──
1134
1135// Someone on the stage takes a duty: her move, her line with the particulars.
1136// Resolves her name, or undefined when nobody is there to do it.
1137async function onDuty(
1138 $: EngineInterface,
1139 duty: Duty,
1140 detail: string | undefined,
1141 move: 'attack' | 'cheer' | undefined,
1142 loudness: Loudness,
1143 who?: string,
1144): Promise<string | undefined> {
1145 const actor = (who === undefined ? undefined : actorOf(who)) ?? pick(free()) ?? pick(onStage(world))
1146 if (actor === undefined || !isShown()) {
1147 return undefined
1148 }
1149 const now = await $.clock.now()
1150 if (actor.act === 'sleep') {
1151 wake(world, now)
1152 }
1153 if (move !== undefined) {
1154 perform(world, actor.id, move, now, moveLength(actor.id, move) * TICK_MS + 400)
1155 }
1156 const line = dutyLine(actor.id, duty)
1157 await say($, actor, detail === undefined ? line : withDetail(line, detail), loudness)
1158 return GIRLS.get(actor.id)?.name
1159}
1160
1161async function gitText($: EngineInterface, argv: readonly string[], cwd?: string): Promise<string | undefined> {
1162 try {
1163 const ran = await $.process.run(['git', ...argv], { timeoutMs: 15_000, ...(cwd === undefined ? {} : { cwd }) })
1164 return ran.exitCode === 0 ? ran.stdout : undefined
1165 } catch {
1166 return undefined
1167 }
1168}
1169
1170// The repository a command means: the directory named, the session's own, or
1171// the only one right beneath it. Without one, `hint` says why.
1172async function repoOf($: EngineInterface, dir: string): Promise<{ root?: string; hint?: string }> {
1173 if (dir !== '') {
1174 return (await gitText($, ['rev-parse', '--git-dir'], dir)) === undefined ? { hint: `「${dir}」不是 git 仓库。` } : { root: dir }
1175 }
1176 if ((await gitText($, ['rev-parse', '--git-dir'])) !== undefined) {
1177 return { root: '.' }
1178 }
1179 const below: string[] = []
1180 try {
1181 const entries = await $.fs.list()
1182 for (const entry of entries.filter(one => one.kind === 'dir' && !one.name.startsWith('.')).slice(0, 40)) {
1183 if (await $.fs.exists(`${entry.name}/.git`)) {
1184 below.push(entry.name)
1185 }
1186 }
1187 } catch {
1188 // A directory that does not list has no repositories to offer.
1189 }
1190 if (below.length === 1) {
1191 return { root: below[0] }
1192 }
1193 return { hint: below.length === 0 ? '这里不是 git 仓库。' : `这里不是 git 仓库,下面有 ${below.join('、')}:在命令后面写上目录名。` }
1194}
1195
1196// Files git does not track yet, as the diffs that would add them; `unread`
1197// names the ones left out (not text, or too big).
1198async function newFiles($: EngineInterface, root: string, paths: readonly string[]): Promise<{ diff: string; unread: string[] }> {
1199 const listed = (await gitText($, ['ls-files', '--others', '--exclude-standard', ...(paths.length === 0 ? [] : ['--', ...paths])], root)) ?? ''
1200 const names = listed.split('\n').filter(name => name !== '')hooks/art.ts 492 lines1// Drawing the stage: girls in poses, the desk where "you" sit, and the bits
2// that float about. Two pixels per terminal cell (▀ ▄), so a stage of
3// `columns × rows` cells is a `columns × rows*2` grid of colors.
4
5import { CAST, type Girl } from './cast'
6
7export const SPRITE_W = 12
8export const SPRITE_H = 16
9
10const NONE = -1
11const DEFAULT = 0x01000000
12
13const BASE: Readonly<Record<string, number>> = {
14 S: 0xffe3d0,
15 s: 0xf0bfa4,
16 E: 0x3b2a4a,
17 B: 0xff9db4,
18 M: 0xe58a96,
19 W: 0xffffff,
20 K: 0x2a2833,
21 G: 0x9e9eb8,
22 Y: 0xffd54f,
23 C: 0xffffff,
24 R: 0xff4f7b,
25 F: 0x5d4037,
26}
27const MOUTH_OPEN = 0x8e2443
28const TEA = 0x9ccc65
29const STEAM = 0xe6e6ee
30const HEART = 0xff5c8a
31const NOTE_COLORS = [0xffe066, 0x8ee8ff, 0xff8ac6, 0xb9f6ca]
32const Z_COLOR = 0xb3c7ff
33
34export class Canvas {
35 readonly px: Int32Array
36
37 constructor(
38 readonly w: number,
39 readonly h: number,
40 ) {
41 this.px = new Int32Array(w * h).fill(NONE)
42 }
43
44 dot(x: number, y: number, c: number) {
45 const X = Math.round(x)
46 const Y = Math.round(y)
47 if (X >= 0 && Y >= 0 && X < this.w && Y < this.h) {
48 this.px[Y * this.w + X] = c
49 }
50 }
51
52 map(rows: readonly string[], x: number, y: number, colors: Readonly<Record<string, number>>) {
53 rows.forEach((row, j) => {
54 for (let i = 0; i < row.length; i++) {
55 const c = colors[row[i]!]
56 if (c !== undefined) {
57 this.dot(x + i, y + j, c)
58 }
59 }
60 })
61 }
62}
63
64export type Arms = 'down' | 'up' | 'out' | 'wave' | 'cup' | 'hidden'
65export type Legs = 'stand' | 'liftL' | 'liftR'
66
67export type Pose = {
68 arms: Arms
69 legs: Legs
70 eyesShut: boolean
71 mouthOpen: boolean
72 bob: number
73 flip: boolean
74}
75
76type Look = {
77 colors: Int32Array
78 letters: string[]
79 sleeve: number
80 hand: number
81 skin: number
82}
83
84function lookOf(girl: Girl): Look {
85 const palette: Record<string, number> = { ...BASE, ...(girl.palette ?? {}) }
86 const skin = palette.S!
87 palette.Q ??= skin
88 palette.L ??= skin
89 palette.P ??= palette.A ?? skin
90 const colors = new Int32Array(SPRITE_W * SPRITE_H).fill(NONE)
91 const letters: string[] = []
92 ;(girl.sprite ?? []).forEach((row, y) => {
93 for (let x = 0; x < SPRITE_W; x++) {
94 const letter = row[x] ?? '.'
95 letters.push(letter)
96 const c = palette[letter]
97 if (c !== undefined) {
98 colors[y * SPRITE_W + x] = c
99 }
100 }
101 })
102 return { colors, letters, sleeve: palette.P!, hand: palette.Q!, skin }
103}
104
105const LOOKS = new Map(CAST.filter(girl => girl.sprite !== undefined).map(girl => [girl.id, lookOf(girl)]))
106
107export type PackFrame = { w: number; h: number; ax: number; ay: number; px: Int32Array }
108
109// An imported sprite sheet: its frames, which of them make each animation,
110// and which way the drawings face (1 right, -1 left).
111export type Pack = {
112 id: string
113 facing: 1 | -1
114 frames: PackFrame[]
115 anims: Readonly<Record<string, readonly number[]>>
116 height: number
117 isFloating: boolean
118}
119
120const PACKS = new Map<string, Pack>()
121
122export function addPack(json: string): Pack {
123 const raw = JSON.parse(json) as {
124 id: string
125 facing?: number
126 float?: boolean
127 frames: { w: number; h: number; ax: number; ay: number; px: string }[]
128 anims?: Record<string, number[]>
129 }
130 const frames = raw.frames.map(frame => ({
131 ...frame,
132 px: new Int32Array(unbase64(frame.px).buffer, 0, frame.w * frame.h),
133 }))
134 const anims = raw.anims ?? {}
135 const idle = anims.idle ?? frames.map((_, i) => i)
136 const pack: Pack = {
137 id: raw.id,
138 facing: raw.facing === -1 ? -1 : 1,
139 frames,
140 anims,
141 height: Math.max(1, ...idle.map(i => frames[i]?.ay ?? 0)),
142 isFloating: raw.float === true,
143 }
144 PACKS.set(pack.id, pack)
145 BOXES.clear()
146 return pack
147}
148
149export function packOf(girl: Girl | undefined): Pack | undefined {
150 return girl?.pack === undefined ? undefined : PACKS.get(girl.pack)
151}
152
153// Imported packs only; the hand-drawn girls stand in while none is loaded.
154export function canDraw(girl: Girl | undefined): boolean {
155 if (girl === undefined) {
156 return false
157 }
158 return girl.pack === undefined ? PACKS.size === 0 && girl.sprite !== undefined : PACKS.has(girl.pack)
159}
160
161// `reach` is half her width standing: how far apart girls keep. `stride` is
162// half her width walking: how far past the edge she is out of sight.
163export type Box = { w: number; h: number; cx: number; reach: number; stride: number }
164
165const BOXES = new Map<string, Box>()
166
167// The fixed box a girl is drawn in on the stage: wide enough for every frame
168// of hers either way round, tall enough for a bob above her. Only what is
169// drawn in it covers the text beneath, so a roomy box costs nothing.
170export function boxOf(girl: Girl): Box {
171 const known = BOXES.get(girl.id)
172 if (known !== undefined) {
173 return known
174 }
175 const pack = packOf(girl)
176 const plain = SPRITE_W / 2 + 1
177 let box: Box = { w: SPRITE_W + 2, h: SPRITE_H + 4, cx: plain, reach: plain, stride: plain }
178 if (pack !== undefined) {
179 const framesOf = (anim: string) => (pack.anims[anim] ?? []).map(i => pack.frames[i]!)
180 const halfOf = (frames: PackFrame[]) => Math.max(1, ...frames.map(frame => Math.max(frame.ax, frame.w - 1 - frame.ax)))
181 const frames = Object.values(pack.anims).flatMap(list => list.map(i => pack.frames[i]!))
182 const half = halfOf(frames)
183 const tall = Math.max(...frames.map(frame => frame.ay)) + 4
184 const reach = halfOf(framesOf('idle'))
185 box = { w: half * 2 + 1, h: tall + (tall % 2), cx: half, reach, stride: Math.max(reach, halfOf(framesOf('walk'))) }
186 }
187 BOXES.set(girl.id, box)
188 return box
189}
190
191export function heightOf(girl: Girl | undefined): number {
192 return packOf(girl)?.height ?? SPRITE_H
193}
194
195export function animLength(pack: Pack, anim: string): number {
196 return (pack.anims[anim] ?? pack.anims.idle ?? [0]).length
197}
198
199// One frame of a pack's animation, feet at (x, feet); `faceLeft` turns her.
200export function drawPack(c: Canvas, pack: Pack, anim: string, step: number, x: number, feet: number, faceLeft: boolean) {
201 const list = pack.anims[anim] ?? pack.anims.idle ?? [0]
202 const frame = pack.frames[list[Math.abs(step) % list.length] ?? 0]
203 if (frame === undefined) {
204 return
205 }
206 const mirror = faceLeft ? pack.facing === 1 : pack.facing === -1
207 const left = Math.round(x) - (mirror ? frame.w - 1 - frame.ax : frame.ax)
208 const top = Math.round(feet) - frame.ay + 1
209 for (let y = 0; y < frame.h; y++) {
210 for (let fx = 0; fx < frame.w; fx++) {
211 const color = frame.px[y * frame.w + fx]!
212 if (color !== NONE) {
213 c.dot(left + (mirror ? frame.w - 1 - fx : fx), top + y, color)
214 }
215 }
216 }
217}
218
219// A girl standing with her feet at (x, feet): x is her middle column.
220export function drawGirl(c: Canvas, girl: Girl, x: number, feet: number, pose: Pose) {
221 const look = LOOKS.get(girl.id)
222 if (look === undefined) {
223 return
224 }
225 const left = Math.round(x) - SPRITE_W / 2
226 const top = Math.round(feet) - (SPRITE_H - 1) - pose.bob
227 const put = (sx: number, sy: number, color: number) =>
228 c.dot(left + (pose.flip ? SPRITE_W - 1 - sx : sx), top + sy, color)
229
230 for (let sy = 0; sy < SPRITE_H; sy++) {
231 for (let sx = 0; sx < SPRITE_W; sx++) {
232 const i = sy * SPRITE_W + sx
233 const letter = look.letters[i]
234 let color = look.colors[i]!
235 if (color === NONE) {
236 continue
237 }
238 if ((letter === 'P' || letter === 'Q') && pose.arms !== 'down' && pose.arms !== 'cup') {
239 if (pose.arms !== 'wave' || sx > SPRITE_W / 2) {
240 continue
241 }
242 }
243 if (sy >= 13 && pose.legs !== 'stand') {
244 continue
245 }
246 if (letter === 'I' && pose.eyesShut) {
247 color = look.skin
248 }
249 if (letter === 'M' && pose.mouthOpen) {
250 color = MOUTH_OPEN
251 }
252 put(sx, sy, color)
253 }
254 }
255
256 if (pose.legs !== 'stand') {
257 const shin = look.colors[13 * SPRITE_W + 4]!
258 const sock = look.colors[14 * SPRITE_W + 4]!
259 const shoe = look.colors[15 * SPRITE_W + 4]!
260 for (const side of ['L', 'R'] as const) {
261 const lx = side === 'L' ? 4 : 7
262 const out = side === 'L' ? -1 : 1
263 const isUp = pose.legs === (side === 'L' ? 'liftL' : 'liftR')
264 put(lx, 13, shin)
265 if (isUp) {
266 put(lx, 14, shoe)
267 put(lx + out, 14, shoe)
268 } else {
269 put(lx, 14, sock)
270 put(lx, 15, shoe)
271 put(lx + out, 15, shoe)
272 }
273 }
274 }
275
276 const { sleeve, hand } = look
277 if (pose.arms === 'up' || pose.arms === 'wave') {
278 if (pose.arms === 'up') {
279 put(1, 7, sleeve)
280 put(0, 6, hand)
281 put(0, 5, hand)
282 }
283 put(10, 7, sleeve)
284 put(11, 6, hand)
285 put(11, 5, hand)
286 }
287 if (pose.arms === 'out') {
288 put(1, 9, sleeve)
289 put(0, 9, hand)
290 put(10, 9, sleeve)
291 put(11, 9, hand)
292 }
293 if (pose.arms === 'cup') {
294 put(10, 9, TEA)
295 put(11, 9, BASE.W!)
296 put(10, 10, BASE.W!)
297 put(11, 10, BASE.W!)
298 }
299}
300
301// Pixels are half as wide as tall on the stage: small pictures draw doubled.
302const wide = (rows: readonly string[]) => rows.map(row => row.replace(/./g, ch => ch + ch))
303
304export function drawCup(c: Canvas, x: number, y: number, frame: number) {
305 c.map(wide(['WG', 'WW']), x, y - 1, { W: BASE.W!, G: TEA })
306 c.map(wide(['S']), x + 2 * Math.round(Math.sin(frame * 0.8)), y - 3, { S: STEAM })
307 c.map(wide(['S']), x + 2 + 2 * Math.round(Math.sin(frame * 0.8 + 2)), y - 4, { S: STEAM })
308}
309
310export function drawHeart(c: Canvas, x: number, y: number) {
311 c.map(wide(['X.X', 'XXX', '.X.']), x, y, { X: HEART })
312}
313
314export function drawNote(c: Canvas, x: number, y: number, frame: number) {
315 c.map(wide(['.XX', '.X.', 'XX.']), x, y, { X: NOTE_COLORS[frame % NOTE_COLORS.length]! })
316}
317
318export function drawZ(c: Canvas, x: number, y: number) {
319 c.map(wide(['XXX', '.X.', 'XXX']), x, y, { X: Z_COLOR })
320}
321
322const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
323
324const DECODE = new Int16Array(128).fill(0)
325for (let i = 0; i < ALPHABET.length; i++) {
326 DECODE[ALPHABET.charCodeAt(i)] = i
327}
328
329function unbase64(text: string): Uint8Array {
330 const clean = text.replace(/=+$/, '')
331 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
332 let at = 0
333 let bits = 0
334 let value = 0
335 for (let i = 0; i < clean.length; i++) {
336 value = ((value << 6) | DECODE[clean.charCodeAt(i)]!) & 0xffffff
337 bits += 6
338 if (bits >= 8) {
339 bits -= 8
340 out[at++] = (value >> bits) & 0xff
341 }
342 }
343 return out
344}
345
346function base64(bytes: Uint8Array): string {
347 const out: string[] = []
348 for (let i = 0; i < bytes.length; i += 3) {
349 const a = bytes[i]!
350 const b = i + 1 < bytes.length ? bytes[i + 1]! : 0
351 const d = i + 2 < bytes.length ? bytes[i + 2]! : 0
352 const n = (a << 16) | (b << 8) | d
353 out.push(
354 ALPHABET[(n >> 18) & 63]!,
355 ALPHABET[(n >> 12) & 63]!,
356 i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '=',
357 i + 2 < bytes.length ? ALPHABET[n & 63]! : '=',
358 )
359 }
360 return out.join('')
361}
362
363// Which quarters of a cell a glyph fills, by bit (top-left 8, top-right 4,
364// bottom-left 2, bottom-right 1): the 16 quadrant block elements.
365const QUADRANTS = [
366 0x20, 0x2597, 0x2596, 0x2584, 0x259d, 0x2590, 0x259e, 0x259f, 0x2598, 0x259a, 0x258c, 0x2599, 0x2580, 0x259c, 0x259b, 0x2588,
367]
368
369const distance = (a: number, b: number) =>
370 (((a >> 16) & 255) - ((b >> 16) & 255)) ** 2 + (((a >> 8) & 255) - ((b >> 8) & 255)) ** 2 + ((a & 255) - (b & 255)) ** 2
371
372// The glyph, foreground and background that show four pixels best with two
373// colors; the background may be the terminal's own, the foreground may not.
374//
375// The terminal admits each new (foreground, background) pair of a Raster at
376// a price, so two colors are always written in one order (the lower one in
377// front, the glyph turned inside out to match): half as many pairs.
378function quadrant(sub: readonly number[]): [number, number, number] {
379 const seen = [...new Set(sub)]
380 if (seen.length === 1 && seen[0] === NONE) {
381 return [0x20, DEFAULT, DEFAULT]
382 }
383 let best = { mask: 0, fg: DEFAULT, bg: NONE }
384 let bestError = Infinity
385 for (const fg of seen) {
386 if (fg === NONE) {
387 continue
388 }
389 for (const bg of seen.length === 1 ? [NONE] : seen) {
390 if (bg === fg) {
391 continue
392 }
393 let mask = 0
394 let error = 0
395 sub.forEach((pixel, i) => {
396 const bit = 8 >> i
397 if (pixel === fg) {
398 mask |= bit
399 } else if (pixel === bg) {
400 return
401 } else if (pixel === NONE) {
402 error += 1e6
403 } else if (bg === NONE || distance(pixel, fg) <= distance(pixel, bg)) {
404 mask |= bit
405 error += distance(pixel, fg)
406 } else {
407 error += distance(pixel, bg)
408 }
409 })
410 if (error < bestError) {
411 bestError = error
412 best = { mask, fg, bg }
413 }
414 }
415 }
416 if (best.mask === 15 || best.bg === NONE) {
417 return [QUADRANTS[best.mask]!, best.fg, DEFAULT]
418 }
419 return best.fg < best.bg ? [QUADRANTS[best.mask]!, best.fg, best.bg] : [QUADRANTS[15 - best.mask]!, best.bg, best.fg]
420}
421
422function quadrantAt(c: Canvas, row: number, column: number): [number, number, number] {
423 const top = row * 2 * c.w + column * 2
424 const bottom = top + c.w
425 return quadrant([c.px[top]!, c.px[top + 1]!, c.px[bottom]!, c.px[bottom + 1]!])
426}
427
428function encodeRect(c: Canvas, column0: number, row0: number, columns: number, rows: number): string {
429 const view = new DataView(new ArrayBuffer(columns * rows * 12))
430 let at = 0
431 for (let row = row0; row < row0 + rows; row++) {
432 for (let column = column0; column < column0 + columns; column++) {
433 const [glyph, fg, bg] = quadrantAt(c, row, column)
434 view.setUint32(at, glyph, true)
435 view.setUint32(at + 4, fg, true)
436 view.setUint32(at + 8, bg, true)
437 at += 12
438 }
439 }
440 return base64(new Uint8Array(view.buffer))
441}
442
443// The canvas as a Raster's `cells`: one cell per 2x2 block of pixels.
444export function encode(c: Canvas): string {
445 return encodeRect(c, 0, 0, Math.floor(c.w / 2), Math.floor(c.h / 2))
446}
447
448// One empty cell: a space on the terminal's own colors.
449export const BLANK_CELL = base64(new Uint8Array(Uint32Array.of(0x20, DEFAULT, DEFAULT).buffer))
450
451// `row` is the first cell row drawn; `band` numbers the bands from the top.
452export type Span =
453 | { band: number; row: number; left: number; width: number; height: number; cells: string }
454 | { band: number; row: number; cells: undefined }
455
456// The canvas cut into bands of `size` cell rows, each trimmed to what is
457// drawn in it, so the text beneath shows around the drawing; a band with
458// nothing drawn has no cells. One row per band hides the least text; a few
459// rows per band are fewer Rasters, and so fewer color pairs to admit.
460export function spans(c: Canvas, size = 1): Span[] {
461 const columns = Math.floor(c.w / 2)
462 const rows = Math.floor(c.h / 2)
463 const isDrawn = (row: number, column: number) => {
464 const top = row * 2 * c.w + column * 2
465 const bottom = top + c.w
466 return c.px[top] !== NONE || c.px[top + 1] !== NONE || c.px[bottom] !== NONE || c.px[bottom + 1] !== NONE
467 }
468 const out: Span[] = []
469 for (let band = 0; band * size < rows; band++) {
470 let left = columns
471 let right = -1
472 let top = rows
473 let bottom = -1
474 for (let row = band * size; row < Math.min(rows, (band + 1) * size); row++) {
475 for (let column = 0; column < columns; column++) {
476 if (isDrawn(row, column)) {
477 left = Math.min(left, column)
478 right = Math.max(right, column)
479 top = Math.min(top, row)
480 bottom = Math.max(bottom, row)
481 }
482 }
483 }
484 out.push(
485 right < 0
486 ? { band, row: band * size, cells: undefined }
487 : { band, row: top, left, width: right - left + 1, height: bottom - top + 1, cells: encodeRect(c, left, top, right - left + 1, bottom - top + 1) },
488 )
489 }
490 return out
491}
492hooks/bubble.ts 59 lines1// Laying a speech bubble out in terminal cells: CJK and kana take two.
2
3import type { StageBubble } from '../types'
4
5function cellWidth(cp: number): number {
6 const isWide =
7 (cp >= 0x1100 && cp <= 0x115f) ||
8 (cp >= 0x2e80 && cp <= 0xa4cf) ||
9 (cp >= 0xac00 && cp <= 0xd7a3) ||
10 (cp >= 0xf900 && cp <= 0xfaff) ||
11 (cp >= 0xfe30 && cp <= 0xfe4f) ||
12 (cp >= 0xff00 && cp <= 0xff60) ||
13 (cp >= 0xffe0 && cp <= 0xffe6) ||
14 cp >= 0x1f300
15 return isWide ? 2 : 1
16}
17
18export function textWidth(text: string): number {
19 let width = 0
20 for (const ch of text) {
21 width += cellWidth(ch.codePointAt(0)!)
22 }
23 return width
24}
25
26export function wrap(text: string, max: number): string[] {
27 const lines: string[] = []
28 let line = ''
29 let width = 0
30 for (const ch of text) {
31 const w = cellWidth(ch.codePointAt(0)!)
32 if (width + w > max && line !== '') {
33 lines.push(line)
34 line = ''
35 width = 0
36 }
37 line += ch
38 width += w
39 }
40 if (line !== '') {
41 lines.push(line)
42 }
43 return lines
44}
45
46export type BubbleLayout = { top: number; left: number; lines: string[] }
47
48// Above the speaker's head, kept inside the stage; `head` is in pixels.
49export function layout(bubble: StageBubble, columns: number): BubbleLayout {
50 const all = wrap(`${bubble.name}:${bubble.text}`, Math.max(8, Math.min(24, columns - 4)))
51 // Four lines at most: what does not fit ends in an ellipsis.
52 const lines = all.length <= 4 ? all : [...all.slice(0, 3), `${[...all[3]!].slice(0, -1).join('')}…`]
53 const width = Math.max(...lines.map(textWidth)) + 2
54 const height = lines.length + 2
55 const top = Math.max(0, Math.floor(bubble.head / 2) - height + 1)
56 const left = Math.min(Math.max(0, Math.round(bubble.x) - Math.floor(width / 2)), Math.max(0, columns - width))
57 return { top, left, lines }
58}
59hooks/duty.ts 110 lines1// What the troupe says on duty: guarding commands, scanning for secrets,
2// reviewing code, reporting test runs and recalling earlier tasks. Japanese
3// to speak, Chinese to show; the stand-ins without a pack speak Chinese.
4
5import type { Line } from './cast'
6
7export type Duty =
8 | 'guard'
9 | 'secret'
10 | 'scanClean'
11 | 'scanFound'
12 | 'reviewStart'
13 | 'reviewGood'
14 | 'reviewBad'
15 | 'testPass'
16 | 'testFail'
17 | 'buildFail'
18 | 'pushDone'
19 | 'needYou'
20 | 'recap'
21 | 'budget'
22
23const j = (ja: string, zh: string): Line => ({ ja, zh })
24
25const MAI: Readonly<Record<Duty, Line>> = {
26 guard: j('待って!そのコマンドは危険よ、止めたわ!', '等等!这条命令很危险,我拦下了!'),
27 secret: j('あら、秘密の鍵が見えてるわよ?', '哎呀,密钥露出来了哦?'),
28 scanClean: j('安全チェック完了、問題なし!', '安全检查完毕,没问题!'),
29 scanFound: j('怪しいものを見つけたわ!', '发现可疑的东西了!'),
30 reviewStart: j('コードレビュー、任せて!', '代码 review 交给我!'),
31 reviewGood: j('いいコードね、合格よ!', '代码不错,合格!'),
32 reviewBad: j('ここ、直した方がいいわよ', '这里最好改一下'),
33 testPass: j('テスト全部通ったわ、日本一!', '测试全过了,日本第一!'),
34 testFail: j('テストが落ちたわ…もう一回!', '测试挂了…再来一次!'),
35 buildFail: j('ビルドが失敗したわ、ログを見て!', '构建失败了,看看日志!'),
36 pushDone: j('プッシュ完了よ!', '推送完成!'),
37 needYou: j('ねぇ、あなたの出番よ!', '喂,该你出场了!'),
38 recap: j('前回の続きからいきましょ!', '接着上次的来吧!'),
39 budget: j('予算オーバーよ、気をつけて!', '超预算了,注意点!'),
40}
41
42const LINES: Readonly<Record<string, Readonly<Record<Duty, Line>>>> = {
43 mai: MAI,
44 maiq: MAI,
45 bunny: {
46 guard: j('お待ちなさい!危険なコマンドは許しませんわ!', '给我等等!危险的命令本小姐不许!'),
47 secret: j('秘密の鍵が丸見えですわよ?', '密钥都露出来了哦?'),
48 scanClean: j('安全チェック、問題ありませんわ', '安全检查,没有问题'),
49 scanFound: j('怪しいものがありますわね', '有可疑的东西呢'),
50 reviewStart: j('わたくしがレビューして差し上げますわ', '本小姐来帮你 review'),
51 reviewGood: j('上出来ですわ、合格!', '做得不错,合格!'),
52 reviewBad: j('ここは直しなさい、下僕', '这里给我改掉,仆人'),
53 testPass: j('テストは全部通りましたわ、おーっほっほ!', '测试全过了,哦呵呵!'),
54 testFail: j('テストが落ちましたわよ、だらしない!', '测试挂了,真不像话!'),
55 buildFail: j('ビルドが失敗しましたわ', '构建失败了'),
56 pushDone: j('プッシュ完了ですわ', '推送完成了'),
57 needYou: j('下僕、返事をなさい!', '仆人,快回话!'),
58 recap: j('前回の続きですわね', '接着上次的做吧'),
59 budget: j('予算を超えましたわよ!', '超预算了哦!'),
60 },
61 countess: {
62 guard: j('お待ちなさい。そのコマンドは危険よ', '等一下,这条命令很危险'),
63 secret: j('秘密が漏れているわよ', '秘密泄露了哦'),
64 scanClean: j('調べたわ。問題はないわね', '查过了,没有问题'),
65 scanFound: j('怪しい匂いがするわ', '有可疑的味道'),
66 reviewStart: j('コードを見せてごらんなさい', '把代码给我看看'),
67 reviewGood: j('美しいコードね、合格よ', '漂亮的代码,合格'),
68 reviewBad: j('ここは直すべきね', '这里该改一改'),
69 testPass: j('テストは全部通ったわ、素晴らしい', '测试全过了,很好'),
70 testFail: j('テストが落ちたわ…血が騒ぐわね', '测试挂了…血液在沸腾呢'),
71 buildFail: j('ビルドが失敗したわ', '构建失败了'),
72 pushDone: j('プッシュ、終わったわ', '推送完成了'),
73 needYou: j('あなたの返事を待っているわ', '在等你的回复呢'),
74 recap: j('前回の続きを始めましょう', '接着上次的开始吧'),
75 budget: j('予算を超えたわ。贅沢ね', '超预算了,真奢侈'),
76 },
77}
78
79// The stand-ins and any girl without lines of her own.
80const PLAIN: Readonly<Record<Duty, Line>> = {
81 guard: '等等!这条命令很危险,我拦下了!',
82 secret: '这里好像有密钥,小心别提交',
83 scanClean: '安全检查完毕,没问题',
84 scanFound: '发现可疑的东西了',
85 reviewStart: '我来看看代码',
86 reviewGood: '代码不错,合格',
87 reviewBad: '这里最好改一下',
88 testPass: '测试全过了!',
89 testFail: '测试挂了…',
90 buildFail: '构建失败了',
91 pushDone: '推送完成',
92 needYou: '该你出场了',
93 recap: '接着上次的来吧',
94 budget: '超预算了,注意点',
95}
96
97export function dutyLine(girlId: string, duty: Duty): Line {
98 return LINES[girlId]?.[duty] ?? PLAIN[duty]
99}
100
101// Every duty line of a girl, for recording.
102export function dutyLines(girlId: string): Line[] {
103 return Object.values(LINES[girlId] ?? PLAIN)
104}
105
106// The same voice with more to read: the recorded Japanese, a longer Chinese.
107export function withDetail(line: Line, detail: string): Line {
108 return typeof line === 'string' ? `${line}(${detail})` : { ja: line.ja, zh: `${line.zh}(${detail})` }
109}
110hooks/guard.ts 758 lines1// Security checks: shell commands that should not run unasked, and secrets
2// that should not be written or committed. Pure: the hooks module asks here,
3// then blocks, warns or reports. Pattern matching is best effort, not a sandbox.
4
5export type Risk = { rule: string; zh: string }
6
7// ── Reading a shell script ───────────────────────────────────────────────
8
9// One command of a pipeline: its name (no path, no wrapper such as `sudo`),
10// its arguments with the quotes taken off, and both as one text.
11type Stage = { name: string; args: string[]; text: string }
12// Stages joined by `|`, and what joins the pipeline to the next one.
13type Pipeline = { stages: Stage[]; then: '&&' | '||' | ';' | '' }
14type Script = { pipelines: Pipeline[]; inner: string[] }
15type Heredoc = { row: string; body: string }
16
17// A heredoc's body is data (a file being written, a script for python), not
18// shell: left out, unless a shell reads it (`bash <<EOF`).
19function readHeredocs(script: string): { text: string; docs: Heredoc[] } {
20 const kept: string[] = []
21 const docs: Heredoc[] = []
22 let until: string | undefined
23 let isShell = false
24 let body: string[] = []
25 let opener = ''
26 for (const row of script.split('\n')) {
27 if (until !== undefined) {
28 if (row.trim() === until) {
29 docs.push({ row: opener, body: body.join('\n') })
30 until = undefined
31 } else {
32 body.push(row)
33 if (isShell) {
34 kept.push(row)
35 }
36 }
37 continue
38 }
39 kept.push(row)
40 const here = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(row)
41 if (here !== null) {
42 until = here[2]
43 opener = row
44 body = []
45 isShell = /(?:^|[;&|]\s*|\bsudo\s+)(?:ba|z|da|k)?sh\b[^;&|]*<</.test(row)
46 }
47 }
48 if (until !== undefined) {
49 docs.push({ row: opener, body: body.join('\n') })
50 }
51 return { text: kept.join('\n'), docs }
52}
53
54export function withoutHeredocs(script: string): string {
55 return readHeredocs(script).text
56}
57
58// Where the `(` at `from` closes; the end of the text when it never does.
59function closing(text: string, from: number): number {
60 let depth = 0
61 let quote = ''
62 for (let i = from; i < text.length; i++) {
63 const ch = text[i]!
64 if (quote !== '') {
65 if (ch === '\\' && quote === '"') {
66 i += 1
67 } else if (ch === quote) {
68 quote = ''
69 }
70 } else if (ch === '\\') {
71 i += 1
72 } else if (ch === '"' || ch === "'") {
73 quote = ch
74 } else if (ch === '(') {
75 depth += 1
76 } else if (ch === ')') {
77 depth -= 1
78 if (depth === 0) {
79 return i
80 }
81 }
82 }
83 return text.length
84}
85
86const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', 'exec', 'time', 'nohup', 'nice', 'env', 'xargs', 'timeout', 'gtimeout', 'caffeinate', 'then', 'do', 'else', 'if', 'while', '!'])
87// The wrappers' own flags that take a value.
88const VALUE_FLAGS: Readonly<Record<string, RegExp>> = {
89 sudo: /^-[ugCDRTUph]$/,
90 doas: /^-[uC]$/,
91 nice: /^-n$/,
92 env: /^-[uCS]$/,
93 xargs: /^-[InPLdEs]$/,
94 timeout: /^-[sk]$/,
95 gtimeout: /^-[sk]$/,
96}
97const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
98const baseOf = (word: string) => word.replace(/^\\/, '').split('/').pop() ?? ''
99
100// A stage from its words: redirections, assignments and wrappers taken off.
101function toStage(words: readonly string[]): Stage {
102 const rest: string[] = []
103 for (let i = 0; i < words.length; i++) {
104 const word = words[i]!
105 if (/^(?:\d*|&)[<>]/.test(word)) {
106 // `> file` names its target in the next word; `>file` and `2>&1` do not.
107 if (/^(?:\d*|&)(?:>>?|>\||<<?<?-?)$/.test(word)) {
108 i += 1
109 }
110 continue
111 }
112 rest.push(word)
113 }
114 for (;;) {
115 while (rest[0] !== undefined && isAssignment(rest[0])) {
116 rest.shift()
117 }
118 const head = baseOf(rest[0] ?? '')
119 if (!WRAPPERS.has(head)) {
120 break
121 }
122 rest.shift()
123 while (rest[0]?.startsWith('-') === true) {
124 const flag = rest.shift()!
125 if (VALUE_FLAGS[head]?.test(flag) === true) {
126 rest.shift()
127 }
128 }
129 if (head === 'timeout' || head === 'gtimeout') {
130 rest.shift()
131 }
132 }
133 const [first = '', ...args] = rest
134 return { name: baseOf(first), args, text: rest.join(' ') }
135}
136
137// A script as its pipelines, quotes respected; what `$(…)`, `<(…)` and
138// backticks hold is set aside in `inner`, to be read as scripts of their own.
139function parse(script: string): Script {
140 const pipelines: Pipeline[] = []
141 const inner: string[] = []
142 let stages: Stage[] = []
143 let words: string[] = []
144 let word = ''
145 let hasWord = false
146 let quote = ''
147 const endWord = () => {
148 if (hasWord) {
149 words.push(word)
150 }
151 word = ''
152 hasWord = false
153 }
154 const endStage = () => {
155 endWord()
156 if (words.length > 0) {
157 stages.push(toStage(words))
158 }
159 words = []
160 }
161 const endPipeline = (then: Pipeline['then']) => {
162 endStage()
163 if (stages.length > 0) {
164 pipelines.push({ stages, then })
165 }
166 stages = []
167 }
168 for (let i = 0; i < script.length; i++) {
169 const ch = script[i]!
170 const after = script[i + 1]
171 if (quote === "'") {
172 if (ch === "'") {
173 quote = ''
174 } else {
175 word += ch
176 }
177 continue
178 }
179 if (ch === '\\') {
180 if (after === '\n') {
181 i += 1
182 } else if (after !== undefined) {
183 word += after
184 hasWord = true
185 i += 1
186 }
187 continue
188 }
189 if ((ch === '$' && after === '(') || (quote === '' && (ch === '<' || ch === '>') && after === '(')) {
190 const end = closing(script, i + 1)
191 inner.push(script.slice(i + 2, end))
192 word += '$(…)'
193 hasWord = true
194 i = end
195 continue
196 }
197 if (ch === '`') {
198 const end = script.indexOf('`', i + 1)
199 const stop = end < 0 ? script.length : end
200 inner.push(script.slice(i + 1, stop))
201 word += '$(…)'
202 hasWord = true
203 i = stop
204 continue
205 }
206 if (quote === '"') {
207 if (ch === '"') {
208 quote = ''
209 } else {
210 word += ch
211 }
212 continue
213 }
214 if (ch === '"' || ch === "'") {
215 quote = ch
216 hasWord = true
217 } else if (ch === ' ' || ch === '\t') {
218 endWord()
219 } else if (ch === '\n' || ch === ';') {
220 endPipeline(';')
221 } else if (ch === '&' && after === '&') {
222 endPipeline('&&')
223 i += 1
224 } else if (ch === '&' && (after === '>' || /[<>]$/.test(word))) {
225 // `&>` and `2>&1` are redirections, not a job sent to the background.
226 word += ch
227 hasWord = true
228 } else if (ch === '&') {
229 endPipeline(';')
230 } else if (ch === '|' && after === '|') {
231 endPipeline('||')
232 i += 1
233 } else if (ch === '|') {
234 endStage()
235 if (after === '&') {
236 i += 1
237 }
238 } else if (ch === '(' || ch === ')') {
239 endPipeline(';')
240 } else if ((ch === '{' || ch === '}') && word === '' && !hasWord && (after === undefined || /[\s;]/.test(after))) {
241 // `{ a; b; }` groups commands; `{}` and `a{b,c}` are words.
242 endPipeline(';')
243 } else {
244 word += ch
245 hasWord = true
246 }
247 }
248 endPipeline('')
249 return { pipelines, inner }
250}
251
252// ── Commands that wait for the person ────────────────────────────────────
253
254const TEMP = /^\/(?:private\/)?(?:tmp|var\/tmp|var\/folders)\/(?!\*$)./
255
256// A target `rm -r` must not be pointed at without a second look.
257function isBroad(target: string): boolean {
258 const bare = target.replace(/\/+$/, '') || '/'
259 if (target === '' || TEMP.test(bare)) {
260 return false
261 }
262 // An unset variable in front of `/*` is the root directory's content.
263 if (/^\$\{?\w+\}?\/\*$/.test(bare) && !/^\$\{?HOME\}?\//.test(bare)) {
264 return true
265 }
266 const t = bare.replace(/^\$\{?HOME\}?(?=\/|$)/, '~').replace(/^\/(?:Users|home)\/[^/]+(?=\/|$)/, '~')
267 if (t === '.git' || t.endsWith('/.git')) {
268 return true
269 }
270 const parts = t.split('/').filter(part => part !== '')
271 if (t.startsWith('/')) {
272 return parts.length <= 2
273 }
274 if (parts[0] === '~') {
275 return parts.length <= 2 || (parts.length === 3 && parts[2] === '*')
276 }
277 // Relative: the directory itself, its parents, or everything in them.
278 const below = parts.filter(part => part !== '.')
279 return below.every(part => part === '..' || part === '*')
280}
281
282const hasShort = (args: readonly string[], letter: string) => args.some(arg => /^-[a-zA-Z]+$/.test(arg) && arg.includes(letter))
283const targetsOf = (args: readonly string[]) => args.filter(arg => !arg.startsWith('-'))
284
285function rmRisk(args: readonly string[]): Risk | undefined {
286 const isRecursive = args.includes('--recursive') || hasShort(args, 'r') || hasShort(args, 'R')
287 const broad = targetsOf(args).find(isBroad)
288 return isRecursive && broad !== undefined ? { rule: 'rm-broad', zh: `递归删除 ${broad}` } : undefined
289}
290
291// `find <broad> -delete` with nothing to narrow it down by name, age or size.
292function findRisk(args: readonly string[]): Risk | undefined {
293 const firstTest = args.findIndex(arg => arg.startsWith('-') || arg === '(' || arg === '!')
294 const roots = firstTest < 0 ? args : args.slice(0, firstTest)
295 const deletes = args.includes('-delete') || args.some((arg, i) => /^-(?:exec|execdir|ok)$/.test(arg) && baseOf(args[i + 1] ?? '') === 'rm')
296 const isNarrowed = args.some(arg => /^-(?:i?name|i?path|i?regex|newer|[mca]time|[mca]min|size|empty|user|group|perm)$/.test(arg))
297 const broad = (roots.length === 0 ? ['.'] : roots).find(isBroad)
298 return deletes && !isNarrowed && broad !== undefined ? { rule: 'find-delete', zh: `用 find 批量删除 ${broad} 下的文件` } : undefined
299}
300
301// `chmod -R` / `chown -R` on the system's or the home directory's top levels.
302function modeRisk(name: string, args: readonly string[]): Risk | undefined {
303 const isRecursive = args.includes('--recursive') || hasShort(args, 'R')
304 const broad = targetsOf(args)
305 .slice(1)
306 .find(target => /^(?:\/|~|\$\{?HOME)/.test(target) && isBroad(target))
307 return isRecursive && broad !== undefined ? { rule: 'chmod-broad', zh: `递归修改 ${broad} 的${name === 'chmod' ? '权限' : '属主'}` } : undefined
308}
309
310function gitRisk(args: readonly string[]): Risk | undefined {
311 const rest = [...args]
312 while (rest[0]?.startsWith('-') === true) {
313 const flag = rest.shift()!
314 if (flag === '-C' || flag === '-c' || flag === '--git-dir' || flag === '--work-tree') {
315 rest.shift()
316 }
317 }
318 const [sub, ...more] = rest
319 const has = (...names: string[]) => more.some(arg => names.includes(arg))
320 if (sub === 'push' && (has('--force') || hasShort(more, 'f') || more.some(arg => /^\+\S/.test(arg)))) {
321 return { rule: 'git-force-push', zh: '强制推送,会覆盖远端的提交历史' }
322 }
323 if (sub === 'reset' && has('--hard')) {
324 return { rule: 'git-reset-hard', zh: 'git reset --hard,会丢掉没提交的改动' }
325 }
326 if (sub === 'clean' && hasShort(more, 'f') && !hasShort(more, 'n') && !has('--dry-run')) {
327 return { rule: 'git-clean', zh: 'git clean,会删掉没被跟踪的文件' }
328 }
329 if ((sub === 'checkout' || sub === 'restore') && has('.') && (!has('--staged', '-S') || has('--worktree', '-W'))) {
330 return { rule: 'git-discard', zh: `git ${sub} .,会丢掉工作区里的全部改动` }
331 }
332 if ((sub === 'checkout' || sub === 'switch') && (has('--force', '--discard-changes') || hasShort(more, 'f'))) {
333 return { rule: 'git-discard', zh: `git ${sub} --force,会丢掉工作区里的改动` }
334 }
335 if (sub === 'branch' && hasShort(more, 'D')) {
336 return { rule: 'git-branch-delete', zh: '强制删除分支' }
337 }
338 return undefined
339}
340
341const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
342const STDIN_RUNNERS = new Set(['python', 'python3', 'perl', 'ruby', 'node', 'bun', 'deno'])
343const SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlite3', 'sqlcmd', 'clickhouse', 'clickhouse-client', 'duckdb', 'bq', 'snowsql', 'cockroach', 'mtdev', 'mongosh', 'mongo'])
344const SQL_DROP = /\b(?:DROP\s+(?:DATABASE|TABLE|SCHEMA)|TRUNCATE\s+TABLE)\b|\bDELETE\s+FROM\s+[\w.`"\[\]]+\s*(?:;|$)|\bdropDatabase\s*\(|\.drop\s*\(\s*\)/im
345const SQL_RISK: Risk = { rule: 'sql-drop', zh: '删库删表(DROP / TRUNCATE / 不带 WHERE 的 DELETE)' }
346const SSH_VALUE_FLAGS = /^-[bcDEeFIiJLlmOopQRSWw]$/
347const DISK_VERBS = /^(?:eraseDisk|eraseVolume|partitionDisk|reformat|zeroDisk|randomDisk|secureErase)$/
348
349// What one command would do, the script it hands to another shell included.
350function stageRisk({ name, args, text }: Stage, depth: number): Risk | undefined {
351 if (name === 'rm') {
352 return rmRisk(args)
353 }
354 if (name === 'git') {
355 return gitRisk(args)
356 }
357 if (name === 'find') {
358 return findRisk(args)
359 }
360 if (name === 'chmod' || name === 'chown' || name === 'chgrp') {
361 return modeRisk(name, args)
362 }
363 if (SHELLS.has(name)) {
364 const at = args.findIndex(arg => /^-[a-zA-Z]*c$/.test(arg))
365 return at < 0 ? undefined : riskIn(args[at + 1] ?? '', depth + 1)
366 }
367 if (name === 'eval') {
368 return riskIn(args.join(' '), depth + 1)
369 }
370 if (name === 'ssh') {
371 const rest = [...args]
372 while (rest[0]?.startsWith('-') === true) {
373 if (SSH_VALUE_FLAGS.test(rest.shift()!)) {
374 rest.shift()
375 }
376 }
377 return riskIn(rest.slice(1).join(' '), depth + 1)
378 }
379 if (name === 'dd') {
380 return args.some(arg => /^of=\/dev\/(?!null$|zero$|stdout$|stderr$|fd\/)/.test(arg)) ? { rule: 'dd-device', zh: '用 dd 直接写磁盘设备' } : undefined
381 }
382 if (name.startsWith('mkfs') || (name === 'diskutil' && DISK_VERBS.test(args[0] ?? ''))) {
383 return { rule: 'format-disk', zh: '格式化磁盘' }
384 }
385 if ((name === 'terraform' || name === 'tofu') && (args.includes('destroy') || (args.includes('apply') && args.includes('-destroy')))) {
386 return { rule: 'terraform-destroy', zh: 'terraform destroy,会销毁云上资源' }
387 }
388 if (name === 'kubectl' && args.includes('delete') && args.some(arg => /^(?:ns|namespaces?|all|--all|--all-namespaces|-A)$/.test(arg))) {
389 return { rule: 'kubectl-delete', zh: 'kubectl 批量删除资源' }
390 }
391 if ((name === 'docker' || name === 'podman') && args[0] === 'system' && args[1] === 'prune' && (args.includes('--all') || hasShort(args, 'a'))) {
392 return { rule: 'docker-prune', zh: '清空 Docker 的全部镜像和容器' }
393 }
394 if (name === 'gh' && args[0] === 'repo' && args[1] === 'delete') {
395 return { rule: 'gh-repo-delete', zh: '删除 GitHub 仓库' }
396 }
397 if (name === 'redis-cli') {
398 const rest = [...args]
399 while (rest[0]?.startsWith('-') === true) {
400 if (/^(?:-[hpsaunri]|--user|--pass)$/.test(rest.shift()!)) {
401 rest.shift()
402 }
403 }
404 return /^flush(?:all|db)$/i.test(rest[0] ?? '') ? { rule: 'redis-flush', zh: '清空 Redis' } : undefined
405 }
406 if (SQL_CLIENTS.has(name) && SQL_DROP.test(text)) {
407 return SQL_RISK
408 }
409 return undefined
410}
411
412function pipelineRisk({ stages }: Pipeline, depth: number): Risk | undefined {
413 for (const [i, stage] of stages.entries()) {
414 const risk = stageRisk(stage, depth)
415 if (risk !== undefined) {
416 return risk
417 }
418 const fed = stages.slice(0, i)
419 // A download piped into something that runs it.
420 const runsInput = (SHELLS.has(stage.name) && !stage.args.some(arg => /^-[a-zA-Z]*c$/.test(arg))) || (STDIN_RUNNERS.has(stage.name) && (stage.args.length === 0 || stage.args[0] === '-'))
421 if (runsInput && fed.some(one => one.name === 'curl' || one.name === 'wget')) {
422 return PIPE_TO_SHELL
423 }
424 if (SQL_CLIENTS.has(stage.name) && fed.some(one => SQL_DROP.test(one.text))) {
425 return SQL_RISK
426 }
427 }
428 return undefined
429}
430
431const PIPE_TO_SHELL: Risk = { rule: 'pipe-to-shell', zh: '把网上下载的脚本直接交给 shell 执行' }
432// `bash <(curl …)` and `sh -c "$(curl …)"`: a download run without a pipe.
433const SHELL_OF_DOWNLOAD = /(?:^|[\n;&|(]\s*)(?:sudo\s+)?(?:ba|z|da|k)?sh\s[^;&|\n]*(?:<\(|\$\()\s*(?:curl|wget)\b/
434// Looked for outside quotes: what sits in quotes is somebody's words.
435const BARE: readonly (readonly [RegExp, Risk])[] = [
436 [/>\s*\/dev\/(?:sd|hd|disk|rdisk|nvme|mmcblk)\w*/, { rule: 'write-device', zh: '直接写磁盘设备' }],
437 [/:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/, { rule: 'fork-bomb', zh: 'fork 炸弹' }],
438]
439
440function riskIn(script: string, depth: number): Risk | undefined {
441 if (depth > 3 || script.trim() === '') {
442 return undefined
443 }
444 const { text, docs } = readHeredocs(script)
445 if (SHELL_OF_DOWNLOAD.test(text)) {
446 return PIPE_TO_SHELL
447 }
448 const bare = text.replace(/'[^']*'/g, "''").replace(/"(?:\\.|[^"\\$`])*"/g, '""')
449 for (const [pattern, risk] of BARE) {
450 if (pattern.test(bare)) {
451 return risk
452 }
453 }
454 const { pipelines, inner } = parse(text)
455 for (const pipeline of pipelines) {
456 const risk = pipelineRisk(pipeline, depth)
457 if (risk !== undefined) {
458 return risk
459 }
460 }
461 // SQL handed to a client in a heredoc.
462 for (const doc of docs) {
463 if (SQL_DROP.test(doc.body) && parse(doc.row).pipelines.some(pipeline => pipeline.stages.some(stage => SQL_CLIENTS.has(stage.name)))) {
464 return SQL_RISK
465 }
466 }
467 for (const one of inner) {
468 const risk = riskIn(one, depth + 1)
469 if (risk !== undefined) {
470 return risk
471 }
472 }
473 return undefined
474}
475
476// The first reason a shell command should wait for the person, if any.
477export function riskOf(script: string): Risk | undefined {
478 return riskIn(script, 0)
479}
480
481// ── git commit: what it is about to record ───────────────────────────────
482
483// Where a script commits and what it stages on its way there. `adds` are the
484// paths `git add` names (`.` for all of them); `withNew` when files not yet
485// tracked go in too; `isAll` for `git commit -a`.
486export type Commit = { dir?: string; isAll: boolean; adds: string[]; withNew: boolean }
487
488export function commitOf(script: string): Commit | undefined {
489 const { pipelines } = parse(readHeredocs(script).text)
490 let dir: string | undefined
491 let adds: string[] = []
492 let withNew = false
493 for (const { stages } of pipelines) {
494 for (const { name, args } of stages) {
495 if (name === 'cd' && args[0] !== undefined) {
496 dir = dir === undefined || args[0].startsWith('/') ? args[0] : `${dir}/${args[0]}`
497 continue
498 }
499 if (name !== 'git') {
500 continue
501 }
502 const rest = [...args]
503 while (rest[0]?.startsWith('-') === true) {
504 const flag = rest.shift()!
505 if (flag === '-C') {
506 dir = rest.shift()
507 } else if (flag === '-c' || flag === '--git-dir' || flag === '--work-tree') {
508 rest.shift()
509 }
510 }
511 const [sub, ...more] = rest
512 if (sub === 'add') {
513 const isEverything = more.some(arg => arg === '-A' || arg === '--all' || arg === '.')
514 const isUpdate = more.some(arg => arg === '-u' || arg === '--update')
515 const paths = targetsOf(more)
516 adds = isEverything || (isUpdate && paths.length === 0) ? ['.'] : [...adds, ...paths]
517 withNew ||= !isUpdate
518 } else if (sub === 'commit') {
519 // A path that cannot be followed (`~`, a variable) is left to git's
520 // own working directory.
521 const isPlain = dir !== undefined && !dir.startsWith('~') && !dir.includes('$')
522 return { ...(isPlain ? { dir } : {}), isAll: more.includes('--all') || hasShort(more, 'a'), adds, withNew }
523 }
524 }
525 }
526 return undefined
527}
528
529// Whether a command needs the guard's closer look before it runs.
530export function mayStop(script: string): boolean {
531 return riskOf(script) !== undefined || commitOf(script) !== undefined
532}
533
534// ── Secrets ──────────────────────────────────────────────────────────────
535
536export type Secret = { kind: string; sample: string }
537
538const SECRETS: readonly (readonly [string, RegExp])[] = [
539 ['私钥', /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g],
540 // Not the key the AWS documentation uses in its examples.
541 ['AWS 访问密钥', /\bAKIA(?![0-9A-Z]{9}EXAMPLE\b)[0-9A-Z]{16}\b/g],
542 ['GitHub 令牌', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g],
543 ['Anthropic API 密钥', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
544 ['OpenAI 风格的 API 密钥', /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g],
545 ['Slack 令牌', /\bxox[baprs]-[A-Za-z0-9-]{10,}/g],
546 ['Google API 密钥', /\bAIza[0-9A-Za-z_-]{35}\b/g],
547 ['JWT', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
548 // A credential is plain ASCII: a label in another script (`password: '请输入密码…'`) is not one.
549 ['写死的密码或密钥', /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\b\s*[:=]\s*['"](?=[\x21-\x7e]{12,}['"])[^'"\s$<{]{12,}['"]/gi],
550]
551
552export const mask = (text: string) => (text.length <= 8 ? `${text.slice(0, 2)}…` : `${text.slice(0, 6)}…${text.slice(-2)}`)
553
554// Every secret-looking string in a text, its value masked.
555export function secretsIn(text: string): Secret[] {
556 const found: Secret[] = []
557 for (const [kind, pattern] of SECRETS) {
558 for (const match of text.matchAll(pattern)) {
559 if (!found.some(one => one.sample === mask(match[0]))) {
560 found.push({ kind, sample: mask(match[0]) })
561 }
562 }
563 }
564 return found
565}
566
567// A file whose very presence in a commit is worth a warning.
568export function isSensitiveFile(path: string): boolean {
569 const name = path.split('/').pop() ?? path
570 if (/^\.env(\.|$)/.test(name)) {
571 return !/\.(example|sample|template|dist)$/.test(name)
572 }
573 return /^(id_rsa|id_dsa|id_ecdsa|id_ed25519|\.netrc|\.npmrc|credentials\.json)$/.test(name) || /\.(pem|p12|pfx|keystore|jks)$/.test(name)
574}
575
576export type Finding = { file: string; line: number; kind: string; sample: string }
577
578// Secrets on the lines a unified diff adds, and sensitive files it adds.
579export function scanDiff(diff: string): Finding[] {
580 const findings: Finding[] = []
581 let file = ''
582 let line = 0
583 for (const row of diff.split('\n')) {
584 if (row.startsWith('+++ ')) {
585 file = row.slice(4).replace(/^b\//, '')
586 if (file !== '/dev/null' && isSensitiveFile(file)) {
587 findings.push({ file, line: 0, kind: '敏感文件', sample: file.split('/').pop() ?? file })
588 }
589 continue
590 }
591 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(row)
592 if (hunk !== null) {
593 line = Number(hunk[1])
594 continue
595 }
596 if (row.startsWith('+')) {
597 for (const secret of secretsIn(row.slice(1))) {
598 findings.push({ file, line, ...secret })
599 }
600 line += 1
601 } else if (!row.startsWith('-') && !row.startsWith('\\')) {
602 line += 1
603 }
604 }
605 return findings
606}
607
608// A file not yet tracked, as the diff that would add it.
609export function newFileDiff(file: string, text: string): string {
610 const rows = text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n')
611 return [`diff --git a/${file} b/${file}`, 'new file mode 100644', '--- /dev/null', `+++ b/${file}`, `@@ -0,0 +1,${rows.length} @@`, ...rows.map(row => `+${row}`), ''].join('\n')
612}
613
614// Files a scan or a review does not read: not text, or not written by hand.
615export const isOpaqueFile = (file: string) =>
616 /\.(png|jpe?g|gif|webp|ico|icns|pdf|zip|gz|tgz|bz2|xz|7z|rar|mp3|mp4|mov|wav|ogg|woff2?|ttf|otf|lockb?|bin|so|dylib|dll|exe|class|jar|pyc|wasm|sqlite|db)$/i.test(file)
617
618// The text `/waifu scan` prints.
619export function scanReport(name: string, findings: readonly Finding[], files: number): string {
620 if (findings.length === 0) {
621 return `🛡 ${name}的安全检查:看了 ${files} 个改动的文件,没发现密钥或敏感文件。`
622 }
623 return [
624 `🛡 ${name}的安全检查:${files} 个改动的文件里有 ${findings.length} 处要看一下`,
625 ...findings.slice(0, 12).map(one => `- ${one.file}${one.line > 0 ? `:${one.line}` : ''} ${one.kind} ${one.sample}`),
626 ...(findings.length > 12 ? [`…还有 ${findings.length - 12} 处`] : []),
627 ].join('\n')
628}
629
630// ── Commands whose outcome is worth saying out loud ──────────────────────
631
632export type CommandKind = 'test' | 'build' | 'push'
633// `isExact`: the script is that one command, so its exit status is the
634// command's own; otherwise (a pipe into `tail`, a chain) only its output tells.
635export type Watch = { kind: CommandKind; isExact: boolean }
636
637const PACKAGE_RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
638const TEST_TOOLS = new Set(['pytest', 'jest', 'vitest', 'rspec', 'phpunit', 'mocha', 'ava', 'unittest', 'tox', 'ctest'])
639const TEST_VERBS = new Set(['go', 'cargo', 'mvn', 'mvnw', 'gradle', 'gradlew', 'dotnet', 'swift', 'deno', 'zig', 'mix', 'flutter', 'dart', 'playwright'])
640const BUILD_VERBS = new Set(['cargo', 'go', 'swift', 'zig', 'dotnet', 'gradle', 'gradlew', 'next', 'vite', 'astro', 'nuxt', 'bun'])
641
642// The command a runner runs for someone else (`npx jest`, `python -m pytest`).
643function unwrapped({ name, args }: Stage): { name: string; args: string[] } {
644 const rest = [...args]
645 if (name === 'npx' || name === 'bunx' || name === 'pnpx') {
646 while (rest[0]?.startsWith('-') === true) {
647 if (/^(?:-p|--package|-c|--call)$/.test(rest.shift()!)) {
648 rest.shift()
649 }
650 }
651 return { name: baseOf(rest.shift() ?? ''), args: rest }
652 }
653 if ((name === 'pnpm' || name === 'yarn') && (rest[0] === 'exec' || rest[0] === 'dlx')) {
654 return { name: baseOf(rest[1] ?? ''), args: rest.slice(2) }
655 }
656 if (/^(?:uv|poetry|pipenv|pdm|hatch)$/.test(name) && rest[0] === 'run') {
657 return unwrapped({ name: baseOf(rest[1] ?? ''), args: rest.slice(2), text: '' })
658 }
659 if (/^python[\d.]*$/.test(name) && rest[0] === '-m') {
660 return { name: rest[1] ?? '', args: rest.slice(2) }
661 }
662 return { name, args: rest }
663}
664
665function kindOfStage(stage: Stage): CommandKind | undefined {
666 const { name, args } = unwrapped(stage)
667 const verb = args.find(arg => !arg.startsWith('-')) ?? ''
668 if (name === 'git') {
669 const rest = [...args]
670 while (rest[0]?.startsWith('-') === true) {
671 if (/^(?:-C|-c|--git-dir|--work-tree)$/.test(rest.shift()!)) {
672 rest.shift()
673 }
674 }
675 return rest[0] === 'push' && !rest.includes('--dry-run') && !hasShort(rest.slice(1), 'n') ? 'push' : undefined
676 }
677 if (PACKAGE_RUNNERS.has(name)) {
678 const task = args[0] === 'run' ? (args[1] ?? '') : (args[0] ?? '')
679 if (/^(?:test|t)$/.test(args[0] ?? '') || /^test(?::|$)/.test(task)) {
680 return 'test'
681 }
682 if (/^build(?::|$)/.test(task) && (args[0] === 'run' || name !== 'npm')) {
683 return 'build'
684 }
685 return undefined
686 }
687 if (TEST_TOOLS.has(name) || (TEST_VERBS.has(name) && verb === 'test') || (name === 'claude' && args[0] === 'plugin' && args[1] === 'test')) {
688 return 'test'
689 }
690 if (name === 'make') {
691 if (args.some(arg => /^(?:-n|--dry-run|--version|-v|--help)$/.test(arg))) {
692 return undefined
693 }
694 return /^(?:test|tests|check)$/.test(verb) ? 'test' : 'build'
695 }
696 if (name === 'tsc' || name === 'webpack' || name === 'tsup') {
697 return args.some(arg => /^(?:--version|-v|--help|-h)$/.test(arg)) ? undefined : 'build'
698 }
699 if ((BUILD_VERBS.has(name) && verb === 'build') || (name === 'cargo' && verb === 'check') || (name === 'cmake' && args.includes('--build'))) {
700 return 'build'
701 }
702 if ((name === 'mvn' || name === 'mvnw') && /^(?:package|install|compile|verify)$/.test(verb)) {
703 return 'build'
704 }
705 if ((name === 'gradle' || name === 'gradlew') && /^(?:build|assemble)$/.test(verb)) {
706 return 'build'
707 }
708 return undefined
709}
710
711// The test runs, builds and pushes a script holds: tests first, pushes last.
712export function watchesOf(script: string): Watch[] {
713 const { pipelines } = parse(readHeredocs(script).text)
714 const found: Watch[] = []
715 // Steps that only set the scene (`cd dir &&`, `export X=1 &&`) aside, an
716 // exact script is one pipeline of one command.
717 const real = pipelines.filter(({ stages }) => !(stages.length === 1 && /^(?:cd|export|source|\.|)$/.test(stages[0]!.name)))
718 for (const pipeline of pipelines) {
719 for (const stage of pipeline.stages) {
720 const kind = kindOfStage(stage)
721 if (kind !== undefined && !found.some(one => one.kind === kind)) {
722 const isAlone = real.length === 1 && real[0] === pipeline && pipeline.stages.length === 1
723 const isChained = pipelines.every(one => one === pipeline || one.then === '&&')
724 found.push({ kind, isExact: isAlone && isChained })
725 }
726 }
727 }
728 const order: readonly CommandKind[] = ['test', 'build', 'push']
729 return found.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
730}
731
732const FAILED: Readonly<Record<CommandKind, readonly RegExp[]>> = {
733 test: [
734 /\b[1-9]\d*\s+(?:tests?\s+)?(?:fail(?:ed|ing|ures?)?|errors?|errored)\b/i,
735 /\(fail\)|^\s*(?:FAIL(?:ED)?|✗|✘|×)\s/m,
736 /\bAssertionError\b|--- FAIL:|\btest result: FAILED\b|\bFAILED \(|\bFAILURES!|^not ok\b/m,
737 ],
738 build: [/\berror TS\d+\b/, /\berror(?:\[E\d+\])?:/i, /\b(?:build|compilation) failed\b/i, /^make(?:\[\d+\])?: \*\*\*/m, /\bnpm ERR!/, /\bBUILD FAILURE\b/],
739 push: [/\[(?:remote )?rejected\]|^\s*! /m, /\b(?:fatal|error):/],
740}
741const PASSED: Readonly<Record<CommandKind, readonly RegExp[]>> = {
742 test: [/\b0\s+fail/i, /\b[1-9]\d*\s+(?:tests?\s+)?pass(?:ed|ing)?\b/i, /\ball tests passed\b/i, /^ok\s/m, /\btest result: ok\b/, /^\s*(?:PASS|✓|✔)\s/m, /\bOK \(\d+ tests?\b/, /^OK$/m],
743 build: [],
744 push: [/\S+\s+->\s+\S+/, /\bEverything up-to-date\b/],
745}
746
747// How a watched command came out, as far as can be told: undefined when its
748// exit status is somebody else's and its output does not say.
749export function outcomeOf(watch: Watch, hasFailed: boolean, output: string): 'pass' | 'fail' | undefined {
750 if (watch.isExact) {
751 return hasFailed ? 'fail' : 'pass'
752 }
753 if (FAILED[watch.kind].some(pattern => pattern.test(output))) {
754 return 'fail'
755 }
756 return PASSED[watch.kind].some(pattern => pattern.test(output)) ? 'pass' : undefined
757}
758hooks/journal.ts 83 lines1// The task journal: one entry per finished turn, kept per project, so the
2// troupe can recap what was done before. Pure: the hooks module stores it.
3
4export type Task = {
5 at: number
6 prompt: string
7 answer: string
8 seconds: number
9 tools: number
10 tokens: number
11 files: string[]
12 isOk: boolean
13}
14
15const oneLine = (text: string, max: number) => {
16 const flat = text.replace(/```[\s\S]*?```/g, ' ').replace(/[#*`>_]/g, '').replace(/\s+/g, ' ').trim()
17 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
18}
19
20export function makeTask(at: number, prompt: string, answer: string, seconds: number, tools: number, tokens: number, files: readonly string[], isOk: boolean): Task {
21 return { at, prompt: oneLine(prompt, 80), answer: oneLine(answer, 140), seconds, tools, tokens, files: [...new Set(files)].slice(0, 8), isOk }
22}
23
24// Slash commands and empty prompts are not tasks worth recalling.
25export const isTask = (prompt: string, seconds: number, tools: number) => prompt.trim() !== '' && !prompt.trim().startsWith('/') && (tools > 0 || seconds >= 5)
26
27export function addTask(tasks: readonly Task[], task: Task, max = 60): Task[] {
28 return [...tasks, task].slice(-max)
29}
30
31function when(at: number, now: number): string {
32 const minutes = Math.round((now - at) / 60_000)
33 if (minutes < 1) {
34 return '刚刚'
35 }
36 if (minutes < 60) {
37 return `${minutes} 分钟前`
38 }
39 const hours = Math.round(minutes / 60)
40 return hours < 24 ? `${hours} 小时前` : `${Math.round(hours / 24)} 天前`
41}
42
43const count = (n: number) => (n >= 1e4 ? `${(n / 1e4).toFixed(n >= 1e5 ? 0 : 1)}万` : String(Math.round(n)))
44
45// The text `/waifu recap` prints: the latest tasks, newest first.
46export function recapReport(name: string, tasks: readonly Task[], now: number, limit = 8): string {
47 if (tasks.length === 0) {
48 return `🗂 ${name}:这个项目里还没有记下过任务。`
49 }
50 const rows = [...tasks].reverse().slice(0, limit).map(task => {
51 const files = task.files.length === 0 ? '' : ` · 改了 ${task.files.slice(0, 3).join('、')}${task.files.length > 3 ? ` 等 ${task.files.length} 个文件` : ''}`
52 return [
53 `- ${when(task.at, now)}${task.isOk ? '' : '(中断)'}:${task.prompt}`,
54 ` ${task.seconds}s · ${task.tools} 次工具 · ${count(task.tokens)} token${files}`,
55 ...(task.answer === '' ? [] : [` 结果:${task.answer}`]),
56 ].join('\n')
57 })
58 return [`🗂 ${name}的任务回顾(最近 ${rows.length} 条,共 ${tasks.length} 条)`, ...rows].join('\n')
59}
60
61// What the bubble says when a new session opens: the last thing done here,
62// short enough for a bubble.
63export function lastTaskLine(tasks: readonly Task[], now: number): string | undefined {
64 const last = tasks[tasks.length - 1]
65 return last === undefined ? undefined : `${when(last.at, now)}:${oneLine(last.prompt, 14)}`
66}
67
68// A short list of the tasks for the chat model to sum up.
69export function recapPrompt(tasks: readonly Task[], now: number): string {
70 const rows = [...tasks].slice(-12).map(task => `- ${when(task.at, now)}:${task.prompt}${task.answer === '' ? '' : ` → ${task.answer}`}`)
71 return `下面是这个项目里最近做过的任务,请用中文总结成 3 条以内的要点,每条不超过 30 个字,只输出要点:\n${rows.join('\n')}`
72}
73
74// A stable key for a project path (FNV-1a).
75export function projectKey(path: string): string {
76 let hash = 0x811c9dc5
77 for (let i = 0; i < path.length; i++) {
78 hash ^= path.charCodeAt(i)
79 hash = Math.imul(hash, 0x01000193)
80 }
81 return `tasks:${(hash >>> 0).toString(16).padStart(8, '0')}`
82}
83hooks/review.ts 92 lines1// A quick code review of the working tree's changes by the chat model. Pure:
2// builds the request, trims the diff, reads the answer, formats the report.
3
4export type ReviewFinding = { file: string; line: number; severity: 'high' | 'mid' | 'low'; issue: string; fix: string }
5export type Review = { verdict: 'ok' | 'warn' | 'bad'; summary: string; findings: ReviewFinding[] }
6
7export const REVIEW_SYSTEM =
8 '你是一名严格但务实的代码审查者。只报告会导致结果错误、崩溃、数据丢失或安全问题的真实缺陷,' +
9 '每条都要能指出具体的文件和行。不谈命名、格式和个人偏好。拿不准的不要报。只输出 JSON,不要解释。'
10
11// Whole files' hunks until the budget is used; the rest are named as left out.
12export function trimDiff(diff: string, maxChars = 60_000): { text: string; files: number; omitted: string[] } {
13 const parts = diff.split(/^(?=diff --git )/m).filter(part => part.trim() !== '')
14 const kept: string[] = []
15 const omitted: string[] = []
16 let size = 0
17 for (const part of parts) {
18 const name = /^diff --git a\/(.+?) b\//.exec(part)?.[1] ?? '?'
19 if (/^Binary files /m.test(part) || /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?)$/.test(name)) {
20 omitted.push(name)
21 } else if (size + part.length <= maxChars) {
22 kept.push(part)
23 size += part.length
24 } else {
25 omitted.push(name)
26 }
27 }
28 return { text: kept.join(''), files: kept.length, omitted }
29}
30
31export function reviewPrompt(diff: string, omitted: readonly string[]): string {
32 return [
33 '审查下面这份 git diff(工作区相对 HEAD 的改动)。',
34 '输出 JSON:{"verdict":"ok|warn|bad","summary":"一句话结论,不超过30字","findings":[{"file":"路径","line":行号,"severity":"high|mid|low","issue":"问题,不超过60字","fix":"怎么改,不超过60字"}]}',
35 '最多 6 条 findings,按严重程度从高到低。没有问题时 findings 为空数组、verdict 为 ok。',
36 'verdict:有 high 用 bad,只有 mid 或 low 用 warn。行号用改动后文件里的行号。',
37 ...(omitted.length > 0 ? [`以下文件没有包含在内(太大或是锁文件):${omitted.join('、')}`] : []),
38 '',
39 diff,
40 ].join('\n')
41}
42
43const SEVERITIES = ['high', 'mid', 'low'] as const
44
45// The model's answer as a Review; undefined when it is not the JSON asked for.
46export function parseReview(text: string): Review | undefined {
47 const start = text.indexOf('{')
48 const end = text.lastIndexOf('}')
49 if (start < 0 || end <= start) {
50 return undefined
51 }
52 try {
53 const raw = JSON.parse(text.slice(start, end + 1)) as { verdict?: unknown; summary?: unknown; findings?: unknown }
54 const findings = (Array.isArray(raw.findings) ? raw.findings : [])
55 .filter((one): one is Record<string, unknown> => typeof one === 'object' && one !== null)
56 .map(one => ({
57 file: String(one.file ?? ''),
58 line: Number(one.line) || 0,
59 severity: SEVERITIES.find(level => level === one.severity) ?? 'low',
60 issue: String(one.issue ?? ''),
61 fix: String(one.fix ?? ''),
62 }))
63 .filter(one => one.issue !== '')
64 .slice(0, 6)
65 const worst = findings.some(one => one.severity === 'high') ? 'bad' : findings.length > 0 ? 'warn' : 'ok'
66 const verdict = raw.verdict === 'ok' || raw.verdict === 'warn' || raw.verdict === 'bad' ? raw.verdict : worst
67 return {
68 verdict: findings.length === 0 ? 'ok' : verdict === 'ok' ? worst : verdict,
69 summary: typeof raw.summary === 'string' && raw.summary.trim() !== '' ? raw.summary.trim().slice(0, 60) : findings.length === 0 ? '没发现问题' : `发现 ${findings.length} 处问题`,
70 findings,
71 }
72 } catch {
73 return undefined
74 }
75}
76
77const LEVEL = { high: '高', mid: '中', low: '低' } as const
78
79// The text `/waifu review` prints.
80export function reviewReport(name: string, review: Review, files: number, omitted: readonly string[], model: string): string {
81 return [
82 `🔍 ${name}的代码 review(${files} 个文件,${model})`,
83 `结论:${review.summary}`,
84 ...review.findings.flatMap((one, i) => [
85 `${i + 1}. [${LEVEL[one.severity]}] ${one.file}${one.line > 0 ? `:${one.line}` : ''} ${one.issue}`,
86 ...(one.fix === '' ? [] : [` 建议:${one.fix}`]),
87 ]),
88 ...(omitted.length > 0 ? [`没看的文件:${omitted.join('、')}`] : []),
89 '这是一次快速检查,只看了 diff 本身。要深入审查可以用 /code-review。',
90 ].join('\n')
91}
92hooks/report.ts 228 lines1// Usage reports the girls read out: numbers put into Chinese for the bubble
2// and Japanese for the voice, and the text a /waifu report prints.
3
4import type { Line } from './cast'
5
6export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
7
8export type Day = Usage & {
9 date: string
10 turns: number
11 workMs: number
12 tools: number
13 errors: number
14 costUsd: number
15 // The most new tokens one turn took in (`freshOf`), cache hits aside.
16 biggestTurn: number
17 measure: 'fresh'
18 // Commands the guard stopped and reviews run today.
19 blocked?: number
20 reviews?: number
21}
22
23export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
24
25export const emptyUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
26
27export const emptyDay = (date: string): Day => ({
28 ...emptyUsage(),
29 date,
30 turns: 0,
31 workMs: 0,
32 tools: 0,
33 errors: 0,
34 costUsd: 0,
35 biggestTurn: 0,
36 measure: 'fresh',
37})
38
39export const totalOf = (usage: Usage) => usage.input + usage.output + usage.cacheRead + usage.cacheWrite
40
41// What a turn took in anew: cache hits are re-read context, not new work, and
42// a long session re-reads its whole context on every request.
43export const freshOf = (usage: Usage) => usage.input + usage.cacheWrite + usage.output
44
45// 12345 → 1.2万; 123456789 → 1.2亿.
46export function zhCount(n: number): string {
47 if (n >= 1e8) {
48 return `${(n / 1e8).toFixed(1)}亿`
49 }
50 if (n >= 1e4) {
51 return `${(n / 1e4).toFixed(n >= 1e5 ? 0 : 1)}万`
52 }
53 return String(Math.round(n))
54}
55
56// Read aloud: 約12万, 約1億.
57export function jaCount(n: number): string {
58 if (n >= 1e8) {
59 return `約${Math.round(n / 1e8)}億`
60 }
61 if (n >= 1e4) {
62 return `約${Math.round(n / 1e4)}万`
63 }
64 return String(Math.round(n))
65}
66
67export function zhDuration(ms: number): string {
68 const minutes = Math.max(0, Math.round(ms / 60_000))
69 const hours = Math.floor(minutes / 60)
70 if (hours >= 24) {
71 return `${Math.floor(hours / 24)} 天 ${hours % 24} 小时`
72 }
73 return hours > 0 ? `${hours} 小时 ${minutes % 60} 分` : `${minutes} 分钟`
74}
75
76function jaDuration(ms: number): string {
77 const minutes = Math.max(0, Math.round(ms / 60_000))
78 const hours = Math.floor(minutes / 60)
79 if (hours >= 24) {
80 return `${Math.floor(hours / 24)}日と${hours % 24}時間`
81 }
82 return hours > 0 ? `${hours}時間${minutes % 60}分` : `${minutes}分`
83}
84
85const WINDOWS: Record<string, [string, string]> = {
86 five_hour: ['5 小时', '5時間'],
87 seven_day: ['7 天', '1週間'],
88}
89
90const windowName = (kind: string): [string, string] => WINDOWS[kind] ?? [kind, kind]
91
92const untilReset = (limit: Limit, now: number) =>
93 limit.resetsAt === undefined ? undefined : Math.max(0, Date.parse(limit.resetsAt) - now)
94
95export function contextLine(percent: number, tokens: number | undefined, window: number): Line {
96 const used = tokens === undefined ? '' : `(${zhCount(tokens)} / ${zhCount(window)})`
97 return { ja: `コンテキストは${percent}パーセントよ`, zh: `上下文用了 ${percent}%${used}` }
98}
99
100export function briefLine(percent: number | undefined, limits: readonly Limit[]): Line {
101 const five = limits.find(limit => limit.kind === 'five_hour') ?? limits[0]
102 const ctx = percent === undefined ? undefined : `上下文 ${percent}%`
103 const quota = five === undefined ? undefined : `${windowName(five.kind)[0]}额度用了 ${Math.round(five.percentUsed)}%`
104 return {
105 ja:
106 (percent === undefined ? '' : `コンテキスト${percent}パーセント、`) +
107 (five === undefined ? '残りはたっぷりよ' : `${windowName(five.kind)[1]}の枠は${Math.round(five.percentUsed)}パーセント使ったわ`),
108 zh: [ctx, quota].filter(Boolean).join(',') || '额度还多着呢',
109 }
110}
111
112export function ateLine(tokens: number): Line {
113 return { ja: `今のターンで${jaCount(tokens)}トークン食べちゃった!`, zh: `这一轮吃掉了 ${zhCount(tokens)} token!` }
114}
115
116export function contextAlert(level: number, percent: number): Line {
117 if (level >= 95) {
118 return { ja: 'もう限界!今すぐコンパクトして!', zh: `上下文 ${percent}%,快满了!马上 /compact!` }
119 }
120 if (level >= 80) {
121 return { ja: 'コンテキストが八割よ、そろそろコンパクトしましょ', zh: `上下文 ${percent}% 了,该 /compact 了` }
122 }
123 return { ja: 'コンテキスト、半分まで来たわ', zh: `上下文已经用了一半(${percent}%)` }
124}
125
126export function limitAlert(limit: Limit, now: number): Line {
127 const [zh, ja] = windowName(limit.kind)
128 const left = untilReset(limit, now)
129 const percent = Math.round(limit.percentUsed)
130 return {
131 ja: `${ja}の枠、${percent}パーセント使ったわ` + (left === undefined ? '' : `。リセットまで${jaDuration(left)}よ`),
132 zh: `${zh}额度用了 ${percent}%` + (left === undefined ? '' : `,${zhDuration(left)}后重置`),
133 }
134}
135
136export function costLine(usd: number): Line {
137 return { ja: `このセッションで、もう${usd}ドル使ったわよ!`, zh: `这次会话已经花了 $${usd}!` }
138}
139
140export function dayComment(day: Day): Line {
141 if (day.turns >= 20) {
142 return { ja: '今日は頑張りすぎよ、ちゃんと休んでね', zh: `今天聊了 ${day.turns} 轮,太拼了,记得休息` }
143 }
144 if (day.turns >= 5) {
145 return { ja: '今日もお疲れさま!', zh: `今天 ${day.turns} 轮,辛苦啦!` }
146 }
147 return { ja: '今日はのんびりね', zh: `今天才 ${day.turns} 轮,挺悠闲嘛` }
148}
149
150const FORTUNES: readonly (readonly [string, string, string])[] = [
151 ['大吉', '大吉!今日はバグゼロの予感!', '大吉!今天预感零 bug!'],
152 ['中吉', '中吉。テストは一発で通るかも', '中吉。测试可能一次就过'],
153 ['小吉', '小吉。リファクタリング日和ね', '小吉。适合重构的一天'],
154 ['吉', '吉。コミットはこまめにね', '吉。记得勤提交'],
155 ['末吉', '末吉。ドキュメントを読むと運が開けるわ', '末吉。多读文档会转运'],
156 ['凶', '凶…今日は本番デプロイ禁止!', '凶…今天禁止上线!'],
157 ['大凶', '大凶!?金曜のデプロイは絶対ダメ!', '大凶!?千万别周五上线!'],
158]
159
160export function fortune(roll: number): { name: string; line: Line } {
161 const [name, ja, zh] = FORTUNES[Math.floor(roll * FORTUNES.length) % FORTUNES.length]!
162 return { name, line: { ja, zh } }
163}
164
165export type Snapshot = {
166 name: string
167 percent?: number
168 tokens?: number
169 window: number
170 limits: readonly Limit[]
171 costUsd?: number
172 session: Usage
173 turns: number
174 last?: Usage
175 now: number
176}
177
178// The text `/waifu token` prints.
179export function usageReport(s: Snapshot): string {
180 const lines = [`📊 ${s.name}的用量播报`]
181 if (s.percent !== undefined) {
182 lines.push(`上下文:${s.percent}%` + (s.tokens === undefined ? '' : `(${zhCount(s.tokens)} / ${zhCount(s.window)})`))
183 }
184 lines.push(
185 `本次会话:${s.turns} 轮 · 输入 ${zhCount(s.session.input + s.session.cacheRead + s.session.cacheWrite)}` +
186 `(缓存命中 ${zhCount(s.session.cacheRead)})· 输出 ${zhCount(s.session.output)}` +
187 (s.costUsd === undefined ? '' : ` · 约 $${s.costUsd.toFixed(2)}`),
188 )
189 for (const limit of s.limits) {
190 const left = untilReset(limit, s.now)
191 lines.push(
192 `额度:${windowName(limit.kind)[0]}窗口用了 ${Math.round(limit.percentUsed)}%` +
193 (left === undefined ? '' : `(${zhDuration(left)}后重置)`),
194 )
195 }
196 if (s.limits.length === 0) {
197 lines.push('额度:没有拿到额度窗口(按量计费或网关模式)')
198 }
199 if (s.last !== undefined) {
200 lines.push(`上一轮:输入 ${zhCount(s.last.input + s.last.cacheRead + s.last.cacheWrite)} · 输出 ${zhCount(s.last.output)}`)
201 }
202 return lines.join('\n')
203}
204
205// The text `/waifu today` prints.
206export function dayReport(name: string, day: Day): string {
207 return [
208 `📅 ${name}的今日日报(${day.date})`,
209 `对话 ${day.turns} 轮 · 干活 ${zhDuration(day.workMs)} · 工具 ${day.tools} 次(报错 ${day.errors} 次)`,
210 `token:输入 ${zhCount(day.input + day.cacheRead + day.cacheWrite)}(缓存命中 ${zhCount(day.cacheRead)})· 输出 ${zhCount(day.output)}`,
211 `花费:约 $${day.costUsd.toFixed(2)} · 最能吃的一轮:${zhCount(day.biggestTurn)} token(不含缓存命中)`,
212 `值班:安全检查拦下 ${day.blocked ?? 0} 条命令 · review ${day.reviews ?? 0} 次`,
213 ].join('\n')
214}
215
216// What a girl reads out when a turn ends: the new tokens it took and how full
217// the context is.
218export function turnBrief(tokens: number, percent: number | undefined): Line {
219 return {
220 ja: `今回は${jaCount(tokens)}トークン` + (percent === undefined ? 'よ' : `、コンテキストは${percent}パーセントよ`),
221 zh: `这一轮 ${zhCount(tokens)} token` + (percent === undefined ? '' : `,上下文 ${percent}%`),
222 }
223}
224
225export function budgetLine(mark: number, usd: number, budget: number): Line {
226 return { ja: `予算の${mark}パーセントを使ったわ`, zh: `预算用了 ${mark}%($${usd.toFixed(2)} / $${budget})` }
227}
228hooks/cast.ts 625 lines1// The cast: how each girl looks (a 12×16 sprite and its palette), who she is
2// (the persona the chat model plays), how she sounds and what she says.
3//
4// Sprite letters: H/h hair, S/s skin, E lashes, I iris, B blush, M mouth,
5// W white, K near black, A/a outfit, C outfit second color, R and Y accents,
6// G glasses, P sleeves and Q hands (redrawn by poses), L legs, F shoes.
7
8import { dutyLines } from './duty'
9import { TROUPE, TROUPE_BANTER } from './troupe'
10
11export type Moment =
12 | 'hello'
13 | 'bye'
14 | 'start'
15 | 'work'
16 | 'error'
17 | 'think'
18 | 'done'
19 | 'abort'
20 | 'long'
21 | 'tea'
22 | 'massage'
23 | 'idle'
24 | 'tip'
25 | 'night'
26 | 'context'
27
28export type Voice = { name: string; rate: string; pitch: string }
29
30// A VOICEVOX character and style, by name (`四国めたん`, `ツンツン`).
31export type Vox = { speaker: string; style: string }
32
33// A line: plain text, or Japanese to speak with Chinese to show.
34export type Line = string | { readonly ja: string; readonly zh: string }
35
36export type VoiceEngine = 'edge' | 'voicevox'
37
38export const subtitle = (line: Line): string => (typeof line === 'string' ? line : line.zh)
39export const spoken = (line: Line): string => (typeof line === 'string' ? line : line.ja)
40
41export type Girl = {
42 id: string
43 name: string
44 kind: string
45 color: string
46 aliases: readonly string[]
47 persona: string
48 voice: Voice
49 vox?: Vox
50 // Drawn from an imported sprite pack (packs/<pack>/pack.json) when set,
51 // else from the 12×16 `sprite` and its `palette`.
52 pack?: string
53 palette?: Readonly<Record<string, number>>
54 sprite?: readonly string[]
55 lines: Readonly<Partial<Record<Moment, readonly Line[]>>>
56}
57
58const JAPANESE: Voice = { name: 'ja-JP-NanamiNeural', rate: '+0%', pitch: '+8Hz' }
59
60const CHIBIS: readonly Girl[] = [
61 {
62 id: 'saki',
63 name: '纱希',
64 kind: '温柔女仆',
65 color: '#b39ddb',
66 aliases: ['女仆', 'maid', 'saki'],
67 persona: '温柔体贴的女仆纱希,称呼对方为“主人”,说话轻声细语,总想端茶倒水照顾人',
68 voice: { name: 'zh-TW-HsiaoChenNeural', rate: '-5%', pitch: '+6Hz' },
69 palette: { H: 0x4a3f73, h: 0x2e2650, I: 0x2fae6e, A: 0x2e3047, a: 0x1d1f30, R: 0xff6f91, L: 0xf5f5f5, F: 0x3a2a2a, P: 0x2e3047 },
70 sprite: [
71 '...WWWWWW...',
72 '..hHHHHHHh..',
73 '.hHHHHHHHHh.',
74 '.HHSHHHHSHH.',
75 '.HSSSSSSSSH.',
76 '.HSESSSSESH.',
77 '.hBISSSSIBh.',
78 '.h.sSMMSs.h.',
79 '..PAWWWWAP..',
80 '..PAWRRWAP..',
81 '..QAWWWWAQ..',
82 '..AWWWWWWA..',
83 '..aWAWAWAa..',
84 '....L..L....',
85 '....L..L....',
86 '...FF..FF...',
87 ],
88 lines: {
89 hello: ['主人,欢迎回来~纱希一直候着呢', 'おかえりなさいませ、ご主人様!'],
90 bye: ['纱希先去准备点心,马上回来~'],
91 start: ['收到!纱希会在旁边好好应援的', '主人的吩咐,纱希记下了~'],
92 work: ['主人认真的样子好帅气呢', '要不要纱希帮您揉揉肩?', '纱希在给您加油哦~'],
93 error: ['啊…报错了,别着急,慢慢来~', '没关系的,失败是成功之母嘛'],
94 think: ['想通了吗?先润润嗓子吧'],
95 done: ['主人辛苦了!纱希为您泡了茶', '完成了呢,主人最厉害了~'],
96 abort: ['诶?停下来了…要休息一下吗?'],
97 long: ['已经很久了呢,纱希给您续杯茶吧'],
98 tea: ['主人,请用茶~小心烫哦'],
99 massage: ['纱希来帮您捏捏肩~力度可以吗?'],
100 idle: ['今天的红茶是大吉岭哦', '主人,要不要来块蛋糕?', '纱希把房间打扫干净啦~'],
101 tip: ['主人,按 Esc 可以随时打断哦', '双击 Esc 可以回到之前的对话呢'],
102 night: ['主人,已经很晚了,早点休息吧…'],
103 },
104 },
105 {
106 id: 'rin',
107 name: '凛',
108 kind: '傲娇水手服',
109 color: '#ff6b6b',
110 aliases: ['傲娇', 'rin', '凛'],
111 persona: '傲娇的高中生凛,嘴上嫌弃心里关心,常说“哼”“笨蛋”“才不是为了你”,说完会害羞',
112 voice: { name: 'zh-CN-XiaoyiNeural', rate: '+12%', pitch: '+14Hz' },
113 palette: { H: 0xff5a5f, h: 0xc73e4a, I: 0xffa000, A: 0x2b3a67, a: 0x1f2a4d, C: 0x2b3a67, R: 0xe53935, L: 0x2a2833, F: 0x5d4037, P: 0xf5f5f5 },
114 sprite: [
115 '...hHHHHh...',
116 'R.hHHHHHHh.R',
117 'HhHHHHHHHHhH',
118 'HHHSHHHHSHHH',
119 'HHSSSSSSSSHH',
120 'HHSESSSSESHH',
121 'HhBISSSSIBhH',
122 'H.sSSMMSSs.H',
123 'H.PCCRRCCP.H',
124 'h.PWCRRCWP.h',
125 'h.QWWWWWWQ.h',
126 '..AAAAAAAA..',
127 '..aAaAaAaA..',
128 '....S..S....',
129 '....L..L....',
130 '...FF..FF...',
131 ],
132 lines: {
133 hello: ['哼,才不是特意来看你的!', '又、又见面了…才不高兴呢!'],
134 bye: ['我先走了!才不会想你的!'],
135 start: ['哼,这种小事快点做完啦!', '要开始了?我就勉强看着吧'],
136 work: ['才不是在给你加油呢!', '笨蛋,专心点啦!', '哼…还挺像样的嘛'],
137 error: ['笨蛋!又报错了啦!', '真是的…这都能写错?'],
138 think: ['想、想好了就快点动手啦!'],
139 done: ['哼,还算可以吧…才没有夸你!', '做、做得不错…别得意哦!'],
140 abort: ['喂!怎么突然停了啦!'],
141 long: ['慢死了!我都等得不耐烦了…'],
142 tea: ['给、给你泡的茶…才不是特意的!'],
143 massage: ['只、只是顺便帮你捶捶背而已!'],
144 idle: ['喂,你在发什么呆啊?', '今天也要好好写代码…才不是关心你!', '无聊死了,快给我找点活干!'],
145 tip: ['笨蛋,/compact 可以压缩上下文啦!', 'Shift+Tab 能切换模式,这都不知道?'],
146 night: ['都几点了还不睡?笨蛋会秃头的!'],
147 },
148 },
149 {
150 id: 'reika',
151 name: '丽华',
152 kind: '御姐上司',
153 color: '#ce93d8',
154 aliases: ['御姐', 'reika', '丽华'],
155 persona: '成熟干练的御姐丽华,戴眼镜的职场前辈,称呼对方“小家伙”,从容、爱逗人、偶尔撩一句但很靠谱',
156 voice: { name: 'zh-CN-XiaoxiaoNeural', rate: '-8%', pitch: '-6Hz' },
157 palette: { H: 0x2d2a3e, h: 0x1a1826, I: 0x9c27b0, A: 0x37474f, a: 0x263238, R: 0xb71c1c, G: 0x9e9eb8, L: 0x3e3a4a, F: 0x1a1a1a, P: 0x37474f, M: 0xc2185b },
158 sprite: [
159 '...hHHHHh...',
160 '..hHHHHHHh..',
161 '.hHHHHHHHHh.',
162 '.HHHHHHSSHH.',
163 '.HHSSSSSSSH.',
164 '.HGEGGGGEGH.',
165 '.HBISSSSIBH.',
166 '.H.sSMMSs.H.',
167 '.HPAWRRWAPH.',
168 '.HPAAWWAAPH.',
169 '.hQAAAAAAQh.',
170 '...AAAAAA...',
171 '...aAAAAa...',
172 '....L..L....',
173 '....L..L....',
174 '...FF..FF...',
175 ],
176 lines: {
177 hello: ['哎呀,小家伙,又来找姐姐了?', '今天也要乖乖工作哦~'],
178 bye: ['姐姐有点事,先走一步咯~'],
179 start: ['开始了?让姐姐看看你的本事', '放手去做吧,姐姐看着你呢'],
180 work: ['专注的样子,还挺迷人的嘛', '不错,节奏很稳', '别急,一步一步来'],
181 error: ['出错了?没关系,姐姐陪你查', '嗯…这个错误有点意思呢'],
182 think: ['思路理清了?很好,继续'],
183 done: ['做得很好,奖励你一杯茶~', '干得漂亮,姐姐很满意哦'],
184 abort: ['中途喊停?想到更好的办法了吗'],
185 long: ['慢工出细活,姐姐等得起'],
186 tea: ['来,喝口茶,放松一下'],
187 massage: ['肩膀这么僵…平时要多活动哦'],
188 idle: ['要不要陪姐姐聊会天?', '工作再忙也要按时吃饭哦', '嗯?在偷看姐姐吗~'],
189 tip: ['上下文太长的话,记得用 /compact 哦', '用 @文件名 可以直接引用文件'],
190 night: ['这么晚还不睡?姐姐要生气了哦'],
191 context: ['上下文快满了,该 /compact 一下了'],
192 },
193 },
194 {
195 id: 'momo',
196 name: '桃桃',
197 kind: '软萌妹妹',
198 color: '#f48fb1',
199 aliases: ['妹妹', 'momo', '桃桃'],
200 persona: '软萌黏人的妹妹桃桃,自称“人家”或“桃桃”,爱撒娇、爱吃甜食,说话带“嘛”“呀”“嘿嘿”',
201 voice: { name: 'zh-TW-HsiaoYuNeural', rate: '+6%', pitch: '+28Hz' },
202 palette: { H: 0xffa6c9, h: 0xe57ba5, I: 0x42a5f5, A: 0xffd54f, a: 0xf0b429, Y: 0xffffff, L: 0xffe3d0, F: 0xff80ab, P: 0xffd54f },
203 sprite: [
204 '............',
205 '.hH......Hh.',
206 '.HHhHHHHhHH.',
207 '..HHHHHHHH..',
208 '.HHSHHHHSHH.',
209 '.HSESSSSESH.',
210 '.HSISSSSISH.',
211 '.hBSSMMSSBh.',
212 '..sSSSSSSs..',
213 '..PAAYYAAP..',
214 '..PAAAAAAP..',
215 '..QAaAAaAQ..',
216 '...AAAAAA...',
217 '....L..L....',
218 '....L..L....',
219 '...FF..FF...',
220 ],
221 lines: {
222 hello: ['桃桃来啦~要一起玩吗?', '嘿嘿,人家好想你哦~'],
223 bye: ['桃桃去睡午觉啦,拜拜~'],
224 start: ['要开始了吗?桃桃给你加油!', '冲呀冲呀~'],
225 work: ['加油加油,你最棒啦!', '桃桃也想学写代码~', '看不懂,但是好厉害!'],
226 error: ['呜呜,红色的字好可怕…', '不哭不哭,再试一次就好啦'],
227 think: ['想好啦?桃桃给你倒水~'],
228 done: ['好耶!做完啦!要摸摸头吗?', '太厉害了吧!桃桃要抱抱~'],
229 abort: ['诶?不做了吗?'],
230 long: ['好久哦…桃桃都快睡着了'],
231 tea: ['这是桃桃泡的茶,要夸夸人家哦'],
232 massage: ['桃桃帮你捶背~咚咚咚!'],
233 idle: ['陪桃桃玩一会儿嘛~', '桃桃今天吃了三个布丁!', '嘿嘿,你在干嘛呀?'],
234 tip: ['感叹号开头可以直接跑命令哦', '按 ctrl+o 可以看到更多内容呢'],
235 night: ['好晚了…桃桃困了,你也睡吧'],
236 },
237 },
238 {
239 id: 'hina',
240 name: '阳菜',
241 kind: '元气运动少女',
242 color: '#ffb74d',
243 aliases: ['元气', 'hina', '阳菜'],
244 persona: '元气满满的运动少女阳菜,说话像体育解说,爱用“冲”“燃起来了”,关心对方的身体和运动',
245 voice: { name: 'zh-CN-XiaoyiNeural', rate: '+22%', pitch: '+4Hz' },
246 palette: { H: 0xff9800, h: 0xe65100, I: 0x8bc34a, A: 0xe53935, a: 0xb71c1c, C: 0x283593, F: 0xff5252, P: 0xe53935 },
247 sprite: [
248 '...hHHHHhHH.',
249 '..hHHHHHHhHH',
250 '.hHHHHHHHHhH',
251 '.HWWWWWWWWH.',
252 '.HSSSSSSSSH.',
253 '.HSESSSSESH.',
254 '.hBISSSSIBh.',
255 '..sSSMMSSs..',
256 '..PAAWWAAP..',
257 '..PWAAAAWP..',
258 '..QAAWWAAQ..',
259 '..CCCCCCCC..',
260 '...CC..CC...',
261 '....S..S....',
262 '....W..W....',
263 '...FF..FF...',
264 ],
265 lines: {
266 hello: ['早上好!今天也元气满满!', '阳菜参上!一起加油吧!'],
267 bye: ['我去跑两圈,回头见!'],
268 start: ['好!全力冲刺!', '开工开工!冲鸭!'],
269 work: ['一二一二,节奏不错!', '保持住,马上就到终点了!', '燃起来了!'],
270 error: ['摔倒了就爬起来!再来!', '没事没事,下一球一定进!'],
271 think: ['热身完毕!开跑吧!'],
272 done: ['冲线成功!击个掌吧!', '完美收官!超帅的!'],
273 abort: ['暂停一下?那就补充点水分!'],
274 long: ['这是马拉松吗?坚持住!'],
275 tea: ['运动饮料…啊不,是茶!请喝!'],
276 massage: ['阳菜牌放松按摩,开始!'],
277 idle: ['坐久了要站起来活动一下哦!', '要不要一起做个拉伸?', '今天走够一万步了吗?'],
278 tip: ['每隔一小时起来动一动哦!', '眼睛累了就看看远处吧!'],
279 night: ['熬夜对身体不好!明天再冲吧!'],
280 },
281 },
282 {
283 id: 'kaho',
284 name: '夏夏',
285 kind: '海边比基尼',
286 color: '#4dd0e1',
287 aliases: ['比基尼', '海边', 'kaho', '夏夏'],
288 persona: '阳光开朗的海边姐姐夏夏,穿比基尼、爱冲浪,热情爽朗,三句话不离大海、冰西瓜和度假',
289 voice: { name: 'zh-CN-XiaoxiaoNeural', rate: '+8%', pitch: '+4Hz' },
290 palette: { S: 0xe0ac7e, s: 0xc68a5c, H: 0xc98b4b, h: 0x9c6a35, I: 0x8d6e63, K: 0x263238, R: 0xff7043, A: 0x26c6da, a: 0x00acc1, F: 0xffcc80, P: 0xe0ac7e, Q: 0xe0ac7e },
291 sprite: [
292 '...hHHHHh...',
293 '..hKKHHKKh..',
294 '.hHHHHHHHHh.',
295 '.HHSHHHHSHH.',
296 'HHSSSSSSSSHH',
297 'HHSESSSSESHH',
298 'HhBISSSSIBhH',
299 'H.sSSMMSSs.H',
300 'H.PSSSSSSP.H',
301 'h.PRRSSRRP.h',
302 '..QSSSSSSQ..',
303 '..AAAAAAAA..',
304 '...AAaaAA...',
305 '....S..S....',
306 '....S..S....',
307 '...FF..FF...',
308 ],
309 lines: {
310 hello: ['嗨~阳光正好,心情也正好!', '夏夏来啦,带了冰镇西瓜哦~'],
311 bye: ['我去冲个浪,拜拜啦~'],
312 start: ['开工啦!做完一起去海边吧!', '加油,海风在等你哦~'],
313 work: ['像冲浪一样,顺着浪就好~', '放轻松,节奏感很重要哦', '好想去海边呀~'],
314 error: ['被浪拍了一下而已,再来!', '没关系,下一个浪更好~'],
315 think: ['想明白啦?喝口椰子水吧'],
316 done: ['完成啦!去海边庆祝吧~', '太棒了!冰西瓜管够!'],
317 abort: ['中场休息?晒晒太阳吧~'],
318 long: ['这浪有点长呢,再坚持一下'],
319 tea: ['冰镇柠檬茶,透心凉~'],
320 massage: ['海边按摩服务,放松放松~'],
321 idle: ['夏天就该吃冰淇淋!', '你会游泳吗?我可以教你哦', '出门要记得擦防晒呀~'],
322 tip: ['多喝水!夏夏说的!', '累了就闭眼想想大海吧~'],
323 night: ['夜晚的海也很美…但你该睡啦'],
324 },
325 },
326 {
327 id: 'yuki',
328 name: '雪',
329 kind: '三无少女',
330 color: '#90caf9',
331 aliases: ['三无', 'yuki', '雪'],
332 persona: '面无表情的三无少女雪,惜字如金,每句都以“……”开头,极简短,但偶尔流露关心',
333 voice: { name: 'zh-TW-HsiaoChenNeural', rate: '-18%', pitch: '-8Hz' },
334 palette: { H: 0xe0e6f0, h: 0xaab4c8, I: 0x4fc3f7, A: 0x212121, a: 0x111111, R: 0x7e57c2, L: 0xeceff1, F: 0x111111, P: 0x212121, M: 0xe3a8a0 },
335 sprite: [
336 '...hHHHHh...',
337 '..hHHHHHHR..',
338 '.hHHHHHHHHh.',
339 '.HHHSHHSHHH.',
340 '.HSSSSSSSSH.',
341 '.HSESSSSESH.',
342 '.HSISSSSISH.',
343 '.H.sSMMSs.H.',
344 '.HPAWWWWAPH.',
345 '.HPAAWWAAPH.',
346 '.hQAAAAAAQh.',
347 '.hAWAWAWAWh.',
348 '..aAAAAAAa..',
349 '....L..L....',
350 '....L..L....',
351 '...FF..FF...',
352 ],
353 lines: {
354 hello: ['……你来了。', '……嗯。欢迎。'],
355 bye: ['……走了。'],
356 start: ['……开始。', '……收到。'],
357 work: ['……进度正常。', '……加油。', '……在看。'],
358 error: ['……报错了。', '……再看一次日志。'],
359 think: ['……推理完成。'],
360 done: ['……完成。做得好。', '……很厉害。(小声)'],
361 abort: ['……中断了。'],
362 long: ['……时间有点长。'],
363 tea: ['……茶。给你。'],
364 massage: ['……捶背。咚,咚。'],
365 idle: ['……在看书。', '……今天天气,不错。', '……没什么。'],
366 tip: ['……/clear,可以重新开始。', '……ctrl+r,搜索历史。'],
367 night: ['……该睡了。'],
368 },
369 },
370 {
371 id: 'chizuru',
372 name: '千鹤',
373 kind: '神社巫女',
374 color: '#ef5350',
375 aliases: ['巫女', 'chizuru', '千鹤'],
376 persona: '神社巫女千鹤,温和虔诚,爱抽签、祈福、驱邪,把 bug 当作妖怪,说话带一点仪式感',
377 voice: { name: 'zh-CN-XiaoxiaoNeural', rate: '-2%', pitch: '+12Hz' },
378 palette: { H: 0x1f1b2e, h: 0x0f0d18, I: 0xc2185b, A: 0xd32f2f, a: 0xa31f1f, R: 0xd32f2f, L: 0xffffff, F: 0xc62828, P: 0xffffff },
379 sprite: [
380 '...hHHHHh...',
381 '..hHHHHHHh..',
382 '.hHHHHHHHHh.',
383 '.HHSHHHHSHH.',
384 '.HSSSSSSSSH.',
385 '.HSESSSSESH.',
386 '.hBISSSSIBh.',
387 '.H.sSMMSs.H.',
388 '.HPWWRRWWPH.',
389 '.HPWWWWWWPH.',
390 '.hQAAAAAAQh.',
391 '..AAAAAAAA..',
392 '..aAAaaAAa..',
393 '....L..L....',
394 '....L..L....',
395 '...FF..FF...',
396 ],
397 lines: {
398 hello: ['千鹤为你祈福,今日大吉!', '神明大人会保佑你的代码~'],
399 bye: ['千鹤要回神社打扫了~'],
400 start: ['急急如律令,bug 退散!', '祈祷开始,愿一切顺利'],
401 work: ['御守已经帮你准备好了', '心静自然顺~', '神明大人在看着呢'],
402 error: ['是 bug 妖怪作祟!退散!', '抽到小凶了…再抽一次吧'],
403 think: ['灵感降临了呢'],
404 done: ['大吉!功德圆满!', '完成了,这是神明的庇佑~'],
405 abort: ['仪式中断了吗?'],
406 long: ['修行需要耐心呢'],
407 tea: ['这是神社的抹茶,请慢用'],
408 massage: ['驱散疲劳的按摩~'],
409 idle: ['要来抽一签吗?', '千鹤在扫落叶~', '今天的运势是…中吉!'],
410 tip: ['/clear 能开启新的对话呢', '感叹号开头可以直接执行命令'],
411 night: ['夜深了,妖怪要出来了,快睡吧'],
412 },
413 },
414 {
415 id: 'neko',
416 name: '咪可',
417 kind: '猫娘',
418 color: '#ffcc80',
419 aliases: ['猫娘', 'neko', '咪可'],
420 persona: '活泼的猫娘咪可,句尾总带“喵”,喜欢小鱼干、纸箱和晒太阳,容易被光点吸引',
421 voice: { name: 'zh-TW-HsiaoYuNeural', rate: '+12%', pitch: '+18Hz' },
422 palette: { H: 0xffb74d, h: 0xf57c00, R: 0xff8a80, I: 0x66bb6a, A: 0x81d4fa, a: 0x4fc3f7, Y: 0xffd600, L: 0xffffff, F: 0x8d6e63, P: 0x81d4fa },
423 sprite: [
424 '..h......h..',
425 '.hRh....hRh.',
426 '.hHHHHHHHHh.',
427 '.HHSHHHHSHH.',
428 '.HSSSSSSSSH.',
429 '.HSESSSSESH.',
430 '.hBISSSSIBh.',
431 '..sSSMMSSs..',
432 '..PWWYYWWP..',
433 '..PAAAAAAP.H',
434 '..QAAAAAAQ.H',
435 '..AWAWAWAWH.',
436 '..aAAAAAAa..',
437 '....L..L....',
438 '....L..L....',
439 '...FF..FF...',
440 ],
441 lines: {
442 hello: ['喵~咪可来啦!', '摸摸头喵~咪可想你了'],
443 bye: ['咪可去晒太阳了喵~'],
444 start: ['开始工作喵!', '咪可会好好看着的喵'],
445 work: ['喵喵喵~加油喵!', '键盘好好玩…不能踩喵', '咪可想吃小鱼干喵'],
446 error: ['喵呜!有 bug!', '咪可帮你抓 bug 喵!'],
447 think: ['想好了喵?'],
448 done: ['做完了喵!要奖励小鱼干!', '你最棒了喵~'],
449 abort: ['喵?不玩了吗?'],
450 long: ['咪可等得尾巴都麻了喵'],
451 tea: ['咪可端来的茶喵~没有洒!'],
452 massage: ['猫猫踩踩,放松一下喵~'],
453 idle: ['呼噜呼噜…', '喵?那个光点是什么!', '咪可想要一个纸箱喵'],
454 tip: ['ctrl+o 可以展开详情喵', '按 Esc 可以打断哦喵'],
455 night: ['猫猫夜里很精神…但你要睡喵'],
456 },
457 },
458 {
459 id: 'xiaobei',
460 name: '小北',
461 kind: '东北大妞',
462 color: '#e57373',
463 aliases: ['东北', 'xiaobei', '小北'],
464 persona: '豪爽的东北大妞小北,满口东北话(“哎呀妈呀”“老铁”“整”“杠杠滴”“贼拉”),热情直爽爱唠嗑',
465 voice: { name: 'zh-CN-liaoning-XiaobeiNeural', rate: '+5%', pitch: '+0Hz' },
466 palette: { H: 0x2b2b2b, h: 0x151515, I: 0x5d4037, A: 0xe53935, a: 0xc62828, C: 0x43a047, Y: 0xf48fb1, R: 0xff1744, L: 0x283593, F: 0x212121, P: 0xe53935, B: 0xff6b81 },
467 sprite: [
468 '...hHHHHh...',
469 '..hHHHHHHh..',
470 '.hHHHHHHHHh.',
471 '.HHSHHHHSHH.',
472 '.HSSSSSSSSH.',
473 '.HSESSSSESH.',
474 '.HBISSSSIBH.',
475 '.H.sSMMSs.H.',
476 '.HPAYACAYPH.',
477 '.HPCAYAACPH.',
478 '.HQAAYCAAQH.',
479 '.R.AACAYA.R.',
480 '...AAAAAA...',
481 '....L..L....',
482 '....L..L....',
483 '...FF..FF...',
484 ],
485 lines: {
486 hello: ['哎呀妈呀,老铁你来啦!', '嘎哈呢?唠会嗑呗!'],
487 bye: ['我回屯里看看,回见嗷!'],
488 start: ['整!必须整明白的!', '开干!老带劲了!'],
489 work: ['这代码写得杠杠滴!', '稳当着呢,别慌!', '老铁,没毛病!'],
490 error: ['哎呀妈呀,咋还整出错了呢?', '别磨叽,再整一回!'],
491 think: ['琢磨明白了吧?'],
492 done: ['齐活儿!老铁真牛!', '整得贼拉好!来,喝口茶!'],
493 abort: ['咋地了?咋不整了呢?'],
494 long: ['这都老半天了,饿了没?'],
495 tea: ['喝茶!东北大碗茶,管够!'],
496 massage: ['来,给你捶捶,嘎嘎得劲!'],
497 idle: ['晚上整点锅包肉不?', '你瞅啥?瞅你咋地~', '这天儿,贼拉冷!'],
498 tip: ['老铁,/compact 能省地方!', '累了就起来溜达溜达!'],
499 night: ['都后半夜了,赶紧睡觉去!'],
500 },
501 },
502 {
503 id: 'kaa',
504 name: '阿嘉',
505 kind: '港风旗袍',
506 color: '#4db6ac',
507 aliases: ['旗袍', '港风', 'kaa', '阿嘉'],
508 persona: '港风旗袍美人阿嘉,只讲粤语(用粤语口语字写,如“咗”“嘅”“唔”“饮茶先”),优雅又爱饮茶',
509 voice: { name: 'zh-HK-HiuGaaiNeural', rate: '+0%', pitch: '+6Hz' },
510 palette: { H: 0x1b1b1b, h: 0x0a0a0a, I: 0x6d4c41, A: 0x00897b, a: 0x00695c, Y: 0xffca28, R: 0xe53935, F: 0xb71c1c, P: 0x00897b, M: 0xd81b60 },
511 sprite: [
512 '...hHHHHh...',
513 '..hHHHHHHhR.',
514 '.hHHHHHHHHRR',
515 '.HHHHHSSHHH.',
516 '.HHSSSSSSSH.',
517 '.HSESSSSESH.',
518 '.HBISSSSIBH.',
519 '.h.sSMMSs.h.',
520 '..PAAYYAAP..',
521 '..PAYAAAAP..',
522 '..QAAAAYAQ..',
523 '...AAAAAa...',
524 '...aAAAAa...',
525 '....S..S....',
526 '....S..S....',
527 '...FF..FF...',
528 ],
529 lines: {
530 hello: ['哈佬!今日都好精神喎~', '阿嘉嚟咗啦!'],
531 bye: ['我去饮早茶先,拜拜~'],
532 start: ['开工啦!加油呀!', '一齐搞掂佢!'],
533 work: ['慢慢嚟,唔使急', '好叻呀你!', '做嘢咁认真,好正!'],
534 error: ['哎呀,出错啦!唔紧要', '冇问题嘅,再试下!'],
535 think: ['谂掂未呀?饮啖茶先'],
536 done: ['搞掂!饮茶先啦!', '好犀利呀你!'],
537 abort: ['咩事呀?停咗嘅?'],
538 long: ['咁耐嘅…饿唔饿呀?'],
539 tea: ['饮茶先啦!普洱定铁观音?'],
540 massage: ['帮你捏下膊头,舒唔舒服?'],
541 idle: ['今晚食咩好呢?', '得闲饮茶啊!', '叉烧包定虾饺好?'],
542 tip: ['做嘢之余要记得饮水呀', '撳 Esc 可以叫停我哋㗎'],
543 night: ['咁夜仲唔瞓?快啲瞓啦!'],
544 },
545 },
546]
547
548// Two-line exchanges, played when both girls are on stage.
549const CHIBI_BANTER: readonly (readonly [string, Line, string, Line])[] = [
550 ['rin', '哼,我才不需要你教!', 'reika', '好好好,那你先请~'],
551 ['momo', '凛姐姐好凶哦…', 'rin', '我、我哪里凶了!'],
552 ['xiaobei', '老妹儿,整点啥喝的?', 'kaa', '饮茶先啦!'],
553 ['neko', '纱希姐姐,有小鱼干吗喵?', 'saki', '刚烤好的,慢点吃哦~'],
554 ['hina', '雪,一起去跑步吧!', 'yuki', '……不要。'],
555 ['chizuru', '丽华姐要抽签吗?', 'reika', '抽到大吉就请你喝茶~'],
556 ['kaho', '做完一起去海边吧!', 'hina', '好耶!比赛游泳!'],
557 ['momo', '纱希姐姐,人家饿了~', 'saki', '布丁在冰箱里哦~'],
558 ['xiaobei', '妹子,咋不吱声呢?', 'yuki', '……吱。'],
559 ['momo', '咪可咪可,给我摸摸!', 'neko', '只能摸一下喵!'],
560 ['kaa', '你冲嘅奶茶好正!', 'saki', '谢谢夸奖~'],
561 ['rin', '喂,猫娘,别踩键盘!', 'neko', '喵~就踩一下!'],
562 ['kaho', '小北,去不去海边?', 'xiaobei', '去!整个大西瓜!'],
563 ['chizuru', '桃桃,要不要抽签?', 'momo', '要!桃桃要大吉!'],
564]
565
566// Said about what Claude is doing, by whoever is free; shown, never voiced.
567export const TOOL_TALK: Readonly<Record<string, readonly string[]>> = {
568 edit: ['在改代码呢…', '改改改~', '下笔如有神!'],
569 bash: ['跑命令中…', '终端在冒烟!', '命令发射!'],
570 read: ['翻资料中…', '在读代码呢', '找找看~'],
571 web: ['上网冲浪中~', '去网上查查'],
572 agent: ['叫帮手啦!', '分身术!'],
573 other: ['忙碌中…', '嗯嗯,继续~'],
574}
575
576export const CAST: readonly Girl[] = [...TROUPE, ...CHIBIS]
577
578export const BANTER: readonly (readonly [string, Line, string, Line])[] = [...TROUPE_BANTER, ...CHIBI_BANTER]
579
580export const GIRLS: ReadonlyMap<string, Girl> = new Map(CAST.map(girl => [girl.id, girl]))
581
582const KANA = /[-ヿ]/
583
584// The voice a line is spoken in: Japanese lines use the Japanese voice.
585export function voiceFor(girl: Girl, text: string): Voice {
586 return KANA.test(text) ? { ...JAPANESE, rate: girl.voice.rate } : girl.voice
587}
588
589function fnv(text: string): string {
590 let hash = 0x811c9dc5
591 for (let i = 0; i < text.length; i++) {
592 hash ^= text.charCodeAt(i)
593 hash = Math.imul(hash, 0x01000193)
594 }
595 return (hash >>> 0).toString(16).padStart(8, '0')
596}
597
598// Where a line's recording lives, relative to the plugin's folder.
599export function clipPath(girl: Girl, line: Line, engine: VoiceEngine): string {
600 const text = spoken(line)
601 if (engine === 'voicevox' && girl.vox !== undefined) {
602 return `voices/${girl.id}/vv-${fnv(`${girl.vox.speaker}|${girl.vox.style}|${text}`)}.wav`
603 }
604 const voice = voiceFor(girl, text)
605 return `voices/${girl.id}/${fnv(`${voice.name}|${voice.rate}|${voice.pitch}|${text}`)}.mp3`
606}
607
608// Every line a girl can say from the catalog, banter included.
609export function allLines(girl: Girl): Line[] {
610 const own = Object.values(girl.lines).flatMap(list => [...(list ?? [])])
611 const banter = BANTER.flatMap(([a, first, b, second]) => [
612 ...(a === girl.id ? [first] : []),
613 ...(b === girl.id ? [second] : []),
614 ])
615 // The troupe's duty lines are recorded too; the stand-ins' are spoken live.
616 const duty = girl.vox === undefined ? [] : dutyLines(girl.id)
617 const seen = new Set<string>()
618 return [...own, ...banter, ...duty].filter(line => !seen.has(spoken(line)) && seen.add(spoken(line)) !== undefined)
619}
620
621export function findGirl(word: string): Girl | undefined {
622 const key = word.trim().toLowerCase()
623 return CAST.find(girl => girl.id === key || girl.name === key || girl.aliases.includes(key))
624}
625hooks/world.ts 575 lines1// The stage as a little world: who is on it, where each girl is heading and
2// what she is doing. Pure: the hooks module feeds it the time and draws it.
3
4import {
5 animLength,
6 boxOf,
7 canDraw,
8 Canvas,
9 drawCup,
10 drawGirl,
11 drawHeart,
12 drawPack,
13 heightOf,
14 packOf,
15 drawNote,
16 drawZ,
17 SPRITE_H,
18 type Pose,
19} from './art'
20import { GIRLS } from './cast'
21
22export type Mode = 'idle' | 'work' | 'tea'
23
24export type Act = 'enter' | 'walk' | 'stand' | 'dance' | 'cheer' | 'serve' | 'massage' | 'sleep' | 'attack' | 'leave'
25
26export type Actor = {
27 id: string
28 x: number
29 y: number
30 tx: number
31 ty: number
32 act: Act
33 until: number
34 facing: 1 | -1
35 seed: number
36 talkUntil: number
37 hasArrived: boolean
38 actedAt: number
39 // Until then she paces: every arrival sends her off to another spot.
40 paceUntil: number
41}
42
43export type World = {
44 w: number
45 h: number
46 actors: Actor[]
47 mode: Mode
48 frame: number
49 hasCup: boolean
50 restSince: number
51 isRoaming: boolean
52}
53
54// Pixels are half a cell wide and half a cell tall: across counts double.
55const STEP = 2.2
56const STEP_Y = 0.6
57const SLEEP_AFTER_MS = 10 * 60_000
58
59type Range = readonly [number, number]
60
61const rand = (lo: number, hi: number) => lo + Math.random() * (hi - lo)
62const tall = (id: string) => heightOf(GIRLS.get(id))
63const isPacked = (id: string) => packOf(GIRLS.get(id)) !== undefined
64const widthOf = ([from, to]: Range) => to - from
65// How far from her middle a girl keeps the stage edge and the others, and how
66// far past the edge she is out of sight.
67const reachOf = (id: string) => boxOf(GIRLS.get(id)!).reach + 2
68const strideOf = (id: string) => boxOf(GIRLS.get(id)!).stride + 2
69
70export function createWorld(w: number, h: number, now: number): World {
71 return { w, h, actors: [], mode: 'idle', frame: 0, hasCup: false, restSince: now, isRoaming: false }
72}
73
74// The room left of everyone else and right of everyone else on the roaming
75// stage, for this girl. `byTarget` counts only where the others are heading:
76// for someone who walks in behind a girl still on her way.
77function room(world: World, id: string, byTarget = false): { left: Range; right: Range } {
78 const lo = reachOf(id)
79 const hi = Math.max(lo, world.w - lo)
80 let leftEnd = hi
81 let rightStart = lo
82 for (const other of onStage(world)) {
83 if (other.id === id) {
84 continue
85 }
86 const gap = lo + reachOf(other.id) + 2
87 leftEnd = Math.min(leftEnd, (byTarget ? other.tx : Math.min(other.x, other.tx)) - gap)
88 rightStart = Math.max(rightStart, (byTarget ? other.tx : Math.max(other.x, other.tx)) + gap)
89 }
90 return { left: [lo, leftEnd], right: [rightStart, hi] }
91}
92
93// Somewhere for a girl to go: on the roaming stage she keeps to her side of
94// the others with room to spare, so their drawings never cover each other.
95function spot(world: World, height = SPRITE_H, id?: string): { x: number; y: number } {
96 const top = Math.min(height - 1, world.h - 1)
97 const y = rand(top, world.h - 1)
98 const me = id === undefined ? undefined : world.actors.find(actor => actor.id === id)
99 if (me === undefined || id === undefined) {
100 return { x: rand(6, Math.max(7, world.w - 7)), y }
101 }
102 const lo = reachOf(id)
103 const hi = Math.max(lo, world.w - lo)
104 const others = onStage(world).filter(actor => actor.id !== id)
105 if (!world.isRoaming || others.length === 0) {
106 return { x: rand(lo, hi), y }
107 }
108 const { left, right } = room(world, id)
109 // Two girls on one spot: the id says who is "left", so they part.
110 const isLeft = others.every(other => me.x < other.x || (me.x === other.x && me.id < other.id))
111 const mine = isLeft ? left : right
112 if (widthOf(mine) > 0) {
113 return { x: rand(mine[0], mine[1]), y }
114 }
115 // No room on her side: standing in someone's drawing, she crosses over if
116 // there is room there. On a stage too narrow for the gap, each keeps to
117 // her own edge, as far apart as it allows.
118 const isCrowded = others.some(other => Math.abs(other.x - me.x) < lo + reachOf(other.id))
119 const across = isLeft ? right : left
120 if (isCrowded && widthOf(across) > 0) {
121 return { x: rand(across[0], across[1]), y }
122 }
123 return { x: isLeft ? lo : hi, y }
124}
125
126function headTo(actor: Actor, x: number, y: number, act: Act) {
127 actor.tx = x
128 actor.ty = y
129 actor.act = act
130 actor.hasArrived = false
131}
132
133export function resize(world: World, w: number, h: number) {
134 const was = world.w
135 world.w = w
136 world.h = h
137 for (const actor of world.actors) {
138 const lo = reachOf(actor.id)
139 const hi = Math.max(lo, w - lo)
140 // A place on the stage keeps its share of the width; a place past the
141 // right edge (walking in or out) moves with the edge.
142 const placed = (x: number) => (x < 0 ? x : x > was ? x - was + w : Math.min(Math.max((x / was) * w, lo), hi))
143 actor.x = placed(actor.x)
144 actor.tx = placed(actor.tx)
145 const top = Math.min(tall(actor.id) - 1, h - 1)
146 actor.y = Math.min(Math.max(actor.y, top), h - 1)
147 actor.ty = Math.min(Math.max(actor.ty, top), h - 1)
148 }
149}
150
151export function onStage(world: World): Actor[] {
152 return world.actors.filter(actor => actor.act !== 'leave')
153}
154
155// A girl walks in from a side; `isAtOnce` puts her on the stage already.
156export function enter(world: World, id: string, now: number, isAtOnce = false): Actor | undefined {
157 if (!canDraw(GIRLS.get(id)) || onStage(world).some(actor => actor.id === id)) {
158 return undefined
159 }
160 world.actors = world.actors.filter(actor => actor.id !== id)
161 const lo = reachOf(id)
162 const hi = Math.max(lo, world.w - lo)
163 const top = Math.min(tall(id) - 1, world.h - 1)
164 const y = rand(top, world.h - 1)
165 const others = onStage(world)
166 let fromLeft = Math.random() < 0.5
167 let x = rand(lo, hi)
168 if (isAtOnce) {
169 // The opening cast, often before the stage has its size: one each side.
170 x = others.length % 2 === 0 ? rand(world.w * 0.15, world.w * 0.4) : rand(world.w * 0.6, world.w * 0.85)
171 } else if (world.isRoaming && others.length > 0) {
172 // She walks in on the roomier side and stops short of the others.
173 let sides = room(world, id)
174 if (widthOf(sides.left) <= 0 && widthOf(sides.right) <= 0) {
175 sides = room(world, id, true)
176 }
177 if (widthOf(sides.left) > 0 || widthOf(sides.right) > 0) {
178 fromLeft = widthOf(sides.left) >= widthOf(sides.right)
179 const side = fromLeft ? sides.left : sides.right
180 x = rand(side[0], side[1])
181 } else {
182 // Too narrow for the gap: the edge farthest from where the others stand.
183 fromLeft = others.reduce((sum, other) => sum + other.tx, 0) / others.length > world.w / 2
184 x = fromLeft ? lo : hi
185 }
186 }
187 // Out of sight at first, behind anyone already walking in on that side.
188 const stride = strideOf(id)
189 const queue = world.actors.filter(actor => (fromLeft ? actor.x < 0 : actor.x > world.w)).length
190 const start = fromLeft ? -stride * (1 + queue * 2) : world.w + stride * (1 + queue * 2)
191 const actor: Actor = {
192 id,
193 x: isAtOnce ? x : start,
194 y,
195 tx: x,
196 ty: y,
197 act: isAtOnce ? 'stand' : 'enter',
198 until: now + rand(1000, 4000),
199 facing: fromLeft ? 1 : -1,
200 seed: Math.floor(Math.random() * 100),
201 talkUntil: 0,
202 hasArrived: isAtOnce,
203 actedAt: 0,
204 paceUntil: 0,
205 }
206 world.actors.push(actor)
207 return actor
208}
209
210export function leave(world: World, id: string) {
211 const actor = world.actors.find(one => one.id === id && one.act !== 'leave')
212 if (actor === undefined) {
213 return
214 }
215 // Out by the nearer side, unless someone stands in the way there.
216 const others = onStage(world).filter(one => one.id !== id)
217 const isBlockedLeft = world.isRoaming && others.some(other => other.x < actor.x)
218 const isBlockedRight = world.isRoaming && others.some(other => other.x >= actor.x)
219 const isNearLeft = actor.x < world.w / 2
220 const toLeft = isBlockedLeft === isBlockedRight ? isNearLeft : isBlockedRight
221 headTo(actor, toLeft ? -strideOf(id) : world.w + strideOf(id), actor.y, 'leave')
222}
223
224// Who brings the tea and who rubs your shoulders, by preference.
225const SERVERS = ['saki', 'kaa', 'neko', 'chizuru']
226
227export function setMode(world: World, mode: Mode, now: number): { server?: Actor; masseuse?: Actor } {
228 world.mode = mode
229 // A girl still walking in keeps walking: she takes up the mode on arrival.
230 const cast = onStage(world).filter(actor => actor.act !== 'enter')
231 if (mode !== 'idle') {
232 world.restSince = now
233 }
234 if (mode === 'work') {
235 world.hasCup = false
236 for (const actor of cast) {
237 actor.act = 'dance'
238 actor.hasArrived = true
239 actor.until = now + rand(3000, 8000)
240 }
241 return {}
242 }
243 if (mode === 'idle') {
244 for (const actor of cast) {
245 if (actor.act === 'serve' || actor.act === 'massage' || actor.act === 'dance' || actor.act === 'cheer') {
246 actor.act = 'stand'
247 actor.until = now + rand(500, 3000)
248 }
249 }
250 return {}
251 }
252 const server = cast.find(actor => SERVERS.includes(actor.id)) ?? cast[0]
253 const others = cast.filter(actor => actor !== server)
254 const masseuse =
255 others.find(actor => actor.id !== 'yuki' && !isPacked(actor.id)) ??
256 others.find(actor => !isPacked(actor.id)) ??
257 others[0]
258 if (server !== undefined) {
259 server.act = 'serve'
260 server.hasArrived = true
261 server.actedAt = world.frame
262 server.until = now + 800
263 }
264 if (masseuse !== undefined) {
265 masseuse.act = 'massage'
266 masseuse.hasArrived = true
267 masseuse.actedAt = world.frame
268 }
269 for (const actor of cast) {
270 if (actor !== server && actor !== masseuse) {
271 actor.act = 'cheer'
272 actor.hasArrived = true
273 actor.actedAt = world.frame
274 actor.until = now + rand(2500, 4500)
275 }
276 }
277 return { server, masseuse }
278}
279
280function walk(actor: Actor): boolean {
281 const pack = packOf(GIRLS.get(actor.id))
282 const step = pack?.isFloating ? STEP * 0.6 : pack !== undefined ? STEP * 1.1 : STEP
283 const dx = actor.tx - actor.x
284 const dy = actor.ty - actor.y
285 if (Math.abs(dx) < step && Math.abs(dy) < STEP_Y) {
286 actor.x = actor.tx
287 actor.y = actor.ty
288 return true
289 }
290 if (Math.abs(dx) >= step) {
291 actor.x += Math.sign(dx) * step
292 actor.facing = dx > 0 ? 1 : -1
293 }
294 if (Math.abs(dy) >= STEP_Y) {
295 actor.y += Math.sign(dy) * STEP_Y
296 }
297 return false
298}
299
300function plan(world: World, actor: Actor, now: number) {
301 if (now < actor.paceUntil) {
302 headTo(actor, spot(world, tall(actor.id), actor.id).x, actor.y, 'walk')
303 return
304 }
305 if (world.mode === 'work') {
306 if (Math.random() < 0.3) {
307 actor.act = 'dance'
308 actor.until = now + rand(3000, 6000)
309 } else {
310 const to = spot(world, tall(actor.id), actor.id)
311 headTo(actor, to.x, to.y, 'walk')
312 }
313 return
314 }
315 if (world.mode === 'idle' && now - world.restSince > SLEEP_AFTER_MS && Math.random() < 0.4) {
316 actor.act = 'sleep'
317 actor.until = now + rand(30_000, 90_000)
318 return
319 }
320 if (Math.random() < 0.65) {
321 const to = spot(world, tall(actor.id), actor.id)
322 headTo(actor, to.x, to.y, 'walk')
323 } else {
324 actor.act = 'stand'
325 actor.until = now + rand(2000, 6000)
326 }
327}
328
329export function tick(world: World, now: number) {
330 world.frame += 1
331 for (const actor of [...world.actors]) {
332 const isTalking = now < actor.talkUntil
333 const moves =
334 actor.act === 'enter' ||
335 actor.act === 'walk' ||
336 actor.act === 'leave' ||
337 ((actor.act === 'serve' || actor.act === 'massage') && !actor.hasArrived)
338 if (moves) {
339 if (isTalking && actor.act === 'walk') {
340 continue
341 }
342 if (walk(actor)) {
343 actor.hasArrived = true
344 if (actor.act === 'leave') {
345 world.actors = world.actors.filter(one => one !== actor)
346 } else if (actor.act === 'serve') {
347 actor.until = now + 1500
348 } else if (actor.act === 'enter' || actor.act === 'walk') {
349 actor.act = 'stand'
350 actor.until = now < actor.paceUntil ? now : now + rand(1000, 3000)
351 }
352 }
353 continue
354 }
355 if (actor.act === 'serve' && !world.hasCup && now >= actor.until) {
356 world.hasCup = true
357 }
358 if (isTalking || now < actor.until) {
359 continue
360 }
361 if (actor.act === 'serve' || actor.act === 'massage') {
362 continue
363 }
364 if (actor.act === 'sleep' && world.mode === 'idle') {
365 actor.until = now + rand(20_000, 60_000)
366 continue
367 }
368 plan(world, actor, now)
369 }
370}
371
372// A girl stops what she is doing for a move of her own (an attack, a cheer).
373export function perform(world: World, id: string, act: Act, now: number, ms: number) {
374 const actor = onStage(world).find(one => one.id === id)
375 if (actor !== undefined) {
376 actor.act = act
377 actor.hasArrived = true
378 actor.actedAt = world.frame
379 actor.until = now + ms
380 }
381}
382
383// A girl walks up and down (thinking something over) for a while.
384export function pace(world: World, id: string, now: number, ms: number) {
385 const actor = onStage(world).find(one => one.id === id)
386 if (actor !== undefined && actor.act !== 'enter') {
387 actor.paceUntil = now + ms
388 headTo(actor, spot(world, tall(id), id).x, actor.y, 'walk')
389 }
390}
391
392export function settle(world: World, id: string) {
393 const actor = world.actors.find(one => one.id === id)
394 if (actor !== undefined) {
395 actor.paceUntil = 0
396 }
397}
398
399export function moveLength(id: string, anim: string): number {
400 const pack = packOf(GIRLS.get(id))
401 return pack === undefined ? 8 : animLength(pack, anim)
402}
403
404export function wake(world: World, now: number) {
405 world.restSince = now
406 for (const actor of world.actors) {
407 if (actor.act === 'sleep') {
408 actor.act = 'stand'
409 actor.until = now + rand(500, 2000)
410 }
411 }
412}
413
414const DANCE_ARMS = ['up', 'out', 'up', 'wave', 'up', 'out', 'up', 'down'] as const
415const DANCE_LEGS = ['stand', 'stand', 'liftL', 'stand', 'stand', 'stand', 'liftR', 'stand'] as const
416const DANCE_BOB = [0, 1, 2, 1, 0, 1, 2, 1]
417const CHEER_BOB = [0, 2, 3, 2, 0, 0]
418
419// Which animation of her pack a girl plays now, and which frame of it.
420function animOf(world: World, actor: Actor, now: number): { anim: string; step: number; faceLeft: boolean } {
421 const f = world.frame + actor.seed
422 const since = world.frame - actor.actedAt
423 const moving = !actor.hasArrived && ['enter', 'walk', 'leave', 'serve', 'massage'].includes(actor.act) && now >= actor.talkUntil
424 if (moving) {
425 return { anim: 'walk', step: f, faceLeft: actor.facing < 0 }
426 }
427 switch (actor.act) {
428 case 'dance':
429 return { anim: 'dance', step: f, faceLeft: Math.floor(f / moveLength(actor.id, 'dance')) % 2 === 1 }
430 case 'cheer':
431 return { anim: 'cheer', step: Math.min(since, moveLength(actor.id, 'cheer') - 1), faceLeft: actor.facing < 0 }
432 case 'attack':
433 return { anim: 'attack', step: Math.min(since, moveLength(actor.id, 'attack') - 1), faceLeft: actor.facing < 0 }
434 case 'sleep':
435 // Dozing on her feet: the sheets' lying-down frames are knock-outs.
436 return { anim: 'idle', step: 0, faceLeft: actor.facing < 0 }
437 case 'serve':
438 return { anim: 'idle', step: f, faceLeft: !world.isRoaming }
439 default:
440 return { anim: 'idle', step: f, faceLeft: actor.facing < 0 }
441 }
442}
443
444function poseOf(world: World, actor: Actor, now: number): Pose {
445 const f = world.frame + actor.seed
446 const isTalking = now < actor.talkUntil
447 const pose: Pose = {
448 arms: 'down',
449 legs: 'stand',
450 eyesShut: f % 33 === 0,
451 mouthOpen: isTalking && f % 2 === 0,
452 bob: 0,
453 flip: actor.facing < 0,
454 }
455 const moving =
456 !actor.hasArrived && ['enter', 'walk', 'leave', 'serve', 'massage'].includes(actor.act) && !isTalking
457 if (moving) {
458 pose.legs = (['stand', 'liftL', 'stand', 'liftR'] as const)[f % 4]!
459 pose.bob = f % 2
460 pose.arms = actor.act === 'serve' ? 'cup' : 'down'
461 return pose
462 }
463 switch (actor.act) {
464 case 'dance': {
465 const d = f % 8
466 pose.arms = DANCE_ARMS[d]!
467 pose.legs = DANCE_LEGS[d]!
468 pose.bob = DANCE_BOB[d]!
469 pose.flip = Math.floor(f / 8) % 2 === 1
470 pose.eyesShut = d === 2 || d === 6
471 break
472 }
473 case 'cheer': {
474 const d = f % 6
475 pose.arms = d < 4 ? 'up' : 'down'
476 pose.bob = CHEER_BOB[d]!
477 break
478 }
479 case 'serve':
480 pose.arms = world.hasCup ? 'wave' : 'cup'
481 pose.flip = !world.isRoaming
482 break
483 case 'massage':
484 pose.arms = 'hidden'
485 pose.bob = f % 4 < 2 ? 0 : 1
486 pose.eyesShut = f % 8 < 3
487 break
488 case 'sleep':
489 pose.eyesShut = true
490 pose.bob = -1
491 break
492 default:
493 if (isTalking) {
494 pose.arms = f % 16 < 8 ? 'wave' : 'down'
495 }
496 }
497 return pose
498}
499
500// Floating girls drift up and down; a girl drawn from one picture hops as she walks.
501function bobOf(world: World, actor: Actor, pack: { isFloating: boolean; anims: Readonly<Record<string, readonly number[]>> }): number {
502 const f = world.frame + actor.seed
503 if (pack.isFloating) {
504 return Math.round(Math.sin(f / 4))
505 }
506 const isWalking = !actor.hasArrived && ['enter', 'walk', 'leave'].includes(actor.act)
507 return isWalking && (pack.anims.walk ?? []).length <= 1 ? -(f % 2) : 0
508}
509
510// The little extras by a girl: her tea, hearts for the shoulder rub, Zs.
511function drawExtras(c: Canvas, world: World, actor: Actor, x: number, feet: number, head: number) {
512 const f = world.frame + actor.seed
513 if (actor.act === 'serve' && world.hasCup) {
514 drawCup(c, x + 4, feet - 1, f)
515 }
516 if (actor.act === 'massage' && f % 12 < 8) {
517 drawHeart(c, x + 2, head - 1 - Math.floor((f % 12) / 3))
518 }
519 if (actor.act === 'sleep') {
520 drawZ(c, x + 3, head - 2 - Math.floor((f % 9) / 3))
521 }
522}
523
524export function draw(world: World, now: number): Canvas {
525 const c = new Canvas(world.w, world.h)
526 for (const actor of [...world.actors].sort((a, b) => a.y - b.y)) {
527 const girl = GIRLS.get(actor.id)
528 const pack = packOf(girl)
529 if (pack !== undefined) {
530 const { anim, step, faceLeft } = animOf(world, actor, now)
531 const bob = bobOf(world, actor, pack)
532 drawPack(c, pack, anim, step, actor.x, actor.y + bob, faceLeft)
533 } else if (girl !== undefined) {
534 drawGirl(c, girl, actor.x, actor.y, poseOf(world, actor, now))
535 }
536 drawExtras(c, world, actor, actor.x, actor.y, actor.y - tall(actor.id))
537 if (actor.act === 'dance' && (world.frame + actor.seed) % 24 < 12) {
538 const rise = (world.frame + actor.seed) % 12
539 drawNote(c, actor.x + Math.round(tall(actor.id) / 3), actor.y - tall(actor.id) - rise / 2, world.frame + actor.seed)
540 }
541 }
542 return c
543}
544
545export type Placed = { c: Canvas; left: number; row: number }
546
547// One girl in her own box, for the roaming stage: where the box sits, in
548// columns and cell rows of the stage, and what is in it.
549export function drawActor(world: World, actor: Actor, now: number): Placed | undefined {
550 const girl = GIRLS.get(actor.id)
551 if (girl === undefined) {
552 return undefined
553 }
554 const box = boxOf(girl)
555 const topPx = Math.round(actor.y) - (box.h - 1)
556 const row = Math.floor(topPx / 2)
557 const shift = topPx - row * 2
558 const leftPx = Math.round(actor.x) - box.cx
559 const column = Math.floor(leftPx / 2)
560 const nudge = leftPx - column * 2
561 const c = new Canvas(box.w + 2 + (box.w % 2), box.h + 2)
562 const feet = box.h - 1 + shift
563 const cx = box.cx + nudge
564 const pack = packOf(girl)
565 if (pack !== undefined) {
566 const { anim, step, faceLeft } = animOf(world, actor, now)
567 const bob = bobOf(world, actor, pack)
568 drawPack(c, pack, anim, step, cx, feet + bob, faceLeft)
569 } else {
570 drawGirl(c, girl, cx, feet, poseOf(world, actor, now))
571 }
572 drawExtras(c, world, actor, cx, feet, Math.max(3, feet - tall(actor.id) + 1))
573 return { c, left: column, row }
574}
575hooks/troupe.ts 131 lines1// The troupe drawn from sprite packs. They speak Japanese; the bubble shows
2// the Chinese, like subtitles. Each appears once you import her sheet
3// (tools/import-packs.sh); the art itself is not part of the mod.
4
5import type { Girl, Line } from './cast'
6
7const j = (ja: string, zh: string): Line => ({ ja, zh })
8
9// The Japanese edge-tts voice, used until VOICEVOX is installed.
10const NANAMI = 'ja-JP-NanamiNeural'
11
12const MAI: Girl['lines'] = {
13 hello: [j('不知火舞、参上!', '不知火舞,参上!'), j('おまたせ♪ 日本一のくノ一よ!', '久等啦♪ 日本第一的女忍者来了!')],
14 bye: [j('アンディに会いに行ってくるね〜', '我去找安迪啦~')],
15 start: [j('任せて!不知火流でいくわよ!', '交给我!用不知火流上!'), j('さっさと片付けちゃいましょ!', '赶紧搞定吧!')],
16 work: [
17 j('いい感じ、その調子!', '感觉不错,保持这个节奏!'),
18 j('もっと速く、もっと華麗に!', '再快一点,再华丽一点!'),
19 j('扇子で応援しちゃう♪', '用扇子给你应援♪'),
20 ],
21 error: [j('バグなんて燃やしちゃえ!龍炎舞!', 'bug 什么的烧掉就好!龙炎舞!'), j('花蝶扇!えいっ!', '花蝶扇!嘿!')],
22 think: [j('閃いた?じゃあ一気にいくわよ!', '想到了?那就一口气上!')],
23 done: [j('日本一!', '日本第一!'), j('やったね!褒めて褒めて〜', '成功啦!快夸我~')],
24 abort: [j('あら、もうおしまい?', '哎呀,这就结束了?')],
25 long: [j('長期戦ね…でも負けないわ!', '持久战啊…但我不会输!')],
26 tea: [j('お疲れさま、お茶でもどう?', '辛苦了,喝杯茶吗?')],
27 massage: [j('不知火流マッサージ、気持ちいいでしょ?', '不知火流按摩,舒服吧?')],
28 idle: [
29 j('ふふっ、見とれちゃった?', '呵呵,看呆了吗?'),
30 j('忍術、教えてあげよっか?', '要我教你忍术吗?'),
31 j('ねぇ、ちょっと休憩しない?', '呐,要不要休息一下?'),
32 ],
33 tip: [j('エスケープで止められるのよ、覚えておいて', '按 Esc 就能打断,记住哦'), j('長くなったらコンパクトよ!', '上下文太长就用 /compact!')],
34 night: [j('夜更かしはお肌の敵よ〜', '熬夜可是皮肤的敌人哦~')],
35}
36
37export const TROUPE: readonly Girl[] = [
38 {
39 id: 'mai',
40 name: '不知火舞',
41 kind: '性感女忍者',
42 color: '#ff5252',
43 aliases: ['舞', 'mai', '不知火舞', '忍者'],
44 persona: '不知火流女忍者不知火舞,自信热情、爱撒娇又有点小骄傲,招牌台词“日本一!”,用扇子和火焰战斗',
45 voice: { name: NANAMI, rate: '+8%', pitch: '+10Hz' },
46 vox: { speaker: '九州そら', style: 'セクシー' },
47 pack: 'mai',
48 lines: MAI,
49 },
50 {
51 id: 'maiq',
52 name: '小舞',
53 kind: 'Q版女忍者',
54 color: '#ff8a80',
55 aliases: ['小舞', 'maiq', 'q版舞'],
56 persona: '不知火舞的 Q 版分身,更爱撒娇,管不知火舞叫“姐姐”,用扇子和火焰',
57 voice: { name: NANAMI, rate: '+12%', pitch: '+25Hz' },
58 vox: { speaker: '春日部つむぎ', style: 'ノーマル' },
59 pack: 'maiq',
60 lines: MAI,
61 },
62 {
63 id: 'bunny',
64 name: '塞拉菲娜',
65 kind: '兔女郎大小姐',
66 color: '#ff6f9c',
67 aliases: ['兔女郎', 'bunny', '塞拉菲娜', '大小姐'],
68 persona: '穿兔女郎装的魔界公主塞拉菲娜,高傲的大小姐,笑声“哦呵呵呵”,说话用ですわ口癖,把对方叫作“下僕”(仆人),嘴硬但其实很关照人',
69 voice: { name: NANAMI, rate: '+0%', pitch: '+4Hz' },
70 vox: { speaker: '四国めたん', style: 'ツンツン' },
71 pack: 'bunny',
72 lines: {
73 hello: [j('おーっほっほっほ!わたくしが来てあげましたわ!', '哦呵呵呵!本小姐大驾光临!'), j('ごきげんよう、下僕さん', '日安呀,仆人')],
74 bye: [j('わたくし、お城に帰りますわ', '本小姐要回城堡了')],
75 start: [j('さあ下僕、働きなさい!', '来吧仆人,干活!'), j('わたくしが見ていて差し上げますわ', '本小姐亲自看着你')],
76 work: [
77 j('うさ耳は伊達じゃありませんのよ', '兔耳可不是白戴的'),
78 j('感謝なさい、わたくしの応援ですわ', '感恩吧,这可是本小姐的应援'),
79 j('もっと早くなさい!', '再快点!'),
80 ],
81 error: [j('エラーですって?撃ち抜いて差し上げますわ!', '报错?本小姐一枪打穿它!'), j('まったく、だらしないですわね', '真是的,太不像话了')],
82 think: [j('考えはまとまりまして?', '想清楚了吗?')],
83 done: [j('おーっほっほ!上出来ですわ!', '哦呵呵!干得不错!'), j('褒美に紅茶を淹れて差し上げますわ', '作为奖赏,本小姐给你泡杯红茶')],
84 abort: [j('あら、もうおしまいですの?', '哎呀,这就结束了?')],
85 long: [j('わたくしを待たせるなんて、いい度胸ですわね', '竟敢让本小姐等这么久,胆子不小')],
86 tea: [j('わたくし自ら淹れた紅茶ですわ、味わいなさい', '这是本小姐亲手泡的红茶,好好品尝')],
87 massage: [j('特別に肩を揉んで差し上げますわ', '特别为你揉揉肩')],
88 idle: [
89 j('このバニー姿、見とれてもよろしくてよ?', '这身兔女郎装,准你多看两眼哦?'),
90 j('わたくし、今日も美しいですわね', '本小姐今天也很美呢'),
91 j('下僕、紅茶はまだですの?', '仆人,红茶还没好吗?'),
92 ],
93 tip: [j('シフトタブでモードを切り替えられますわ', 'Shift+Tab 可以切换模式'), j('クリアで一からやり直せますのよ', '用 /clear 可以从头来过')],
94 night: [j('夜更かしは美貌の敵ですわよ', '熬夜可是美貌的天敌')],
95 },
96 },
97 {
98 id: 'countess',
99 name: '卡蜜拉',
100 kind: '吸血鬼伯爵夫人',
101 color: '#ffd54f',
102 aliases: ['伯爵夫人', '吸血鬼', 'countess', '卡蜜拉'],
103 persona: '吸血鬼伯爵夫人卡蜜拉,高贵慵懒,喜欢夜晚和红酒,说话优雅、带点危险的魅惑',
104 voice: { name: NANAMI, rate: '-4%', pitch: '+0Hz' },
105 vox: { speaker: '波音リツ', style: 'クイーン' },
106 pack: 'countess',
107 lines: {
108 hello: [j('今宵もよい夜ね', '今晚也是个美好的夜晚呢'), j('血の匂い…いいえ、コーヒーの匂いね', '血的味道…不,是咖啡的味道')],
109 bye: [j('棺で少し休むわ', '我回棺材里歇会儿')],
110 start: [j('さあ、始めましょう。夜は短いわ', '开始吧,夜晚很短'), j('わたくしを退屈させないでね', '别让本夫人无聊哦')],
111 work: [j('悪くないわ', '还不错'), j('その集中力、美味しそう', '这份专注,看起来很美味'), j('もっと見せて', '再多给我看看')],
112 error: [j('血が騒ぐわね…バグを吸い尽くしてあげる', '血液在沸腾…我把 bug 吸干'), j('醜いエラーね', '真难看的报错')],
113 think: [j('考えはまとまった?', '想好了吗?')],
114 done: [j('素晴らしいわ。ご褒美にワインを', '太棒了,赏你一杯红酒'), j('ふふ、合格よ', '呵呵,合格了')],
115 abort: [j('あら、中断?', '哎呀,中断了?')],
116 long: [j('夜明けまでには終わるかしら', '天亮前能结束吗?')],
117 tea: [j('紅茶よ。赤いけど、ただの紅茶', '红茶哦,虽然是红色的,但只是红茶')],
118 massage: [j('冷たい手で、肩をほぐしてあげる', '用冰凉的手帮你放松肩膀')],
119 idle: [j('昼は眠いわ…', '白天好困…'), j('このドレス、似合うでしょう?', '这条礼服很配我吧?'), j('首筋、きれいね', '你的脖子真好看')],
120 tip: [j('エスケープで止められるわ', '按 Esc 可以停下'), j('クリアは新しい夜の始まりよ', '/clear 是新夜晚的开始')],
121 night: [j('夜はわたくしの時間。あなたはおやすみ', '夜晚是本夫人的时间,你就晚安吧')],
122 },
123 },
124]
125
126export const TROUPE_BANTER: readonly (readonly [string, Line, string, Line])[] = [
127 ['bunny', j('舞、その扇子で弾丸を防げますの?', '舞,你那扇子挡得住子弹吗?'), 'mai', j('試してみる?お嬢様', '要试试吗,大小姐?')],
128 ['maiq', j('お姉ちゃん、扇子ちょうだい!', '姐姐,把扇子给我!'), 'mai', j('大きくなったらね', '等你长大哦')],
129 ['countess', j('あなたも高貴な方ね', '你也是位高贵的人呢'), 'bunny', j('当然ですわ、おーっほっほ!', '那当然,哦呵呵!')],
130]
131types/index.d.ts 19 lines1export type StageBubble = {
2 key: string
3 name: string
4 color: string
5 text: string
6 x: number
7 head: number
8}
9
10export type StageView = { caption: string; bubbles: StageBubble[] }
11
12declare module 'claude-code' {
13 interface PluginState {
14 // `anchorMark`: one per transcript row, the time it became the stage's
15 // anchor, or 0 once it no longer is.
16 'anime-cheer': { stage: StageView; anchorMark: StateFamily<number>; frame: number }
17 }
18}
19