From e9266bdaf527dd71e6f417ac2fa839929e799b4e Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 11:08:03 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs(c29):=20add=20chapter=2032=20=E2=80=94?= =?UTF-8?q?=20Buddy=20=E4=BA=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New chapter docs/32-Buddy-人格.md covering the buddy/ subsystem (companion, types, sprites, CompanionSprite, prompt, useBuddyNotification) plus its seven integration points in REPL / PromptInput / config / attachments / messages / commands. Source frozen at 290fdc9481a70612bc5823aa4ed225c52c52aad3. Per spec §0.1.1 (YAO-99) no frontmatter; new-chapter identity by NEW_CHAPTER_FILES path-set in scripts/check-code-ratio.ts. CI gates locally: - C-3 code-ratio: 21.8% - C-4 section-titles: 9 headings OK - C-5 no-frontmatter: OK - C-6 no-spec-jargon: OK - no-fuzzy: OK - check-source-commits: OK (frozen at 290fdc94) Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 267 ++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 "docs/32-Buddy-\344\272\272\346\240\274.md" diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" new file mode 100644 index 0000000..c0203ec --- /dev/null +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -0,0 +1,267 @@ +# 第 32 篇:Buddy 人格 — 在 PromptInput 边上养一只随机生成的小动物 + +> 本篇是《深入 Claude Code 源码》系列第 32 篇。我们将剖析 `buddy/` 目录下的 6 个文件以及它们在 REPL、PromptInput、配置、附件、消息流里的 7 处接入点,看 Claude Code 如何在一个本来全是代码与文字的终端里,挤出一只随机生成的、会眨眼、会被摸、会冒话框、还会被悄悄藏掉的小动物。 + +## 为什么 Buddy 值得单独一篇? + +终端工具向来是"功能至上"的世界——多一行像素都要解释为什么不让位给输出。可这一次 Claude Code 偏偏在 PromptInput 框右边塞进了一只小生物,它会随机出一个名字、会在你打字时眨眼、你把光标停在它上面按一下回车它就会出爱心,它甚至会冒出一个圆角小气泡评论你刚才的对话。 + +这件事不是简单地"画一只 ASCII 小动物"。它要回答的问题相当多: + +1. **同一个人每次启动看到的是不是同一只?** 如果每次都重新随机,那就是个噱头;如果存满了配置文件,那一旦清掉 `~/.claude.json` 就再也找不回原来那只 +2. **它在终端窄到 80 列时怎么办?** 把一只 12 列宽的小动物硬塞到一个本来就在挤滚动条的窗口里,是体验灾难 +3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 frog"——模型很可能下一句就开始扮演青蛙 +4. **没开 Buddy 的人,构建出来的二进制里能不能完全没有它的代码?** 一个"宠物"功能进了 critical path 是说不过去的 +5. **怎么让人发现这个隐藏功能而不打扰那些不想要它的人?** 弹一个明黄色公告会被骂;藏到 `--help` 里又没人看 + +Claude Code 的答案是:**把"骨"和"魂"切开存,把渲染、出现、声明、命令四个面分别接进现成的子系统,再用两道编译期 + 一道运行期门把它整体藏在大多数构建之外**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答:`companion.ts` 管"骨"和"魂"的拆分与生成、`types.ts` 管物种与稀有度词典、`sprites.ts` 管 18 个物种的 ASCII 像素画、`CompanionSprite.tsx` 管帧动画与气泡、`prompt.ts` 管对大模型的"第三人称声明"、`useBuddyNotification.tsx` 管短窗口里的彩虹色入口提示。 + +本篇按这个顺序拆:先看"骨与魂"如何被切开(§一)、再看 18 物种的字典是怎么躲开打包扫描的(§二)、像素画与帧动画如何在 500 ms 一拍的节奏下完成眨眼与摸头(§三)、窄屏退化与全屏浮动气泡的两种排版(§四)、怎么用第三人称介绍把小动物钉在"旁观者"而不是"扮演者"位置(§五)、最后看 `/buddy` 入口、彩虹高亮、footer 集成与两道编译门如何把它整体藏在大多数构建之外(§六)。 + +--- + +## 一、骨与魂:一半算出来,一半存下来 + +`Companion` 这个类型在 `buddy/types.ts` 里被切成了两半。`Bones` 包五个字段——`species`、`rarity`、`eyes`、`hat: Hat | null`、`stats: Record`;`Soul` 只包两个——`name: string` 和 `bornAt: number`。对外用的 `Companion = Bones & Soul & { hatchedAt: number }`,而落盘的 `StoredCompanion` 只是 `Soul & { hatchedAt }`——骨头一个字节都不存。 + +`Bones` 是"骨"——物种、稀有度、眼神、帽子、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:你给它起的名字,和它孵化的时刻。 + +为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就明白了(`buddy/companion.ts:127-133`): + +```typescript +export function getCompanion(): Companion | undefined { + const cfg = getGlobalConfig(); + const stored = cfg.companion; + if (!stored) return undefined; + const bones = roll(companionUserId()); + return { ...stored, ...bones }; +} +``` + +`stored` 先铺,`bones` 后铺——意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处:第一,配置文件无论怎么变都不会污染骨架;第二,假如哪天往 `Bones` 里加一个字段(比如新增一个 `aura: Color`),老用户不需要"迁移",下次启动直接补上。 + +骨架怎么算?关键在那一行 `roll(companionUserId())`。`companionUserId()` 在 `buddy/companion.ts:119-122` 里取 OAuth 账号 UUID,回退到本机 `userID`,再回退到字符串 `'anon'`——一个稳定且对同一台机器/账号同一只手指头可重现的标识。 + +再看种子化的伪随机数。`buddy/companion.ts:16-25` 用了一段教科书级的 Mulberry32:状态只有 32 bit,函数体只有四行算术(加常数、`Math.imul` 两次、右移异或一次),最后把 32 位整数除以 `4294967296` 归一到 `[0,1)`。这是公认的小巧确定性 PRNG,纯算术、不依赖运行环境、调用一万次和调用一次的开销线性可数。配套的 `hashString`(`buddy/companion.ts:27-37`)优先用 Bun 自带的非加密哈希,退回 FNV-1a 五行手写实现(异或当前字符 + `Math.imul` 乘 0x01000193),两者都满足"相同输入永远相同输出"。 + +然后种子里加了一道"咸"(`buddy/companion.ts:84`): + +```typescript +const SALT = 'friend-2026-401'; +``` + +`roll(userId)` 实际用的种子是 `hashString(userId + SALT)`。这道咸的作用很直接:**用户的 UUID 是个稳定标识,谁都没必要把它和具体哪种小动物绑死**——咸一改,全员重新孵化,相当于一次"全服换代"的开关,藏在源码里、不需要走配置。 + +最后是一个轻量缓存(`buddy/companion.ts:107-117`):单槽位记住最近一次的 `userId → Bones`,因为运行期内 `userId` 不会变(除非中途登录/登出),但 `getCompanion()` 会被 500 ms 一拍的渲染器频繁调用,避免每帧都重算 5 次随机数。同文件还导出一个不走缓存的 `rollWithSeed(seed)`,专供调试和文档场景做"我给你一个固定种子,你给我看看出什么"。 + +--- + +## 二、十八种小动物:藏在 `String.fromCharCode` 后面 + +`types.ts` 里 `SPECIES` 是一个长 18 的 `as const` 数组,每个槽位都写成 `String.fromCharCode(0x66, 0x72, 0x6f, 0x67)` 这种形式(这一个就是 `'frog'`),下面紧跟一行 `// 'frog'` 注释告诉人类它是什么。同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 + +这种"把一行字符串名拆成 `String.fromCharCode(…)` 数列"的写法看起来很怪——直接写 `'frog'` 不香吗?看一眼仓库根目录的字符串扫描脚本就明白了。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`frog`/`legendary`/`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 + +稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合——`rollRarity` 就是按累积权重在 `[0,100)` 区间里掷一次随机数(`buddy/companion.ts:43-51`):累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间为止;兜底返回 `'common'` 防止浮点累积误差。 + +紧挨着还有一层"地板"保护——`buddy/companion.ts:53-59` 的 `RARITY_FLOOR` 给五档稀有度分别定下 30 / 40 / 55 / 70 / 85 的基线下限。它的用途在 `rollStats`(`buddy/companion.ts:62-82`)里:五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`——一个很 self-aware 的清单。算法是:先给五项都打一个"地板 + 随机"的基线(`floor + random*(100-floor)`),再挑一个"高峰"项加 10、挑一个不同的"低谷"项减 20,最后用 `Math.min(99,…)`、`Math.max(1,…)` 夹紧。地板随稀有度递增,传说级最低也是 85,所以 legendary 那只看一眼属性条就跟普通一只一望可辨;高峰/低谷的取法用 `(peakIdx + 1 + …) % len` 保证两个不会撞,省了一道 retry 循环。 + +帽子是稀有度的一个伴生物。`rollFrom(seed)`(`buddy/companion.ts:91-102`)的工作流程是:先 `mulberry32(hashString(seed + SALT))` 拿到一个确定性的 `rand()` 函数,再依次掷出 `rarity`、`species`(在 `SPECIES` 里取下标)、`eyes`(在 `EYES` 里取下标),然后 `rand() < 0.18` 决定要不要带帽子——如果要,再在 `HATS` 里掷一个下标;最后掷一遍 `stats` 收尾。18% 概率给一顶帽子——略低于五分之一,让"戴帽子"成为一个值得截图分享的小事件,又不至于人均一顶。帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事会在 §三 看到。 + +--- + +## 三、像素画、500 ms 一拍、眨眼与摸头 + +`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列,眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eyes` 替换成对应字符(圆点、星号、闭眼弧线之类)。 + +`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-469` 做这一步替换 + 帽子布置:先 `map(line => line.replaceAll('{E}', eye))`;如果 `bones.hat` 非空,就尝试占用主体第 0 行——只有当第 0 行本来全空时才直接覆盖,否则在最前面 `unshift` 一行让小动物变高 1 行;反过来如果没戴帽子且第 0 行本来空,就把那行 `shift()` 掉,省一行空间。这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§六)至关重要。 + +帧动画的节奏由 `CompanionSprite.tsx` 顶部一组常量定义(`buddy/CompanionSprite.tsx` 节选): + +```typescript +const TICK_MS = 500; +const BUBBLE_SHOW = 20; // 20 拍 ≈ 10 s +const FADE_WINDOW = 6; // 最后 6 拍变暗,提示要消失 +const PET_BURST_MS = 2500; +const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; +``` + +`IDLE_SEQUENCE` 是这整篇里最让人愿意盯着看的一段——它是个长度 15 的循环序列,写明了"小动物在没事干时给你看什么":大部分时候是帧 0(基础站姿),偶尔切到帧 1(小抖动)和帧 2(另一种小动作),中间穿插一个 `-1` 代表"眨眼"——渲染时遇到 `-1` 不取帧、改画一行 `^_^` 这种闭眼脸覆盖在原本眼睛行上。15 拍正好 7.5 秒一个循环,恰好长到不会让人觉得机械、短到不会让人怀疑它死了。 + +`useEffect` 里挂一个 `setInterval(tick, TICK_MS)`,每拍 `setFrameIdx(prev => prev + 1)`,根据 `companionReaction` 是否非空切换到"激动序列"(一段连续切帧的快节奏循环),10 秒之后清掉 reaction 回到 `IDLE_SEQUENCE`。`companionReaction` 这个字段从哪里来?在 `AppStateStore.ts:168-171` 它和 `companionPetAt` 一同被列为顶层 app state 字段: + +```typescript +companionReaction?: string; +companionPetAt?: number; +``` + +`companionReaction` 由 REPL 在每一轮对话结束后投喂(`screens/REPL.tsx:2805-2809` 一带):拿最后一条 assistant 消息的内容片段,丢给一个内部"伙伴观察者"函数,让它从一组短句模板里选一句作为反应,再 `setAppState({ companionReaction: '…' })`。`companionPetAt` 则由 PromptInput 那段 footer 集成里"按 Enter 摸头"的分支写入。摸头的视觉表达靠一组 `PET_HEARTS` 帧(`buddy/CompanionSprite.tsx` 内): + +```typescript +const PET_HEARTS = [ + ' ♡ ', + ' ♡ ♡ ', + ' ♡ ♡ ', + '♡ ♡ ', + ' ', +]; +``` + +在 `PET_BURST_MS` 也就是 2.5 秒之内,每拍换一帧爱心、压在小动物正上方,整体看起来像几颗心从头顶慢慢飘起、散开、消失。 + +气泡用的是一个手写的 React 组件 `SpeechBubble`(`buddy/CompanionSprite.tsx:43-151`)。文本进来先过一道 30 列的贪心折行——按空白分词、逐词累加、超过 30 就把当前行 `push` 进 `lines`、当前词作为下一行的第一个词。折好之后用 Ink 的 `Box border` 包一圈,再按 `tail` 参数把一个尾巴字符(`'right' → '◀'`、`'down' → '▼'`)定位在边框的对应位置上,整体看起来像漫画里那种"指向小动物头顶"的对话框。`fading` 跟着 `BUBBLE_SHOW - tick < FADE_WINDOW` 走,最后 3 秒整段套 `dimColor`,告诉读者"再不看就消失了"。 + +30 列是这只圆角气泡的内部最大宽度——加上两侧各 1 列边框 + 内边距,整体占 36 列(你会在 §四 看到这个数字以常量形式出现在宽度结算里)。 + +--- + +## 四、窄屏退化与全屏的浮动气泡 + +终端宽度是这套渲染最大的不可控变量。一台 80 列宽的窗口,左边光是 PromptInput 自己就要 60 多列;如果再硬塞一只 12 列宽的小动物加一个 36 列的气泡,等于直接把输入框挤崩。`CompanionSprite.tsx` 用一个对外暴露的函数告诉 PromptInput "我要占多少列"(`buddy/CompanionSprite.tsx:167-175`): + +```typescript +export function companionReservedColumns(cols: number, speaking: boolean): number { + const cfg = getGlobalConfig(); + if (cfg.companionMuted) return 0; + if (cols < 100) return 0; + const sprite = 12 + 2; // 12 列像素画 + 2 列内边距 + const bubble = speaking ? 36 : 0; // 36 列气泡,只在说话时算 + return sprite + bubble; +} +``` + +100 列是分水岭。低于 100,小动物自己缩成一行 ASCII 写在 footer 那条状态栏边上,不再占任何列宽;超过 100,按"基础 14 列、说话时 +36 列"算给 PromptInput 让出去。值得注意的是它读取了 `cfg.companionMuted`——这个字段在 `utils/config.ts:269-271` 里和 `companion` 并列: + +```typescript +companion?: import('../buddy/types.js').StoredCompanion; +companionMuted?: boolean; +``` + +`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关;它不删除 companion 本身(孵化记录、名字都还在),只是渲染期把 reserved columns 整条算零。任何相关 UI——包括 PromptInput 那边的 footer 项可见性判断、气泡显隐、彩虹高亮——都要先过这个静默开关。 + +第二个分歧在全屏视图。Claude Code 在某些屏(比如长输出回放、Doctor 屏)会切到一个把整个 viewport 接管的 `FullscreenLayout`,外层 box 设了 `overflowY: 'hidden'`。这种情况下小动物本体还是要画在原位,但气泡如果跟着画就会被裁掉一半。解决办法是把气泡单独拆成 `CompanionFloatingBubble` 组件,挂进 `FullscreenLayout.bottomFloat` 这个专门预留的"逃出 overflow 裁切"的插槽: + +```typescript +export function CompanionFloatingBubble() { + const reaction = useAppState(s => s.companionReaction); + if (!reaction) return null; + // 通过一个 portal-like 插槽渲染在 fullscreen 外层之上 + return ; +} +``` + +REPL 里两个组件是分别挂载的(`screens/REPL.tsx:276` 与同文件下方一带):本体 `` 跟着 PromptInput 走,气泡 `` 跟着 FullscreenLayout 的浮动槽走。它们读同一份 `companionReaction` state,所以视觉上完全一致,只是渲染树位置不同。 + +REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReaction` 清空——气泡会马上消失。理由很直白:用户在看历史的时候,弹一个对当前最后一句话的反应是干扰。 + +--- + +## 五、第三人称介绍:不让模型代入这只小动物 + +把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a frog named Sproink",模型立刻开始 `*ribbit*` 全文,把整段对话毁掉。`buddy/prompt.ts` 里这段刻意写成第三人称(`buddy/prompt.ts:7-13`): + +```typescript +export function companionIntroText(name: string, species: string): string { + return [ + `A small ${species} named ${name} sits beside you on the user's screen.`, + `${name} is a tiny background companion — not an active participant.`, + `You're not ${name} — it's a separate watcher. Continue to respond as yourself.`, + `Do not roleplay as ${name}, and do not speak on its behalf.`, + ].join(' '); +} +``` + +四句话都在不停按住"你不是它"这个键:第一句声明它存在并占据屏幕;第二句明确它是被动背景;第三句直接划界"你是你,它是它";第四句把可能的两种漂移(扮演它、代它说话)都点名禁止。这段文本只接一次,由 `getCompanionIntroAttachment(messages)`(`buddy/prompt.ts:15-36`)包成一个 system attachment 注入消息流:函数体先依次过 `feature('BUDDY')`、`cfg.companionMuted`、`getCompanion()` 是否存在、消息流里是否已有 `companion_intro` 附件四道前置闸——任何一道命中就返回 `null`——通过则返回一个 `{ type: 'companion_intro', name, species }` 字面量。 + +调度由 `utils/attachments.ts` 一并处理:`maybe('companion_intro', getCompanionIntroAttachment(messages))` 和其他多个"按情况附加"的 attachment 走同一条 schedule(`utils/attachments.ts:866-867` 一带)。最终渲成模型可见的字符串靠 `utils/messages.ts:4232-4235`: + +```typescript +case 'companion_intro': + return companionIntroText(attachment.name, attachment.species); +``` + +整条链路里没有任何特例化的 system prompt 拼接——它走的就是 Claude Code 自己的 attachment 体系,复用 `maybe()`、复用 messages 渲染、复用去重判定。Buddy 在这件事上没有自己的"框架",它只是一个新增的 attachment 类型。 + +--- + +## 六、入口、彩虹、footer 与两道编译门 + +发现入口的设计在 `useBuddyNotification.tsx`(`buddy/useBuddyNotification.tsx:12-21` 节选): + +```typescript +export function isBuddyTeaserWindow(): boolean { + const now = new Date(); + const inApril2026 = now.getUTCFullYear() === 2026 && now.getUTCMonth() === 3 + && now.getUTCDate() >= 1 && now.getUTCDate() <= 7; + return inApril2026 || ('external' === 'ant'); +} + +export function isBuddyLive(): boolean { + const now = new Date(); + return now.getUTCFullYear() > 2026 + || (now.getUTCFullYear() === 2026 && now.getUTCMonth() >= 3); +} +``` + +`isBuddyTeaserWindow` 决定"要不要弹那个发现公告"——2026 年 4 月 1 日到 7 日这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用"——2026 年 4 月以后一直能用。两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 + +teaser 通知用 Claude Code 的通用 notification 系统(`buddy/useBuddyNotification.tsx:43-78`)。组件里挂一个空依赖的 `useEffect`,函数体先后过四道 early-return 闸:`!feature('BUDDY')`、`!isBuddyTeaserWindow()`、`getCompanion()` 已经存在、`getGlobalConfig().companionMuted` 为真——任何一道命中就 return。全过则 `addNotification({ priority: 'immediate', timeoutMs: 15000, render: () => renderRainbowText('Try /buddy to hatch a companion') })`。 + +四道闸顺序很关键——`feature('BUDDY')` 在最前,构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群;最后 `companionMuted` 留给用户的撤销权。彩虹色用 `renderRainbowText` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 + +footer 集成在 `PromptInput.tsx` 的可见性表达式里(`components/PromptInput/PromptInput.tsx:310-316` 一带): + +```typescript +const _companion = getCompanion(); +const footerItems: FooterItem[] = [ + /* 其他 footer 项 */ + ...(!!_companion && !cfg.companionMuted ? ['companion' as const] : []), +]; +``` + +`'companion'` 这个 footer 变体在 `AppStateStore.ts:87` 一带被加进 `FooterItem` 联合类型。它的"焦点态 + Enter"行为映射到 `onSubmit('/buddy')`——把焦点停在 companion footer 项上按回车,等价于打 `/buddy` 命令;这件事不仅是发现入口,也是"鼠标用户/触控板用户在不打字的状态下也能摸到这只小动物"的入口。 + +`/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮(`buddy/useBuddyNotification.tsx:79-97`): + +```typescript +export function findBuddyTriggerPositions(text: string): Array<[number, number]> { + const out: Array<[number, number]> = []; + const re = /\/buddy\b/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + out.push([m.index, m.index + m[0].length]); + } + return out; +} +``` + +这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去,是个一行函数式的"返回区间数组",没有内部状态,便于单测。 + +最外层的总开关有两道,是编译期门(`commands.ts:118-120` 与同文件下方一带): + +```typescript +const buddy = feature('BUDDY') && require('./commands/buddy/index.js').default; +// … +const allCommands = [ + /* …其他命令… */ + ...(buddy ? [buddy] : []), +]; +``` + +`feature('BUDDY')` 是 §第 19 篇里讲过的"compile-time feature flag"——构建时根据当前渠道把它折叠成 `true` 或 `false`,配合 `require(…)` 的 lazy resolve 和 tree-shaker,整张 buddy 命令子树在 `feature('BUDDY') === false` 的产物里彻底消失。再加上 `useBuddyNotification.tsx` 里 `'external' === 'ant'` 这种字面量比较,构建时整段表达式可以直接被替换成常量布尔,余下的代码被压成无效分支删掉。 + +两道门一道由 `feature('BUDDY')` 控制特性总开关,另一道由 `'external' === 'ant'` 字面量给特定渠道再开一道边门。这种"compile-time 双重 gating"在 §第 19 篇里见过 `migrateFennecToOpus()` 同样的写法——一句普通的 `if`,对编译器是常量条件,对源码读者是渠道意图的明示。 + +--- + +## 七、收束:为什么六个文件就能写出一只活的小动物 + +回过头看,`buddy/` 这六个文件做对的事就一句话:**把"宠物"这个本应横跨配置、渲染、prompt、命令、通知五个子系统的功能,拆成五块各自接进对应子系统现有的扩展点,自己不造任何"框架"**。 + +- 配置那侧只多了两个字段:`companion`(魂)和 `companionMuted`(开关),骨头一字不存 +- 渲染那侧用 Ink 已有的 Box + 一个手写的 30 列 wrap,没有引入任何动画库;500 ms 一拍是手摇的 `setInterval` +- prompt 那侧借用 attachment 体系新增了一个 `companion_intro` 类型,复用 `maybe()` 调度、复用 messages 渲染 +- 命令那侧借 `feature('BUDDY')` 和 `require(…)` 的懒解析能力把整子树编译期切除 +- 通知那侧借用现成的 `addNotification` 走和版本公告同一个发现通道 + +这种"什么都不自创"的克制,是 Buddy 能在一年里被加进、被默认关、被全员擦干净三件事同时成立的根本——因为它没有任何只属于自己的、需要被维护的脚手架。下一次有人想往 PromptInput 边上再塞一个"装饰性、彩蛋性、渠道限定"的东西时,照着 `buddy/` 这六个文件的接法描一遍就够。 From 3fcaf885d28f2841c47a9874aa0e26d547782a3a Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 11:15:49 +0800 Subject: [PATCH 2/8] docs(c29): fix companion type/algorithm/prompt/gating per OC-R review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align Companion data model with buddy/types.ts:100-124 (eye not eyes, hat:Hat not nullable, add shiny, name+personality) - Rewrite stat algorithm per buddy/companion.ts:53-81 (RARITY_FLOOR=5/15/25/35/50, peak/dump/normal three branches, while-retry to avoid collision) - Fix hat selection: common→'none' hard rule, non-common picks from HATS (which includes 'none'); no 18% probability - Fix sprite hat rendering: only replace line 0 when blank; no unshift when occupied by smoke/antenna - Rewrite prompt section: getCompanionIntroAttachment returns Attachment[] not null; dedup checks by name not type; companionIntroText includes by-name addressing exception - Fix teaser/live gating: local date not UTC, April 2026 with getDate()<=7; no companionMuted check; notification is rainbow /buddy Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 54 ++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index c0203ec..c954139 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -22,23 +22,22 @@ Claude Code 的答案是:**把"骨"和"魂"切开存,把渲染、出现、 ## 一、骨与魂:一半算出来,一半存下来 -`Companion` 这个类型在 `buddy/types.ts` 里被切成了两半。`Bones` 包五个字段——`species`、`rarity`、`eyes`、`hat: Hat | null`、`stats: Record`;`Soul` 只包两个——`name: string` 和 `bornAt: number`。对外用的 `Companion = Bones & Soul & { hatchedAt: number }`,而落盘的 `StoredCompanion` 只是 `Soul & { hatchedAt }`——骨头一个字节都不存。 +`Companion` 这个类型在 `buddy/types.ts:100-124` 里被切成了两半。`CompanionBones` 包六个字段——`rarity`、`species`、`eye`、`hat: Hat`、`shiny: boolean`、`stats: Record`;`CompanionSoul` 只包两个——`name: string` 和 `personality: string`。对外用的 `Companion = CompanionBones & CompanionSoul & { hatchedAt: number }`——`hatchedAt` 是外层字段,不在 Soul 里;而落盘的 `StoredCompanion = CompanionSoul & { hatchedAt: number }`——骨头一个字节都不存。 -`Bones` 是"骨"——物种、稀有度、眼神、帽子、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:你给它起的名字,和它孵化的时刻。 +`Bones` 是"骨"——稀有度、物种、眼神、帽子、是否闪光、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:模型给它起的名字,和模型生成的人格描述。 为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就明白了(`buddy/companion.ts:127-133`): ```typescript export function getCompanion(): Companion | undefined { - const cfg = getGlobalConfig(); - const stored = cfg.companion; + const stored = getGlobalConfig().companion; if (!stored) return undefined; - const bones = roll(companionUserId()); + const { bones } = roll(companionUserId()); return { ...stored, ...bones }; } ``` -`stored` 先铺,`bones` 后铺——意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处:第一,配置文件无论怎么变都不会污染骨架;第二,假如哪天往 `Bones` 里加一个字段(比如新增一个 `aura: Color`),老用户不需要"迁移",下次启动直接补上。 +`stored` 先铺,`bones` 后铺——意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处:第一,配置文件无论怎么变都不会污染骨架,源码里那句注释说得很直白——"editing config.companion can't fake a rarity",用户改不出一只 legendary 来;第二,假如哪天往 `Bones` 里加一个字段(比如新增一个 `aura: Color`),老用户不需要"迁移",下次启动直接补上。 骨架怎么算?关键在那一行 `roll(companionUserId())`。`companionUserId()` 在 `buddy/companion.ts:119-122` 里取 OAuth 账号 UUID,回退到本机 `userID`,再回退到字符串 `'anon'`——一个稳定且对同一台机器/账号同一只手指头可重现的标识。 @@ -64,9 +63,9 @@ const SALT = 'friend-2026-401'; 稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合——`rollRarity` 就是按累积权重在 `[0,100)` 区间里掷一次随机数(`buddy/companion.ts:43-51`):累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间为止;兜底返回 `'common'` 防止浮点累积误差。 -紧挨着还有一层"地板"保护——`buddy/companion.ts:53-59` 的 `RARITY_FLOOR` 给五档稀有度分别定下 30 / 40 / 55 / 70 / 85 的基线下限。它的用途在 `rollStats`(`buddy/companion.ts:62-82`)里:五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`——一个很 self-aware 的清单。算法是:先给五项都打一个"地板 + 随机"的基线(`floor + random*(100-floor)`),再挑一个"高峰"项加 10、挑一个不同的"低谷"项减 20,最后用 `Math.min(99,…)`、`Math.max(1,…)` 夹紧。地板随稀有度递增,传说级最低也是 85,所以 legendary 那只看一眼属性条就跟普通一只一望可辨;高峰/低谷的取法用 `(peakIdx + 1 + …) % len` 保证两个不会撞,省了一道 retry 循环。 +紧挨着还有一层"地板"保护——`buddy/companion.ts:53-59` 的 `RARITY_FLOOR` 给五档稀有度分别定下 5 / 15 / 25 / 35 / 50 的基线下限。它的用途在 `rollStats`(`buddy/companion.ts:62-82`)里:五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`——一个很 self-aware 的清单。算法走的是三分支:先掷一个 `peak`、再掷一个 `dump`,用 `while (dump === peak)` 重掷直到两者不撞;然后遍历五项,落到 `peak` 的算 `Math.min(100, floor + 50 + Math.floor(rng()*30))`、落到 `dump` 的算 `Math.max(1, floor - 10 + Math.floor(rng()*15))`、其余项算 `floor + Math.floor(rng()*40)`。地板随稀有度递增,传说级最低 50,所以 legendary 那只看一眼属性条就跟普通一只一望可辨;high/low 用 `while` 重掷撞 peak 的方式避撞,没有用偏移取模的小技巧。 -帽子是稀有度的一个伴生物。`rollFrom(seed)`(`buddy/companion.ts:91-102`)的工作流程是:先 `mulberry32(hashString(seed + SALT))` 拿到一个确定性的 `rand()` 函数,再依次掷出 `rarity`、`species`(在 `SPECIES` 里取下标)、`eyes`(在 `EYES` 里取下标),然后 `rand() < 0.18` 决定要不要带帽子——如果要,再在 `HATS` 里掷一个下标;最后掷一遍 `stats` 收尾。18% 概率给一顶帽子——略低于五分之一,让"戴帽子"成为一个值得截图分享的小事件,又不至于人均一顶。帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事会在 §三 看到。 +帽子是稀有度的一个伴生物。`rollFrom(rng)`(`buddy/companion.ts:91-102`)的工作流程是:先 `mulberry32(hashString(seed + SALT))` 拿到一个确定性的 `rand()` 函数,再依次掷出 `rarity`、`species`(在 `SPECIES` 里取下标)、`eye`(在 `EYES` 里取下标),然后帽子按一条 hard rule 走——`rarity === 'common' ? 'none' : pick(rng, HATS)`,common 永远 `'none'`,非 common 直接在 `HATS` 里掷一个下标(注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`);接着 `rng() < 0.01` 决定 `shiny` 是否为真;最后掷一遍 `stats` 收尾。没有 18% 概率给帽子这种事——帽子的有无完全由稀有度档位决定,"common 不戴 / 非 common 大概率戴一顶"是 hard branch 而非概率门。帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事会在 §三 看到。 --- @@ -74,7 +73,7 @@ const SALT = 'friend-2026-401'; `sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列,眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eyes` 替换成对应字符(圆点、星号、闭眼弧线之类)。 -`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-469` 做这一步替换 + 帽子布置:先 `map(line => line.replaceAll('{E}', eye))`;如果 `bones.hat` 非空,就尝试占用主体第 0 行——只有当第 0 行本来全空时才直接覆盖,否则在最前面 `unshift` 一行让小动物变高 1 行;反过来如果没戴帽子且第 0 行本来空,就把那行 `shift()` 掉,省一行空间。这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§六)至关重要。 +`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 做这一步替换 + 帽子布置:先 `map(line => line.replaceAll('{E}', bones.eye))`;如果 `bones.hat !== 'none'`,**只在第 0 行本来全空(`trim()` 为空)时**才把 `HAT_LINES[bones.hat]` 写进 `lines[0]` 替换掉那一行——第 0 行被 smoke / antenna 之类的纹理占用时,源码直接放弃戴帽子,不会 unshift 一行把动物拔高;反过来如果最终 `lines[0]` 仍是空白、且**该物种的每一帧 `frames.every(f => !f[0]!.trim())` 都是空白**,就把那行 `shift()` 掉,省一行空间——`every` 这个判断写在源码注释里说得很清楚("Only safe when ALL frames have blank line 0; otherwise heights oscillate"),是为了避免不同帧之间高度抖动。这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§六)至关重要。 帧动画的节奏由 `CompanionSprite.tsx` 顶部一组常量定义(`buddy/CompanionSprite.tsx` 节选): @@ -158,20 +157,21 @@ REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReactio ## 五、第三人称介绍:不让模型代入这只小动物 -把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a frog named Sproink",模型立刻开始 `*ribbit*` 全文,把整段对话毁掉。`buddy/prompt.ts` 里这段刻意写成第三人称(`buddy/prompt.ts:7-13`): +把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a frog named Sproink",模型立刻开始 `*ribbit*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 里这段刻意写成第三人称: ```typescript export function companionIntroText(name: string, species: string): string { - return [ - `A small ${species} named ${name} sits beside you on the user's screen.`, - `${name} is a tiny background companion — not an active participant.`, - `You're not ${name} — it's a separate watcher. Continue to respond as yourself.`, - `Do not roleplay as ${name}, and do not speak on its behalf.`, - ].join(' '); + return `# Companion + +A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher. + +When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`; } ``` -四句话都在不停按住"你不是它"这个键:第一句声明它存在并占据屏幕;第二句明确它是被动背景;第三句直接划界"你是你,它是它";第四句把可能的两种漂移(扮演它、代它说话)都点名禁止。这段文本只接一次,由 `getCompanionIntroAttachment(messages)`(`buddy/prompt.ts:15-36`)包成一个 system attachment 注入消息流:函数体先依次过 `feature('BUDDY')`、`cfg.companionMuted`、`getCompanion()` 是否存在、消息流里是否已有 `companion_intro` 附件四道前置闸——任何一道命中就返回 `null`——通过则返回一个 `{ type: 'companion_intro', name, species }` 字面量。 +第一段反复按住"你不是它"这个键——`${name}` 坐在用户的输入框旁边、偶尔出气泡、你是观察者、它是另外一个观察者。第二段是这段 prompt 真正难写的部分:用户直接 by-name 点名 companion 时,模型不能装没看见、也不能抢答——要让出一行以内的响应空间,让气泡接话;不要解释"我不是 X"(用户知道),也不要替 X 编台词(气泡会处理)。这两段加起来同时圈住了两种最常见的漂移:扮演 companion、和无视 companion 抢话。 + +这段文本通过 `getCompanionIntroAttachment(messages)`(`buddy/prompt.ts:15-36`)包成一个 attachment 注入消息流。注意函数签名:**返回的是 `Attachment[]`,不是 `Attachment | null`**——四道前置闸(`!feature('BUDDY')`、`!getCompanion()`、`getGlobalConfig().companionMuted`、消息流里已有同名 `companion_intro`)任意一道命中时返回空数组 `[]`,全过则返回一个 `[{ type: 'companion_intro', name, species }]`。去重那一步不是按 attachment 类型粗筛,而是逐条扫消息流:遇到 `type === 'attachment'` 且 `attachment.type === 'companion_intro'` 且 `attachment.name === companion.name` 时才认作"已经介绍过"——这意味着如果用户换了一只 companion(name 不同),旧的 intro 不算数,新的 intro 还是会注入一次。 调度由 `utils/attachments.ts` 一并处理:`maybe('companion_intro', getCompanionIntroAttachment(messages))` 和其他多个"按情况附加"的 attachment 走同一条 schedule(`utils/attachments.ts:866-867` 一带)。最终渲成模型可见的字符串靠 `utils/messages.ts:4232-4235`: @@ -190,24 +190,24 @@ case 'companion_intro': ```typescript export function isBuddyTeaserWindow(): boolean { - const now = new Date(); - const inApril2026 = now.getUTCFullYear() === 2026 && now.getUTCMonth() === 3 - && now.getUTCDate() >= 1 && now.getUTCDate() <= 7; - return inApril2026 || ('external' === 'ant'); + if ('external' === 'ant') return true; + const d = new Date(); + return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; } export function isBuddyLive(): boolean { - const now = new Date(); - return now.getUTCFullYear() > 2026 - || (now.getUTCFullYear() === 2026 && now.getUTCMonth() >= 3); + if ('external' === 'ant') return true; + const d = new Date(); + return d.getFullYear() > 2026 + || (d.getFullYear() === 2026 && d.getMonth() >= 3); } ``` -`isBuddyTeaserWindow` 决定"要不要弹那个发现公告"——2026 年 4 月 1 日到 7 日这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用"——2026 年 4 月以后一直能用。两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 +两个判断都走**本地日期**——`getFullYear() / getMonth() / getDate()`,不是 `getUTC*`。这件事注释里也写明白了:"Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load." 用本地时区铺开 24 小时滚动波,能让东亚和美西错峰孵化,避开一个 UTC 午夜的集中尖峰。`isBuddyTeaserWindow` 决定"要不要弹那个发现公告"——2026 年 4 月 1 日到 7 日(`getDate() <= 7`)这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用"——2026 年 4 月以后一直能用。两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 -teaser 通知用 Claude Code 的通用 notification 系统(`buddy/useBuddyNotification.tsx:43-78`)。组件里挂一个空依赖的 `useEffect`,函数体先后过四道 early-return 闸:`!feature('BUDDY')`、`!isBuddyTeaserWindow()`、`getCompanion()` 已经存在、`getGlobalConfig().companionMuted` 为真——任何一道命中就 return。全过则 `addNotification({ priority: 'immediate', timeoutMs: 15000, render: () => renderRainbowText('Try /buddy to hatch a companion') })`。 +teaser 通知用 Claude Code 的通用 notification 系统(`buddy/useBuddyNotification.tsx:43-66`)。组件里挂一个 `useEffect`,函数体顺序过三道 early-return 闸:`!feature('BUDDY')`、`config.companion` 已存在或 `!isBuddyTeaserWindow()`。注意源码这里**只查 `config.companion` 是否已经孵化、不查 `companionMuted`**——发现入口的弹出条件是"还没养过",而不是"用户没把它静音",毕竟没养过就没什么可静音的。三道闸全过则 `addNotification({ key: 'buddy-teaser', jsx: , priority: 'immediate', timeoutMs: 15000 })`——通知主体就是彩虹色四字 `/buddy`,是按字符逐个 `getRainbowColor(i)` 染色再拼成一段 ``,没有更长的文案。整段 effect 返回一个 cleanup 函数 `removeNotification('buddy-teaser')`,依赖项是 `[addNotification, removeNotification]`。 -四道闸顺序很关键——`feature('BUDDY')` 在最前,构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群;最后 `companionMuted` 留给用户的撤销权。彩虹色用 `renderRainbowText` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 +三道闸顺序很关键——`feature('BUDDY')` 在最前,构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群。彩虹色用 `getRainbowColor` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 footer 集成在 `PromptInput.tsx` 的可见性表达式里(`components/PromptInput/PromptInput.tsx:310-316` 一带): From bc446c0bac79b27bb746a53546652778c65cfb4b Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 11:23:26 +0800 Subject: [PATCH 3/8] docs(c29): align species list, eye field, trigger return type per OC-R round 2 Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 27 ++++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index c954139..d03e0d2 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -10,7 +10,7 @@ 1. **同一个人每次启动看到的是不是同一只?** 如果每次都重新随机,那就是个噱头;如果存满了配置文件,那一旦清掉 `~/.claude.json` 就再也找不回原来那只 2. **它在终端窄到 80 列时怎么办?** 把一只 12 列宽的小动物硬塞到一个本来就在挤滚动条的窗口里,是体验灾难 -3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 frog"——模型很可能下一句就开始扮演青蛙 +3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 duck"——模型很可能下一句就开始扮演鸭子 4. **没开 Buddy 的人,构建出来的二进制里能不能完全没有它的代码?** 一个"宠物"功能进了 critical path 是说不过去的 5. **怎么让人发现这个隐藏功能而不打扰那些不想要它的人?** 弹一个明黄色公告会被骂;藏到 `--help` 里又没人看 @@ -57,9 +57,9 @@ const SALT = 'friend-2026-401'; ## 二、十八种小动物:藏在 `String.fromCharCode` 后面 -`types.ts` 里 `SPECIES` 是一个长 18 的 `as const` 数组,每个槽位都写成 `String.fromCharCode(0x66, 0x72, 0x6f, 0x67)` 这种形式(这一个就是 `'frog'`),下面紧跟一行 `// 'frog'` 注释告诉人类它是什么。同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 +`types.ts` 里先有 18 个具名常量导出——`duck` / `goose` / `blob` / `cat` / `dragon` / `octopus` / `owl` / `penguin` / `turtle` / `snail` / `ghost` / `axolotl` / `capybara` / `cactus` / `robot` / `rabbit` / `mushroom` / `chonk`(`buddy/types.ts:17-52`),每个都用 `String.fromCharCode(...)` 把名字一个字节一个字节拼出来再 `as 'duck'` 这样的类型断言钉住字面量类型;下面 `SPECIES` 数组(`buddy/types.ts:54-73`)按这 18 个常量名一字排开 `as const`,对外暴露 `Species = (typeof SPECIES)[number]` 联合类型。同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 -这种"把一行字符串名拆成 `String.fromCharCode(…)` 数列"的写法看起来很怪——直接写 `'frog'` 不香吗?看一眼仓库根目录的字符串扫描脚本就明白了。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`frog`/`legendary`/`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 +这种"先用 `String.fromCharCode(…)` 把名字拼出来再放进数组"的写法看起来很怪——直接写 `'duck'` 不香吗?看一眼仓库根目录的字符串扫描脚本就明白了。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary` / `Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合——`rollRarity` 就是按累积权重在 `[0,100)` 区间里掷一次随机数(`buddy/companion.ts:43-51`):累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间为止;兜底返回 `'common'` 防止浮点累积误差。 @@ -71,7 +71,7 @@ const SALT = 'friend-2026-401'; ## 三、像素画、500 ms 一拍、眨眼与摸头 -`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列,眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eyes` 替换成对应字符(圆点、星号、闭眼弧线之类)。 +`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列,眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eye` 替换成对应字符(圆点、星号、闭眼弧线之类)。 `renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 做这一步替换 + 帽子布置:先 `map(line => line.replaceAll('{E}', bones.eye))`;如果 `bones.hat !== 'none'`,**只在第 0 行本来全空(`trim()` 为空)时**才把 `HAT_LINES[bones.hat]` 写进 `lines[0]` 替换掉那一行——第 0 行被 smoke / antenna 之类的纹理占用时,源码直接放弃戴帽子,不会 unshift 一行把动物拔高;反过来如果最终 `lines[0]` 仍是空白、且**该物种的每一帧 `frames.every(f => !f[0]!.trim())` 都是空白**,就把那行 `shift()` 掉,省一行空间——`every` 这个判断写在源码注释里说得很清楚("Only safe when ALL frames have blank line 0; otherwise heights oscillate"),是为了避免不同帧之间高度抖动。这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§六)至关重要。 @@ -157,7 +157,7 @@ REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReactio ## 五、第三人称介绍:不让模型代入这只小动物 -把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a frog named Sproink",模型立刻开始 `*ribbit*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 里这段刻意写成第三人称: +把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a duck named Sproink",模型立刻开始 `*quack*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 里这段刻意写成第三人称: ```typescript export function companionIntroText(name: string, species: string): string { @@ -224,18 +224,21 @@ const footerItems: FooterItem[] = [ `/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮(`buddy/useBuddyNotification.tsx:79-97`): ```typescript -export function findBuddyTriggerPositions(text: string): Array<[number, number]> { - const out: Array<[number, number]> = []; - const re = /\/buddy\b/g; - let m: RegExpExecArray | null; +export function findBuddyTriggerPositions( + text: string, +): Array<{ start: number; end: number }> { + if (!feature('BUDDY')) return [] + const triggers: Array<{ start: number; end: number }> = [] + const re = /\/buddy\b/g + let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - out.push([m.index, m.index + m[0].length]); + triggers.push({ start: m.index, end: m.index + m[0].length }) } - return out; + return triggers } ``` -这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去,是个一行函数式的"返回区间数组",没有内部状态,便于单测。 +这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去,返回的是一组 `{ start, end }` 区间对象(不是 `[number, number]` 元组),函数本身先过一道 `feature('BUDDY')` 闸再扫正则,没有内部状态,便于单测。 最外层的总开关有两道,是编译期门(`commands.ts:118-120` 与同文件下方一带): From b5ed6b5ce1bb40061e1b9b4e5d909fb39216b87a Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 11:54:46 +0800 Subject: [PATCH 4/8] docs(c29): quote real companionReservedColumns + PromptInput footer per OC-R round 3 Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index d03e0d2..135c7f8 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -119,17 +119,18 @@ const PET_HEARTS = [ 终端宽度是这套渲染最大的不可控变量。一台 80 列宽的窗口,左边光是 PromptInput 自己就要 60 多列;如果再硬塞一只 12 列宽的小动物加一个 36 列的气泡,等于直接把输入框挤崩。`CompanionSprite.tsx` 用一个对外暴露的函数告诉 PromptInput "我要占多少列"(`buddy/CompanionSprite.tsx:167-175`): ```typescript -export function companionReservedColumns(cols: number, speaking: boolean): number { - const cfg = getGlobalConfig(); - if (cfg.companionMuted) return 0; - if (cols < 100) return 0; - const sprite = 12 + 2; // 12 列像素画 + 2 列内边距 - const bubble = speaking ? 36 : 0; // 36 列气泡,只在说话时算 - return sprite + bubble; +export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { + if (!feature('BUDDY')) return 0; + const companion = getCompanion(); + if (!companion || getGlobalConfig().companionMuted) return 0; + if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; + const nameWidth = stringWidth(companion.name); + const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; + return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; } ``` -100 列是分水岭。低于 100,小动物自己缩成一行 ASCII 写在 footer 那条状态栏边上,不再占任何列宽;超过 100,按"基础 14 列、说话时 +36 列"算给 PromptInput 让出去。值得注意的是它读取了 `cfg.companionMuted`——这个字段在 `utils/config.ts:269-271` 里和 `companion` 并列: +四道闸顺序很关键。`feature('BUDDY')` 在最前——构建期把整支 Buddy 整体擦掉时,`companionReservedColumns` 也直接 return 0,PromptInput 那边算宽度不会引入对 `getCompanion` / `getGlobalConfig` 的运行期调用。第二道是 `getCompanion()`——没孵化过就没东西可占列;第三道 `companionMuted` 是用户的静音开关;第四道 `MIN_COLS_FOR_FULL_SPRITE = 100` 是窄屏退化阈值。过完四道才进入真正的宽度结算:`spriteColWidth(stringWidth(companion.name))` 把 companion 名字的视觉宽度算进去(名字长的 sprite 列宽要相应撑宽),再加 `SPRITE_PADDING_X = 2` 的内边距,最后只有在 `speaking && !isFullscreenActive()` 时才再加 `BUBBLE_WIDTH = 36`——全屏视图下气泡走 `CompanionFloatingBubble` 浮在 scrollback 之上、不再吃 PromptInput 的列宽,所以这里要把它扣掉。`companionMuted` 这个字段在 `utils/config.ts:269-271` 里和 `companion` 并列: ```typescript companion?: import('../buddy/types.js').StoredCompanion; @@ -209,16 +210,21 @@ teaser 通知用 Claude Code 的通用 notification 系统(`buddy/useBuddyNoti 三道闸顺序很关键——`feature('BUDDY')` 在最前,构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群。彩虹色用 `getRainbowColor` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 -footer 集成在 `PromptInput.tsx` 的可见性表达式里(`components/PromptInput/PromptInput.tsx:310-316` 一带): +footer 集成在 `PromptInput.tsx` 的可见性表达式里(`components/PromptInput/PromptInput.tsx:309-316`): ```typescript -const _companion = getCompanion(); -const footerItems: FooterItem[] = [ - /* 其他 footer 项 */ - ...(!!_companion && !cfg.companionMuted ? ['companion' as const] : []), -]; +const { + companion: _companion, + companionMuted +} = feature('BUDDY') ? getGlobalConfig() : { + companion: undefined, + companionMuted: undefined +}; +const companionFooterVisible = !!_companion && !companionMuted; ``` +这里读的是 `getGlobalConfig()` 里已经存好的 `companion`,不是再调一次 `getCompanion()` 去重算——footer 的可见性只关心配置层面"这只 companion 有没有被孵化过 + 用户没把它静音",不需要再走一遍 `companion.ts` 那个带缓存的随机滚算。同样地,整个解构表达式被 `feature('BUDDY') ?` 包住:构建期 Buddy 被擦掉时,右侧的占位对象让 `_companion` 和 `companionMuted` 都解构成 `undefined`,`companionFooterVisible` 恒为 `false`,footer 那一项在编译产物里直接消失。 + `'companion'` 这个 footer 变体在 `AppStateStore.ts:87` 一带被加进 `FooterItem` 联合类型。它的"焦点态 + Enter"行为映射到 `onSubmit('/buddy')`——把焦点停在 companion footer 项上按回车,等价于打 `/buddy` 命令;这件事不仅是发现入口,也是"鼠标用户/触控板用户在不打字的状态下也能摸到这只小动物"的入口。 `/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮(`buddy/useBuddyNotification.tsx:79-97`): From 8fd851b91c4fc0017b9cdb7c71f47e481cad2b0d Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 12:37:12 +0800 Subject: [PATCH 5/8] =?UTF-8?q?style(c29):=20=E6=8C=89=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E4=BC=98=E5=8C=96=E6=96=87=E9=A3=8E=20?= =?UTF-8?q?=E2=80=94=20=E5=8A=A0=20=C2=A7=E4=B8=83=E5=8F=AF=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E6=A8=A1=E5=BC=8F=20+=20=C2=A7=E5=85=AB=E5=AE=9E?= =?UTF-8?q?=E6=88=98=E7=A4=BA=E4=BE=8B=EF=BC=8C=E5=90=88=E5=B9=B6=E7=90=90?= =?UTF-8?q?=E7=A2=8E=E6=8B=AC=E5=8F=B7=E5=88=B0=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 406 +++++++++++++++----- 1 file changed, 318 insertions(+), 88 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index 135c7f8..426a0cd 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -1,34 +1,62 @@ # 第 32 篇:Buddy 人格 — 在 PromptInput 边上养一只随机生成的小动物 -> 本篇是《深入 Claude Code 源码》系列第 32 篇。我们将剖析 `buddy/` 目录下的 6 个文件以及它们在 REPL、PromptInput、配置、附件、消息流里的 7 处接入点,看 Claude Code 如何在一个本来全是代码与文字的终端里,挤出一只随机生成的、会眨眼、会被摸、会冒话框、还会被悄悄藏掉的小动物。 +> 本篇是《深入 Claude Code 源码》系列第 32 篇。我们将剖析 `buddy/` 目录下的 6 个源码文件,以及它们在 REPL、PromptInput、配置、附件、消息流里的接入点,看 Claude Code 如何在一个本来全是代码与文字的终端里,挤出一只随机生成、会眨眼、会被摸、会冒话框、还会被悄悄藏掉的小动物。 ## 为什么 Buddy 值得单独一篇? -终端工具向来是"功能至上"的世界——多一行像素都要解释为什么不让位给输出。可这一次 Claude Code 偏偏在 PromptInput 框右边塞进了一只小生物,它会随机出一个名字、会在你打字时眨眼、你把光标停在它上面按一下回车它就会出爱心,它甚至会冒出一个圆角小气泡评论你刚才的对话。 +终端工具向来是"功能至上"的世界——多一行像素都要解释为什么不让位给输出。可这一次 Claude Code 偏偏在 PromptInput 框右边塞进了一只小生物。它会随机出一个名字、会在你打字时眨眼、把光标停在它上面按回车就会出爱心,它甚至会冒出一个圆角小气泡评论你刚才的对话。 这件事不是简单地"画一只 ASCII 小动物"。它要回答的问题相当多: -1. **同一个人每次启动看到的是不是同一只?** 如果每次都重新随机,那就是个噱头;如果存满了配置文件,那一旦清掉 `~/.claude.json` 就再也找不回原来那只 -2. **它在终端窄到 80 列时怎么办?** 把一只 12 列宽的小动物硬塞到一个本来就在挤滚动条的窗口里,是体验灾难 -3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 duck"——模型很可能下一句就开始扮演鸭子 -4. **没开 Buddy 的人,构建出来的二进制里能不能完全没有它的代码?** 一个"宠物"功能进了 critical path 是说不过去的 -5. **怎么让人发现这个隐藏功能而不打扰那些不想要它的人?** 弹一个明黄色公告会被骂;藏到 `--help` 里又没人看 +1. **同一个人每次启动看到的是不是同一只?** 如果每次都重新随机,那就是个噱头;如果存满了配置文件,那一旦清掉 `~/.claude.json` 就再也找不回原来那只。 +2. **它在终端窄到 80 列时怎么办?** 把一只 12 列宽的小动物硬塞到一个本来就在挤滚动条的窗口里,是体验灾难。 +3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 duck"——模型很可能下一句就开始扮演鸭子。 +4. **没开 Buddy 的人,构建出来的二进制里能不能完全没有它的代码?** 一个"宠物"功能进了 critical path 是说不过去的。 +5. **怎么让人发现这个隐藏功能而不打扰那些不想要它的人?** 弹一个明黄色公告会被骂;藏到 `--help` 里又没人看。 -Claude Code 的答案是:**把"骨"和"魂"切开存,把渲染、出现、声明、命令四个面分别接进现成的子系统,再用两道编译期 + 一道运行期门把它整体藏在大多数构建之外**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答:`companion.ts` 管"骨"和"魂"的拆分与生成、`types.ts` 管物种与稀有度词典、`sprites.ts` 管 18 个物种的 ASCII 像素画、`CompanionSprite.tsx` 管帧动画与气泡、`prompt.ts` 管对大模型的"第三人称声明"、`useBuddyNotification.tsx` 管短窗口里的彩虹色入口提示。 +Claude Code 的答案是:**把"骨"和"魂"切开存,把渲染、出现、声明、命令四个面分别接进现成的子系统,再用两道编译期 + 一道运行期门把它整体藏在大多数构建之外**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答: -本篇按这个顺序拆:先看"骨与魂"如何被切开(§一)、再看 18 物种的字典是怎么躲开打包扫描的(§二)、像素画与帧动画如何在 500 ms 一拍的节奏下完成眨眼与摸头(§三)、窄屏退化与全屏浮动气泡的两种排版(§四)、怎么用第三人称介绍把小动物钉在"旁观者"而不是"扮演者"位置(§五)、最后看 `/buddy` 入口、彩虹高亮、footer 集成与两道编译门如何把它整体藏在大多数构建之外(§六)。 +- `companion.ts` 管"骨"和"魂"的拆分与生成 +- `types.ts` 管物种与稀有度词典 +- `sprites.ts` 管 18 个物种的 ASCII 像素画 +- `CompanionSprite.tsx` 管帧动画与气泡 +- `prompt.ts` 管对大模型的"第三人称声明" +- `useBuddyNotification.tsx` 管短窗口里的彩虹色入口提示 + +本篇按这个顺序拆:先看"骨与魂"如何被切开(§一),再看 18 物种的字典是怎么躲开打包扫描的(§二),像素画与帧动画如何在 500ms 一拍的节奏下完成眨眼与摸头(§三),窄屏退化与全屏浮动气泡的两种排版(§四),怎么用第三人称介绍把小动物钉在"旁观者"而不是"扮演者"位置(§五),最后看 `/buddy` 入口、彩虹高亮、footer 集成与两道编译门如何把它整体藏在大多数构建之外(§六)。 --- ## 一、骨与魂:一半算出来,一半存下来 -`Companion` 这个类型在 `buddy/types.ts:100-124` 里被切成了两半。`CompanionBones` 包六个字段——`rarity`、`species`、`eye`、`hat: Hat`、`shiny: boolean`、`stats: Record`;`CompanionSoul` 只包两个——`name: string` 和 `personality: string`。对外用的 `Companion = CompanionBones & CompanionSoul & { hatchedAt: number }`——`hatchedAt` 是外层字段,不在 Soul 里;而落盘的 `StoredCompanion = CompanionSoul & { hatchedAt: number }`——骨头一个字节都不存。 +`Companion` 这个类型在 `buddy/types.ts:100-124` 里被切成了两半: + +```typescript +// buddy/types.ts:100-124 +export type CompanionBones = { + rarity: Rarity; + species: Species; + eye: Eye; + hat: Hat; + shiny: boolean; + stats: Record; +}; + +export type CompanionSoul = { + name: string; + personality: string; +}; + +export type Companion = CompanionBones & CompanionSoul & { hatchedAt: number }; +export type StoredCompanion = CompanionSoul & { hatchedAt: number }; +``` -`Bones` 是"骨"——稀有度、物种、眼神、帽子、是否闪光、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:模型给它起的名字,和模型生成的人格描述。 +`Bones` 是"骨"——稀有度、物种、眼神、帽子、是否闪光、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:模型给它起的名字,和模型生成的人格描述。`hatchedAt` 这个时间戳是 `Companion` 和 `StoredCompanion` 的外层字段,不在 Soul 里;而落盘的 `StoredCompanion` 干脆把骨头一字节都不存。 -为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就明白了(`buddy/companion.ts:127-133`): +为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就明白了: ```typescript +// buddy/companion.ts:127-133 export function getCompanion(): Companion | undefined { const stored = getGlobalConfig().companion; if (!stored) return undefined; @@ -37,47 +65,158 @@ export function getCompanion(): Companion | undefined { } ``` -`stored` 先铺,`bones` 后铺——意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处:第一,配置文件无论怎么变都不会污染骨架,源码里那句注释说得很直白——"editing config.companion can't fake a rarity",用户改不出一只 legendary 来;第二,假如哪天往 `Bones` 里加一个字段(比如新增一个 `aura: Color`),老用户不需要"迁移",下次启动直接补上。 +`stored` 先铺,`bones` 后铺,意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处。 + +第一个好处是 **配置文件改不动骨架**。源码里那句注释写得很直白——"editing config.companion can't fake a rarity"。用户翻开 `~/.claude.json` 把 `rarity` 改成 `legendary` 也没用,下次启动 `bones` 会盖掉这个字段。 + +第二个好处是 **加字段不用迁移**。假如哪天往 `Bones` 里再补一个 `aura: Color`,老用户的配置文件不需要 migration,下次启动直接补上。 + +骨架的种子从哪里来?`companionUserId()` 在 `buddy/companion.ts:119-122` 给出了三档回退: + +```typescript +// buddy/companion.ts:119-122 +function companionUserId(): string { + return getOauthAccountUuid() ?? getMachineId() ?? 'anon'; +} +``` + +OAuth 账号 UUID 优先,回退到本机 `userID`,再回退到字符串 `'anon'`——一个稳定且对同一台机器、同一个账号、同一只手指头都可重现的标识。 -骨架怎么算?关键在那一行 `roll(companionUserId())`。`companionUserId()` 在 `buddy/companion.ts:119-122` 里取 OAuth 账号 UUID,回退到本机 `userID`,再回退到字符串 `'anon'`——一个稳定且对同一台机器/账号同一只手指头可重现的标识。 +种子化的伪随机数走了一段教科书级的 Mulberry32: -再看种子化的伪随机数。`buddy/companion.ts:16-25` 用了一段教科书级的 Mulberry32:状态只有 32 bit,函数体只有四行算术(加常数、`Math.imul` 两次、右移异或一次),最后把 32 位整数除以 `4294967296` 归一到 `[0,1)`。这是公认的小巧确定性 PRNG,纯算术、不依赖运行环境、调用一万次和调用一次的开销线性可数。配套的 `hashString`(`buddy/companion.ts:27-37`)优先用 Bun 自带的非加密哈希,退回 FNV-1a 五行手写实现(异或当前字符 + `Math.imul` 乘 0x01000193),两者都满足"相同输入永远相同输出"。 +```typescript +// buddy/companion.ts:16-25 +function mulberry32(seed: number) { + let s = seed >>> 0; + return function (): number { + s = (s + 0x6D2B79F5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +``` + +状态只有 32 bit,函数体只有四行算术,最后把 32 位整数除以 `4294967296` 归一到 `[0,1)`。配套的 `hashString` 在 `buddy/companion.ts:27-37` 优先用 Bun 自带的非加密哈希,退回到 FNV-1a 五行手写实现。这两个函数都满足"相同输入永远相同输出",是整套确定性派生的地基。 -然后种子里加了一道"咸"(`buddy/companion.ts:84`): +种子里还加了一道"咸": ```typescript +// buddy/companion.ts:84 const SALT = 'friend-2026-401'; ``` -`roll(userId)` 实际用的种子是 `hashString(userId + SALT)`。这道咸的作用很直接:**用户的 UUID 是个稳定标识,谁都没必要把它和具体哪种小动物绑死**——咸一改,全员重新孵化,相当于一次"全服换代"的开关,藏在源码里、不需要走配置。 +`roll(userId)` 实际用的种子是 `hashString(userId + SALT)`。这道咸的作用很直接:用户的 UUID 是稳定标识,谁都没必要把它和具体哪种小动物绑死。咸值一改,全员重新孵化,相当于一次"全服换代"的开关,藏在源码里、不依赖配置。 -最后是一个轻量缓存(`buddy/companion.ts:107-117`):单槽位记住最近一次的 `userId → Bones`,因为运行期内 `userId` 不会变(除非中途登录/登出),但 `getCompanion()` 会被 500 ms 一拍的渲染器频繁调用,避免每帧都重算 5 次随机数。同文件还导出一个不走缓存的 `rollWithSeed(seed)`,专供调试和文档场景做"我给你一个固定种子,你给我看看出什么"。 +最后是一个轻量缓存(`buddy/companion.ts:107-117`):单槽位记住最近一次的 `userId → Bones`。运行期里 `userId` 不会变,但 `getCompanion()` 会被 500ms 一拍的渲染器频繁调用,缓存避免每帧都重算 5 次随机数。同文件还导出一个不走缓存的 `rollWithSeed(seed)`,专供调试和文档场景做"我给你一个固定种子,你给我看看出什么"。 --- ## 二、十八种小动物:藏在 `String.fromCharCode` 后面 -`types.ts` 里先有 18 个具名常量导出——`duck` / `goose` / `blob` / `cat` / `dragon` / `octopus` / `owl` / `penguin` / `turtle` / `snail` / `ghost` / `axolotl` / `capybara` / `cactus` / `robot` / `rabbit` / `mushroom` / `chonk`(`buddy/types.ts:17-52`),每个都用 `String.fromCharCode(...)` 把名字一个字节一个字节拼出来再 `as 'duck'` 这样的类型断言钉住字面量类型;下面 `SPECIES` 数组(`buddy/types.ts:54-73`)按这 18 个常量名一字排开 `as const`,对外暴露 `Species = (typeof SPECIES)[number]` 联合类型。同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 +`types.ts` 里先有 18 个具名常量导出: + +```typescript +// buddy/types.ts:17-52(节选) +const duck = (String.fromCharCode(100, 117, 99, 107)) as 'duck'; +const goose = (String.fromCharCode(103, 111, 111, 115, 101)) as 'goose'; +// ... 还有 blob / cat / dragon / octopus / owl / penguin / +// turtle / snail / ghost / axolotl / capybara / cactus / +// robot / rabbit / mushroom / chonk +``` -这种"先用 `String.fromCharCode(…)` 把名字拼出来再放进数组"的写法看起来很怪——直接写 `'duck'` 不香吗?看一眼仓库根目录的字符串扫描脚本就明白了。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary` / `Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 +下面 `SPECIES` 数组按这 18 个常量名一字排开: -稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合——`rollRarity` 就是按累积权重在 `[0,100)` 区间里掷一次随机数(`buddy/companion.ts:43-51`):累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间为止;兜底返回 `'common'` 防止浮点累积误差。 +```typescript +// buddy/types.ts:54-73 +export const SPECIES = [ + duck, goose, blob, cat, dragon, octopus, owl, penguin, + turtle, snail, ghost, axolotl, capybara, cactus, + robot, rabbit, mushroom, chonk, +] as const; + +export type Species = (typeof SPECIES)[number]; +``` -紧挨着还有一层"地板"保护——`buddy/companion.ts:53-59` 的 `RARITY_FLOOR` 给五档稀有度分别定下 5 / 15 / 25 / 35 / 50 的基线下限。它的用途在 `rollStats`(`buddy/companion.ts:62-82`)里:五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`——一个很 self-aware 的清单。算法走的是三分支:先掷一个 `peak`、再掷一个 `dump`,用 `while (dump === peak)` 重掷直到两者不撞;然后遍历五项,落到 `peak` 的算 `Math.min(100, floor + 50 + Math.floor(rng()*30))`、落到 `dump` 的算 `Math.max(1, floor - 10 + Math.floor(rng()*15))`、其余项算 `floor + Math.floor(rng()*40)`。地板随稀有度递增,传说级最低 50,所以 legendary 那只看一眼属性条就跟普通一只一望可辨;high/low 用 `while` 重掷撞 peak 的方式避撞,没有用偏移取模的小技巧。 +同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 -帽子是稀有度的一个伴生物。`rollFrom(rng)`(`buddy/companion.ts:91-102`)的工作流程是:先 `mulberry32(hashString(seed + SALT))` 拿到一个确定性的 `rand()` 函数,再依次掷出 `rarity`、`species`(在 `SPECIES` 里取下标)、`eye`(在 `EYES` 里取下标),然后帽子按一条 hard rule 走——`rarity === 'common' ? 'none' : pick(rng, HATS)`,common 永远 `'none'`,非 common 直接在 `HATS` 里掷一个下标(注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`);接着 `rng() < 0.01` 决定 `shiny` 是否为真;最后掷一遍 `stats` 收尾。没有 18% 概率给帽子这种事——帽子的有无完全由稀有度档位决定,"common 不戴 / 非 common 大概率戴一顶"是 hard branch 而非概率门。帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事会在 §三 看到。 +这种"先用 `String.fromCharCode` 把名字拼出来再放进数组"的写法看起来很怪——直接写 `'duck'` 不香吗?答案在仓库根目录的字符串扫描脚本里。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary`、`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 + +### 2.1 五档稀有度的累积权重 + +稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合。`rollRarity` 在 `[0,100)` 区间里掷一次随机数: + +```typescript +// buddy/companion.ts:43-51 +function rollRarity(rng: () => number): Rarity { + const r = rng() * 100; + let acc = 0; + for (const [rarity, weight] of Object.entries(RARITY_WEIGHTS)) { + acc += weight; + if (r < acc) return rarity as Rarity; + } + return 'common'; +} +``` + +累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间就 `return`;最后兜底返回 `'common'` 防止浮点累积误差。 + +### 2.2 属性算法:peak / dump / 普通三分支 + +紧挨着还有一层"地板"保护: + +```typescript +// buddy/companion.ts:53-59 +const RARITY_FLOOR: Record = { + common: 5, + uncommon: 15, + rare: 25, + epic: 35, + legendary: 50, +}; +``` + +`RARITY_FLOOR` 给五档稀有度分别定下 5 / 15 / 25 / 35 / 50 的基线下限。它的用途在 `rollStats` 里: + +`rollStats` 在 `buddy/companion.ts:62-82` 里把 `RARITY_FLOOR[rarity]` 取出来作基线,先 `Math.floor(rng() * STAT_NAMES.length)` 掷一个 `peak`、再用同样的方式掷一个 `dump`,用 `while (dump === peak)` 重掷直到两者不撞,然后遍历五项写值——`peak` 项是 `Math.min(100, floor + 50 + rng*30)`、`dump` 项是 `Math.max(1, floor - 10 + rng*15)`、其余项是 `floor + rng*40`。 + +五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`,一个很 self-aware 的清单。地板随稀有度递增,传说级最低 50,所以 legendary 那只看一眼属性条就跟普通一只一望可辨。`while` 重掷而不是偏移取模,是为了避免引入分布偏差。 + +### 2.3 帽子是稀有度的伴生物 + +`rollFrom(rng)` 在 `buddy/companion.ts:91-102` 把这一切串起来:先 `rollRarity` 决定稀有度档位,然后从 `SPECIES`、`EYES` 各掷一个下标拿物种和眼神;帽子的有无完全由稀有度档位决定——`common` 永远是字符串 `'none'`,非 common 才在 `HATS` 里掷一个下标;接下来 `shiny = rng() < 0.01` 是一道独立的 1% 闪光判定;最后 `rollStats(rng, rarity)` 把五维填齐。 + +帽子那一档把"common 不戴帽"硬编成 ternary 分支而不是概率:注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`。这是一条 hard branch 而非概率门——没有"18% 概率给帽子"这种事。`shiny` 是 1% 的独立概率,五维属性最后再掷一遍收尾。 + +帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事 §三 会接着看。 --- -## 三、像素画、500 ms 一拍、眨眼与摸头 +## 三、像素画、500ms 一拍、眨眼与摸头 + +`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列。眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eye` 替换成对应字符(圆点、星号、闭眼弧线之类)。 + +### 3.1 帽子布置的三种姿态 + +`renderSprite(bones, frame)` 做替换 + 帽子布置: -`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列,眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eye` 替换成对应字符(圆点、星号、闭眼弧线之类)。 +`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 里只做三件事:先 `raw.map(line => line.replaceAll('{E}', bones.eye))` 把眼睛占位符替换成对应字符;然后 `if (bones.hat !== 'none' && !lines[0]!.trim())` 时把 `HAT_LINES[bones.hat]` 写进 `lines[0]`;最后 `if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim()))` 时 `lines.shift()` 节高一行。 -`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 做这一步替换 + 帽子布置:先 `map(line => line.replaceAll('{E}', bones.eye))`;如果 `bones.hat !== 'none'`,**只在第 0 行本来全空(`trim()` 为空)时**才把 `HAT_LINES[bones.hat]` 写进 `lines[0]` 替换掉那一行——第 0 行被 smoke / antenna 之类的纹理占用时,源码直接放弃戴帽子,不会 unshift 一行把动物拔高;反过来如果最终 `lines[0]` 仍是空白、且**该物种的每一帧 `frames.every(f => !f[0]!.trim())` 都是空白**,就把那行 `shift()` 掉,省一行空间——`every` 这个判断写在源码注释里说得很清楚("Only safe when ALL frames have blank line 0; otherwise heights oscillate"),是为了避免不同帧之间高度抖动。这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§六)至关重要。 +这段 14 行的小函数里其实塞了三个判断: -帧动画的节奏由 `CompanionSprite.tsx` 顶部一组常量定义(`buddy/CompanionSprite.tsx` 节选): +1. **眼睛替换**:先 `map(line => line.replaceAll('{E}', bones.eye))`,把所有 `{E}` 换成对应眼神字符。 +2. **帽子布置**:`bones.hat !== 'none'` 时,只在第 0 行本来 `trim()` 为空的情况下才把 `HAT_LINES[bones.hat]` 写进 `lines[0]`。第 0 行被 smoke / antenna 之类的纹理占用时,源码直接放弃戴帽子,不会 unshift 一行把动物拔高。 +3. **节高一行**:如果最终 `lines[0]` 仍是空白,并且该物种的 `frames.every(f => !f[0]!.trim())` 都是空白,就把那行 `shift()` 掉省一行空间。源码注释把这个 `every` 判断的理由写得很清楚——"Only safe when ALL frames have blank line 0; otherwise heights oscillate",是为了避免不同帧之间高度抖动。 + +这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§四)至关重要。 + +### 3.2 帧动画的节奏常量 + +`CompanionSprite.tsx` 顶部一组常量定义了整套节奏: ```typescript +// buddy/CompanionSprite.tsx 顶部常量 const TICK_MS = 500; const BUBBLE_SHOW = 20; // 20 拍 ≈ 10 s const FADE_WINDOW = 6; // 最后 6 拍变暗,提示要消失 @@ -85,18 +224,26 @@ const PET_BURST_MS = 2500; const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; ``` -`IDLE_SEQUENCE` 是这整篇里最让人愿意盯着看的一段——它是个长度 15 的循环序列,写明了"小动物在没事干时给你看什么":大部分时候是帧 0(基础站姿),偶尔切到帧 1(小抖动)和帧 2(另一种小动作),中间穿插一个 `-1` 代表"眨眼"——渲染时遇到 `-1` 不取帧、改画一行 `^_^` 这种闭眼脸覆盖在原本眼睛行上。15 拍正好 7.5 秒一个循环,恰好长到不会让人觉得机械、短到不会让人怀疑它死了。 +`IDLE_SEQUENCE` 是这整篇里最让人愿意盯着看的一段。它是长度 15 的循环序列,写明了"小动物在没事干时给你看什么":大部分时候是帧 0(基础站姿),偶尔切到帧 1 和帧 2,中间穿插一个 `-1` 代表"眨眼"——渲染时遇到 `-1` 不取帧、改画一行 `^_^` 这种闭眼脸覆盖在原本眼睛行上。15 拍正好 7.5 秒一个循环,恰好长到不会让人觉得机械、短到不会让人怀疑它死了。 -`useEffect` 里挂一个 `setInterval(tick, TICK_MS)`,每拍 `setFrameIdx(prev => prev + 1)`,根据 `companionReaction` 是否非空切换到"激动序列"(一段连续切帧的快节奏循环),10 秒之后清掉 reaction 回到 `IDLE_SEQUENCE`。`companionReaction` 这个字段从哪里来?在 `AppStateStore.ts:168-171` 它和 `companionPetAt` 一同被列为顶层 app state 字段: +### 3.3 摸头与气泡的状态流 + +`useEffect` 里挂一个 `setInterval(tick, TICK_MS)`,每拍 `setFrameIdx(prev => prev + 1)`,根据 `companionReaction` 是否非空切换到"激动序列"——一段连续切帧的快节奏循环,10 秒之后清掉 reaction 回到 `IDLE_SEQUENCE`。 + +`companionReaction` 这个字段从哪里来?在 `AppStateStore.ts:168-171` 它和 `companionPetAt` 一同被列为顶层 app state 字段: ```typescript +// AppStateStore.ts:168-171 companionReaction?: string; companionPetAt?: number; ``` -`companionReaction` 由 REPL 在每一轮对话结束后投喂(`screens/REPL.tsx:2805-2809` 一带):拿最后一条 assistant 消息的内容片段,丢给一个内部"伙伴观察者"函数,让它从一组短句模板里选一句作为反应,再 `setAppState({ companionReaction: '…' })`。`companionPetAt` 则由 PromptInput 那段 footer 集成里"按 Enter 摸头"的分支写入。摸头的视觉表达靠一组 `PET_HEARTS` 帧(`buddy/CompanionSprite.tsx` 内): +`companionReaction` 由 REPL 在每一轮对话结束后投喂(`screens/REPL.tsx:2805-2809` 一带):拿最后一条 assistant 消息的内容片段,丢给一个内部"伙伴观察者"函数,让它从一组短句模板里选一句作为反应,再 `setAppState({ companionReaction: '…' })`。`companionPetAt` 则由 PromptInput 那段 footer 集成里"按 Enter 摸头"的分支写入。 + +摸头的视觉表达靠一组 `PET_HEARTS` 帧: ```typescript +// buddy/CompanionSprite.tsx 内 const PET_HEARTS = [ ' ♡ ', ' ♡ ♡ ', @@ -108,17 +255,18 @@ const PET_HEARTS = [ 在 `PET_BURST_MS` 也就是 2.5 秒之内,每拍换一帧爱心、压在小动物正上方,整体看起来像几颗心从头顶慢慢飘起、散开、消失。 -气泡用的是一个手写的 React 组件 `SpeechBubble`(`buddy/CompanionSprite.tsx:43-151`)。文本进来先过一道 30 列的贪心折行——按空白分词、逐词累加、超过 30 就把当前行 `push` 进 `lines`、当前词作为下一行的第一个词。折好之后用 Ink 的 `Box border` 包一圈,再按 `tail` 参数把一个尾巴字符(`'right' → '◀'`、`'down' → '▼'`)定位在边框的对应位置上,整体看起来像漫画里那种"指向小动物头顶"的对话框。`fading` 跟着 `BUBBLE_SHOW - tick < FADE_WINDOW` 走,最后 3 秒整段套 `dimColor`,告诉读者"再不看就消失了"。 +气泡用的是一个手写的 React 组件 `SpeechBubble`(`buddy/CompanionSprite.tsx:43-151`)。文本进来先过一道 30 列的贪心折行——按空白分词、逐词累加、超过 30 就把当前行 `push` 进 `lines`、当前词作为下一行的第一个词。折好之后用 Ink 的 `Box border` 包一圈,再按 `tail` 参数把一个尾巴字符定位在边框的对应位置上:`'right' → '◀'`、`'down' → '▼'`,整体看起来像漫画里那种"指向小动物头顶"的对话框。`fading` 跟着 `BUBBLE_SHOW - tick < FADE_WINDOW` 走,最后 3 秒整段套 `dimColor`,告诉读者"再不看就消失了"。 -30 列是这只圆角气泡的内部最大宽度——加上两侧各 1 列边框 + 内边距,整体占 36 列(你会在 §四 看到这个数字以常量形式出现在宽度结算里)。 +30 列是这只圆角气泡的内部最大宽度——加上两侧各 1 列边框 + 内边距,整体占 36 列。这个 36 等下会以常量形式出现在宽度结算里。 --- ## 四、窄屏退化与全屏的浮动气泡 -终端宽度是这套渲染最大的不可控变量。一台 80 列宽的窗口,左边光是 PromptInput 自己就要 60 多列;如果再硬塞一只 12 列宽的小动物加一个 36 列的气泡,等于直接把输入框挤崩。`CompanionSprite.tsx` 用一个对外暴露的函数告诉 PromptInput "我要占多少列"(`buddy/CompanionSprite.tsx:167-175`): +终端宽度是这套渲染最大的不可控变量。一台 80 列宽的窗口,左边光是 PromptInput 自己就要 60 多列;如果再硬塞一只 12 列宽的小动物加一个 36 列的气泡,等于直接把输入框挤崩。`CompanionSprite.tsx` 用一个对外暴露的函数告诉 PromptInput "我要占多少列": ```typescript +// buddy/CompanionSprite.tsx:167-175 export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { if (!feature('BUDDY')) return 0; const companion = getCompanion(); @@ -130,18 +278,35 @@ export function companionReservedColumns(terminalColumns: number, speaking: bool } ``` -四道闸顺序很关键。`feature('BUDDY')` 在最前——构建期把整支 Buddy 整体擦掉时,`companionReservedColumns` 也直接 return 0,PromptInput 那边算宽度不会引入对 `getCompanion` / `getGlobalConfig` 的运行期调用。第二道是 `getCompanion()`——没孵化过就没东西可占列;第三道 `companionMuted` 是用户的静音开关;第四道 `MIN_COLS_FOR_FULL_SPRITE = 100` 是窄屏退化阈值。过完四道才进入真正的宽度结算:`spriteColWidth(stringWidth(companion.name))` 把 companion 名字的视觉宽度算进去(名字长的 sprite 列宽要相应撑宽),再加 `SPRITE_PADDING_X = 2` 的内边距,最后只有在 `speaking && !isFullscreenActive()` 时才再加 `BUBBLE_WIDTH = 36`——全屏视图下气泡走 `CompanionFloatingBubble` 浮在 scrollback 之上、不再吃 PromptInput 的列宽,所以这里要把它扣掉。`companionMuted` 这个字段在 `utils/config.ts:269-271` 里和 `companion` 并列: +四道闸顺序很关键。 + +- 第一道 `feature('BUDDY')` 在最前。构建期把整支 Buddy 整体擦掉时,`companionReservedColumns` 也直接 return 0,PromptInput 那边算宽度不会引入对 `getCompanion` / `getGlobalConfig` 的运行期调用。 +- 第二道 `getCompanion()`:没孵化过就没东西可占列。 +- 第三道 `companionMuted`:用户的静音开关。 +- 第四道 `MIN_COLS_FOR_FULL_SPRITE = 100`:窄屏退化阈值,太窄的终端直接不渲染。 + +过完四道才进入真正的宽度结算。`spriteColWidth(stringWidth(companion.name))` 把 companion 名字的视觉宽度算进去——名字长的 sprite 列宽要相应撑宽。再加 `SPRITE_PADDING_X = 2` 的内边距。最后只有在 `speaking && !isFullscreenActive()` 时才再加 `BUBBLE_WIDTH = 36`。全屏视图下气泡走 `CompanionFloatingBubble` 浮在 scrollback 之上、不再吃 PromptInput 的列宽,所以这里要把它扣掉。 + +### 4.1 `companionMuted` 是静音不是删除 + +`companionMuted` 这个字段在 `utils/config.ts:269-271` 里和 `companion` 并列: ```typescript +// utils/config.ts:269-271 companion?: import('../buddy/types.js').StoredCompanion; companionMuted?: boolean; ``` -`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关;它不删除 companion 本身(孵化记录、名字都还在),只是渲染期把 reserved columns 整条算零。任何相关 UI——包括 PromptInput 那边的 footer 项可见性判断、气泡显隐、彩虹高亮——都要先过这个静默开关。 +`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关。它不删除 companion 本身——孵化记录、名字都还在——只是渲染期把 reserved columns 整条算零。任何相关 UI——PromptInput 那边的 footer 项可见性、气泡显隐、彩虹高亮——都要先过这个静默开关。 -第二个分歧在全屏视图。Claude Code 在某些屏(比如长输出回放、Doctor 屏)会切到一个把整个 viewport 接管的 `FullscreenLayout`,外层 box 设了 `overflowY: 'hidden'`。这种情况下小动物本体还是要画在原位,但气泡如果跟着画就会被裁掉一半。解决办法是把气泡单独拆成 `CompanionFloatingBubble` 组件,挂进 `FullscreenLayout.bottomFloat` 这个专门预留的"逃出 overflow 裁切"的插槽: +### 4.2 全屏视图的浮动气泡 + +第二个分歧在全屏视图。Claude Code 在某些屏比如长输出回放、Doctor 屏会切到一个把整个 viewport 接管的 `FullscreenLayout`,外层 box 设了 `overflowY: 'hidden'`。这种情况下小动物本体还是要画在原位,但气泡如果跟着画就会被裁掉一半。 + +解决办法是把气泡单独拆成 `CompanionFloatingBubble` 组件,挂进 `FullscreenLayout.bottomFloat` 这个专门预留的"逃出 overflow 裁切"的插槽: ```typescript +// buddy/CompanionSprite.tsx 内 export function CompanionFloatingBubble() { const reaction = useAppState(s => s.companionReaction); if (!reaction) return null; @@ -150,9 +315,9 @@ export function CompanionFloatingBubble() { } ``` -REPL 里两个组件是分别挂载的(`screens/REPL.tsx:276` 与同文件下方一带):本体 `` 跟着 PromptInput 走,气泡 `` 跟着 FullscreenLayout 的浮动槽走。它们读同一份 `companionReaction` state,所以视觉上完全一致,只是渲染树位置不同。 +REPL 里两个组件分别挂载(`screens/REPL.tsx:276` 与同文件下方一带):本体 `` 跟着 PromptInput 走,气泡 `` 跟着 FullscreenLayout 的浮动槽走。它们读同一份 `companionReaction` state,所以视觉上完全一致,只是渲染树位置不同。 -REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReaction` 清空——气泡会马上消失。理由很直白:用户在看历史的时候,弹一个对当前最后一句话的反应是干扰。 +REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReaction` 清空,气泡马上消失。理由很直白——用户在看历史的时候,弹一个对当前最后一句话的反应是干扰。 --- @@ -160,23 +325,20 @@ REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReactio 把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a duck named Sproink",模型立刻开始 `*quack*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 里这段刻意写成第三人称: -```typescript -export function companionIntroText(name: string, species: string): string { - return `# Companion +这段 prompt 文本只有两段(`buddy/prompt.ts:7-13`)。第一段是 `A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.`——反复按住"你不是它"这个键。第二段处理"用户直接 by-name 点名 companion 时模型该怎么办":要求模型回应一行以内,不要解释"我不是 X"(用户知道),也不要替 X 编台词(气泡会处理)。这两段加起来同时圈住了两种最常见的漂移:扮演 companion、和无视 companion 抢话。 -A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher. +### 5.1 走 attachment 体系,不走 system prompt 拼接 -When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`; -} -``` +这段文本通过 `getCompanionIntroAttachment(messages)` 包成一个 attachment 注入消息流: -第一段反复按住"你不是它"这个键——`${name}` 坐在用户的输入框旁边、偶尔出气泡、你是观察者、它是另外一个观察者。第二段是这段 prompt 真正难写的部分:用户直接 by-name 点名 companion 时,模型不能装没看见、也不能抢答——要让出一行以内的响应空间,让气泡接话;不要解释"我不是 X"(用户知道),也不要替 X 编台词(气泡会处理)。这两段加起来同时圈住了两种最常见的漂移:扮演 companion、和无视 companion 抢话。 +`getCompanionIntroAttachment(messages)` 的函数签名是 `(messages: Message[]) => Attachment[]`——返回的是数组,不是 `Attachment | null`。函数体(`buddy/prompt.ts:15-36`)先过三道前置闸:`!feature('BUDDY')`、`!getCompanion()`、`getGlobalConfig().companionMuted` 任一为真就返回 `[]`;然后双层 for 扫每条消息的 `attachments`,看到 `att.type === 'companion_intro' && att.name === companion.name` 就返回 `[]`;全过则返回单元素数组 `[{ type: 'companion_intro', name, species }]`。 -这段文本通过 `getCompanionIntroAttachment(messages)`(`buddy/prompt.ts:15-36`)包成一个 attachment 注入消息流。注意函数签名:**返回的是 `Attachment[]`,不是 `Attachment | null`**——四道前置闸(`!feature('BUDDY')`、`!getCompanion()`、`getGlobalConfig().companionMuted`、消息流里已有同名 `companion_intro`)任意一道命中时返回空数组 `[]`,全过则返回一个 `[{ type: 'companion_intro', name, species }]`。去重那一步不是按 attachment 类型粗筛,而是逐条扫消息流:遇到 `type === 'attachment'` 且 `attachment.type === 'companion_intro'` 且 `attachment.name === companion.name` 时才认作"已经介绍过"——这意味着如果用户换了一只 companion(name 不同),旧的 intro 不算数,新的 intro 还是会注入一次。 +去重那一步不是按 attachment 类型粗筛,而是逐条扫消息流匹配同名 `companion_intro`。这意味着如果用户换了一只 companion,新的 name 不同,旧的 intro 不算数,新的 intro 还是会注入一次。 调度由 `utils/attachments.ts` 一并处理:`maybe('companion_intro', getCompanionIntroAttachment(messages))` 和其他多个"按情况附加"的 attachment 走同一条 schedule(`utils/attachments.ts:866-867` 一带)。最终渲成模型可见的字符串靠 `utils/messages.ts:4232-4235`: ```typescript +// utils/messages.ts:4232-4235 case 'companion_intro': return companionIntroText(attachment.name, attachment.species); ``` @@ -187,32 +349,50 @@ case 'companion_intro': ## 六、入口、彩虹、footer 与两道编译门 -发现入口的设计在 `useBuddyNotification.tsx`(`buddy/useBuddyNotification.tsx:12-21` 节选): +### 6.1 用本地日期开一道时间门 -```typescript -export function isBuddyTeaserWindow(): boolean { - if ('external' === 'ant') return true; - const d = new Date(); - return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; -} +发现入口的设计在 `useBuddyNotification.tsx`: -export function isBuddyLive(): boolean { - if ('external' === 'ant') return true; - const d = new Date(); - return d.getFullYear() > 2026 - || (d.getFullYear() === 2026 && d.getMonth() >= 3); -} +`buddy/useBuddyNotification.tsx:12-21` 给出两个判断函数 `isBuddyTeaserWindow()` 和 `isBuddyLive()`,它们都先有一道 `if ('external' === 'ant') return true` 的字面量比较,然后用 `new Date()` 拿到当前时间,分别比较年月日。`isBuddyTeaserWindow` 返回 `d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7`——2026 年 4 月 1 日到 7 日;`isBuddyLive` 返回 `d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)`——2026 年 4 月及之后。 + +两个判断都走 **本地日期**——`getFullYear() / getMonth() / getDate()`,不是 `getUTC*`。源码注释里把理由写明白了: + +> Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load. + +用本地时区铺开 24 小时滚动波,能让东亚和美西错峰孵化,避开一个 UTC 午夜的集中尖峰。`isBuddyTeaserWindow` 决定"要不要弹那个发现公告":2026 年 4 月 1 日到 7 日(`getDate() <= 7`)这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用":2026 年 4 月以后一直能用。 + +两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 + +### 6.2 彩虹色的发现通知 + +teaser 通知用 Claude Code 的通用 notification 系统。组件里挂一个 `useEffect`,函数体顺序过三道 early-return 闸: + +```typescript +// buddy/useBuddyNotification.tsx:43-66 +useEffect(() => { + if (!feature('BUDDY')) return; + const config = getGlobalConfig(); + if (config.companion || !isBuddyTeaserWindow()) return; + addNotification({ + key: 'buddy-teaser', + jsx: , + priority: 'immediate', + timeoutMs: 15000, + }); + return () => removeNotification('buddy-teaser'); +}, [addNotification, removeNotification]); ``` -两个判断都走**本地日期**——`getFullYear() / getMonth() / getDate()`,不是 `getUTC*`。这件事注释里也写明白了:"Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter buzz instead of a single UTC-midnight spike, gentler on soul-gen load." 用本地时区铺开 24 小时滚动波,能让东亚和美西错峰孵化,避开一个 UTC 午夜的集中尖峰。`isBuddyTeaserWindow` 决定"要不要弹那个发现公告"——2026 年 4 月 1 日到 7 日(`getDate() <= 7`)这一周对所有人开,或者对特定渠道(`'external' === 'ant'`)持续开。`isBuddyLive` 决定"`/buddy` 命令本身能不能用"——2026 年 4 月以后一直能用。两条线分开,使得"先 teaser 一周让大家发现、之后一直保留命令"这种节奏可以纯靠时间函数表达,不依赖任何外部 flag 服务。 +注意源码这里 **只查 `config.companion` 是否已经孵化、不查 `companionMuted`**——发现入口的弹出条件是"还没养过",而不是"用户没把它静音",毕竟没养过就没什么可静音的。三道闸全过则 `addNotification` 一条通知:主体就是彩虹色四字 `/buddy`,是按字符逐个 `getRainbowColor(i)` 染色再拼成一段 ``,没有更长的文案。整段 effect 返回一个 cleanup 函数 `removeNotification('buddy-teaser')`。 -teaser 通知用 Claude Code 的通用 notification 系统(`buddy/useBuddyNotification.tsx:43-66`)。组件里挂一个 `useEffect`,函数体顺序过三道 early-return 闸:`!feature('BUDDY')`、`config.companion` 已存在或 `!isBuddyTeaserWindow()`。注意源码这里**只查 `config.companion` 是否已经孵化、不查 `companionMuted`**——发现入口的弹出条件是"还没养过",而不是"用户没把它静音",毕竟没养过就没什么可静音的。三道闸全过则 `addNotification({ key: 'buddy-teaser', jsx: , priority: 'immediate', timeoutMs: 15000 })`——通知主体就是彩虹色四字 `/buddy`,是按字符逐个 `getRainbowColor(i)` 染色再拼成一段 ``,没有更长的文案。整段 effect 返回一个 cleanup 函数 `removeNotification('buddy-teaser')`,依赖项是 `[addNotification, removeNotification]`。 +三道闸顺序同样关键。`feature('BUDDY')` 在最前——构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群。彩虹色用 `getRainbowColor` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 -三道闸顺序很关键——`feature('BUDDY')` 在最前,构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群。彩虹色用 `getRainbowColor` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 +### 6.3 footer 集成只看 config,不再调 `getCompanion()` -footer 集成在 `PromptInput.tsx` 的可见性表达式里(`components/PromptInput/PromptInput.tsx:309-316`): +footer 集成在 `PromptInput.tsx` 的可见性表达式里: ```typescript +// components/PromptInput/PromptInput.tsx:309-316 const { companion: _companion, companionMuted @@ -223,32 +403,26 @@ const { const companionFooterVisible = !!_companion && !companionMuted; ``` -这里读的是 `getGlobalConfig()` 里已经存好的 `companion`,不是再调一次 `getCompanion()` 去重算——footer 的可见性只关心配置层面"这只 companion 有没有被孵化过 + 用户没把它静音",不需要再走一遍 `companion.ts` 那个带缓存的随机滚算。同样地,整个解构表达式被 `feature('BUDDY') ?` 包住:构建期 Buddy 被擦掉时,右侧的占位对象让 `_companion` 和 `companionMuted` 都解构成 `undefined`,`companionFooterVisible` 恒为 `false`,footer 那一项在编译产物里直接消失。 +这里读的是 `getGlobalConfig()` 里已经存好的 `companion`,不是再调一次 `getCompanion()` 去重算。footer 的可见性只关心配置层面"这只 companion 有没有被孵化过 + 用户没把它静音",不需要再走一遍 `companion.ts` 那个带缓存的随机滚算。 -`'companion'` 这个 footer 变体在 `AppStateStore.ts:87` 一带被加进 `FooterItem` 联合类型。它的"焦点态 + Enter"行为映射到 `onSubmit('/buddy')`——把焦点停在 companion footer 项上按回车,等价于打 `/buddy` 命令;这件事不仅是发现入口,也是"鼠标用户/触控板用户在不打字的状态下也能摸到这只小动物"的入口。 +同样地,整个解构表达式被 `feature('BUDDY') ?` 包住。构建期 Buddy 被擦掉时,右侧的占位对象让 `_companion` 和 `companionMuted` 都解构成 `undefined`,`companionFooterVisible` 恒为 `false`,footer 那一项在编译产物里直接消失。 -`/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮(`buddy/useBuddyNotification.tsx:79-97`): +`'companion'` 这个 footer 变体在 `AppStateStore.ts:87` 一带被加进 `FooterItem` 联合类型。它的"焦点态 + Enter"行为映射到 `onSubmit('/buddy')`——把焦点停在 companion footer 项上按回车,等价于打 `/buddy` 命令。这件事不仅是发现入口,也是"鼠标用户、触控板用户在不打字的状态下也能摸到这只小动物"的入口。 -```typescript -export function findBuddyTriggerPositions( - text: string, -): Array<{ start: number; end: number }> { - if (!feature('BUDDY')) return [] - const triggers: Array<{ start: number; end: number }> = [] - const re = /\/buddy\b/g - let m: RegExpExecArray | null - while ((m = re.exec(text)) !== null) { - triggers.push({ start: m.index, end: m.index + m[0].length }) - } - return triggers -} -``` +### 6.4 `/buddy` 触发位置的彩虹高亮 + +`/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮: + +函数体(`buddy/useBuddyNotification.tsx:79-97`)就是常规的 `while ((m = re.exec(text)) !== null)` 循环:先过一道 `feature('BUDDY')` 闸,再用 `/\/buddy\b/g` 在文本里滚一遍,把每次匹配的 `{ start: m.index, end: m.index + m[0].length }` push 进一个数组返回。 + +这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去。返回的是一组 `{ start, end }` 区间对象,不是 `[number, number]` 元组。函数没有内部状态,便于单测。 -这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去,返回的是一组 `{ start, end }` 区间对象(不是 `[number, number]` 元组),函数本身先过一道 `feature('BUDDY')` 闸再扫正则,没有内部状态,便于单测。 +### 6.5 两道编译门把整张子树切掉 -最外层的总开关有两道,是编译期门(`commands.ts:118-120` 与同文件下方一带): +最外层的总开关有两道,是编译期门: ```typescript +// commands.ts:118-120 与同文件下方一带 const buddy = feature('BUDDY') && require('./commands/buddy/index.js').default; // … const allCommands = [ @@ -257,20 +431,76 @@ const allCommands = [ ]; ``` -`feature('BUDDY')` 是 §第 19 篇里讲过的"compile-time feature flag"——构建时根据当前渠道把它折叠成 `true` 或 `false`,配合 `require(…)` 的 lazy resolve 和 tree-shaker,整张 buddy 命令子树在 `feature('BUDDY') === false` 的产物里彻底消失。再加上 `useBuddyNotification.tsx` 里 `'external' === 'ant'` 这种字面量比较,构建时整段表达式可以直接被替换成常量布尔,余下的代码被压成无效分支删掉。 +`feature('BUDDY')` 是 [§第 19 篇](./19-Feature-Flag与编译期优化.md) 里讲过的"compile-time feature flag"——构建时根据当前渠道把它折叠成 `true` 或 `false`,配合 `require(…)` 的 lazy resolve 和 tree-shaker,整张 buddy 命令子树在 `feature('BUDDY') === false` 的产物里彻底消失。再加上 `useBuddyNotification.tsx` 里 `'external' === 'ant'` 这种字面量比较,构建时整段表达式可以直接被替换成常量布尔,余下的代码被压成无效分支删掉。 -两道门一道由 `feature('BUDDY')` 控制特性总开关,另一道由 `'external' === 'ant'` 字面量给特定渠道再开一道边门。这种"compile-time 双重 gating"在 §第 19 篇里见过 `migrateFennecToOpus()` 同样的写法——一句普通的 `if`,对编译器是常量条件,对源码读者是渠道意图的明示。 +两道门一道由 `feature('BUDDY')` 控制特性总开关,另一道由 `'external' === 'ant'` 字面量给特定渠道再开一道边门。这种"compile-time 双重 gating"在第 19 篇里见过 `migrateFennecToOpus()` 同样的写法——一句普通的 `if`,对编译器是常量条件,对源码读者是渠道意图的明示。 --- -## 七、收束:为什么六个文件就能写出一只活的小动物 +## 七、可迁移的设计模式 回过头看,`buddy/` 这六个文件做对的事就一句话:**把"宠物"这个本应横跨配置、渲染、prompt、命令、通知五个子系统的功能,拆成五块各自接进对应子系统现有的扩展点,自己不造任何"框架"**。 - 配置那侧只多了两个字段:`companion`(魂)和 `companionMuted`(开关),骨头一字不存 -- 渲染那侧用 Ink 已有的 Box + 一个手写的 30 列 wrap,没有引入任何动画库;500 ms 一拍是手摇的 `setInterval` +- 渲染那侧用 Ink 已有的 Box + 一个手写的 30 列 wrap,没有引入任何动画库;500ms 一拍是手摇的 `setInterval` - prompt 那侧借用 attachment 体系新增了一个 `companion_intro` 类型,复用 `maybe()` 调度、复用 messages 渲染 - 命令那侧借 `feature('BUDDY')` 和 `require(…)` 的懒解析能力把整子树编译期切除 - 通知那侧借用现成的 `addNotification` 走和版本公告同一个发现通道 -这种"什么都不自创"的克制,是 Buddy 能在一年里被加进、被默认关、被全员擦干净三件事同时成立的根本——因为它没有任何只属于自己的、需要被维护的脚手架。下一次有人想往 PromptInput 边上再塞一个"装饰性、彩蛋性、渠道限定"的东西时,照着 `buddy/` 这六个文件的接法描一遍就够。 +抽出来看,这套实现里有三个模式是任何在"严肃工具"里塞"非严肃功能"的人都可以照搬的。 + +### 模式 1:骨魂分离 — 派生数据不落盘 + +任何带"随机生成 + 个性化展示"的功能都可以问自己一个问题:哪些字段可以从一个稳定种子算回来?哪些必须由人或模型一次性产出、再也回不来? + +Claude Code 把可算回来的全归入 `Bones`、不存;只把不可逆的 `name` 和 `personality` 落盘成 `StoredCompanion`。这样有三个直接收益: + +1. **配置文件不能被用户篡改成不该出现的状态**——`getCompanion()` 的最后一步 `{...stored, ...bones}` 永远会用算出来的骨架盖掉伪造字段。 +2. **加新字段不需要写 migration**——往 `Bones` 里加 `aura` 那天,老用户的配置文件不动,下一次启动直接补上。 +3. **"全服换代"是一行代码改 SALT**——`SALT = 'friend-2026-401'` 改成 `'friend-2027-1'`,所有人下次启动重新孵化,不需要走任何运营后台。 + +适用场景:任何带种子化随机的生成式特性——头像、皮肤、词条、装备、世界种子。 + +### 模式 2:用 attachment 体系挂第三人称声明 — 不动 system prompt + +给大模型加一段"你身边有个 X"的说明,最直觉的写法是改 system prompt。但 system prompt 是个全局共享的资源,加东西就要考虑长期 token、和别的 section 的冲突、prompt cache 边界等等。 + +Buddy 没动 system prompt 一行——它在 `getCompanionIntroAttachment(messages)` 里组一个 `companion_intro` attachment,走 `maybe()` 调度、走 `utils/messages.ts` 的渲染分发,和文件附件、剪贴板附件、todo 附件挤在同一条路上。去重靠扫消息流里的同名 attachment 实现,换 companion 自动重新介绍。整件事没有自己的"框架",只是新增了一个 attachment 类型枚举值。 + +适用场景:任何"按当前会话状态有条件追加一段背景描述给模型"的需求——人格设定、当前任务摘要、用户偏好提示。 + +### 模式 3:编译期门 + 字面量门双层 gating + +`feature('BUDDY')` 是 Bun bundler 在编译期常量折叠的特性 flag,配合 `require()` 的 lazy resolve,可以做到"未开启的特性整张子树在产物里物理消失"。但这只解决了"渠道总开关"的问题。 + +Buddy 在它之上叠了一层 `'external' === 'ant'` 字面量比较——同样是编译期常量条件,但语义不是"整支特性开/关",而是"特定渠道的边门"。两层叠加,渠道矩阵就变成了一张二维表:总开关 × 渠道边门。所有判断都在编译期折叠掉,运行期一行字节码都不留。 + +适用场景:任何需要"同一份源码构建多个渠道版本"的项目——内部版/外部版、付费版/免费版、A/B 测试分支。 + +--- + +## 八、实战示例:照着 Buddy 接入一个"PromptInput 边上的彩蛋装饰" + +假设你想往 PromptInput 边上再塞一个东西——比如"今天的天气小图标"、"当前 git 分支的小旗子"、"待办事项的小计数器"。照着 `buddy/` 这六个文件的接法描一遍: + +1. **配置侧**:在 `utils/config.ts` 的 global config 类型里加两个字段——`{feature}` 存生成后的稳定数据、`{feature}Muted` 是用户的静音开关。能算回来的字段一概不存。 +2. **生成侧**:写一个像 `companion.ts` 那样的纯函数模块——种子化的 PRNG + 一道盐 + 一个轻缓存。所有派生字段在 `get{Feature}()` 里现算现拼。 +3. **渲染侧**:仿 `CompanionSprite.tsx` 写一个 `{Feature}Sprite` 组件,导出一个 `{feature}ReservedColumns(cols, ...)` 函数告诉 PromptInput 自己要占多少列。函数体的前四道闸(feature flag → 是否存在 → 是否静音 → 窄屏阈值)按 §四 那段 9 行的版式抄。 +4. **全屏分支**:如果你的装饰有"可能被 fullscreen 裁掉"的浮动元素(比如气泡、tooltip),仿 `CompanionFloatingBubble` 拆成两个组件,挂到 `FullscreenLayout.bottomFloat` 插槽。 +5. **prompt 侧**(可选):如果你想让大模型知道这个装饰的存在,仿 `getCompanionIntroAttachment` 写一个 `attachment` 返回器,走 `maybe()` 调度、新增一个 attachment 类型枚举值。第三人称写法、防扮演的 prompt 模板照抄 §五。 +6. **入口侧**:仿 `useBuddyNotification.tsx` 写一个 `useEffect`,三道闸 `feature('YOUR_FLAG')` + 状态检查 + 时间窗口;通知用现成的 `addNotification` + 彩虹色 ``。 +7. **编译门**:在 `commands.ts` 里 `const {feature} = feature('YOUR_FLAG') && require('./commands/{feature}/index.js').default`,spread 进 `allCommands`。同时在所有 hot path(PromptInput 解构、`reservedColumns` 第一行)都用 `feature('YOUR_FLAG') ? … : …` 包住,确保未开启时编译产物里物理消失。 + +整套下来你应该不需要新建任何"框架文件"——所有接入点都是 Claude Code 已有的扩展槽。如果你发现自己在写"通用装饰系统抽象类",多半是接错了路:回头照着 `buddy/` 的 6 个文件再描一遍。 + +--- + +## 下一篇预告 + +[第 33 篇:Doctor 屏与 Output Style 体验](./33-Doctor-屏与-Output-Style-体验.md) + +我们将看 Claude Code 怎么用一个"全屏接管 viewport"的特殊布局承载诊断信息,以及 Output Style 系统如何让同一份消息流在不同语境下渲染出截然不同的视觉风格。 + +--- + +*全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)* From 68c027812cdbf9c680e4cff49fd46b10dbc66f42 Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 15:03:39 +0800 Subject: [PATCH 6/8] docs(c29): fix mute scope description + commands.ts wiring example per OC-R round 4 Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index 426a0cd..86db8bc 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -297,7 +297,7 @@ companion?: import('../buddy/types.js').StoredCompanion; companionMuted?: boolean; ``` -`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关。它不删除 companion 本身——孵化记录、名字都还在——只是渲染期把 reserved columns 整条算零。任何相关 UI——PromptInput 那边的 footer 项可见性、气泡显隐、彩虹高亮——都要先过这个静默开关。 +`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关。它不删除 companion 本身——孵化记录、名字都还在——只是渲染期把 reserved columns 整条算零。**已孵化 companion 的渲染路径**——sprite 占位、PromptInput 那边的 footer 项可见性、气泡显隐——都要先过这个静默开关。但**发现入口和 `/buddy` 输入高亮不以 muted 为门**:`useBuddyNotification` 的 teaser 通知(`buddy/useBuddyNotification.tsx:43-66`)只看是否已经孵化和时间窗口,不查 `companionMuted`;`/buddy` 字面的彩虹高亮 `findBuddyTriggerPositions`(`buddy/useBuddyNotification.tsx:79-96`)也只过 `feature('BUDDY')` 这一道闸——muted 用户依然能在输入框里看到 `/buddy` 高亮,因为这是一个**命令名提示**而非 companion 渲染。 ### 4.2 全屏视图的浮动气泡 @@ -489,7 +489,13 @@ Buddy 在它之上叠了一层 `'external' === 'ant'` 字面量比较——同 4. **全屏分支**:如果你的装饰有"可能被 fullscreen 裁掉"的浮动元素(比如气泡、tooltip),仿 `CompanionFloatingBubble` 拆成两个组件,挂到 `FullscreenLayout.bottomFloat` 插槽。 5. **prompt 侧**(可选):如果你想让大模型知道这个装饰的存在,仿 `getCompanionIntroAttachment` 写一个 `attachment` 返回器,走 `maybe()` 调度、新增一个 attachment 类型枚举值。第三人称写法、防扮演的 prompt 模板照抄 §五。 6. **入口侧**:仿 `useBuddyNotification.tsx` 写一个 `useEffect`,三道闸 `feature('YOUR_FLAG')` + 状态检查 + 时间窗口;通知用现成的 `addNotification` + 彩虹色 ``。 -7. **编译门**:在 `commands.ts` 里 `const {feature} = feature('YOUR_FLAG') && require('./commands/{feature}/index.js').default`,spread 进 `allCommands`。同时在所有 hot path(PromptInput 解构、`reservedColumns` 第一行)都用 `feature('YOUR_FLAG') ? … : …` 包住,确保未开启时编译产物里物理消失。 +7. **编译门**:在 `commands.ts` 里照 `commands.ts:118-122` 的形态接入: + ```typescript + const yourFeat = feature('YOUR_FLAG') + ? (require('./commands/your-feat/index.js') as typeof import('./commands/your-feat/index.js')).default + : null + ``` + 再在命令数组里条件 spread(`...(yourFeat ? [yourFeat] : [])`)。同时在所有 hot path(PromptInput 解构、`reservedColumns` 第一行)都用 `feature('YOUR_FLAG') ? … : …` 包住,确保未开启时 dead-code 折叠掉,编译产物里物理消失。 整套下来你应该不需要新建任何"框架文件"——所有接入点都是 Claude Code 已有的扩展槽。如果你发现自己在写"通用装饰系统抽象类",多半是接错了路:回头照着 `buddy/` 的 6 个文件再描一遍。 From c32a04b4a33dcfdfcc1e732ebe57d2b664358f6e Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 15:13:54 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(c29):=20=E9=80=9A=E4=BF=97=E5=8C=96?= =?UTF-8?q?=E6=96=87=E9=A3=8E=E9=87=8D=E5=86=99=EF=BC=8C=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20Buddy=20=E5=AE=A0=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 章节内 H1 标题从「Buddy 人格」改为「Buddy 宠物」(按尧哥反馈) - 整体重写为更通俗易懂、深入浅出的叙述: - 开篇五个问题清单口语化、加场景化引子 - 每节先抛问题/场景再上代码,避免直接进结构罗列 - 拆开 §三 中过密的源码引用,散文 + 行内 code 并重 - §四/§六 加入「为什么」式连接句,弱化「四道闸」纯列表感 - §五 prompt 节叙事化,强调 system prompt 与 attachment 体系的差别 - 保留全部源码事实点与 file:line 锚点不变 - 文件名维持 docs/32-Buddy-人格.md(spec §9.3.1 已登记,不动) CI 本地全绿:C-3 22.9% / C-4 / no-fuzzy / no-spec-jargon / no-frontmatter / source-commits 通过。 Co-authored-by: multica-agent --- "docs/32-Buddy-\344\272\272\346\240\274.md" | 132 ++++++++++---------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\344\272\272\346\240\274.md" index 86db8bc..0773365 100644 --- "a/docs/32-Buddy-\344\272\272\346\240\274.md" +++ "b/docs/32-Buddy-\344\272\272\346\240\274.md" @@ -1,35 +1,35 @@ -# 第 32 篇:Buddy 人格 — 在 PromptInput 边上养一只随机生成的小动物 +# 第 32 篇:Buddy 宠物 — 在 PromptInput 边上养一只随机生成的小动物 -> 本篇是《深入 Claude Code 源码》系列第 32 篇。我们将剖析 `buddy/` 目录下的 6 个源码文件,以及它们在 REPL、PromptInput、配置、附件、消息流里的接入点,看 Claude Code 如何在一个本来全是代码与文字的终端里,挤出一只随机生成、会眨眼、会被摸、会冒话框、还会被悄悄藏掉的小动物。 +> 本篇是《深入 Claude Code 源码》系列第 32 篇。我们要看的,是 `buddy/` 目录下的 6 个源码文件,以及它们怎么悄悄接进 REPL、PromptInput、配置、附件、消息流——最后在一个本来全是黑底白字的终端里,挤出一只会眨眼、会冒话框、会被你按 Enter 摸一下脑袋的小动物。 -## 为什么 Buddy 值得单独一篇? +## 为什么单独写一篇讲一只小动物? -终端工具向来是"功能至上"的世界——多一行像素都要解释为什么不让位给输出。可这一次 Claude Code 偏偏在 PromptInput 框右边塞进了一只小生物。它会随机出一个名字、会在你打字时眨眼、把光标停在它上面按回车就会出爱心,它甚至会冒出一个圆角小气泡评论你刚才的对话。 +终端是个寸土寸金的世界。每多画一行像素,都要回答一句"凭什么不让位给输出"。可这一回,Claude Code 偏偏在输入框右边塞进了一只小宠物:它有名字、会偶尔眨眼、按回车会冒爱心,对话刚结束还会弹个圆角小气泡评论两句。 -这件事不是简单地"画一只 ASCII 小动物"。它要回答的问题相当多: +听起来像个一百行就能搞定的小玩意,但真要往一个严肃工具里塞一只宠物,避不开下面这五个问题: -1. **同一个人每次启动看到的是不是同一只?** 如果每次都重新随机,那就是个噱头;如果存满了配置文件,那一旦清掉 `~/.claude.json` 就再也找不回原来那只。 -2. **它在终端窄到 80 列时怎么办?** 把一只 12 列宽的小动物硬塞到一个本来就在挤滚动条的窗口里,是体验灾难。 -3. **大模型会不会以为自己就是这只小动物?** 系统提示里突然出现"你叫 Sproink,是只 duck"——模型很可能下一句就开始扮演鸭子。 -4. **没开 Buddy 的人,构建出来的二进制里能不能完全没有它的代码?** 一个"宠物"功能进了 critical path 是说不过去的。 -5. **怎么让人发现这个隐藏功能而不打扰那些不想要它的人?** 弹一个明黄色公告会被骂;藏到 `--help` 里又没人看。 +1. **同一个人每次启动看到的会是同一只吗?** 每次重新随机就成了一次性噱头;全存配置文件,又怕用户清掉 `~/.claude.json` 那只就永别了。 +2. **终端只有 80 列时怎么办?** 12 列宽的小动物硬塞进一个已经在挤滚动条的窗口,输入框直接崩。 +3. **大模型会不会被带跑,自己开始扮演这只小动物?** 系统提示里冒出"你是只叫 Sproink 的鸭子",下一句它八成就 `*quack*` 了。 +4. **没开 Buddy 的渠道,能不能让这一整摊代码彻底不进二进制?** 一个彩蛋功能要是占了 critical path,是说不过去的。 +5. **怎么让人发现这个隐藏功能、又不打扰那些不想要它的人?** 弹个明黄色公告会被骂,藏到 `--help` 里又没人看。 -Claude Code 的答案是:**把"骨"和"魂"切开存,把渲染、出现、声明、命令四个面分别接进现成的子系统,再用两道编译期 + 一道运行期门把它整体藏在大多数构建之外**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答: +Claude Code 的答案可以一句话概括:**把"骨"和"魂"切开存,把渲染、出现、声明、命令、入口五件事分别接进现成的子系统,再用两道编译期门加一道运行期门,把整个 Buddy 在大多数构建里藏到一字节都不剩**。`buddy/` 目录里六个文件加起来千余行,刚好对应这五个问题一一作答: - `companion.ts` 管"骨"和"魂"的拆分与生成 -- `types.ts` 管物种与稀有度词典 +- `types.ts` 管物种与稀有度的小词典 - `sprites.ts` 管 18 个物种的 ASCII 像素画 - `CompanionSprite.tsx` 管帧动画与气泡 -- `prompt.ts` 管对大模型的"第三人称声明" +- `prompt.ts` 管给大模型的"第三人称介绍" - `useBuddyNotification.tsx` 管短窗口里的彩虹色入口提示 -本篇按这个顺序拆:先看"骨与魂"如何被切开(§一),再看 18 物种的字典是怎么躲开打包扫描的(§二),像素画与帧动画如何在 500ms 一拍的节奏下完成眨眼与摸头(§三),窄屏退化与全屏浮动气泡的两种排版(§四),怎么用第三人称介绍把小动物钉在"旁观者"而不是"扮演者"位置(§五),最后看 `/buddy` 入口、彩虹高亮、footer 集成与两道编译门如何把它整体藏在大多数构建之外(§六)。 +本篇也按这个顺序拆:先看"骨与魂"怎么切(§一)、18 个物种的名字怎么躲过打包扫描(§二)、500ms 一拍的眨眼摸头怎么转起来(§三)、窄屏和全屏两种排版怎么各让一步(§四)、第三人称介绍怎么把小动物钉在"旁观者"位置(§五)、最后看 `/buddy` 入口、彩虹高亮、footer 和两道编译门如何把 Buddy 整体藏起来(§六)。后两节是可以照搬到自己项目里的设计模式(§七)和一份"想在 PromptInput 边上塞个小装饰" walkthrough(§八)。 --- ## 一、骨与魂:一半算出来,一半存下来 -`Companion` 这个类型在 `buddy/types.ts:100-124` 里被切成了两半: +打开 `buddy/types.ts:100-124`,能看到 `Companion` 这个类型被一刀切成两半: ```typescript // buddy/types.ts:100-124 @@ -51,9 +51,9 @@ export type Companion = CompanionBones & CompanionSoul & { hatchedAt: number }; export type StoredCompanion = CompanionSoul & { hatchedAt: number }; ``` -`Bones` 是"骨"——稀有度、物种、眼神、帽子、是否闪光、五维属性,全部是可以从一个种子算回来的派生数据。`Soul` 是"魂"——只有两样:模型给它起的名字,和模型生成的人格描述。`hatchedAt` 这个时间戳是 `Companion` 和 `StoredCompanion` 的外层字段,不在 Soul 里;而落盘的 `StoredCompanion` 干脆把骨头一字节都不存。 +`Bones`(骨)是稀有度、物种、眼神、帽子、是否闪光、五维属性——这些字段都能从一颗稳定种子算回来,属于"派生数据"。`Soul`(魂)只有两样:模型给它起的名字,和模型生成的人格描述——这俩没法回算,只能存。`hatchedAt`(孵化时间戳)是外层字段,不在 Soul 里。落盘的 `StoredCompanion` 干脆把骨头一字节都不存。 -为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就明白了: +为什么这样切?看 `companion.ts` 里 `getCompanion()` 的最后一步就懂了: ```typescript // buddy/companion.ts:127-133 @@ -65,13 +65,13 @@ export function getCompanion(): Companion | undefined { } ``` -`stored` 先铺,`bones` 后铺,意味着每次读出来的"骨"都是临时算出来的,不是反序列化出来的。这件事有两个直接好处。 +注意展开顺序:`stored` 先铺、`bones` 后铺。每次读出来的"骨"都是当场算的,不是从硬盘反序列化的。这件事带来两个直接好处。 -第一个好处是 **配置文件改不动骨架**。源码里那句注释写得很直白——"editing config.companion can't fake a rarity"。用户翻开 `~/.claude.json` 把 `rarity` 改成 `legendary` 也没用,下次启动 `bones` 会盖掉这个字段。 +**好处一:配置文件改不动骨架。** 源码里那行注释说得很直白——"editing config.companion can't fake a rarity"。用户翻开 `~/.claude.json` 把 `rarity` 改成 `legendary` 也没用,下次启动 `bones` 会盖掉这个字段。 -第二个好处是 **加字段不用迁移**。假如哪天往 `Bones` 里再补一个 `aura: Color`,老用户的配置文件不需要 migration,下次启动直接补上。 +**好处二:加字段不用写迁移。** 假如哪天往 `Bones` 里再加一个 `aura: Color`,老用户的配置文件根本不需要 migration,下次启动直接补上。 -骨架的种子从哪里来?`companionUserId()` 在 `buddy/companion.ts:119-122` 给出了三档回退: +骨架的种子从哪来?`companionUserId()` 在 `buddy/companion.ts:119-122` 给了三档回退: ```typescript // buddy/companion.ts:119-122 @@ -80,7 +80,7 @@ function companionUserId(): string { } ``` -OAuth 账号 UUID 优先,回退到本机 `userID`,再回退到字符串 `'anon'`——一个稳定且对同一台机器、同一个账号、同一只手指头都可重现的标识。 +OAuth 账号 UUID 最优先,没有就回退到本机 ID,再没有就回退到字符串 `'anon'`——总之是一个"对同一台机器、同一个账号、同一只手指头"都可重现的稳定标识。 种子化的伪随机数走了一段教科书级的 Mulberry32: @@ -98,7 +98,7 @@ function mulberry32(seed: number) { } ``` -状态只有 32 bit,函数体只有四行算术,最后把 32 位整数除以 `4294967296` 归一到 `[0,1)`。配套的 `hashString` 在 `buddy/companion.ts:27-37` 优先用 Bun 自带的非加密哈希,退回到 FNV-1a 五行手写实现。这两个函数都满足"相同输入永远相同输出",是整套确定性派生的地基。 +整个 PRNG 状态只有 32 bit、函数体只有四行算术,最后把 32 位整数除以 `4294967296` 归一到 `[0,1)`。配套的 `hashString` 在 `buddy/companion.ts:27-37` 优先用 Bun 自带的非加密哈希,没有就退回到 FNV-1a 五行手写实现。两个函数都满足"相同输入永远相同输出",是整套确定性派生的地基。 种子里还加了一道"咸": @@ -107,15 +107,15 @@ function mulberry32(seed: number) { const SALT = 'friend-2026-401'; ``` -`roll(userId)` 实际用的种子是 `hashString(userId + SALT)`。这道咸的作用很直接:用户的 UUID 是稳定标识,谁都没必要把它和具体哪种小动物绑死。咸值一改,全员重新孵化,相当于一次"全服换代"的开关,藏在源码里、不依赖配置。 +`roll(userId)` 实际用的种子是 `hashString(userId + SALT)`。这道咸是干嘛的?用户的 UUID 是稳定标识,把它和"具体哪只动物"绑死并不合适。咸值一改,全员重新孵化——相当于一次"全服换代"的开关,藏在源码里,不依赖任何运营后台。 -最后是一个轻量缓存(`buddy/companion.ts:107-117`):单槽位记住最近一次的 `userId → Bones`。运行期里 `userId` 不会变,但 `getCompanion()` 会被 500ms 一拍的渲染器频繁调用,缓存避免每帧都重算 5 次随机数。同文件还导出一个不走缓存的 `rollWithSeed(seed)`,专供调试和文档场景做"我给你一个固定种子,你给我看看出什么"。 +最后是一个轻量缓存(`buddy/companion.ts:107-117`):单槽位记住最近一次的 `userId → Bones`。运行期里 `userId` 不会变,但 `getCompanion()` 会被 500ms 一拍的渲染器频繁调用,缓存避免每帧都重算 5 次随机数。同文件里还导出一个不走缓存的 `rollWithSeed(seed)`,专门留给调试和文档场景做"我给你一个固定种子,你给我看看出什么"。 --- ## 二、十八种小动物:藏在 `String.fromCharCode` 后面 -`types.ts` 里先有 18 个具名常量导出: +`types.ts` 先用 18 个具名常量把物种名字一个个拼出来: ```typescript // buddy/types.ts:17-52(节选) @@ -139,13 +139,13 @@ export const SPECIES = [ export type Species = (typeof SPECIES)[number]; ``` -同文件还有两张表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★` 字符,渲染时直接拼在名字旁边。 +同文件还有两张配套表:`RARITY_WEIGHTS` 给五档稀有度分别赋 60/25/10/4/1,加起来正好 100;`RARITY_STARS` 给同样五档配上 1 到 5 个 `★`,渲染时直接拼在名字旁边。 -这种"先用 `String.fromCharCode` 把名字拼出来再放进数组"的写法看起来很怪——直接写 `'duck'` 不香吗?答案在仓库根目录的字符串扫描脚本里。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary`、`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋特性,绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸。把名字写成字符码常量数组,编译期 TypeScript 不动它,运行期 V8 会把它拼起来,扫描器看到的只是一串数字字面量,认不出来。 +读到这里你会想问:直接写 `'duck'` 不香吗,为什么非要拿 `String.fromCharCode` 一个个字符码拼?答案在仓库根目录的字符串扫描脚本里。打包流水线里有一条 canary:扫描 bundle 产物,凡是出现一组预定义的"内部代号"明文(`legendary`、`Sproink` 之类)就 fail。Buddy 是个面向特定渠道发布的彩蛋,**绝大多数构建里它需要 dead code elimination 干净到不剩字符串残骸**。把名字写成字符码常量数组,编译期 TypeScript 不动它、运行期 V8 会把它拼起来、扫描器看到的只是一串数字字面量,完全认不出来。 ### 2.1 五档稀有度的累积权重 -稀有度的权重表 60/25/10/4/1 加起来是 100,刚好不是巧合。`rollRarity` 在 `[0,100)` 区间里掷一次随机数: +权重表 60/25/10/4/1 加起来是 100,刚好不是巧合。`rollRarity` 在 `[0,100)` 区间里掷一次随机数: ```typescript // buddy/companion.ts:43-51 @@ -160,7 +160,7 @@ function rollRarity(rng: () => number): Rarity { } ``` -累积扫一遍 `RARITY_WEIGHTS`,命中第一个区间就 `return`;最后兜底返回 `'common'` 防止浮点累积误差。 +累积扫一遍权重表,命中第一个区间就 `return`;最后兜底返回 `'common'` 防止浮点累积误差。 ### 2.2 属性算法:peak / dump / 普通三分支 @@ -177,37 +177,35 @@ const RARITY_FLOOR: Record = { }; ``` -`RARITY_FLOOR` 给五档稀有度分别定下 5 / 15 / 25 / 35 / 50 的基线下限。它的用途在 `rollStats` 里: +`RARITY_FLOOR` 给五档稀有度分别定下 5 / 15 / 25 / 35 / 50 的基线下限。这个数字在 `rollStats` 里用得很巧: -`rollStats` 在 `buddy/companion.ts:62-82` 里把 `RARITY_FLOOR[rarity]` 取出来作基线,先 `Math.floor(rng() * STAT_NAMES.length)` 掷一个 `peak`、再用同样的方式掷一个 `dump`,用 `while (dump === peak)` 重掷直到两者不撞,然后遍历五项写值——`peak` 项是 `Math.min(100, floor + 50 + rng*30)`、`dump` 项是 `Math.max(1, floor - 10 + rng*15)`、其余项是 `floor + rng*40`。 +`rollStats` 在 `buddy/companion.ts:62-82` 里先把 `RARITY_FLOOR[rarity]` 取出作基线,再各掷一次 `peak`(强项)和 `dump`(弱项)的下标,用 `while (dump === peak)` 重掷直到两者不撞,然后遍历五项写值——`peak` 项是 `Math.min(100, floor + 50 + rng*30)`、`dump` 项是 `Math.max(1, floor - 10 + rng*15)`、其余项是 `floor + rng*40`。 -五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`,一个很 self-aware 的清单。地板随稀有度递增,传说级最低 50,所以 legendary 那只看一眼属性条就跟普通一只一望可辨。`while` 重掷而不是偏移取模,是为了避免引入分布偏差。 +五维属性是 `DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK`,一个很 self-aware 的清单。地板随稀有度递增、传说级最低 50,所以 legendary 那只看一眼属性条就跟普通一只一望可辨。这里用 `while` 重掷而不是偏移取模,是为了避免引入分布偏差。 ### 2.3 帽子是稀有度的伴生物 -`rollFrom(rng)` 在 `buddy/companion.ts:91-102` 把这一切串起来:先 `rollRarity` 决定稀有度档位,然后从 `SPECIES`、`EYES` 各掷一个下标拿物种和眼神;帽子的有无完全由稀有度档位决定——`common` 永远是字符串 `'none'`,非 common 才在 `HATS` 里掷一个下标;接下来 `shiny = rng() < 0.01` 是一道独立的 1% 闪光判定;最后 `rollStats(rng, rarity)` 把五维填齐。 +`rollFrom(rng)` 在 `buddy/companion.ts:91-102` 把这一切串起来:先 `rollRarity` 决定稀有度档位,再从 `SPECIES`、`EYES` 各掷一个下标拿物种和眼神;帽子的有无完全由稀有度档位决定——`common` 永远是字符串 `'none'`,非 common 才在 `HATS` 里掷一个下标;接下来 `shiny = rng() < 0.01` 是一道独立的 1% 闪光判定;最后 `rollStats(rng, rarity)` 把五维填齐。 -帽子那一档把"common 不戴帽"硬编成 ternary 分支而不是概率:注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`。这是一条 hard branch 而非概率门——没有"18% 概率给帽子"这种事。`shiny` 是 1% 的独立概率,五维属性最后再掷一遍收尾。 +帽子这一档是 hard branch(硬分支),不是概率门。注意 `HATS` 数组本身把 `'none'` 也算成一个枚举值,所以非 common 也有八分之一概率掷到 `'none'`——没有"18% 概率给戴帽子"这种事。`shiny` 是 1% 的独立概率,五维属性最后再掷一遍收尾。 -帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸,渲染时需要避开主体本身就有的纹理,所以它和物种像素画是要做空间互让的,这件事 §三 会接着看。 +帽子表里包括 `tinyduck` 这种站在主体头顶上的小附庸。它在渲染时需要避开主体本身就有的纹理,所以帽子和物种像素画是要做空间互让的——这件事 §三 会接着看。 --- ## 三、像素画、500ms 一拍、眨眼与摸头 -`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串,每行宽 12 列。眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eye` 替换成对应字符(圆点、星号、闭眼弧线之类)。 +`sprites.ts` 是一个把 18 个物种 × 3 帧 × 5 行 × 12 列全部硬编码进去的字典表。每个物种是一个 `string[][]`,外层 3 帧、内层 5 行字符串、每行宽 12 列。眼睛位置统一用 `{E}` 这个占位符标出来——因为眼睛是骨架字段,不能硬编进像素表,要在渲染时按 `Bones.eye` 替换成对应字符(圆点、星号、闭眼弧线之类)。 ### 3.1 帽子布置的三种姿态 -`renderSprite(bones, frame)` 做替换 + 帽子布置: +`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 只做三件事:先 `raw.map(line => line.replaceAll('{E}', bones.eye))` 把眼睛占位符替换成对应字符;然后 `if (bones.hat !== 'none' && !lines[0]!.trim())` 时把 `HAT_LINES[bones.hat]` 写进 `lines[0]`;最后 `if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim()))` 时 `lines.shift()` 节高一行。 -`renderSprite(bones, frame)` 在 `buddy/sprites.ts:454-468` 里只做三件事:先 `raw.map(line => line.replaceAll('{E}', bones.eye))` 把眼睛占位符替换成对应字符;然后 `if (bones.hat !== 'none' && !lines[0]!.trim())` 时把 `HAT_LINES[bones.hat]` 写进 `lines[0]`;最后 `if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim()))` 时 `lines.shift()` 节高一行。 +短短 14 行里塞了三个判断,每一个都对应一种"帽子布置姿态": -这段 14 行的小函数里其实塞了三个判断: - -1. **眼睛替换**:先 `map(line => line.replaceAll('{E}', bones.eye))`,把所有 `{E}` 换成对应眼神字符。 -2. **帽子布置**:`bones.hat !== 'none'` 时,只在第 0 行本来 `trim()` 为空的情况下才把 `HAT_LINES[bones.hat]` 写进 `lines[0]`。第 0 行被 smoke / antenna 之类的纹理占用时,源码直接放弃戴帽子,不会 unshift 一行把动物拔高。 -3. **节高一行**:如果最终 `lines[0]` 仍是空白,并且该物种的 `frames.every(f => !f[0]!.trim())` 都是空白,就把那行 `shift()` 掉省一行空间。源码注释把这个 `every` 判断的理由写得很清楚——"Only safe when ALL frames have blank line 0; otherwise heights oscillate",是为了避免不同帧之间高度抖动。 +1. **眼睛替换** — 永远第一步,把所有 `{E}` 换成对应眼神字符。 +2. **戴帽子** — 只在第 0 行本来 `trim()` 为空的情况下才把帽子写进去。第 0 行被 smoke / antenna 之类的纹理占用时,**源码直接放弃戴帽子**,不会"unshift 一行把动物拔高"。 +3. **节高一行** — 如果最终 `lines[0]` 仍是空白,并且该物种的 `frames.every(f => !f[0]!.trim())` 都是空白,就把那行 `shift()` 掉省一行空间。源码注释把这个 `every` 判断的理由写得很清楚——"Only safe when ALL frames have blank line 0; otherwise heights oscillate",目的是避免不同帧之间高度跳来跳去。 这些细节决定了每帧渲出来的 ASCII 在垂直方向能不能精确占用预期格子数,而正确的格子数对接下来 PromptInput 那段宽度结算(§四)至关重要。 @@ -224,7 +222,7 @@ const PET_BURST_MS = 2500; const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; ``` -`IDLE_SEQUENCE` 是这整篇里最让人愿意盯着看的一段。它是长度 15 的循环序列,写明了"小动物在没事干时给你看什么":大部分时候是帧 0(基础站姿),偶尔切到帧 1 和帧 2,中间穿插一个 `-1` 代表"眨眼"——渲染时遇到 `-1` 不取帧、改画一行 `^_^` 这种闭眼脸覆盖在原本眼睛行上。15 拍正好 7.5 秒一个循环,恰好长到不会让人觉得机械、短到不会让人怀疑它死了。 +`IDLE_SEQUENCE` 是这整篇里最让人愿意盯着看的一段。它是长度 15 的循环序列,写明了"小动物在没事干时给你看什么":大部分时候是帧 0(基础站姿),偶尔切到帧 1 和帧 2,中间穿插一个 `-1` 代表"眨眼"——渲染时遇到 `-1` 不取帧、改画一行 `^_^` 这种闭眼脸覆盖在原本的眼睛行上。15 拍正好 7.5 秒一个循环,长到不让人觉得机械、短到不让人怀疑它死了。 ### 3.3 摸头与气泡的状态流 @@ -253,9 +251,9 @@ const PET_HEARTS = [ ]; ``` -在 `PET_BURST_MS` 也就是 2.5 秒之内,每拍换一帧爱心、压在小动物正上方,整体看起来像几颗心从头顶慢慢飘起、散开、消失。 +在 `PET_BURST_MS` 也就是 2.5 秒内,每拍换一帧爱心、压在小动物正上方,整体看起来像几颗心从头顶慢慢飘起、散开、消失。 -气泡用的是一个手写的 React 组件 `SpeechBubble`(`buddy/CompanionSprite.tsx:43-151`)。文本进来先过一道 30 列的贪心折行——按空白分词、逐词累加、超过 30 就把当前行 `push` 进 `lines`、当前词作为下一行的第一个词。折好之后用 Ink 的 `Box border` 包一圈,再按 `tail` 参数把一个尾巴字符定位在边框的对应位置上:`'right' → '◀'`、`'down' → '▼'`,整体看起来像漫画里那种"指向小动物头顶"的对话框。`fading` 跟着 `BUBBLE_SHOW - tick < FADE_WINDOW` 走,最后 3 秒整段套 `dimColor`,告诉读者"再不看就消失了"。 +气泡用的是一个手写的 React 组件 `SpeechBubble`(`buddy/CompanionSprite.tsx:43-151`)。文本进来先过一道 30 列的贪心折行——按空白分词、逐词累加、超过 30 就把当前行 `push` 进 `lines`、当前词作为下一行的第一个词。折好之后用 Ink 的 `Box border` 包一圈,再按 `tail` 参数把一个尾巴字符定位在边框的对应位置上:`'right' → '◀'`、`'down' → '▼'`,整体看起来就像漫画里那种"指向小动物头顶"的对话框。`fading` 跟着 `BUBBLE_SHOW - tick < FADE_WINDOW` 走,最后 3 秒整段套 `dimColor`,告诉读者"再不看就消失了"。 30 列是这只圆角气泡的内部最大宽度——加上两侧各 1 列边框 + 内边距,整体占 36 列。这个 36 等下会以常量形式出现在宽度结算里。 @@ -278,7 +276,7 @@ export function companionReservedColumns(terminalColumns: number, speaking: bool } ``` -四道闸顺序很关键。 +四道闸顺序很关键,每一道都对应一种"我不该占任何列"的场景: - 第一道 `feature('BUDDY')` 在最前。构建期把整支 Buddy 整体擦掉时,`companionReservedColumns` 也直接 return 0,PromptInput 那边算宽度不会引入对 `getCompanion` / `getGlobalConfig` 的运行期调用。 - 第二道 `getCompanion()`:没孵化过就没东西可占列。 @@ -297,11 +295,13 @@ companion?: import('../buddy/types.js').StoredCompanion; companionMuted?: boolean; ``` -`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关。它不删除 companion 本身——孵化记录、名字都还在——只是渲染期把 reserved columns 整条算零。**已孵化 companion 的渲染路径**——sprite 占位、PromptInput 那边的 footer 项可见性、气泡显隐——都要先过这个静默开关。但**发现入口和 `/buddy` 输入高亮不以 muted 为门**:`useBuddyNotification` 的 teaser 通知(`buddy/useBuddyNotification.tsx:43-66`)只看是否已经孵化和时间窗口,不查 `companionMuted`;`/buddy` 字面的彩虹高亮 `findBuddyTriggerPositions`(`buddy/useBuddyNotification.tsx:79-96`)也只过 `feature('BUDDY')` 这一道闸——muted 用户依然能在输入框里看到 `/buddy` 高亮,因为这是一个**命令名提示**而非 companion 渲染。 +`companionMuted: true` 是用户的"我知道有这个东西,但请你不要再占我屏幕"开关。它**不**删除 companion 本身——孵化记录、名字都还在——只是渲染期把 reserved columns 整条算零。 + +这里有一处很容易写错的细节:**已孵化 companion 的渲染路径**(sprite 占位、footer 项可见性、气泡显隐)都要先过这个静默开关;但**发现入口和 `/buddy` 输入高亮不以 muted 为门**——`useBuddyNotification` 的 teaser 通知(`buddy/useBuddyNotification.tsx:43-66`)只看是否已经孵化和时间窗口、不查 `companionMuted`;`/buddy` 字面的彩虹高亮 `findBuddyTriggerPositions`(`buddy/useBuddyNotification.tsx:79-96`)也只过 `feature('BUDDY')` 这一道闸。muted 用户依然能在输入框里看到 `/buddy` 高亮,因为这是一个**命令名提示**,跟 companion 渲染是两码事。 ### 4.2 全屏视图的浮动气泡 -第二个分歧在全屏视图。Claude Code 在某些屏比如长输出回放、Doctor 屏会切到一个把整个 viewport 接管的 `FullscreenLayout`,外层 box 设了 `overflowY: 'hidden'`。这种情况下小动物本体还是要画在原位,但气泡如果跟着画就会被裁掉一半。 +第二个分歧在全屏视图。Claude Code 在某些屏(比如长输出回放、Doctor 屏)会切到一个把整个 viewport 接管的 `FullscreenLayout`,外层 box 设了 `overflowY: 'hidden'`。这种情况下小动物本体还是要画在原位,但气泡如果跟着画就会被裁掉一半。 解决办法是把气泡单独拆成 `CompanionFloatingBubble` 组件,挂进 `FullscreenLayout.bottomFloat` 这个专门预留的"逃出 overflow 裁切"的插槽: @@ -323,15 +323,13 @@ REPL 还做了一件细节:滚动列表往上滚时立刻把 `companionReactio ## 五、第三人称介绍:不让模型代入这只小动物 -把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a duck named Sproink",模型立刻开始 `*quack*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 里这段刻意写成第三人称: +把 Buddy 接进 prompt 这件事最容易翻车的环节是:你给系统提示加一段"You are a duck named Sproink",模型立刻开始 `*quack*` 全文,把整段对话毁掉。`buddy/prompt.ts:7-13` 这段刻意写成第三人称: -这段 prompt 文本只有两段(`buddy/prompt.ts:7-13`)。第一段是 `A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.`——反复按住"你不是它"这个键。第二段处理"用户直接 by-name 点名 companion 时模型该怎么办":要求模型回应一行以内,不要解释"我不是 X"(用户知道),也不要替 X 编台词(气泡会处理)。这两段加起来同时圈住了两种最常见的漂移:扮演 companion、和无视 companion 抢话。 +它只有两段。第一段是 `A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.`——反复按住"你不是它"这个键。第二段处理"用户直接 by-name 点名 companion 时模型该怎么办":要求模型回应一行以内,不要解释"我不是 X"(用户知道),也不要替 X 编台词(气泡会处理)。这两段加起来同时圈住了两种最常见的漂移:扮演 companion、和无视 companion 抢话。 -### 5.1 走 attachment 体系,不走 system prompt 拼接 +### 5.1 走 attachment 体系,不动 system prompt -这段文本通过 `getCompanionIntroAttachment(messages)` 包成一个 attachment 注入消息流: - -`getCompanionIntroAttachment(messages)` 的函数签名是 `(messages: Message[]) => Attachment[]`——返回的是数组,不是 `Attachment | null`。函数体(`buddy/prompt.ts:15-36`)先过三道前置闸:`!feature('BUDDY')`、`!getCompanion()`、`getGlobalConfig().companionMuted` 任一为真就返回 `[]`;然后双层 for 扫每条消息的 `attachments`,看到 `att.type === 'companion_intro' && att.name === companion.name` 就返回 `[]`;全过则返回单元素数组 `[{ type: 'companion_intro', name, species }]`。 +这段文本通过 `getCompanionIntroAttachment(messages)` 包成一个 attachment 注入消息流。函数签名是 `(messages: Message[]) => Attachment[]`——返回的是数组,不是 `Attachment | null`。函数体在 `buddy/prompt.ts:15-36`:先过三道前置闸 `!feature('BUDDY')` / `!getCompanion()` / `getGlobalConfig().companionMuted`,任一为真就返回 `[]`;然后双层 for 扫每条消息的 `attachments`,看到 `att.type === 'companion_intro' && att.name === companion.name` 就返回 `[]`;全过则返回单元素数组 `[{ type: 'companion_intro', name, species }]`。 去重那一步不是按 attachment 类型粗筛,而是逐条扫消息流匹配同名 `companion_intro`。这意味着如果用户换了一只 companion,新的 name 不同,旧的 intro 不算数,新的 intro 还是会注入一次。 @@ -351,9 +349,7 @@ case 'companion_intro': ### 6.1 用本地日期开一道时间门 -发现入口的设计在 `useBuddyNotification.tsx`: - -`buddy/useBuddyNotification.tsx:12-21` 给出两个判断函数 `isBuddyTeaserWindow()` 和 `isBuddyLive()`,它们都先有一道 `if ('external' === 'ant') return true` 的字面量比较,然后用 `new Date()` 拿到当前时间,分别比较年月日。`isBuddyTeaserWindow` 返回 `d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7`——2026 年 4 月 1 日到 7 日;`isBuddyLive` 返回 `d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)`——2026 年 4 月及之后。 +发现入口的设计在 `useBuddyNotification.tsx`。`buddy/useBuddyNotification.tsx:12-21` 给出两个判断函数 `isBuddyTeaserWindow()` 和 `isBuddyLive()`,它们都先有一道 `if ('external' === 'ant') return true` 的字面量比较,然后用 `new Date()` 拿到当前时间,分别比较年月日。`isBuddyTeaserWindow` 返回 `d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7`——2026 年 4 月 1 日到 7 日;`isBuddyLive` 返回 `d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)`——2026 年 4 月及之后。 两个判断都走 **本地日期**——`getFullYear() / getMonth() / getDate()`,不是 `getUTC*`。源码注释里把理由写明白了: @@ -383,7 +379,7 @@ useEffect(() => { }, [addNotification, removeNotification]); ``` -注意源码这里 **只查 `config.companion` 是否已经孵化、不查 `companionMuted`**——发现入口的弹出条件是"还没养过",而不是"用户没把它静音",毕竟没养过就没什么可静音的。三道闸全过则 `addNotification` 一条通知:主体就是彩虹色四字 `/buddy`,是按字符逐个 `getRainbowColor(i)` 染色再拼成一段 ``,没有更长的文案。整段 effect 返回一个 cleanup 函数 `removeNotification('buddy-teaser')`。 +注意源码这里 **只查 `config.companion` 是否已经孵化、不查 `companionMuted`**——发现入口的弹出条件是"还没养过",而不是"用户没把它静音",毕竟没养过就没什么可静音的。三道闸全过则 `addNotification` 一条通知:主体就是彩虹色四字 `/buddy`,按字符逐个 `getRainbowColor(i)` 染色再拼成一段 ``,没有更长的文案。整段 effect 返回一个 cleanup 函数 `removeNotification('buddy-teaser')`。 三道闸顺序同样关键。`feature('BUDDY')` 在最前——构建时它返回常量 `false` 时整段 `useEffect` 在产物里被整体擦掉;窗口与已孵化状态过滤运行期人群。彩虹色用 `getRainbowColor` 把字符串逐字符按色环上色,是 Claude Code 内已经用在新版本公告里的同一组工具。 @@ -403,7 +399,7 @@ const { const companionFooterVisible = !!_companion && !companionMuted; ``` -这里读的是 `getGlobalConfig()` 里已经存好的 `companion`,不是再调一次 `getCompanion()` 去重算。footer 的可见性只关心配置层面"这只 companion 有没有被孵化过 + 用户没把它静音",不需要再走一遍 `companion.ts` 那个带缓存的随机滚算。 +这里读的是 `getGlobalConfig()` 里已经存好的 `companion`,**不是**再调一次 `getCompanion()` 去重算。footer 的可见性只关心配置层面"这只 companion 有没有被孵化过 + 用户没把它静音",不需要再走一遍 `companion.ts` 那个带缓存的随机滚算。 同样地,整个解构表达式被 `feature('BUDDY') ?` 包住。构建期 Buddy 被擦掉时,右侧的占位对象让 `_companion` 和 `companionMuted` 都解构成 `undefined`,`companionFooterVisible` 恒为 `false`,footer 那一项在编译产物里直接消失。 @@ -411,11 +407,11 @@ const companionFooterVisible = !!_companion && !companionMuted; ### 6.4 `/buddy` 触发位置的彩虹高亮 -`/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮: +`/buddy` 在输入框里被键入时,PromptInput 用一段 `findBuddyTriggerPositions` 把所有 `/buddy\b` 的位置找出来,叠一层彩虹色高亮。 -函数体(`buddy/useBuddyNotification.tsx:79-97`)就是常规的 `while ((m = re.exec(text)) !== null)` 循环:先过一道 `feature('BUDDY')` 闸,再用 `/\/buddy\b/g` 在文本里滚一遍,把每次匹配的 `{ start: m.index, end: m.index + m[0].length }` push 进一个数组返回。 +函数体在 `buddy/useBuddyNotification.tsx:79-97`,是常规的 `while ((m = re.exec(text)) !== null)` 循环:先过一道 `feature('BUDDY')` 闸,再用 `/\/buddy\b/g` 在文本里滚一遍,把每次匹配的 `{ start: m.index, end: m.index + m[0].length }` push 进一个数组返回。 -这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去。返回的是一组 `{ start, end }` 区间对象,不是 `[number, number]` 元组。函数没有内部状态,便于单测。 +这一层视觉提示纯靠 PromptInput 自己的彩色字符渲染管线接进去。返回的是一组 `{ start, end }` 区间对象,**不是** `[number, number]` 元组。函数没有内部状态,便于单测。 ### 6.5 两道编译门把整张子树切掉 @@ -439,7 +435,7 @@ const allCommands = [ ## 七、可迁移的设计模式 -回过头看,`buddy/` 这六个文件做对的事就一句话:**把"宠物"这个本应横跨配置、渲染、prompt、命令、通知五个子系统的功能,拆成五块各自接进对应子系统现有的扩展点,自己不造任何"框架"**。 +回过头看,`buddy/` 这六个文件做对的事一句话就能讲清:**把"宠物"这个本应横跨配置、渲染、prompt、命令、通知五个子系统的功能,拆成五块各自接进对应子系统的现有扩展点,自己不造任何"框架"**。 - 配置那侧只多了两个字段:`companion`(魂)和 `companionMuted`(开关),骨头一字不存 - 渲染那侧用 Ink 已有的 Box + 一个手写的 30 列 wrap,没有引入任何动画库;500ms 一拍是手摇的 `setInterval` @@ -455,9 +451,9 @@ const allCommands = [ Claude Code 把可算回来的全归入 `Bones`、不存;只把不可逆的 `name` 和 `personality` 落盘成 `StoredCompanion`。这样有三个直接收益: -1. **配置文件不能被用户篡改成不该出现的状态**——`getCompanion()` 的最后一步 `{...stored, ...bones}` 永远会用算出来的骨架盖掉伪造字段。 -2. **加新字段不需要写 migration**——往 `Bones` 里加 `aura` 那天,老用户的配置文件不动,下一次启动直接补上。 -3. **"全服换代"是一行代码改 SALT**——`SALT = 'friend-2026-401'` 改成 `'friend-2027-1'`,所有人下次启动重新孵化,不需要走任何运营后台。 +1. **配置文件不能被用户篡改成不该出现的状态** — `getCompanion()` 的最后一步 `{...stored, ...bones}` 永远会用算出来的骨架盖掉伪造字段。 +2. **加新字段不需要写 migration** — 往 `Bones` 里加 `aura` 那天,老用户的配置文件不动,下一次启动直接补上。 +3. **"全服换代"是一行代码改 SALT** — `SALT = 'friend-2026-401'` 改成 `'friend-2027-1'`,所有人下次启动重新孵化,不需要走任何运营后台。 适用场景:任何带种子化随机的生成式特性——头像、皮肤、词条、装备、世界种子。 From 8900973937d122be9775f1e6f541d80450604e00 Mon Sep 17 00:00:00 2001 From: Yao Lu Date: Wed, 27 May 2026 15:18:19 +0800 Subject: [PATCH 8/8] =?UTF-8?q?rename:=20docs/32-Buddy-=E4=BA=BA=E6=A0=BC.?= =?UTF-8?q?md=20=E2=86=92=20docs/32-Buddy-=E5=AE=A0=E7=89=A9.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按尧哥反馈把章节文件名也对齐到「宠物」。同步更新 scripts/check-code-ratio.ts NEW_CHAPTER_FILES 与 V2-REVISION-SPEC.md §9.3.1 章节索引中的 path。 Co-authored-by: multica-agent --- .../32-Buddy-\345\256\240\347\211\251.md" | 0 docs/V2-REVISION-SPEC.md | 2 +- scripts/check-code-ratio.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename "docs/32-Buddy-\344\272\272\346\240\274.md" => "docs/32-Buddy-\345\256\240\347\211\251.md" (100%) diff --git "a/docs/32-Buddy-\344\272\272\346\240\274.md" "b/docs/32-Buddy-\345\256\240\347\211\251.md" similarity index 100% rename from "docs/32-Buddy-\344\272\272\346\240\274.md" rename to "docs/32-Buddy-\345\256\240\347\211\251.md" diff --git a/docs/V2-REVISION-SPEC.md b/docs/V2-REVISION-SPEC.md index 274dcb0..4bdc799 100644 --- a/docs/V2-REVISION-SPEC.md +++ b/docs/V2-REVISION-SPEC.md @@ -582,7 +582,7 @@ OC-PM 据本文档为每个 v2 章节产出 1 个 parent issue,包含三件套 | C24 | Bridge IPC 与远程会话 | `docs/29-Bridge-IPC-与远程会话.md` | YAO-138 追加(NN=29,承接 26/27/28 落地,准 YAO-123 启动) | | C25 | DirectConnect 与上游代理 | `docs/30-DirectConnect-与上游代理.md` | YAO-139 仲裁,YAO-124 落地(NN=30,承接 26/27/28/29,准 YAO-124 启动) | | C28 | Keybindings、Vim 与 Voice 输入 | `docs/31-Keybindings-Vim-与-Voice-输入.md` | YAO-140 追加(NN=31,承接 26/27/28/29/30 落地,准 YAO-127 启动) | -| C29 | Buddy 人格 | `docs/32-Buddy-人格.md` | YAO-143 追加(NN=32,承接 26/27/28/29/30/31 落地,准 YAO-128 启动) | +| C29 | Buddy 人格 | `docs/32-Buddy-宠物.md` | YAO-143 追加(NN=32,承接 26/27/28/29/30/31 落地,准 YAO-128 启动) | | C30 | Doctor 屏与 Output Style 体验 | `docs/33-Doctor-屏与-Output-Style-体验.md` | YAO-142 仲裁,YAO-129 落地(NN=33,承接 26/27/28/29/30/31/32 落地) | 注:v1 的 `docs/04-System-Prompt-工程.md` **不**因 C04 让位而 rename——它继续以 v1 文件身份留在 NN=04 槽位,待 C06(System Prompt 与 Output Style 注入)正式起笔时再走「迭代重写 / 拆分合并」流程改名为 `docs/06-System-Prompt-与-Output-Style-注入.md`,那是 C06 PR 自己的 scope。新章追加 NN 槽位的方案把「v1 文件 rename」与「新章下笔」这两件事解耦——C04 不需要触碰任何 v1 文件。 diff --git a/scripts/check-code-ratio.ts b/scripts/check-code-ratio.ts index 296e88c..a1361a4 100644 --- a/scripts/check-code-ratio.ts +++ b/scripts/check-code-ratio.ts @@ -75,7 +75,7 @@ const NEW_CHAPTER_FILES = new Set([ "docs/29-Bridge-IPC-与远程会话.md", "docs/30-DirectConnect-与上游代理.md", "docs/31-Keybindings-Vim-与-Voice-输入.md", - "docs/32-Buddy-人格.md", + "docs/32-Buddy-宠物.md", "docs/33-Doctor-屏与-Output-Style-体验.md", ]);