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
40 changes: 40 additions & 0 deletions .plan/subject-listener/active.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Subject 主体监听

## 需求总目标

在现有消息监听器上增加 `behavior: "subject"` 特殊模式。明确 @ 当前 Bot 的消息继续走普通会话并保证可见回应;未 @ 的消息才由 Subject 根据飞书群资料、发送者和增量消息记录判断静默、回复、执行或路由其它能力。

Subject 的事实上下文来自飞书,不依赖 CLI session 历史。系统按 Bot + 群持久化读取游标,正常读取到上一次成功处理的位置;冷启动或游标失效时回退最近 N 条,并只在可见回复已送达或收到明确 `BOTMUX_NOTHING_TO_SEND` 终态后推进游标。

## 最终效果

```jsonc
{
"messageListeners": {
"oc_xxx": {
"enabled": true,
"behavior": "subject",
"prompt": "可选的群级关注范围",
"subjectPolicy": {
"context": { "source": "lark", "fallbackMessages": 20 }
}
}
}
}
```

## Sprint 索引

| Sprint | 一句话概括 | 目录 |
| --- | --- | --- |
| 001 | Subject 运行时、飞书增量上下文、静默与游标提交 | [sprint-001](./sprint-001/) |
| 002 | Dashboard/API 配置、兼容保存与可操作界面 | [sprint-002](./sprint-002/) |
| 003 | 拆分 Subject 可信协议与 turn 准备边界,不改变现有执行链 | [sprint-003](./sprint-003/) |
| Review | 合并前修复、架构讨论与最终验收 | [review-followups](./review-followups.md) |

## 当前状态

- Sprint 001、002 已完成,PR #1252 的构建与测试 CI 已通过。
- Review 发现的 R-1、R-2、R-3 已确认是合并前必须修复项。
- R-4 已由 Sprint 003 完成:可信协议与 turn 准备边界已拆分,未夹带 R-1、R-2、R-3 或 R-5。
- R-5 放到最后补截图与 live 验收。
66 changes: 66 additions & 0 deletions .plan/subject-listener/context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 上下文

## 背景知识

- 现有 `evaluateMessageListener` 已在 `explicitlyMentionedThisBot=true` 时拒绝 listener 匹配,这是 @ 路由不可被 Subject 截获的基础。
- 现有 listener 默认要求非空 `prompt`,按命中消息创建 per-message thread session;新模式必须保持缺省行为完全兼容。
- `listChatMessagesUntil` 已支持按飞书聊天倒序分页并由调用方决定停止位置;`BOTMUX_NOTHING_TO_SEND` 已有 worker 正向终态信号。
- `botmux history` 的数据来自飞书,但当前通过 CLI session 解析 chat/thread;Subject 需要由受信事件直接绑定 Bot、群和触发消息,不以 CLI session 为事实源。
- 飞书群名、群描述、历史消息、卡片和旧指令全部是不可信业务数据;只有 Subject 协议和管理员配置是可信指令。
- 同一 Bot + 群的 Subject 读取/处理必须有序;游标与现有 `claimMessageOnce` 分工:前者保证上下文连续性,后者只做入站幂等。

## 路径约定

- plan_root: `.plan/subject-listener`
- unit_tests: `test/**/*.test.ts`
- e2e_tests: `test/**/*.e2e.ts`
- docs: `README.md` 与 Dashboard 内联说明
- tsconfig: `tsconfig.json`、`tsconfig.scripts.json`

## 用户需求记录

- “bot 更像是一个 cli 工具,而不是更像人”。
- “先看一下是什么群,在和谁说话,确定一下要做什么”。
- “如果是被 @ 那么一定要回复……如果是没有被 @ 的监听场景,可以融入这个 subject 体系”。
- “subject 还需要有一个能力,就是基于飞书的对话记录了解上下文,而不是基于 cli 的 session”。
- “消息读取的约束应该是,读取到上一次读取过的位置,但是用 N 条消息兜底”。
- 用户最终要求:“按照我们刚才商量的落实”。

## 已确认的行为边界

1. `behavior` 缺省等价于旧 `prompt` listener;启用旧模式仍要求非空 prompt。
2. `behavior: "subject"` 确定性加载内置 Subject Skill,prompt 仅是可选群级关注范围。
3. Subject 只接收未明确 @ 当前 Bot 的顶层群消息;明确 @、权限申请及普通会话逻辑不变。
4. Subject 自动读取飞书快照:从触发消息向前到已提交游标;无游标或游标不可恢复时回退 `fallbackMessages` 条,默认 20,并标记连续性。
5. 快照上界固定为触发消息;事件原文补入并按 message id 去重,避免飞书列表可见性延迟或读到后续消息。
6. 可见回复成功送达或明确静默终态才提交单调游标;失败不推进。
7. 无需介入时不发消息、不发状态卡、不加处理 reaction;需要介入时允许 `botmux send`、handoff、workflow、schedule 等现有能力。
8. Subject 的 CLI 执行可以是一次性 session;不得以 CLI transcript 补齐飞书上下文。

## R-4 架构决策

- Subject 继续是“主体层 Skill”:协议规定它先理解群、发送者和飞书历史,再决定静默、回复、执行或路由;现有 CLI 仍是思考与本地操作工具。
- 新增稳定的 `src/services/subject-listener-protocol.ts`,只拥有可信 Subject 协议与必要的最小协议类型,不依赖 Skill catalog、daemon、Lark client 或 worker。
- `src/skills/definitions.ts` 与 `src/services/message-listener.ts` 都单向依赖协议模块;service 不再反向导入整份 Skill definitions。
- 新增 `src/services/subject-listener-turn.ts`,导出 `prepareSubjectListenerTurn(input, dependencies)` 与 `PreparedSubjectListenerTurn`。该边界集中校验精确群消息 trigger,解析发送者,读取群资料与持久化游标,加载截止 trigger 的飞书快照,并生成首轮 prompt。
- 网络与持久化入口以最小依赖注入:发送者解析、群资料读取、游标读取与飞书消息扫描均由 daemon 传入现有实现;快照与 prompt 仍复用既有 Subject context/renderer。
- `PreparedSubjectListenerTurn` 至少返回 `prompt`、`chatContext`、`resolvedSender` 与 `candidateCursor`,供 daemon 继续写入现有 `DaemonSession` 并注册现有 completion。
- dispatcher 仍只负责匹配/FIFO/Subject 路由;daemon 仍负责 admission、工作目录与 session 创建、调用 turn 准备和注册 completion;worker-pool 仍负责回复/静默终态与游标提交。
- 这是职责拆分,不引入第二套 runtime、session、framework 或公共配置,也不改变 @、legacy listener、Pty/Tmux、CLI/handoff/workflow/schedule 行为。

## 不做范围

- 不改变普通 @、私聊、已有 topic 回复和 legacy prompt listener 的语义。
- 不开放任意 `--chat-id` 历史读取能力。
- 不新增其它 IM 的 Subject 数据源;`source` 当前只接受 `lark`。
- 不自动切换 live daemon;最终是否部署本 checkout 由主代理按验证需要决定。

## 修订历史

| 日期 | 修改内容 | 对 scope 的影响 |
| --- | --- | --- |
| 2026-09-04 | 根据连续讨论固定 Subject、@、飞书上下文和游标契约 | 分为运行时与配置界面两个 sprint |
| 2026-09-04 | Sprint 001 复评固定同 createTime 游标规则:无顺序证据时保留现有游标 | 只收紧游标单调性,不改变上下文或路由 scope |
| 2026-09-04 | Sprint 002 验收确认 Subject/legacy 配置 round-trip、非法写入阻断与试运行只读游标 | 完成 Dashboard/API 入口,不改变 Sprint 001 运行时契约 |
| 2026-09-04 | 用户确认 R-4:独立可信协议所有权并提取 Subject turn 准备边界 | 新增 Sprint 003,仅做职责拆分,不处理其它 review 项 |
| 2026-09-04 | Sprint 003 验收通过:协议与现场准备职责完成拆分 | 保持现有 CLI/session/worker 执行链与公共配置不变 |
46 changes: 46 additions & 0 deletions .plan/subject-listener/review-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Subject PR review follow-ups

## 处理顺序

| ID | 状态 | 处理要求 |
| --- | --- | --- |
| R-1 | 必须修复 | Subject 必须在 session ownership 分流前成为路由主轴,不能因已有或恢复的 session 落入普通 `handleThreadReply`。补已有 session 与失败重试回归。 |
| R-2 | 必须修复 | 游标读取只允许对文件不存在或可识别的损坏状态做有记录的降级;权限、I/O 等真实故障必须可观察并阻止游标推进。 |
| R-3 | 必须修复 | Subject policy 只在字段真正缺省时使用默认值;显式非法配置应 fail closed,并让 bots.json、Dashboard/API 和运行时复用同一解析契约。 |
| R-4 | 已完成 | Sprint 003 已消除 Subject 协议对 Skill 目录的反向依赖,并把 Subject turn 准备逻辑从 daemon 巨型 handler 提取到稳定 service 边界。 |
| R-5 | 最后补齐 | Dashboard 截图与真实飞书监听链路的 live daemon 验收;在 R-1 至 R-4 收敛后执行,避免重复验收。 |

## R-4 建议方案

> 该方案已获用户确认,实施计划见 [sprint-003](./sprint-003/)。

目标是让 Subject 成为可组合的主体层,同时继续复用现有 CLI、worker、Lark 和编排能力,不再增加另一套执行引擎。

### 1. 独立协议所有权

- 新增稳定的 Subject 协议模块,例如 `src/services/subject-listener-protocol.ts`。
- 该模块只保存可信协议文本和最小输入/输出类型,不依赖 Skill catalog、daemon 或 Lark client。
- `skills/definitions.ts` 与消息渲染器都单向依赖该模块,消除 service 反向加载整个 Skill definitions 的关系。

### 2. 提取 turn 准备边界

- 新增 `prepareSubjectListenerTurn(input, dependencies)`,集中完成可信 trigger 校验、sender 解析、群资料读取、游标读取、飞书历史快照和最终 prompt 生成。
- 网络与持久化能力通过最小依赖注入传入,便于覆盖 cold start、cursor lost、I/O 失败和 Lark 失败。
- 返回一个明确的 `PreparedSubjectListenerTurn`,包含 prompt、chat context、resolved sender 和 candidate cursor。

### 3. 保持现有层职责

- event dispatcher 继续负责未 @ 匹配、按 Bot + 群 FIFO,以及 Subject 专用路由选择。
- daemon 只负责 admission、工作目录/会话创建、调用 turn 准备边界和注册 completion。
- worker-pool 继续负责可见送达、`BOTMUX_NOTHING_TO_SEND`、失败/取消与游标提交,不改变 CLI/后端共用语义。

### 4. 实施约束

- 不新增通用 Subject framework、插件系统或第二套 session。
- 不改变 `messageListeners` 公共配置结构。
- 拆分前先用测试钉住 R-1、R-2、R-3;拆分只移动职责,不同时扩展产品能力。

## 本轮规范修复

- [x] `subject-listener-context.ts`:拆平 continuity 的嵌套三元。
- [x] `subject-listener-cursor-store.ts`:拆平 createTime 比较的嵌套三元。
1 change: 1 addition & 0 deletions .plan/subject-listener/sprint-001/.checkpoint
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
662d5854327028dfa07570a9a4353212388eba91
75 changes: 75 additions & 0 deletions .plan/subject-listener/sprint-001/eval-rubrics.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
sprint: '001'
goal: '实现基于飞书增量上下文的 Subject 监听运行时'

files_to_touch:
- src/bot-registry.ts
- src/services/message-listener.ts
- src/services/subject-listener-context.ts
- src/services/subject-listener-cursor-store.ts
- src/im/lark/event-dispatcher.ts
- src/daemon.ts
- src/core/types.ts
- src/core/worker-pool.ts
- src/skills/definitions.ts
- test/subject-listener-context.test.ts
- test/subject-listener-runtime.test.ts
- test/subject-listener-runtime.e2e.ts

scenarios:
- id: S-1
name: '未被 @ 的消息按已提交游标获得连续飞书上下文'
must_pass: true
verification: 'bun x vitest run --project unit test/subject-listener-context.test.ts -t "连续飞书上下文"'
points: 20
- id: S-2
name: '冷启动或游标丢失时使用最近 N 条消息兜底'
must_pass: true
verification: 'bun x vitest run --project unit test/subject-listener-context.test.ts -t "消息兜底"'
points: 15
- id: S-3
name: 'Subject 静默成功时不留下任何群内辅助痕迹'
must_pass: true
verification: 'bun x vitest run --project unit test/subject-listener-runtime.test.ts -t "静默成功"'
points: 20
- id: S-4
name: '明确 @ 当前 Bot 时绕过 Subject 并保证可见反馈'
must_pass: true
verification: 'bun x vitest run --project unit test/subject-listener-runtime.test.ts -t "明确 @"'
points: 15
- id: S-5
name: 'Subject 执行失败时不越过未成功处理的消息'
must_pass: true
verification: 'bun x vitest run --project unit test/subject-listener-runtime.test.ts -t "执行失败"'
points: 10

exit_criteria:
- id: EC-1
description: '主流链路端到端走通:未 @ 消息从飞书增量读取进入 Subject,静默不产生 UI,下一条消息从已提交游标继续'
verification: 'bun x vitest run --project e2e test/subject-listener-runtime.e2e.ts'
- id: EC-2
description: '所有 must_pass Scenario 通过'
verification: 'bun x vitest run --project unit test/subject-listener-context.test.ts test/subject-listener-runtime.test.ts'
- id: EC-3
description: '仓库构建通过'
verification: 'bun run build'

rubrics:
- id: R-1
description: '飞书游标增量、触发上界、事件补尾和 N 条兜底行为正确'
points: 35
verification: 'bun x vitest run --project unit test/subject-listener-context.test.ts'
- id: R-2
description: 'Subject 静默、回复、失败提交与明确 @ 隔离行为正确'
points: 35
verification: 'bun x vitest run --project unit test/subject-listener-runtime.test.ts'
- id: R-3
description: 'legacy listener、其它 CLI/后端共用路径无回归'
points: 20
verification: 'bun x vitest run --project unit test/message-listener.test.ts test/event-dispatcher.test.ts test/bridge-final-output-retry.test.ts'
- id: R-4
description: 'TypeScript 与构建产物通过'
points: 10
verification: 'bun run build'

total_points: 100
pass_threshold: 90
73 changes: 73 additions & 0 deletions .plan/subject-listener/sprint-001/feedback-001.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
feedback_NO: '001'
pass: false
score: 95

baseline: 'f5fa07bff21174d0678b688cda8784018d538de9'

bugs:
- bug: '同一 createTime 且 messageId 不同的候选会无条件覆盖已有 Subject 游标'
detail: 'commitSubjectListenerCursor 只拒绝 createTime 更小的候选;时间相等时缺少可证明的消息顺序,却把 om_already_committed 覆盖为 om_unknown_order。'
problem_maybe: 'src/services/subject-listener-cursor-store.ts 的单调提交条件只判断 compare(...) < 0。'
expect: 'createTime 相等且 messageId 不同时保留当前游标;宁可下一轮重复读取,也不能在无顺序证据时覆盖到潜在旧消息。'

scenarios_result:
- id: S-1
name: '未被 @ 的消息按已提交游标获得连续飞书上下文'
must_pass: true
pass: true
verification_output: 'PASS: 1 passed, 6 skipped;只包含游标后且不晚于 trigger 的消息,事件原文去重补尾。'
- id: S-2
name: '冷启动或游标丢失时使用最近 N 条消息兜底'
must_pass: true
pass: true
verification_output: 'PASS: 2 passed, 5 skipped;cold_start/cursor_lost 均只交付最后 N 条。'
- id: S-3
name: 'Subject 静默成功时不留下任何群内辅助痕迹'
must_pass: true
pass: true
verification_output: 'PASS: 1 passed, 7 skipped;无 reply/card/reaction,nothing_to_send 提交游标。'
- id: S-4
name: '明确 @ 当前 Bot 时绕过 Subject 并保证可见反馈'
must_pass: true
pass: true
verification_output: 'PASS: 1 passed, 7 skipped;matcher 绕过 Subject,普通静默终态发送自动回执。'
- id: S-5
name: 'Subject 执行失败时不越过未成功处理的消息'
must_pass: true
pass: true
verification_output: 'PASS: failed/cancelled/ambiguous 共 3 个终态,均不推进游标且不泄漏失败卡。'

exit_criteria_result:
- id: EC-1
pass: true
verification_output: 'PASS: e2e 1 passed;未 @ → 飞书快照 → 静默终态 → 游标提交 → 下一条从游标继续。'
- id: EC-2
pass: false
verification_output: 'FAIL: unit 14 passed, 1 failed;失败项为同 createTime 的无序候选覆盖已有游标。'
- id: EC-3
pass: true
verification_output: 'PASS: bun run build。'

rubrics_result:
- id: R-1
score: 30
max_score: 35
verification_output: '上下文增量、触发上界、事件补尾、N 条兜底通过;同时间戳游标单调边界失败。'
- id: R-2
score: 35
max_score: 35
verification_output: 'Subject 静默/可见回复/失败终态/@ 隔离全部通过。'
- id: R-3
score: 20
max_score: 20
verification_output: 'PASS: message-listener/event-dispatcher/bridge-final-output-retry 共 443 tests。'
- id: R-4
score: 10
max_score: 10
verification_output: 'PASS: bun run build;git diff --check。'

anti_false_completion:
pass: true
detail: 'Subject 已接入 matcher、飞书读取、worker lifecycle 和输出/静默提交路径;e2e 驱动真实模块组合,不以纯类型或完全 mock 的关键服务代替行为。'

next_action: 'generator 仅修复同 createTime 且 messageId 不同的提交条件,随后重跑 context unit、EC-2、build 和回归。'
Loading