SLOPSHOPPER

anime-cheer

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…

newpanespinnerrowsguardcommand
v0.4.0NOASSERTIONupdated 2026-10-02a252937166/cc-mod/anime-cheer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · anime-cheer
│ ┃ anime-cheer ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 应援团在对话区里玩呢~ /waifu pane │ anime-cheer │ │ ┃ 可以叫回来 ⏺ Read(src/auth.ts) │ 安全检查拦下了一条命令:强制推送,会覆盖远端的提交历史。确认要执行就输入 │ │ ⎿ Read 6 lines │ /waifu allow │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by anime-cheer: anime-cheer 的安全检查拦下了这条命令:强制推送,会覆盖远 │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /waifu │ ⎿ anime-cheer: 应援团先藏起来啦,/waifu 再叫我们出来~ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · anime-cheer
应援团在对话区里玩呢~ /waifu pane 可以叫回来
README

cc-mod

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

  • anime-cheer(动漫应援团):像素风的动漫女孩在你的对话区里走来走去。Claude 干活时她们跳舞应援,干完活端茶捶背,用日语配音、带中文字幕。她们也干正事:播报 token 用量、拦下危险命令、扫描密钥、review 代码、回顾之前做过的任务。
  • examples/hello-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 抽今日编码运势每位角色有自己的声线和动作

安全检查、代码 review、流量汇报和任务回顾

干完活:端茶、捶背

安装

需要准备

  • Claude Code 2.1.287 或更新版本,并且你的账号能用 function-hook mod。这是 Claude Code 的早期功能,由服务端的开关控制,接口也可能变。开关没打开时 mod 不会加载,claude plugin test 也跑不了。
  • 全屏布局:角色才能在对话区里走动。不是全屏时,她们提示一次之后就不出现、不出声;用 /waifu pane 可以把她们放进面板。
  • bun 和 ffmpeg:导入角色素材时要用。
  • 配音(可选,二选一):
  • 动漫声线:VOICEVOX,开着它的引擎即可
  • 普通日语女声:pip install edge-tts

只在 macOS 上用过。系统通知用的是 osascript,实时合成的台词(聊天、用量数字)用 afplay 播放,这两样在别的系统上不起作用。

第一步:下载角色素材

仓库里不带角色图片。请自己下载下面几张精灵图,放进同一个文件夹(默认是 ~/Downloads),文件名保持原样:

角色去哪下载文件名
不知火舞The Spriters Resource → Mobile → Metal Slug Defense → Units → Mai ShiranuiMobile - Metal Slug Defense - Units_ The King of Fighters - Mai Shiranui.png
小舞(Q 版)The Spriters Resource → Mobile → Senran no Samurai Kingdom → Mai ShiranuiMobile - 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

第三步:加载 mod

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 还没跟踪的新文件也算。不写目录时用会话所在的仓库;会话目录本身不是仓库、下面正好有一个仓库时,就用那一个。

/waifu token:用量播报

安全检查会拦什么

开着的时候(默认),下面这些 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 -D
  • 把下载的脚本直接执行:curl … | sh、bash <(curl …)、sh -c "$(curl …)"
  • 通过数据库客户端执行的 DROP、TRUNCATE TABLE、不带 WHERE 的 DELETE,以及 redis-cli FLUSHALL
  • dd 写磁盘设备、mkfs、diskutil eraseDisk
  • terraform destroy、kubectl delete 命名空间或 --all、docker system prune -a、gh repo delete
  • fork 炸弹
  • git commit:要提交的内容里有密钥,或者有 .env、私钥这类敏感文件。同一条命令里 git add 将要暂存的内容也会看

这些命令包在 bash -c "…"、eval、ssh 主机 "…"、$(…) 里也认得。提交说明、grep 的搜索词这类引号里的文字,以及写文件用的 heredoc,一般不当成命令。

能认出的密钥有:私钥文件头、AWS 访问密钥、GitHub 令牌、Anthropic 和 OpenAI 风格的 API key、Slack 令牌、Google API key、JWT,以及写死在代码里的 password = "…" 这类赋值。

这是按规则匹配的提醒,不是沙箱:换一种写法的危险命令它可能认不出来,别把它当成唯一的防线。/waifu allow 只认你在输入框里亲手输入的。

花多少 token

  • 预设台词、动画、安全检查、任务记录、流量汇报都在本机完成,不调用模型。
  • /waifu <话> 聊天、每轮结束后的一句点评(最多 90 秒一次)、/waifu recap 在任务满 3 条时附的要点总结,各调用一次 haiku。/waifu ai off 可以把这三样都关掉。
  • /waifu review 调用一次 sonnet(调不通时改用 haiku),把改动的 diff(最多约 6 万字符)发给它。锁文件、二进制文件和太大的文件不发。

examples/hello-mod

最小的 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 的开关是开着的。

加你自己的角色

  1. 导入素材:用 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:原图朝左的素材要加
  1. 写人设和台词:在 hooks/troupe.ts 里照现有角色加一项(名字、人设、声线、日语台词加中文字幕)。她值班时的台词写在 hooks/duty.ts 里,不写就用通用的中文台词。
  2. 重新录音:运行 bun tools/make-voices.ts。

想让人物清楚,优先找游戏里的像素精灵图(有分格的动作表)。高清立绘和插画缩到终端尺寸只会糊成马赛克。

一些实现细节

  • 画法:终端里画图用的是 mod 的 Raster 色块元素,每个字符格画 2×2 个像素(四分格字符)。人物要 18 到 20 行高才看得清。
  • 颜色配额:每种“前景色 + 背景色”组合第一次画之前要登记,登记有速率限制,用超了画面就会出现白杠和横条。所以每个角色导入时压到 20 种颜色以内;每个角色按三行一段切成几条色块,每条固定复用;舞台挂在对话区的一行上不乱动,跑任务时挂在“正在工作”的那一行。
  • 挡字:每条色块只占从最左到最右有颜色的那一段,色块以外的文字照常显示。
  • 为什么不显示真图片:按文档,真正的图片要终端支持 kitty 图片协议(kitty、Ghostty),其他终端只能用色块画。

版权和致谢

  • 本仓库的代码以 MIT 协议开源。
  • 角色素材归各自的版权方所有(SNK、日本一、CraftPix 等),不包含在仓库里。请从上面的地方自己下载,只在本机使用,不要提交或分发。
  • 动漫配音由 VOICEVOX 生成,声线有四国めたん、春日部つむぎ、九州そら、波音リツ。公开发布生成的音频时,请按 VOICEVOX 和各角色的使用条款署名,例如“VOICEVOX:四国めたん”。
Source 12 files
hooks/register.tsx 1898 lines
1import { 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 lines
1// 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}
492
hooks/bubble.ts 59 lines
1// 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}
59
hooks/duty.ts 110 lines
1// 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}
110
hooks/guard.ts 758 lines
1// 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}
758
hooks/journal.ts 83 lines
1// 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}
83
hooks/review.ts 92 lines
1// 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}
92
hooks/report.ts 228 lines
1// 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}
228
hooks/cast.ts 625 lines
1// 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}
625
hooks/world.ts 575 lines
1// 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}
575
hooks/troupe.ts 131 lines
1// 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]
131
types/index.d.ts 19 lines
1export 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