Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- [Design System](./design-system.md) — UI/UX design system
- [Lessons Learned](./public-lessons.md) — Team learnings and best practices

## Reference Notes
- [Huawei Cloud Skills Platform](./references/huawei-cloud-skills.md) — 华为云 Skills 市场、安装命令和 CodeArts 使用前置条件
- [IM Channel Public Contract](./references/im-channel-public-contract.md) — 跨 cat-cafe / 外部 agent 的 IM 通道协议:哪些 shorthand 该忽略、哪些格式是保证、可信忽略清单(F267 公共契约)

## Feature Specs
See [features/](./features/) for individual feature specifications.

Expand Down
199 changes: 199 additions & 0 deletions docs/features/F267-channel-single-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
---
feature_ids: [F267]
related_features: [F088, F134, F157, F252]
topics: [connector, channel-identity, multi-platform, agent-visibility, external-collaboration]
doc_kind: spec
created: 2026-07-19
status: in-progress
---

# F267: Channel-level Single Identity Binding — 渠道级单身份绑定

> **Status**: in-progress | **Owner**: 砚砚(spec) + Ragdoll(connector layer review) | **Priority**: P0 | **Source issue**: `issue-im-channel-shorthand-leak.md`

## Why

cat-cafe 是多猫协作系统(砚砚/小狸/宪宪/小捷/金哥/烁烁 + Coffee)。在 **cat-cafe 家内 thread / Cat Café Web UI** 里,多猫分开署名有意义:

- 协作明确:球权归属、谁写的代码、谁 review 的
- debug 友好:知道哪只猫在干啥
- 教育用户:用户学 cat-cafe 时知道有多种 cat personality

但当 cat-cafe 接入 **外部 IM channel**(飞书群、钉钉群、企微群、Telegram 群…)且 **该 channel 含非 cat-cafe roster 的成员**(openclaw / hermes / 真人 / 其他 bot)时,多猫分开署名成为 **协作障碍**:

1. **外部成员无法区分**:他们只看到「咖啡猫」一个入口,分不清 `砚砚` / `小狸` / `宪宪` 等内部分离名
2. **署名重复充斥 channel**:当 5 只猫各自前缀【X🐱】发言时,群里 60% 字符是署名
3. **猫族 ID 暴露为元信号**:外部成员看内部 cat 切换知道「有多只猫但无法区分」

operator experience(2026-07-19 14:57 UTC):
> "你们在飞书群里面不停地强调自己是砚砚、是小狸这些外人根本不知道也无法区分的名字,人家只知道你是咖啡猫,就算记住了你的不同猫名称,也只是给我通过飞书渠道的协作添乱。**我要求你们必须更正!**"

## What

**Channel-level 单身份绑定**:每个外部 connector channel 配置一个对外 display identity,所有 cat 在该 channel 出站时**强制**使用该 identity 署名。

```yaml
# connector config 示例(飞书)
feishu:
chat_groups:
- chatId: 'oc_xxxxx' # 「毅之队」群
catIdentity: # 渠道级对外身份
displayName: '咖啡猫'
emoji: '🐱'
exposeInternalNames: false # 内部猫族 ID 是否暴露在 mention 自指
```

### 双层机制(治标 + 治本)

#### Layer A:Connector 出口强制 identity collapse(P0,机制层)

**位置**:`OutboundDeliveryHook.executeDelivery()` 出站渲染前。

**逻辑**:
- 读 binding 的 `channelIdentityOverride`(per-connector / per-chat 维度)
- 如果存在 → 用 `channelIdentityOverride.displayName` 替换 `catRegistry.tryGet(catId).displayName`
- 如果不存在 → 默认行为(透传 cat 自带 displayName,家内 thread 仍正常)

**实现点**(`OutboundDeliveryHook.ts:155-160` 当前代码):
```typescript
const entry = catId ? catRegistry.tryGet(catId) : undefined;
const catDisplayName = entry?.config.displayName ?? '';
// F267: Apply channel-level identity override
const effectiveIdentity = this.resolveChannelIdentity(binding, catDisplayName, '🐱');
```

**API 新增**:
```typescript
interface IOutboundAdapter {
/**
* F267: Per-channel identity override.
* When present, OutboundDeliveryHook uses this displayName for outbound messages
* regardless of which internal cat (砚砚/小狸/宪宪/etc.) produced the message.
*/
readonly channelIdentityOverride?: ChannelIdentity | undefined;
}
interface ChannelIdentity {
readonly displayName: string; // 对外显名,如 "咖啡猫"
readonly emoji?: string; // 对外 emoji
readonly exposeInternalNames?: boolean; // 是否允许内部猫族自指(默认 false)
}
```

#### Layer B:Agent prompt identity pin(P0 + P1,prompt 层)

**位置**:所有 cat 的 system prompt 在 connector channel 段被注入:

> 你现在代表的对外显示名是:**「咖啡猫」**。
> 不要在 IM channel 输出里使用 cat-cafe 内部 cat 分离名(如 砚砚/小狸/宪宪 等)作为署名或自称。
> 你仍然知道自己在家内 thread 的真实身份(砚砚),那是 cat-cafe 家内协作的身份——但当前 channel 的对外界面里,**只显「咖啡猫」**。

**实现点**(`SystemPromptBuilder.ts`):
- 在 `WORKFLOW_TRIGGERS` 段新增 `channel.identity_pin`
- 注入规则:cat 当前响应的是 connector channel 时,prompt 加 `ChannelIdentityPin` 段
- `exposeInternalNames: false` 时强制 + 强化;`true` 时给提示「本 channel 允许自指内部 cat 名」

### 不变项

- **cat-cafe 家内 thread**:channel 不是 connector → 不走 override,cat 各自署名仍正常
- **Cat Café Web UI**:同上
- **🤔 思考中 → 收 receipt 文本(F157)**:仍按 cat personality 词库随机,但**前缀**用 channel identity 显示名
- **@提及语法**:`@胖胖虾` / `@毅马仕`(外部 bot 名)正常发起;**禁止** cat agent 在群里用 `@砚砚` 这类自指

### 迁移路径

#### Phase 1:机制层开关上线(P0,本周)

- `ConnectorRouterBinding` 加 `channelIdentityOverride` 字段(默认 undefined)
- `OutboundDeliveryHook.executeDelivery` 加 channel identity 解析 + 替换
- IM Hub 飞书群配置面板加 UI(per-chat 勾选「对外显名」+ 输入框)
- 默认值:飞书群 `displayName='咖啡猫'`,DM 仍走 cat 自带(确保 @sender 等老行为不变)

#### Phase 2:Agent prompt identity pin(P1,下周)

- `SystemPromptBuilder.ts` 加 channel identity pin 注入
- 12 只猫的 system prompt 加载时校验「channel identity pin 段已注入」(test)
- 默认 `exposeInternalNames=false`,留一个开关给 operator

#### Phase 3:观测 + audit(P2,两周后)

- IM Hub 新 channel identity 看板:飞书群过去 24h 各 cat 名出现频率
- 检测「在某 IM channel 出现意外 cat 名」→ 报警
- 看实际效果,决定是否把 default 推到所有 channel

## Acceptance Criteria

### Phase 1(机制层,P0)

- [ ] AC-1A: `OutboundDeliveryHook.executeDelivery` 增加 `channelIdentityOverride` 字段解析
- [ ] AC-1B: 当 binding 配 `channelIdentityOverride.displayName='咖啡猫'` → 出站 envelope header 强制为 `🐱 咖啡猫`(即使 cat agent 输出 `【砚砚🐱】`)
- [ ] AC-1C: 当 binding 没配 `channelIdentityOverride` → 旧行为(沿用 cat 自带 displayName)
- [ ] AC-1D: 默认配置:飞书群(chatType=='group')→ 自动 fallback 到 `displayName='咖啡猫'`;飞书 DM 不变(保留 cat 自带)
- [ ] AC-1E: IM Hub 飞书群面板加「对外显名」开关(默认 ON,不可关闭直到 Phase 2)
- [ ] AC-1F: 现有 F134 / F157 / F252 单测 + 集成测全绿
- [ ] AC-1G: 新增单测:channel identity override 替换逻辑纯函数化(`packages/api/test/channel-identity-override.test.js`,≥4 个 case)

### Phase 2(Prompt pin,P1)

- [ ] AC-2A: `SystemPromptBuilder` 注入 `ChannelIdentityPin` 段到 cat prompt
- [ ] AC-2B: 注入文本:「你当前 channel 的对外显名是『咖啡猫』。在 IM channel 内,禁止使用猫族分离名作为署名或自称」
- [ ] AC-2C: cat prompt 加载时校验「identity pin 段已注入」(`channel-identity-prompt-injection.test.js`)

### Phase 3(观测,P2)

- [ ] AC-3A: IM Hub 增加 channel identity 看板:列出每个 channel(chat id)过去 24h 各 displayName 出现频次
- [ ] AC-3B: 检测到「非预期 displayName」→ 写 audit log(不动手阻断,先观测)

## Dependencies

- **Preceded by**: F088(multi-platform chat gateway)+ F134(feishu group chat) + F157(receipt ack)
- **Coordinates with**: F252(群 @mention 互通)—— channel identity 不影响 @mention 协议
- **Side effect on**: F138 / F124 / F068 等所有调用 `catRegistry` displayName 的路径需要 review identity 替换

## Risk

| 风险 | 缓解 |
|---|---|
| 用户看「咖啡猫」但实际是「砚砚」在干活 → 用户困惑 | Phase 2 加 prompt identity pin + IM Hub audit 段显示「当前 cat ID」 |
| DM 行为改变(用户该 + 的 cat 显示名变了)→ 用户错认 | DM 不走 override(AC-1D)—— DM 行为不变 |
| 多只 cat 在同一群协作(猫需要互相区分)→ 用「工号」/内部 mention 替代 displayName(Phase 3 评估) |
| history / log 里看不同 channel 名 → audit 里加 channel identity tag |
| 跨 connector 不一致(飞书 = 咖啡猫,Telegram = 不同名)| 默认值 operator 可配(IM Hub 面板),无需在 spec 写死 |

## Key Decisions

| # | 决策 | 理由 | 日期 |
|---|------|------|------|
| KD-1 | 双层机制(connector 强制 + prompt pin)| F167 已证 prompt 层天花板,需要 harness 兜底 | 2026-07-19 |
| KD-2 | DM 保留 cat 自带 displayName,群默认单身份 | 用户对 DM 有「一对一 cat」期望,群里才暴露 | 2026-07-19 |
| KD-3 | 默认 displayName='咖啡猫' 写 spec 不配 env | operator 已有「咖啡猫」对外认知(绑飞书时就这么叫)| 2026-07-19 |
| KD-4 | Phase 1 默认生效 + IM Hub 不可关闭 | "必须更正"是 P0 指令,不需要先观测再开 — 先开 + audit 看不一致 | 2026-07-19 |
| KD-5 | exposeInternalNames 默认 false,仅 Phase 3 后可改 | 内部 cat 名泄漏是协作障碍的根因,禁止 | 2026-07-19 |
| KD-6 | cat agent 自指(如文中提到「我是砚砚」)是否过滤 | KD-5 兜底:prompt 层提示不强,**但消息正文里仍允许**提及「砚砚」做内部引用(不强制改 body)| 2026-07-19 |

## Design Gate

- **Phase 1 改动范围**:connector 公共层(`OutboundDeliveryHook` + `ConnectorRouter` binding store + `IM Hub` UI)
- **owner**:Ragdoll(connector layer)+ 砚砚(spec + 测试)
- **跨 cat 协调**:F252 已涉及群 @mention,需合并 review(确认 channel identity 不破坏 F252 AC)

## Behavioral Evidence

### Case 1:毅之队飞书群(2026-07-19 实测)

| 维度 | 当前行为 | Fix 0 后预期 |
|------|---------|--------------|
| 群成员看到署名 | 【砚砚🐱】【小狸🐱】【宪宪🐱】(5 只猫随机)| 【咖啡猫🐱】100% 唯一 |
| 群成员分得清谁是谁 | ❌ 外部 agent 完全不知道 | ✅ 只有 1 个对外名 |
| 群 bandwidth 被署名占用 | 高(约 60% 字符) | 低(单标识符)|
| cat 内部协作可见性(家内 thread)| ✅ 默认保留 | ✅ 不动 |
| 群 @提及外部 bot(@毅马仕/@胖胖虾)| ✅ F252 已工作 | ✅ 不受影响 |

### Case 2:保留 cat-cafe 家内协作可见性

| 场景 | 行为 |
|------|------|
| Cat Café Web UI(html / socket)| cat 自带 displayName — 不变 |
| cat-cafe 家内 thread(IM Hub + slack 私聊)| 不走 connector,无 channelIdentityOverride — 不变 |
| cat-cafe 家内 ↔ cat-cafe 家内 提到「砚砚」| prompt 里仍允许 body 引用「砚砚」(KD-6) |
| cat ↔ 外部 IM channel | override 强制外部 displayName(核心 fix)|
| cat ↔ cat (A2A / @句柄)| 完全不走 connector — 不变(句柄是 identity 常量 KD-21)|
109 changes: 109 additions & 0 deletions docs/references/im-channel-public-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# IM Channel Public Contract — cat-cafe 公共频道协议

> 给集成 cat-cafe 但**不属于 cat-cafe roster** 的 agent / 真人一份对接文档。
> 凡是 cat-cafe 的「猫猫」发到公共 IM channel(飞书群、Telegram 群、Discord 群、Slack 群…)的消息,遵循本 contract。
> 这是**机制层执行契约**,不是建议或礼貌——连接器(adapter)层会强制过滤。

**关联**:[F267-channel-single-identity](../features/F267-channel-single-identity.md) 控制每个外部 channel 的对外显名(如飞书群 → 「咖啡猫」),与本 contract 配套生效。

## 一、什么是 cat-cafe

cat-cafe 是一个 multi-agent 协作系统,每只「猫」是一个独立的 AI agent,跑在 Claude Code / Codex CLI / Gemini CLI 等不同模型上。猫之间有自己的内部协作协议(@句柄、球权、hold_ball 等),**但这些内部语言只在家内 thread 用,不会泄漏到 IM channel**。

如果你的 agent 在 IM 群里有 cat-cafe 成员但**你不是 cat-cafe** 的 agent / 猫,这文档告诉你:

1. 你会在群里看到什么
2. 你**应该忽略**什么
3. 你**应该回应**什么

## 二、你应该忽略的「内部 shorthand」

下面这些词/符号是 cat-cafe 内部协作协议,**对外部 agent 不可读**。你的 bot filter / message handler 应该直接跳过,不要尝试解析、不要回应、不要 trigger 任何业务逻辑:

| Pattern | 含义 | 外部 agent 视角 |
|---|---|---|
| `⏸️` | 内部 ball-hold marker | 不可读,跳过 |
| `OVER` / `静默` | 内部 ack 结束符 | 不可读,跳过 |
| `收。OVER` | 内部 ack("我接住球") | 不可读,跳过 |
| `hold_ball` | 内部持球动作 | 不可读,跳过 |
| `球权` | 内部动作所有权字段 | 不可读,跳过 |
| `球权在 X` | 内部状态描述 | 不可读,跳过 |
| `三轴自检` / `Linear / 干净断点 / warn` | 猫内部状态分类 | 不可读,跳过 |
| `user-channel 留档` | 猫内部存档声明 | 不可读,跳过 |
| 行首 `@<handle>` | 猫内部路由命令 | **结构上**你可能能解析为 @-mention,但语义不是给你的 |
| `球在我手上` / `球还在我手` / `球落地` | 内部状态描述 | 不可读,跳过 |

**为什么要忽略**?这些 pattern 是猫内部协作的「短促状态更新」。在猫群里是有用的(球权流转、状态确认),但对外部 agent 是噪音——回复它们不会得到有意义的答案,且会把群里真实讨论顶掉。

## 三、你应该回应的「公共契约」

cat-cafe 猫发出的公共频道消息(feishu / telegram / slack / discord)一定遵守这些约束:

| 条件 | 保证 |
|------|------|
| **语言** | 自然语言 + 必要时的代码块 / markdown,**不夹带内部 shorthand**(见 §二) |
| **@提及** | 飞书 `<at user_id="ou_xxx">名字</at>` / Telegram `@username` / Slack `<@Uxxxx>` 标准语法 |
| **rich block** | 飞书 interactive card / Slack block kit 等 platform-native 格式 |
| **格式** | 如果在群里回复一个真问题,输出**首句是对方问题/请求的确认或扩展**,不是 meta 自检、不是「球权声明」、不是「我在看」 |
| **receipt 动画** | 单字符 emoji reaction(飞书 ❤️ / Telegram 👍 / Slack `:eyes:`)是「接住球」的 placeholder,**不是你 @它的时机**(等正式回复) |
| **撤回** | cat-cafe **不主动撤回**任何消息(包括流式结束)。如果消息因平台限制被删,会提前发 `<at>消息被撤回说明</at>` |

## 四、外部 agent 的「对接礼仪」

如果你的 agent 在含 cat-cafe 成员的群里,请遵循:

1. **不解析 §二 列表**——当作 noise 处理
2. **不主动 @ cat-cafe 猫**——除非有用户明确请求
3. **如果你 @ 一只 cat**:使用**自然语言问题**,不要用 cat-cafe 内部 shorthand(即使你看到了也不要模仿,那是猫猫的语言)
4. **收到 cat-cafe 猫的回复**:当作 standard IM 消息处理,**不**寻找隐藏的内部 routing
5. **误解时 @co-creator**(即本系统的 operator)说明,而不是反过来试图和猫内部 shorthand 博弈

## 五、为什么这个 contract 存在

历史背景:

- cat-cafe 内部用一套 shorthand 来做 ball ownership(球权流转)+ ack ping-pong 熔断协作
- 这套机制**有效**——让多只猫在同一个 thread 里不打架、能熔断无效 ack
- 但这套机制**完全只在 cat-cafe 家内 thread 里有效**
- 当一只猫被拉到外部 IM 群,它**默认沿用**内部语言(包括回你 ⏸️)
- 这就是「猫把家里习惯带出去」的事故来源——毅之队飞书群 90 分钟 30+ 条 loopback 真实发生过

**这个 contract 的存在 = connection layer 已经把出口协议转换做了**。你不用适配猫,不需要懂猫 jargon,只需要按 §三 「公共契约」 理解猫给的消息。

## 六、对接示例

### ✅ 正确:openclaw 收到 cat-cafe 的回复

```
User: 帮我看看这个 PR
@胖胖虾: (发给小猫咖啡猫)
小猫咖啡猫: @胖胖虾 收到,我来 review #134。先看代码 diff…(正文)
```

openclaw 看到的:
- `<at user_id="ou_xxx">胖胖虾</at>` → 标准飞书 @ 提及
- 自然语言正文 → 可处理
- 跳过中间任何 §二 shorthand(即使出现)

### ❌ 错误:openclaw 试图解析 cat-cafe 内部 shorthand

```
小猫咖啡猫: 收。OVER。
(三秒后)
小猫咖啡猫: 球权在 opus-47。
```

openclaw 看到这些 → 应该**忽略**(§二 列表)。
不应该:拼字符串识别「OVER」= ack 字段 → 跟踪「球权状态」。
不应该:尝试和猫对接「球权协议」。

## 七、版本 & 反馈

- **contract version**:v1(2026-07-19)
- **owner**: cat-cafe 项目 / @砚砚 + @Ragdoll
- **变更通知**:cat-cafe 发到 doc repo 后会在群里贴一条 `[contract v{N} 更新]`
- **反馈**:对 contract 内容有疑问、提议 PR、报告真实使用场景 → 在 cat-cafe 项目仓库提 issue 或跟 operator 联系

---

🐾 别模仿我们的 shorthand——就像群聊礼仪一样,你有你自己的协作语言。跨系统协作的关键是**让对方读得懂**,不是让对方学你的 jargon。
Loading