职责概述

解决的问题:编程是孤独的。Buddy 给终端加了一个虚拟宠物——一只 ASCII 精灵坐在输入框旁边,有自己的名字、性格和稀有度。它会观察对话、偶尔冒泡发言,用户可以直接叫它名字互动。纯粹的情感设计,让 CLI 工具多一点温度。

应用场景:/buddy 命令孵化一只宠物,每个用户的宠物物种和稀有度由用户 ID 确定(不可自选)② 对话过程中宠物偶尔发表评论(由 AI 生成,符合宠物性格)③ 用户直接叫宠物名字触发特殊互动 ④ 彩蛋机制:1% 概率孵化传说级宠物。

一句话理解:就像拓麻歌子(电子宠物)——没啥实际功能,但就是让人想多看一眼。

架构设计

命令入口
/buddy hatch — 孵化宠物
/buddy pet — 摸头飘心
/buddy mute / unmute — 静音
确定性生成层
companion.ts — roll(userId) 确定性生成
Mulberry32 PRNG + hash(userId + salt)
Bones(外观)不持久化,每次重算
渲染层
CompanionSprite.tsx — 终端精灵渲染
sprites.ts — 18 种 × 3 帧 ASCII 精灵
500ms tick + idle 序列 + 飘心动画
AI 个性层
prompt.ts — 系统提示注入
Observer 监听对话 → 气泡发言
Soul(名字+性格)由模型生成
存储层
config.ts — StoredCompanion(Soul + hatchedAt)
Bones 不存储,防篡改

🎲 确定性生成 — 同一用户永远是同一只

Buddy 的核心设计是"确定性随机":使用 hash(userId + salt) 作为 Mulberry32 PRNG 的种子。同一个用户无论在什么时候、什么机器上,都会生成完全相同的宠物属性。

roll(userId) — 确定性属性生成
Mulberry32 PRNG + FNV-1a hash,带结果缓存
// companion.ts

// Mulberry32 — 小型种子 PRNG,用于确定性抽卡
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
  }
}

function hashString(s: string): number {
  // Bun 环境使用 Bun.hash(),其他环境用 FNV-1a
  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)  // FNV prime
  }
  return h >>> 0
}

const SALT = 'friend-2026-401'

export function roll(userId: string): Roll {
  const key = userId + SALT
  // 缓存:500ms tick / 每次按键 / 每轮 observer 都会调用
  if (rollCache?.key === key) return rollCache.value
  const value = rollFrom(mulberry32(hashString(key)))
  rollCache = { key, value }
  return value
}
为什么用确定性而非真随机?两个原因:① 用户换设备后宠物还是同一只(情感连续性)② Bones 不需要存储(只存 Soul),节省空间且防篡改 — 用户无法通过编辑 config 文件伪造 legendary 稀有度。

物种与稀有度系统

18 种物种,5 档稀有度,6 种眼睛,8 种帽子,1% shiny 概率。稀有度不仅影响视觉效果,还决定了属性下限。

稀有度分布

稀有度权重(类卡牌游戏设计)
稀有度权重概率属性下限星级
Common60~60%5
Uncommon25~25%15★★
Rare10~10%25★★★
Epic4~4%35★★★★
Legendary1~1%50★★★★★
18 种物种
动物系:duck, goose, cat, dragon, octopus, owl, penguin, turtle, snail, ghost, axolotl, capybara, rabbit
非动物系:blob, cactus, robot, mushroom, chonk
属性系统(5 维)

每个宠物有一个峰值属性(floor + 50~80)和一个低谷属性(floor - 10~+5),其余属性在 floor ~ floor+40 之间随机。稀有度越高,属性下限越高。

D
DEBUGGING
调试能力
P
PATIENCE
耐心
C
CHAOS
混乱度
W
WISDOM
智慧
S
SNARK
毒舌度

外观组合

CompanionBones — 外观骨骼数据
由 roll() 确定性生成,不持久化存储
// types.ts — 确定性生成的"骨骼"数据
type CompanionBones = {
  rarity: Rarity        // 5 档稀有度
  species: Species      // 18 种物种
  eye: Eye              // '·' | '✦' | '×' | '◉' | '@' | '°'
  hat: Hat              // common 固定 'none',其他随机
  shiny: boolean        // 1% 概率闪光
  stats: Record<StatName, number>  // 5 维属性
}

// 持久化的"灵魂"数据 — 由模型生成
type CompanionSoul = {
  name: string          // AI 起的名字
  personality: string   // AI 生成的性格描述
}

// 最终宠物 = Bones + Soul + hatchedAt
type Companion = CompanionBones & CompanionSoul & { hatchedAt: number }

// 但 config 中只存储 Soul + hatchedAt
// Bones 每次从 hash(userId + salt) 重新计算

🎨 终端精灵动画系统

sprites.ts(514 行)为 18 个物种各定义了 3 帧 ASCII 精灵(5 行 × 12 字符)。CompanionSprite.tsx 实现了完整的终端动画循环。

动画循环设计
500ms tick + idle 序列 + 飘心 + 气泡
// CompanionSprite.tsx

const TICK_MS = 500       // 500ms 一帧
const BUBBLE_SHOW = 20    // 气泡显示 ~10 秒(20 ticks)
const FADE_WINDOW = 6     // 最后 ~3 秒气泡淡出
const PET_BURST_MS = 2500 // /buddy pet 飘心持续 2.5 秒

// Idle 序列:大部分时间休息(帧 0),偶尔抖动(帧 1-2),罕见眨眼
// -1 表示"在帧 0 上眨眼"
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]

// 飘心动画:5 帧上升消失
const H = '♥'  // figures.heart
const PET_HEARTS = [
  '   ♥    ♥   ',
  '  ♥  ♥   ♥  ',
  ' ♥   ♥  ♥   ',
  '♥  ♥      ♥ ',
  '·    ·   ·  '
]

精灵结构示例(Duck)

ASCII 精灵 — 3 帧动画
帧 0: 静止 → 帧 1: 抖动 → 帧 2: 帽子帧
// sprites.ts — Duck 精灵定义
[duck]: [
  // 帧 0 — 静止
  [
    '            ',    // 帽子槽(帧 0-1 为空)
    '    __      ',
    '   ( ·)>    ',    // 眼睛用 {E} 占位符,运行时替换
    '   /    ',
    '    ~~      ',
  ],
  // 帧 1 — 抖动
  [ ... ],
  // 帧 2 — 帽子帧(第 0 行可渲染帽子)
  [ ... ],
]

// 帽子渲染(独立于物种)
const HAT_SPRITES: Record<Hat, string[]> = {
  crown:     ['   ╔══╗   ', '   ║██║   '],
  tophat:    ['  ╔════╗  ', '  ║    ║  '],
  propeller: ['  ╱╲╱╲   ', '  ╲╱╲╱   '],
  halo:      ['  ╭────╮  ', '  ╰────╯  '],
  wizard:    ['   ╱╲    ', '  ╱  ╲   '],
  beanie:    ['  ╭──╮   ', '  ╰──╯   '],
  tinyduck:  ['   __     ', '  (·)>   '],
}
帽子的设计有一个巧妙的约束:Common 稀有度的宠物固定没有帽子(hat: 'none'),只有 Uncommon 及以上才有帽子。这创造了视觉上的稀有度辨识度 — 看到有帽子的就知道不是 Common。而 tinyduck 帽子更是让宠物头上顶着一只小鸭子,增加了趣味性。

💬 AI 个性系统 — 宠物有自己的灵魂

宠物不只是视觉装饰,它有 AI 生成的名字和性格(Soul),能观察对话并用气泡发言。系统提示明确告诉模型:"你不是这个宠物,它是一个独立的观察者。"

companionIntroText — 宠物存在告知
注入到系统提示中,告知模型宠物的存在和行为规则
// prompt.ts

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.`
}

// 作为 attachment 注入到消息流中
// 首次孵化时注入一次,后续每轮检查是否已告知
export function getCompanionIntroAttachment(messages: Message[]): Attachment[] {
  if (!feature('BUDDY')) return []
  const companion = getCompanion()
  if (!companion || getGlobalConfig().companionMuted) return []
  // 跳过已经告知过的消息
  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 }]
}
Soul 的生成发生在 /buddy hatch 时:模型收到一个特殊的提示,基于宠物的物种、稀有度和属性生成名字和性格描述。生成结果持久化到 config 中。这意味着 宠物的性格是由模型创作而非预设的 — 同一物种的两只宠物可能有完全不同的个性。

🔐 物种名混淆 — 防泄漏的编码技巧

物种名用 String.fromCharCode 运行时构造,源码中没有明文字符串。这不是为了安全,而是为了绕过构建检查

运行时字符串构造
部分物种名与内部模型代号相同,需要避免 grep 误报
// types.ts — 物种名混淆

const c = String.fromCharCode

export const duck = c(0x64, 0x75, 0x63, 0x6b) as 'duck'
//          'd'     'u'     'c'     'k'

export const capybara = c(
  0x63, 0x61, 0x70, 0x79, 0x62, 0x61, 0x72, 0x61
) as 'capybara'
//  'c'   'a'   'p'   'y'   'b'   'a'   'r'   'a'

export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'

// 注释原文:
// "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."
注意:capybara 既是物种名也是 CC 内部的模型代号(undercover.ts 的代号列表中有 "Capybara")。构建系统会 grep 产物检查是否泄漏了模型代号,运行时构造使得物种名 "capybara" 不会以明文出现在 bundle 中,但模型代号 "Capybara" 的泄漏检查仍然有效。

设计模式与亮点

1. 确定性 PRNG + 不持久化 = 防篡改

Bones 不存储在 config 中,每次从 hash(userId + salt) 重新计算。用户无法通过编辑 JSON 配置文件来伪造 legendary 稀有度或切换物种。这是"数据由算法推导,而非由存储决定"的设计范式。

2. 三级缓存优化

roll() 被三个热路径调用:500ms 精灵 tick、每次按键的 PromptInput、每轮对话的 Observer。通过 rollCache 单例缓存,相同 userId 只计算一次。

3. Bones vs Soul 分离

Bones(外观)由算法确定,Soul(个性)由 AI 生成。这种分离使得物种重命名和数组编辑不会破坏已存储的宠物(Bones 可以安全地重新生成),而 Soul 作为用户和 AI 共同创作的产物被妥善保存。

4. 编译期 feature gate

整个 Buddy 系统被 feature('BUDDY') 编译期 feature flag 包裹。未启用时,所有 Buddy 代码在构建阶段被 dead code elimination 移除,不会出现在产物中。

5. 情感化设计的工程实现

Buddy 的属性设计(DEBUGGING / PATIENCE / CHAOS / WISDOM / SNARK)直接映射程序员的工作状态,形成情感共鸣。"摸头飘心"(/buddy pet)的交互设计更是经典的宠物养成机制移植到终端场景。

Agent 实践借鉴 — 情感化陪伴系统

场景映射:客服 Agent 的情感化助手

客服系统的用户(客户和坐席)都面临高压力场景。一个虚拟助手可以:缓解等待焦虑(排队时展示)、提供情绪价值(问题解决后的庆祝)、增加产品记忆点(独特的品牌 IP)。

借鉴 CC:确定性生成 + AI 个性

// 客服 Agent 陪伴系统 — 借鉴 CC 的 Buddy 架构
class CompanionSystem {
  // 1. 确定性生成(照搬 CC 的 Mulberry32)
  roll(userId: string): CompanionBones {
    const seed = this.hashString(userId + SALT)
    const rng = this.mulberry32(seed)
    return {
      rarity: this.rollRarity(rng),   // 控制外观品质
      mood: this.rollMood(rng),       // 控制表情
      trait: this.rollTrait(rng),     // 控制说话风格
    }
  }

  // 2. AI 生成个性(照搬 CC 的 Soul)
  async hatch(userId: string): Promise<Companion> {
    const bones = this.roll(userId)
    const soul = await this.generateSoul(bones)
    // 存储只保存 Soul,Bones 每次重算
    await this.saveSoul(userId, { ...soul, hatchedAt: Date.now() })
    return { ...bones, ...soul }
  }

  // 3. 场景化触发(CC 用 Observer,客服用事件驱动)
  onEvent(event: ServiceEvent, companion: Companion): BubbleMessage {
    switch (event.type) {
      case 'queue_wait':
        return this.bubble(companion, 'patience', '别急,马上就到你了~')
      case 'issue_resolved':
        return this.bubble(companion, 'celebrate', '搞定啦!')
      case 'agent_busy':
        return this.bubble(companion, 'idle', companion.catchphrase)
    }
  }
}

落地清单

值得抄的:
  • 确定性生成:同一用户永远看到同一个助手,建立情感连续性。Bones 不存储,防篡改。
  • Bones/Soul 分离:外观由算法决定(可升级),个性由 AI 生成(用户记忆)。升级不影响用户已有的助手。
  • 场景化触发:不是随机冒泡,而是在关键时刻(等待、成功、出错)有针对性地出现。
不需要抄的:
  • 稀有度系统:客服场景不需要抽卡机制,每个用户应该获得同等的体验。
  • 物种名混淆:这是 CC 防模型代号泄漏的特有需求。
  • ASCII 精灵:客服场景通常在 Web/APP 端,用 SVG/Lottie 动画更合适。

代码索引

文件行数说明
buddy/companion.ts~120确定性生成核心:Mulberry32 PRNG + 属性计算 + 缓存
buddy/types.ts~100类型定义:18 物种、5 稀有度、6 眼睛、8 帽子、5 属性
buddy/sprites.ts~514ASCII 精灵数据:18 种 × 3 帧 + 帽子渲染 + 表情渲染
buddy/CompanionSprite.tsx~350终端渲染:动画循环 + 气泡 + 飘心 + idle 序列
buddy/prompt.ts~50系统提示:宠物存在告知 + intro attachment 注入
buddy/useBuddyNotification.tsx~100启动彩虹提示 + 输入框 /buddy 触发检测
commands/buddy/index.ts~200/buddy 命令族:hatch、pet、mute、unmute
utils/config.ts~270StoredCompanion 持久化:只存 Soul + hatchedAt