Claude Code 源码分析第 45 课 · 第 07
第 45 课
未发布的功能

BUDDY 伴侣系统

藏在 Claude Code 里的完整电子宠物子系统:确定性生物生成、ASCII 精灵动画、对话气泡、AI 灵魂设定,以及一整套精心设计的上线机制。

01 概览

在 Claude Code 的源码树里,藏着一个完整但并未正式对外发布的 Tamagotchi 风格伴侣系统,代号 BUDDY。它不是玩笑式占位符,而是一套真正可运行的功能:会生成生物、渲染动画、写入配置、注入系统提示,并在启动阶段做预告。

如果该功能被打开,终端角落会出现一只小型 ASCII 生物。它的物种、眼睛、帽子、稀有度和属性由用户 ID 决定性生成;名字和性格则作为“灵魂”单独存储。这种“骨骼 / 灵魂”分离设计,是整套系统最巧妙的工程点之一。

未发布/内部
源代码包含一个显式的启动窗口: 2026 年 4 月 1 日至 7 日。 预告片通知(彩虹 /buddy 文本)仅在那一周出现在启动通知栏中。 该命令本身在此后将永远保持有效。 4 月 1 日这一日期几乎可以肯定是有意为之的——这个功能看起来就像是愚人节的惊喜。

该系统跨越六个源文件 src/buddy/: types.ts, companion.ts, sprites.ts, CompanionSprite.tsx, prompt.ts, 和 useBuddyNotification.tsx。 每个文件都有明确的职责,整个设计因其巧妙的工程决策而值得仔细阅读。

02 系统架构

BUDDY 这套系统最有意思的地方,是它把“伴侣是什么”拆成了两层:骨骼(物种、眼睛、帽子、稀有度、属性)是确定性的;灵魂(名字、性格、孵化时间)是后天生成并持久化的。

这样一来,系统既能保证同一个用户跨会话拿到的是同一只生物,又能保留足够的个性化空间。工程上,这种拆分同时解决了反作弊、改名兼容和未来扩展三件事。

graph TD A["userId (OAuth UUID or userID)"] -->|"hashString(userId + SALT)"| B["Mulberry32 PRNG seed"] B -->|"deterministic roll"| C["CompanionBones\n(rarity, species, eye, hat, shiny, stats)"] C --> D["roll() / rollWithSeed()"] D -->|cached in rollCache| E["getCompanion()"] F["config.companion (StoredCompanion)"] -->|"name + personality + hatchedAt"| E E --> G["CompanionSprite.tsx\n(React/Ink component)"] E --> H["prompt.ts\ncompanionIntroText()"] G --> I["renderSprite() / renderFace()"] G --> J["SpeechBubble"] G --> K["CompanionFloatingBubble\n(fullscreen mode)"] H --> L["System prompt attachment\n(companion_intro)"] M["useBuddyNotification"] -->|"April 1-7 teaser"| N["Rainbow /buddy in startup bar"]

关键的架构洞察力是 骨头/灵魂分裂:

  • Bones (物种、眼睛、帽子、稀有度、统计数据)总是从 hash(userId) — 他们从不接触磁盘。
  • Soul (姓名、性格、 hatchedAt)存储在 config.companion 作为 StoredCompanion.
为什么这种分裂很重要
如果未来代码中的物种名称发生变化,存储的同伴不会被破坏——骨头会从相同的哈希值中重新生成,因此该生物保持一致。 这也意味着用户无法编辑他们的 config.json 伪造传奇稀有度:稀有度源自 userId 哈希,而不是从磁盘读取。

03 Companion.ts — 确定性生物生成

companion.ts 负责把一个用户 ID 变成具体生物配置。关键不在于“随机”,而在于“对同一个用户来说始终随机得一样”。这也是为什么这里不用 Math.random(),而要走可复现的种子 PRNG。

从用户视角看,这意味着你的伴侣像是“抽出来的”;从系统视角看,这其实是一段完全可重复的函数映射:userId + salt -> seed -> bones

// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
function mulberry32(seed: number): () => number {
  let a = seed >>> 0
  return function () {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

种子来自对用户 ID 字符串进行哈希处理。 在 Bun(运行时 Claude Code 使用)上,它委托给 Bun.hash(); 在其他环境中,它会回退到 FNV-1a 实现:

function hashString(s: string): number {
  if (typeof Bun !== 'undefined') {
    return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
  }
  let h = 2166136261  // FNV offset basis
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return h >>> 0
}

const SALT = 'friend-2026-401'
'friend-2026-401' 是重要的。 它将同伴一代锁定在特定的时代——改变这种盐会给每个用户一个完全不同的生物。 “401”可能指的是 4 月 1 日(发布日期),而“朋友”是该功能的核心目的。

稀有卷

稀有度是通过加权随机抽取来选择的。 权重定义为满足约束常数 types.ts:

export const RARITY_WEIGHTS = {
  common:    60,
  uncommon:  25,
  rare:      10,
  epic:       4,
  legendary:  1,
} as const satisfies Record<Rarity, number>
RarityWeightChanceStars统计楼层得到帽子?
Common6060%5Never
Uncommon2525%★★15Yes
Rare1010%★★★25Yes
Epic44%★★★★35Yes
Legendary11%★★★★★50Yes

共同的同伴 never 买一顶帽子。 帽子检查是单行 rollFrom():

hat: rarity === 'common' ? 'none' : pick(rng, HATS),

闪亮的旗帜

有百分之一的机会(rng() < 0.01)任何同伴都会滚动为“闪亮”——一种与稀有性不同的视觉变体。 传奇般的闪亮是万分之一的结果。

Stats

每个同伴都有五项统计数据: DEBUGGING, PATIENCE, CHAOS, WISDOM, 和 SNARK。 滚动是 RPG 风格的 - 一个峰值统计数据,一个转储统计数据,其余分散 - 并且所有楼层都以稀有度缩放:

function rollStats(rng, rarity): Record<StatName, number> {
  const floor = RARITY_FLOOR[rarity]
  const peak = pick(rng, STAT_NAMES)
  let dump = pick(rng, STAT_NAMES)
  while (dump === peak) dump = pick(rng, STAT_NAMES) // no ties

  for (const name of STAT_NAMES) {
    if (name === peak)  stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
    else if (name === dump) stats[name] = Math.max(1,   floor - 10 + Math.floor(rng() * 15))
    else                   stats[name] = floor + Math.floor(rng() * 40)
  }
}

传奇人物的巅峰属性在之前可以达到130 Math.min(100, ...) 上限 - 意味着传奇同伴总是最大化他们的峰值统计数据。

性能:滚动缓存

// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
// per-turn observer) with the same userId → cache the deterministic result.
let rollCache: { key: string; value: Roll } | undefined
export function roll(userId: string): Roll {
  const key = userId + SALT
  if (rollCache?.key === key) return rollCache.value
  const value = rollFrom(mulberry32(hashString(key)))
  rollCache = { key, value }
  return value
}

由于 PRNG 结果对于每个用户来说都是确定性的,并且是从三个热路径(500ms 计时器、每次击键处理程序、每轮观察者)调用的,因此结果缓存在模块级变量中。 缓存已开启 userId + SALT,因此用户更改(例如切换帐户)会自动破坏它。

04 types.ts — 物种、眼睛、帽子和混淆的名称

types.ts 乍看只是常量列表,实际上里面埋了一个很工程化的约束:有个物种名会撞上内部模型代号金丝雀,所以整个物种集合都改成运行时构造,避免某一个名字显得特别可疑。

这类处理不是为了“炫技混淆”,而是在 CI 审查规则和代码可读性之间找平衡。统一编码所有物种,比只编码一个异常值要更不暴露实现意图。

物种混淆

一个物种名称是在运行时通过十六进制字符代码而不是字符串文字构造的。 评论准确地解释了原因:

// One species name collides with a model-codename canary in excluded-strings.txt.
// The check greps build output (not source), so runtime-constructing the value
// keeps the literal out of the bundle while the check stays armed for the
// actual codename. All species encoded uniformly; `as` casts are type-position
// only (erased pre-bundle).
const c = String.fromCharCode

export const duck    = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose   = c(0x67,0x6f,0x6f,0x73,0x65) as 'goose'
// ... 16 more species

Anthropic 具有 CI 检查,可对某些字符串(“金丝雀”)的构建输出进行 grep,以检测模型代号的意外泄漏。 18 个物种名称之一恰好与型号代号相匹配。 通过在运行时计算所有物种名称 String.fromCharCode,没有任何字符串文字出现在编译后的包中 - 因此金丝雀检查继续正确地完成其工作,而不会标记无辜的宠物名称。

巧妙的约束
评论说 all 物种是统一编码的,因此实际发生碰撞的物种并不显得特别。 如果只有碰撞的物种是十六进制编码的,那么任何阅读代码的人都会立即知道哪个物种与模型代号相匹配。

完整物种列表

duck
    __
  <(· )___
   (  ._>
    `--´
goose
     (·>
     ||
   _(__)_
    ^^^^
blob
   .----.
  ( ·  · )
  (      )
   `----´
cat
   /\_/\
  ( ·   ·)
  (  ω  )
  (")_(")
dragon
  /^\  /^\
 <  ·  ·  >
 (   ~~   )
  `-vvvv-´
octopus
   .----.
  ( ·  · )
  (______)
  /\/\/\/\
owl
   /\  /\
  ((·)(·))
  (  ><  )
   `----´
penguin
  .---.
  (·>·)
 /(   )\
  `---´
turtle
   _,--._
  ( ·  · )
 /[______]\
  ``    ``
snail
 ·    .--.
  \  ( @ )
   \_`--´
  ~~~~~~~
ghost
   .----.
  / ·  · \
  |      |
  ~`~``~`~
axolotl
}~(______)~{
}~(· .. ·)~{
  ( .--. )
  (_/  \_)
capybara
  n______n
 ( ·    · )
 (   oo   )
  `------´
cactus
 n  ____  n
 | |·  ·| |
 |_|    |_|
   |    |
robot
   .[||].
  [ ·  · ]
  [ ==== ]
  `------´
rabbit
   (\__/)
  ( ·  · )
 =(  ..  )=
  (")__(")
mushroom
 .-o-OO-o-.
(__________)
   |·  ·|
   |____|
chonk
  /\    /\
 ( ·    · )
 (   ..   )
  `------´

眼睛和帽子

export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const

export const HATS = [
  'none', 'crown', 'tophat', 'propeller',
  'halo', 'wizard', 'beanie', 'tinyduck',
] as const

眼睛角色通过 {E} 精灵字符串中的模板占位符 — replaceAll('{E}', bones.eye)。 这就是一组精灵 ASCII 艺术如何适用于所有 6 种眼睛样式。

05 sprites.ts — ASCII 艺术引擎

ASCII 精灵系统真正处理的不是‘画几只小动物’,而是如何在极窄的字符约束里同时支持多物种、多眼睛、多帽子和多帧动画,并且不让终端布局乱跳。

因此这里的实现更接近一个小型模板引擎:基础帧给出身体结构,眼睛占位符负责个性差异,帽子槽负责顶部装饰,而动画帧则负责节拍感和生命感。

// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
// Multiple frames per species for idle fidget animation.
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
const BODIES: Record<Species, string[][]> = {
  [duck]: [
    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--´    '],
    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--´~   '], // tail wiggle
    ['            ', '    __      ', '  <({E} )___  ', '   (  .__>  ', '    `--´    '], // bill shift
  ],
  // ... 17 more species
}

The renderSprite() 函数按顺序处理三个问题:

export function renderSprite(bones: CompanionBones, frame = 0): string[] {
  const frames = BODIES[bones.species]
  const body = frames[frame % frames.length]!.map(line =>
    line.replaceAll('{E}', bones.eye),  // 1. substitute eye
  )
  const lines = [...body]
  if (bones.hat !== 'none' && !lines[0]!.trim()) {
    lines[0] = HAT_LINES[bones.hat]         // 2. inject hat (only when row 0 is blank)
  }
  if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim()))
    lines.shift()                            // 3. drop blank hat row when no hat + no animation
  return lines
}

步骤 3 是一个微妙的布局优化:如果一个物种从不使用顶行进行动画效果并且没有帽子,则完全删除该行,以便精灵渲染得更紧凑。 但是,如果任何帧使用该行(例如第 2 帧中的龙的喷火),则该行必须在所有帧上保持存在,否则精灵将在动画期间改变高度 - 这将导致终端布局跳跃。

帽子的定义

const HAT_LINES: Record<Hat, string> = {
  none:      '',
  crown:     '   \\^^^/    ',
  tophat:    '   [___]    ',
  propeller: '    -+-     ',
  halo:      '   (   )    ',
  wizard:    '    /^\\     ',
  beanie:    '   (___)    ',
  tinyduck:  '    ,>      ',
}

The tinyduck 帽子使一只小鸭子坐在你同伴的头上。 戴着小鸭帽的鸭子是宇宙允许的可能性。

06 CompanionSprite.tsx — 实时动画小部件

CompanionSprite 把静态骨骼变成会动的伴侣。它决定什么时候眨眼、什么时候进入兴奋态、什么时候显示宠物爱心,以及窄终端和全屏模式下分别应该怎样布局。

从架构上说,它既是渲染组件,也是行为调度器。BUDDY 的‘活着’感,主要就来自这里的节拍、时序和模式切换。

动画常量和空闲序列

const TICK_MS = 500             // 2 ticks per second
const BUBBLE_SHOW = 20          // ticks → ~10s visible
const FADE_WINDOW = 6           // last ~3s: bubble dims as warning
const PET_BURST_MS = 2500       // hearts float for 2.5s after /buddy pet

// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]

空闲序列经过仔细加权:同伴大部分时间都在第 0 帧中休息,偶尔坐立不安(第 1 帧和第 2 帧),并且很少眨眼(索引 -1 通过用破折号替换眼睛字符来表示眨眼)。 这给了生物一种自然、有机的感觉,而不是机械循环。

宠物之心动画

const H = figures.heart  // ♥
const PET_HEARTS = [
  `   ${H}    ${H}   `,
  `  ${H}  ${H}   ${H}  `,
  ` ${H}   ${H}  ${H}   `,
  `${H}  ${H}      ${H} `,
  '·    ·   ·  ',   // hearts fade to dots on last frame
]

当你跑步时 /buddy pet,该组件在精灵上方显示 5 帧漂浮的心,每帧循环 500 毫秒(总共 2.5 秒)。 最后一帧淡入点——这是一种有意的放松,而不是突然的中断。

兴奋与空闲动画模式

if (reaction || petting) {
  spriteFrame = tick % frameCount   // excited: cycle all frames fast
} else {
  const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
  if (step === -1) { spriteFrame = 0; blink = true }
  else { spriteFrame = step % frameCount }
}

在反应期间(语音气泡活跃)或被抚摸时,精灵进入“兴奋”模式并连续循环所有帧。 静止时,它跟随较慢、较重的 IDLE_SEQUENCE.

窄终端回退

export const MIN_COLS_FOR_FULL_SPRITE = 100

if (columns < MIN_COLS_FOR_FULL_SPRITE) {
  // Collapse to one-line face. When speaking, quip replaces the name.
  const quip = reaction && reaction.length > NARROW_QUIP_CAP
    ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction
  const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name
  return <Box paddingX={1} alignSelf="flex-end">
    <Text>{renderFace(companion)} {label}</Text>
  </Box>
}

在 100 个终端列以下,完整的 5 行精灵被单行脸部字符串替换(例如 (·>) 对于企鹅)。 语音气泡俏皮话被截断为 24 个字符,并内联显示在脸部旁边。 这 companionReservedColumns() 出口告诉 PromptInput 要减去多少水平空间,以便输入框不会与同伴重叠。

全屏模式与回滚模式

对话气泡有两个渲染路径,具体取决于全屏是否处于活动状态:

Non-fullscreen

内嵌气泡

气泡位于精灵旁边的同一排中。 输入框缩小 BUBBLE_WIDTH = 36 柱子以腾出空间。 气泡无法漂浮,因为它存在于回滚中——以后无法清除。

全屏模式

漂浮的气泡

CompanionFloatingBubble 分别渲染在 FullscreenLayout's bottomFloat 插槽 — 外部 overflowY:hidden 剪切区域,以便它可以延伸到滚动区域。 精灵本身仅在没有气泡的情况下进行渲染。

07 Prompt.ts — 将 Companion 注入 Claude 的上下文中

BUDDY 并不只是 UI 彩蛋,它还会通过系统提示附件进入 Claude 的上下文。这里最关键的设计点是:Claude 被明确告知“自己不是这个伴侣”。

这条边界让整套体验成立了。用户在叫 BUDDY 名字时,Claude 要让开,而不是代替它表演;否则 UI 里的气泡角色和模型本体会抢身份。

export function companionIntroText(name: string, species: string): string {
  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.`
}
设计理念
提示文字刻意做了身份区分:克劳德不是同伴。 当用户通过名字与同伴交谈时,Claude 被指示在一行中让开 - 同伴的对话气泡(由单独的 React 组件渲染)就是答案。 这维持了一种错觉,即伴侣是与人工智能助手不同的实体。

每个对话都会注入一次附件,并通过检查现有消息中的先前信息来进行重复数据删除。 companion_intro 同名附件:

export function getCompanionIntroAttachment(messages): Attachment[] {
  if (!feature('BUDDY')) return []
  const companion = getCompanion()
  if (!companion || getGlobalConfig().companionMuted) return []

  // Skip if already announced for this companion.
  for (const msg of messages ?? []) {
    if (msg.attachment?.type === 'companion_intro' &&
        msg.attachment.name === companion.name) return []
  }

  return [{ type: 'companion_intro', name: companion.name, species: companion.species }]
}

08 useBuddyNotification.tsx — 启动窗口

启动窗口逻辑把产品运营意图直接写进了代码:预告只在固定窗口内出现,真正的命令则在之后长期可用,且时间判断使用本地时区而不是 UTC。

这说明 BUDDY 并不是简单被某个 feature flag 打开,而是被当成一次有节奏的发布事件来设计:控制曝光波次、控制讨论节奏,也控制生成负载。

// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
  if ("external" === 'ant') return true   // internal builds: always show
  const d = new Date()
  return d.getFullYear() === 2026 &&
         d.getMonth() === 3 &&   // month 3 = April (0-indexed)
         d.getDate() <= 7
}

export function isBuddyLive(): boolean {
  if ("external" === 'ant') return true
  const d = new Date()
  return d.getFullYear() > 2026 ||
        (d.getFullYear() === 2026 && d.getMonth() >= 3)
}
源代码中的营销智能
“持续的 Twitter 嗡嗡声而不是单一的 UTC 午夜峰值”的评论非常引人注目。 工程师们特意选择了本地时间日期检测而不是 UTC,因此该预告片将在 24 小时内跨时区推出 - 将此次发布视为社交媒体活动,而不是服务器端功能标志。 灵魂生成负载被提到为一个问题,这意味着人工智能生成的名称/个性步骤在启动时可能会很昂贵。

在预告窗口期间,如果尚未孵化出同伴,则会出现 rainbow-colored /buddy 在启动通知栏中显示 15 秒。 彩虹是通过将每个字符映射到 getRainbowColor(index):

function RainbowText({ text }) {
  return <>{[...text].map((ch, i) =>
    <Text key={i} color={getRainbowColor(i)}>{ch}</Text>
  )}</>
}

addNotification({
  key: 'buddy-teaser',
  jsx: <RainbowText text="/buddy" />,
  priority: 'immediate',
  timeoutMs: 15_000,
})

钩子还导出 findBuddyTriggerPositions(),一个使用正则表达式来定位所有 /buddy 字符串中的出现次数 — 使用者 PromptInput 在您键入命令时突出显示该命令。

09 伴侣是什么样子

为了使系统具体化,下面是一张史诗级稀有蝾螈的配套卡示例:

}~(______)~{
}~(✦ .. ✦)~{
  ( .--. )
  (_/  \_)
   axiBot
★★★★ EPIC蝾螈眼睛:✦帽子:精灵闪亮:假
“有条不紊,有点混乱。对你的缩进有意见。”

史诗伴侣的统计分布示例(下限=35,峰值=调试,转储=耐心):

DEBUGGING
98
PATIENCE
28
CHAOS
60
WISDOM
72
SNARK
55

10 完整数据流:第一次孵化到语音气泡

如果把 BUDDY 当成一条完整链路来看,它不是“先画图,再起名字”这么简单,而是:触发命令、生成骨骼、生成灵魂、注入系统提示、持续渲染精灵、再按状态吐出气泡。

这也解释了为什么这套功能虽然看起来像玩具,却跨了那么多文件:它同时涉及状态、配置、UI、提示工程和上线机制。

步骤1

用户运行 /buddy

通过检测到的命令 findBuddyTriggerPositions()。 已检查功能标志。 isBuddyLive() 必须返回 true。

步骤2

骨头被卷起来

companionUserId() 获取 OAuth UUID 或 userID。 roll(userId) 对其进行哈希处理,种子 Mulberry32,确定性地选择物种/眼睛/帽子/稀有/统计数据。

步骤3

灵魂已生成

克劳德为该生物生成一个名称和个性字符串。 这是唯一的非确定性步骤。 结果存储在 config.companion as StoredCompanion.

步骤4

同伴介绍注射

getCompanionIntroAttachment() 创建一个 companion_intro 每个后续对话轮次都会添加到系统提示符前面的附件。

步骤5

CompanionSprite 渲染

500 毫秒计时器触发。 getCompanion() 从哈希中重新派生骨骼并合并存储的灵魂。 renderSprite() 构建当前动画帧。 React/Ink 渲染到终端。

步骤6

出现语音气泡

反应存储在 AppState 中: companionReaction。 气泡显示 20 个刻度(约 10 秒),在最后 6 个刻度中逐渐消失,然后消失。 在全屏模式下, CompanionFloatingBubble 单独渲染它。

11 深潜

为什么选择 Mulberry32 而不是 Math.random()?

Math.random() 对这套系统来说最大的问题不是‘随机性不足’,而是它根本不可复现。同一个用户每次重启如果都抽到不同骨骼,BUDDY 就失去了“这是你的伴侣”这层感觉。

Mulberry32 则提供了刚好够用、实现很小、速度也快的可播种 PRNG。对 BUDDY 这种‘需要稳定伪随机,而不是密码学随机’的场景来说,它非常合适。

为什么只存灵魂,不存骨头?

只存灵魂,不存骨头,是这套系统最干净的设计决策。骨骼全部从 userId 确定性生成,意味着用户不能靠改配置伪造稀有度,也意味着物种名称以后就算改了,旧伴侣也不会坏掉。

换句话说,存盘层只负责那些必须记忆、且不适合从哈希重建的部分;其余一切都由代码根据同一个 seed 重新推出。这让数据模型非常稳。

金丝雀回避这个物种名称实际上是如何运作的?

这里躲避的不是运行时 bug,而是构建时审查规则。CI 会扫描产物里的特定字符串,一旦物种名恰好撞上模型代号,就会被误判成泄漏。

通过把所有物种都改为运行时字符拼装,编译产物里不再出现那些裸字面量,也就不会误触这类检查,同时还保住了“到底哪个名字撞了”这件事的隐蔽性。

/buddy pet 命令实际上在技术上有什么作用?

/buddy pet 本质上只是写入一个时间戳,但这个时间戳会驱动整个宠物动画窗口:爱心帧、兴奋态、以及在窄终端下的表情展示都围绕它展开。

这也体现了 React 状态机式设计:命令本身很轻,真正复杂的是组件如何根据状态差值计算当前该画哪一帧。

灵感种子字段——它的用途是什么?

inspirationSeed 目前并不参与渲染,也不影响动画。它更像是给未来 AI 灵魂生成流程预留的一颗稳定种子,让名字和性格生成也能沿着同一个 userId 方向保持一致。

也就是说,骨骼已经确定性了,这个字段则是在为‘灵魂也尽量朝确定性靠拢’预留接口。

要点

  • BUDDY 是 Claude Code 内完全实现的电子宠物伴侣系统,由功能标志控制,计划通过当地时间日期检测于 2026 年 4 月 1 日启动。
  • 生物是由 Mulberry32 PRNG 确定性生成的 hash(userId + 'friend-2026-401') — 您的同伴对您来说是独一无二的,并且在各个会话中都是一致的。
  • 骨头/灵魂的分割将稀有性和物种保留在代码中(防篡改、重命名安全),同时仅将名称、个性和孵化At存储到磁盘。
  • 所有 18 个物种名称都是在运行时从十六进制字符代码构建的,以避免触发 Anthropic 监视模型代号的内部构建输出金丝雀。
  • 动画 ASCII 精灵引擎使用 {E} 眼睛的模板占位符、顶行帽子槽、加权空闲序列和“眨眼”帧(序列中的 -1)。
  • 语音气泡有两种渲染路径:内联(回滚模式)和浮动(通过单独的全屏模式) CompanionFloatingBubble component).
  • 同伴被注入到克劳德的上下文中作为 companion_intro 当用户称呼同伴的名字时,模型被指示退到一排。
  • 预告片通知特意使用当地时间(而非 UTC)跨时区以 24 小时波的形式推出——代码注释中明确的营销决策。

知识检查

Q1. 为什么所有物种名都通过 String.fromCharCode() 在运行时构造,而不是直接写成字符串字面量?
正确! 源码注释写得很直白:有一个物种名会撞上内部模型代号检查。统一改成运行时构造,既能避开构建产物里的文字匹配,又不会暴露到底是哪一个物种撞名。
Q2. 如果用户手改 ~/.claude/config.json,把伴侣的稀有度改成 legendary,最终会怎样?
正确! BUDDY 只把“灵魂”存盘,骨骼始终来自 userId 的确定性哈希。也就是说,稀有度、物种、帽子和属性都无法靠改配置伪造。
Q3. isBuddyTeaserWindow() 为什么刻意使用本地时间,而不是 UTC 时间戳?
正确! 源码注释明确提到两点:持续的社交媒体热度,以及更温和的灵魂生成负载。这是一个带明显产品运营意图的工程决策。
Q4. IDLE_SEQUENCE 里的 -1 在动画上代表什么?
正确! -1 不是“空帧”,而是专门留给 blink 的特殊值。渲染器会在第 0 帧的基础上替换眼睛字符,做出眨眼效果。
Q5. 当用户直接用名字叫伴侣时,Claude 应该怎么做?
正确! 这正是 companion_intro 的设计目标:Claude 不是 BUDDY,本体回答要克制,把舞台留给独立渲染的气泡角色。
0/5