metame-cli 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +90 -17
  2. package/index.js +76 -25
  3. package/package.json +1 -1
  4. package/scripts/bin/dispatch_to +167 -90
  5. package/scripts/daemon-admin-commands.js +225 -24
  6. package/scripts/daemon-agent-commands.js +263 -8
  7. package/scripts/daemon-bridges.js +395 -6
  8. package/scripts/daemon-claude-engine.js +749 -582
  9. package/scripts/daemon-command-router.js +104 -0
  10. package/scripts/daemon-default.yaml +9 -4
  11. package/scripts/daemon-engine-runtime.js +33 -2
  12. package/scripts/daemon-exec-commands.js +8 -5
  13. package/scripts/daemon-file-browser.js +1 -0
  14. package/scripts/daemon-remote-dispatch.js +82 -0
  15. package/scripts/daemon-runtime-lifecycle.js +87 -0
  16. package/scripts/daemon-session-commands.js +19 -11
  17. package/scripts/daemon-session-store.js +26 -8
  18. package/scripts/daemon-task-scheduler.js +2 -2
  19. package/scripts/daemon.js +363 -8
  20. package/scripts/daemon.yaml +356 -0
  21. package/scripts/distill.js +35 -16
  22. package/scripts/docs/agent-guide.md +36 -3
  23. package/scripts/docs/hook-config.md +131 -0
  24. package/scripts/docs/maintenance-manual.md +214 -3
  25. package/scripts/docs/pointer-map.md +60 -5
  26. package/scripts/feishu-adapter.js +127 -58
  27. package/scripts/hooks/hook-utils.js +61 -0
  28. package/scripts/hooks/intent-agent-manage.js +50 -0
  29. package/scripts/hooks/intent-engine.js +103 -0
  30. package/scripts/hooks/intent-file-transfer.js +51 -0
  31. package/scripts/hooks/intent-hook-config.js +28 -0
  32. package/scripts/hooks/intent-memory-recall.js +35 -0
  33. package/scripts/hooks/intent-ops-assist.js +54 -0
  34. package/scripts/hooks/intent-task-create.js +35 -0
  35. package/scripts/hooks/intent-team-dispatch.js +106 -0
  36. package/scripts/hooks/team-context.js +143 -0
  37. package/scripts/memory-extract.js +1 -1
  38. package/scripts/memory-nightly-reflect.js +109 -43
  39. package/scripts/memory-write.js +21 -4
  40. package/scripts/memory.js +55 -17
  41. package/scripts/publish-public.sh +24 -35
  42. package/scripts/qmd-client.js +1 -1
  43. package/scripts/signal-capture.js +14 -0
  44. package/scripts/team-dispatch.js +176 -0
@@ -59,7 +59,7 @@ feishu:
59
59
  - `/stop`:引擎中性,按 `activeProcesses.killSignal` 停止
60
60
  - `/compact`:
61
61
  - Claude 会话:正常压缩
62
- - Codex 会话:返回“暂不支持,请继续同会话”
62
+ - Codex 会话:返回"暂不支持,请继续同会话"
63
63
  - `/engine`:
64
64
  - 查询当前默认引擎:`/engine`
65
65
  - 切换默认引擎:`/engine claude` 或 `/engine codex`
@@ -69,7 +69,7 @@ feishu:
69
69
  - 也支持严格自然语言:`把蒸馏模型改成 5.1mini`
70
70
  - `/doctor`:
71
71
  - 同时检查 Claude/Codex CLI 可用性
72
- - 仅在“当前默认引擎对应 CLI 不可用”时判为故障
72
+ - 仅在"当前默认引擎对应 CLI 不可用"时判为故障
73
73
  - 自定义 provider 下允许任意合法模型名(不再强制 sonnet/opus/haiku)
74
74
 
75
75
  ## 5. Agent Soul 身份层
@@ -88,6 +88,9 @@ feishu:
88
88
  - 活跃子进程:`~/.metame/active_agent_pids.json`
89
89
  - 热重载备份:`~/.metame/.last-good/`(daemon 稳定运行 60s 后自动备份)
90
90
  - 崩溃计数:`~/.metame/.crash-count`(连续 2 次快速崩溃触发自动恢复)
91
+ - Dispatch 队列:`~/.metame/dispatch/pending.jsonl`(本地 socket 降级)
92
+ - 远端 Dispatch 队列:`~/.metame/dispatch/remote-pending.jsonl`(跨设备中继)
93
+ - Dispatch 签名密钥:`~/.metame/.dispatch_secret`(自动创建)
91
94
 
92
95
  ## 7. 热重载安全机制(三层防护)
93
96
 
@@ -126,12 +129,25 @@ feishu:
126
129
  2. 若仍失败,手动 `/new` 新开会话
127
130
  3. 检查 `~/.metame/active_agent_pids.json` 是否残留异常进程
128
131
 
132
+ ### 远端 Dispatch 失败
133
+
134
+ 症状:`/dispatch to peer:project` 返回 `feishu bot not connected`
135
+
136
+ 处理:
137
+
138
+ 1. 确认 `daemon.yaml` 中 `feishu.remote_dispatch.enabled: true` 且 `self`/`chat_id`/`secret` 非空
139
+ 2. 确认飞书 bot 已连接:`/doctor` 查看飞书状态
140
+ 3. 确认 relay 群已加入 `allowed_chat_ids`(relay 群消息需被 daemon 接收)
141
+ 4. 两端的 `secret` 必须完全一致
142
+
129
143
  ## 9. 双平台/双引擎维护矩阵
130
144
 
131
145
  ### 统一维护(改一处即可)
132
146
  - agent-layer.js / daemon-agent-tools.js / daemon-agent-commands.js / daemon-user-acl.js
133
147
  - ENGINE_MODEL_CONFIG(daemon-engine-runtime.js 集中管理)
134
148
  - daemon-runtime-lifecycle.js 的语法检查和备份机制
149
+ - daemon-remote-dispatch.js(纯逻辑,无平台差异)
150
+ - team-dispatch.js(共享解析/hint/enrichment)
135
151
 
136
152
  ### 需分别维护(有平台/引擎特殊分支)
137
153
 
@@ -145,7 +161,202 @@ feishu:
145
161
  | daemon.js `spawnReplacementDaemon` | POSIX: `detached: true` / Windows: `detached: false` | 改 spawn 参数时注意平台分支 |
146
162
  | NL Mac 控制(command-router) | macOS only,`process.platform === 'darwin'` 守卫 | Windows 天然跳过 |
147
163
 
148
- ## 10. 变更后维护动作
164
+ ## 10. 团队路由(Team Routing)
165
+
166
+ ### 概念
167
+
168
+ 一个项目可以有多个 team 成员(数字分身),共享同一个 `cwd`,通过虚拟 chatId 并行工作。
169
+
170
+ ### 创建团队成员(向导)
171
+
172
+ 在手机端(飞书/Telegram)发送以下任一方式触发创建向导:
173
+
174
+ - 自然语言:`创建团队`、`新建工作组`、`建个团队` 等(`_detectTeamIntent` 识别,位于 `daemon-command-router.js`)
175
+ - 命令:`/agent new team`
176
+
177
+ 向导分三步,全部在 `daemon-agent-commands.js` 中实现:
178
+ 1. **name**:输入团队名称
179
+ 2. **members**:输入成员列表,格式 `名称:icon:颜色`,一行或逗号分隔多个
180
+ 3. **cwd**:通过文件浏览器(`daemon-file-browser.js` `team-new` 模式)选择父目录
181
+
182
+ 目录确认(`/agent-team-dir` 回调)后:
183
+ - 在 `<父目录>/team/<成员key>/` 下创建工作目录及 CLAUDE.md
184
+ - 自动执行 `git init`(支持 checkpoint)
185
+ - 若父目录对应已有项目,自动写入 `daemon.yaml` 的 `team` 数组;否则提示手动注册
186
+
187
+ 中间状态保存在 `pendingTeamFlows` Map(`daemon.js` 中定义)。
188
+
189
+ ### 配置
190
+
191
+ 在 `~/.metame/daemon.yaml` 的项目下添加 `team` 数组和 `broadcast: true`:
192
+
193
+ ```yaml
194
+ metame:
195
+ name: 超级总管 Jarvis
196
+ icon: 🤖
197
+ broadcast: true
198
+ team:
199
+ - key: jia
200
+ name: Jarvis · 甲
201
+ icon: 🤖
202
+ color: green
203
+ cwd: ~/AGI/MetaMe
204
+ nicknames:
205
+ - 甲
206
+ auto_dispatch: true
207
+ - key: hunter
208
+ name: 猎手
209
+ icon: 🎯
210
+ peer: windows # ← 远端成员,运行在 Windows 设备上
211
+ nicknames:
212
+ - 猎手
213
+ ```
214
+
215
+ ### 路由规则(按优先级)
216
+
217
+ 1. **引用回复** → 路由到原 agent + 设置 sticky
218
+ 2. **显式昵称**(如"乙 帮我查下")→ 路由到对应成员 + 设置 sticky
219
+ 3. **主项目昵称**(如"贾维斯")→ 清除 sticky,路由到主项目
220
+ 4. **Sticky**:无昵称时 → 路由到上次显式指定的成员
221
+ 5. **Auto-dispatch**:主忙时自动分配给空闲的 `auto_dispatch` 成员
222
+
223
+ **远端成员**:检测到 `member.peer` 时,bridges 自动走 `sendRemoteDispatch()` → relay 群 → 对端 daemon 接收执行,结果通过 relay 群回传。路由优先级不变,只是传输链路不同。
224
+
225
+ ### /stop 精准路由
226
+
227
+ - `/stop 乙`:停止指定成员
228
+ - `/stop`:停止 sticky 成员
229
+ - 引用回复 `/stop`:停止对应成员
230
+
231
+ ### /msg — 团队直接消息
232
+
233
+ 格式:`/msg <agent昵称> <消息内容>`
234
+
235
+ - 例如:`/msg 乙 帮我看看这个文件`
236
+ - 按昵称解析目标(先查 team 成员,再查 projects)
237
+ - 以 `type='message', priority='normal'` 调度
238
+ - 实现文件:`daemon-admin-commands.js` resolveProjectKey 函数
239
+
240
+ ### Team Broadcast(团队广播 = 可观察模式)
241
+
242
+ `broadcast: true` 时,team 成员之间通过 `dispatch_to` 互发消息会在群里用卡片广播。
243
+
244
+ **这就是"观察模式"**:开启 broadcast 后,你在群里可以实时看到成员之间互相传递任务的全过程(哪个成员发给了哪个成员、发了什么内容),以飞书卡片形式展示。
245
+
246
+ 切换命令:`/broadcast on` / `/broadcast off`(实时生效,写入 daemon.yaml)
247
+
248
+ 实现入口:`daemon.js` `_findTeamBroadcastContext()` + `handleDispatchItem()` 的广播分支。
249
+
250
+ ### 虚拟 chatId
251
+
252
+ team 成员使用 `_agent_{key}` 格式的虚拟 chatId,与物理群 chatId 隔离。
253
+
254
+ ### 卡片标题
255
+
256
+ 由 `icon + name` 拼成,如 `🤖 Jarvis · 乙`。
257
+
258
+ ## 11. 跨设备 Dispatch(Remote Peer Dispatch)
259
+
260
+ ### 概念
261
+
262
+ team 成员可以通过 `peer` 字段标记为"远端成员"——运行在另一台机器上的 daemon。用户在飞书群里对远端成员的操作体验与本地成员完全一致(昵称路由、sticky follow 等),底层通过飞书 relay 群实现跨设备通信。
263
+
264
+ ### 配置
265
+
266
+ 两台设备的 `daemon.yaml` 需要配置相同的 relay 群和共享密钥,不同的 `self` 标识:
267
+
268
+ ```yaml
269
+ # Mac 端
270
+ feishu:
271
+ remote_dispatch:
272
+ enabled: true
273
+ self: mac # 本机标识(唯一)
274
+ chat_id: oc_relay_xxx # 专用中继群(两端相同)
275
+ secret: shared-secret-key # HMAC 签名密钥(两端相同)
276
+
277
+ # Windows 端
278
+ feishu:
279
+ remote_dispatch:
280
+ enabled: true
281
+ self: windows
282
+ chat_id: oc_relay_xxx # 同一个 relay 群
283
+ secret: shared-secret-key # 同一个密钥
284
+ ```
285
+
286
+ team 成员添加 `peer` 字段指向远端设备:
287
+
288
+ ```yaml
289
+ projects:
290
+ business:
291
+ team:
292
+ - key: writer
293
+ name: 编剧 # 无 peer → 本地执行
294
+ - key: hunter
295
+ name: 猎手
296
+ peer: windows # 远端设备
297
+ ```
298
+
299
+ ### 数据流
300
+
301
+ #### 用户消息 → 远端 team member
302
+
303
+ ```
304
+ 飞书群消息 "猎手 去调研竞品"
305
+ → bridges.js findTeamMember → { member: { key:'hunter', peer:'windows' } }
306
+ → _dispatchToTeamMember 检测 member.peer
307
+ → sendRemoteDispatch → encodePacket + HMAC 签名 → relay 群
308
+ → Windows daemon bridges 拦截 → decodePacket + verifyPacket + isDuplicate
309
+ → handleDispatchItem(local) → Claude 执行
310
+ → _replyFn → encode result → relay 群
311
+ → Mac daemon 拦截 → decode → sendMarkdown 到用户飞书群
312
+ ```
313
+
314
+ #### Claude session 内跨设备 dispatch
315
+
316
+ ```
317
+ Claude 看到 hook 注入:
318
+ - hunter(猎手 [远端:windows]): `dispatch_to --from writer windows:hunter "消息"`
319
+ → dispatch_to 解析 peer:project 格式
320
+ → 写 remote-pending.jsonl → daemon heartbeat drain → bot 发 relay 群
321
+ → 对端 daemon 接收执行
322
+ ```
323
+
324
+ ### Packet 协议
325
+
326
+ - 前缀:`[METAME_REMOTE_DISPATCH]`
327
+ - 编码:Base64(JSON)
328
+ - 签名:HMAC-SHA256,payload = packet body 去掉 sig 字段
329
+ - 去重:5 分钟 TTL Map,按 packet.id 去重
330
+
331
+ ### 关键模块
332
+
333
+ | 模块 | 职责 |
334
+ |------|------|
335
+ | `daemon-remote-dispatch.js` | 编解码、签名验签、去重、配置解析、`parseRemoteTargetRef` |
336
+ | `daemon.js:sendRemoteDispatch()` | 构造签名 packet → 通过飞书 bot 发 relay 群 |
337
+ | `daemon.js:handleRemoteDispatchMessage()` | 接收端:decode → verify → dedup → 执行或投递结果 |
338
+ | `daemon-bridges.js` | Feishu bridge 拦截 relay 群消息 + `_dispatchToTeamMember` 远端分流 |
339
+ | `daemon-admin-commands.js` | `/dispatch peers` 查看配置 + `/dispatch to peer:project` 手动派发 |
340
+ | `scripts/bin/dispatch_to` | 支持 `peer:project` 格式 → 写 `remote-pending.jsonl` |
341
+ | `team-dispatch.js` | `buildTeamRosterHint()` 为远端成员生成 `peer:key` 格式命令 |
342
+ | `hooks/team-context.js` | intent hook 注入远端 `peer:key` dispatch 命令 |
343
+
344
+ ### 管理命令
345
+
346
+ - `/dispatch peers`:查看远端配置(self peer、relay chat、所有远端成员列表)
347
+ - `/dispatch to windows:hunter <任务>`:手动跨设备派发
348
+ - `/dispatch to 猎手 <任务>`:按昵称解析,自动检测 `member.peer` 走远端
349
+
350
+ ## 12. 私人配置保护
351
+
352
+ - `daemon.yaml` 是用户私人配置,包含 API keys、chat IDs、个人项目配置
353
+ - **绝不上传**到代码仓库,已加入 `.gitignore`
354
+ - 仓库只追踪 `scripts/daemon-default.yaml`(模板文件)
355
+ - 部署流程(`node index.js`)不会覆盖用户的 `~/.metame/daemon.yaml`
356
+ - 同样不应上传的文件:`MEMORY.md`、`SOUL.md`、`.env*`
357
+ - Agent 在执行任务时,**绝不能** `cp scripts/daemon.yaml ~/.metame/daemon.yaml`,这会覆盖用户私人配置
358
+
359
+ ## 13. 变更后维护动作
149
360
 
150
361
  1. `npm test`
151
362
  2. `npm run sync:plugin`
@@ -1,6 +1,6 @@
1
1
  # MetaMe 脚本/文档指针地图
2
2
 
3
- > 目的:回答“这段能力在哪个文件”“当前升级做到哪一步”“先看哪个脚本”。
3
+ > 目的:回答"这段能力在哪个文件""当前升级做到哪一步""先看哪个脚本"。
4
4
 
5
5
  ## 快速入口
6
6
 
@@ -41,22 +41,71 @@
41
41
  - Agent 命令处理(新):
42
42
  - `scripts/daemon-agent-commands.js`
43
43
  - 关键点:`createAgentCommandHandler()` 处理 `/agent`、`/activate`、`/resume`;
44
- `/agent soul [repair|edit]`;`pendingActivations` 无 TTL(消费即删);防止创建群自激活
44
+ `/agent soul [repair|edit]`;`pendingActivations` 无 TTL(消费即删);防止创建群自激活;
45
+ `/agent new team` 三步向导(name → members → cwd);
46
+ `/agent-team-dir` 回调处理目录选择并最终写入 daemon.yaml `team` 段;
47
+ `pendingTeamFlows` Map 维护向导中间状态
45
48
 
46
49
  - 路由与 Agent 创建:
47
50
  - `scripts/daemon-command-router.js`
48
51
  - `scripts/daemon-agent-tools.js`
49
52
  - 关键点:自然语言提取 `codex` 关键词;默认 `claude` 不写 `engine` 字段,仅 `codex` 持久化 `engine: codex`;
50
- `bindAgentToChat()` 自动调用 `ensureAgentMetadata()` 建立 soul
53
+ `bindAgentToChat()` 自动调用 `ensureAgentMetadata()` 建立 soul 层;
54
+ `_detectTeamIntent()` 自然语言意图识别(含负样本过滤),识别"建团队"意图后自动路由到 `/agent new team` 向导
51
55
 
52
56
  - 会话命令与兼容边界:
53
57
  - `scripts/daemon-exec-commands.js`
54
- - 关键点:`/stop` 引擎中性;`/compact` 在 codex 会话返回“暂不支持”
58
+ - 关键点:`/stop` 引擎中性;`/compact` 在 codex 会话返回"暂不支持"
55
59
 
56
60
  - 运行时引擎切换与诊断:
57
61
  - `scripts/daemon-admin-commands.js`
58
62
  - 关键点:`/engine` 切换默认引擎;`/doctor` 按默认引擎检查 CLI 可用性(Claude/Codex)并兼容自定义 provider 模型名
59
63
 
64
+ ## 团队 Dispatch 与跨设备通信定位
65
+
66
+ - 共享 Dispatch 工具:
67
+ - `scripts/team-dispatch.js`
68
+ - 关键点:`resolveProjectKey()` 名称/昵称解析(含 team member `parent/member` 复合键);
69
+ `findTeamMember()` 文本前缀匹配团队成员昵称;
70
+ `buildTeamRosterHint()` 生成团队上下文块(远端成员自动带 `peer:key` 前缀);
71
+ `buildEnrichedPrompt()` 注入共享上下文(now.md + _latest.md + inbox)
72
+
73
+ - 远端 Dispatch 协议:
74
+ - `scripts/daemon-remote-dispatch.js`
75
+ - 关键点:`normalizeRemoteDispatchConfig()` 解析 `feishu.remote_dispatch` 配置;
76
+ `parseRemoteTargetRef()` 解析 `peer:project` 格式;
77
+ `encodePacket()`/`decodePacket()` Base64 + HMAC-SHA256 编解码;
78
+ `verifyPacket()` 签名验证;
79
+ `isDuplicate()` 5 分钟 TTL 去重;
80
+ `isRemoteMember()` 检测 `member.peer` 字段
81
+
82
+ - Daemon 远端 Dispatch 入口:
83
+ - `scripts/daemon.js`
84
+ - 关键点:`sendRemoteDispatch()` 构造签名 packet → 飞书 bot 发 relay 群;
85
+ `handleRemoteDispatchMessage()` 接收端逻辑(decode → verify → dedup → 按 type 路由);
86
+ `remote-pending.jsonl` drain(heartbeat 中处理 dispatch_to CLI 写入的远端队列)
87
+
88
+ - Bridge 集成:
89
+ - `scripts/daemon-bridges.js`
90
+ - 关键点:Feishu bridge `startReceiving` 回调最前面拦截 relay 群消息 → `handleRemoteDispatchMessage`;
91
+ `_dispatchToTeamMember` 检测 `isRemoteMember(member)` → 走 `sendRemoteDispatch` 而非本地 handleCommand
92
+
93
+ - Dispatch CLI:
94
+ - `scripts/bin/dispatch_to`
95
+ - 关键点:支持 `peer:project` 格式 → `sendRemoteViaRelay()`;
96
+ `--team` broadcast 自动分流远端成员写 `remote-pending.jsonl`;
97
+ 本地走 Unix socket / `pending.jsonl` 降级
98
+
99
+ - 管理命令:
100
+ - `scripts/daemon-admin-commands.js`
101
+ - 关键点:`/dispatch peers` 查看远端配置;
102
+ `/dispatch to peer:project` 手动远端派发;
103
+ 按昵称解析到远端 member 时自动走 `sendRemoteDispatch`
104
+
105
+ - Intent Hook:
106
+ - `scripts/hooks/team-context.js`
107
+ - 关键点:检测通信意图 → 注入 dispatch_to 命令提示;远端成员自动带 `peer:key` 前缀
108
+
60
109
  ## Mentor Mode(Step 1-4)定位
61
110
 
62
111
  - Step 1 数据基建:
@@ -90,6 +139,11 @@
90
139
  - 夜间反思文档:`~/.metame/memory/decisions/`、`~/.metame/memory/lessons/`
91
140
  - 知识胶囊:`~/.metame/memory/capsules/`
92
141
  - 复盘文档:`~/.metame/memory/postmortems/`
142
+ - Dispatch 队列:`~/.metame/dispatch/pending.jsonl`(本地 socket 降级)
143
+ - 远端 Dispatch 队列:`~/.metame/dispatch/remote-pending.jsonl`(跨设备中继)
144
+ - 共享进度白板:`~/.metame/memory/now/shared.md`
145
+ - Agent 最新产出:`~/.metame/memory/agents/{key}_latest.md`
146
+ - Agent 收件箱:`~/.metame/memory/inbox/{key}/`(未读),`read/`(已归档)
93
147
  - **Agent Soul 层**:`~/.metame/agents/<agent_id>/`
94
148
  - `agent.yaml` — id / name / engine / aliases
95
149
  - `soul.md` — 身份定义(主文件,项目目录的 SOUL.md 是其链接)
@@ -102,7 +156,8 @@
102
156
  1. 先看配置:`~/.metame/daemon.yaml` 与 `scripts/daemon-default.yaml`
103
157
  2. 再看命令入口:`scripts/daemon-admin-commands.js`、`scripts/daemon-command-router.js`、`scripts/daemon-exec-commands.js`
104
158
  3. 再看执行链路:`scripts/daemon-engine-runtime.js` → `scripts/daemon-claude-engine.js` → `scripts/mentor-engine.js`
105
- 4. 最后看离线任务:`scripts/distill.js`、`scripts/memory-extract.js`、`scripts/memory-nightly-reflect.js`
159
+ 4. 团队/跨设备:`scripts/team-dispatch.js` → `scripts/daemon-remote-dispatch.js` → `scripts/daemon-bridges.js`
160
+ 5. 最后看离线任务:`scripts/distill.js`、`scripts/memory-extract.js`、`scripts/memory-nightly-reflect.js`
106
161
 
107
162
  ## 同步提示
108
163
 
@@ -97,11 +97,14 @@ function createBot(config) {
97
97
  const res = await withTimeout(client.im.message.create({
98
98
  params: { receive_id_type: 'chat_id' },
99
99
  data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
100
- }));
100
+ }), 30000); // 30s: large card content can be slow; timeout must not fire after delivery
101
101
  const msgId = res?.data?.message_id;
102
102
  return msgId ? { message_id: msgId } : null;
103
103
  }
104
104
 
105
+ let _editBroken = false; // closure var — safe against destructured calls
106
+ let _editBrokenAt = 0; // timestamp when broken; auto-resets after 10min
107
+
105
108
  return {
106
109
  /**
107
110
  * Send a plain text message
@@ -120,22 +123,26 @@ function createBot(config) {
120
123
  return msgId ? { message_id: msgId } : null;
121
124
  },
122
125
 
123
- _editBroken: false, // Set to true if patch API consistently fails
124
- async editMessage(chatId, messageId, text) {
125
- if (this._editBroken) return false;
126
+ async editMessage(chatId, messageId, text, header = null) {
127
+ if (_editBroken && Date.now() - _editBrokenAt < 10 * 60 * 1000) return false;
128
+ if (_editBroken) _editBroken = false; // auto-reset after 10min
126
129
  try {
127
130
  // Feishu patch API only works on card (interactive) messages
128
- // Update card content with markdown element
129
- const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } };
131
+ // Update card content with markdown element; preserve header if provided
132
+ const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text, text_size: 'x-large' }] } };
133
+ if (header && header.title) {
134
+ card.header = { title: { tag: 'plain_text', content: header.title }, template: header.color || 'blue' };
135
+ }
130
136
  await withTimeout(client.im.message.patch({
131
137
  path: { message_id: messageId },
132
138
  data: { content: JSON.stringify(card) },
133
- }));
139
+ }), 30000); // 30s: must not timeout after Feishu has applied the patch
134
140
  return true;
135
141
  } catch (e) {
136
142
  const code = e?.code || e?.response?.data?.code;
137
143
  if (code === 230001 || code === 230002 || /permission|forbidden/i.test(String(e))) {
138
- this._editBroken = true;
144
+ _editBroken = true;
145
+ _editBrokenAt = Date.now();
139
146
  }
140
147
  return false;
141
148
  }
@@ -316,7 +323,7 @@ function createBot(config) {
316
323
  },
317
324
  });
318
325
 
319
- console.log('[Feishu] Upload response:', JSON.stringify(uploadRes));
326
+ // Upload response logged at debug level if needed
320
327
 
321
328
  // Response is { code, msg, data: { file_key } }
322
329
  const fileKey = uploadRes?.data?.file_key || uploadRes?.file_key;
@@ -357,42 +364,50 @@ function createBot(config) {
357
364
  },
358
365
 
359
366
  /**
360
- * Start WebSocket long connection to receive messages
367
+ * Start WebSocket long connection to receive messages (with auto-reconnect)
361
368
  * @param {function} onMessage - callback(chatId, text, event)
362
- * @returns {Promise<{stop: function}>}
369
+ * @param {object} [opts]
370
+ * @param {function} [opts.log] - logger function(level, msg)
371
+ * @returns {Promise<{stop: function, reconnect: function, isAlive: function}>}
363
372
  */
364
- startReceiving(onMessage) {
365
- return new Promise((resolve, reject) => {
366
- const wsClient = new Lark.WSClient({
367
- appId: app_id,
368
- appSecret: app_secret,
369
- loggerLevel: Lark.LoggerLevel.info,
370
- });
371
-
372
- // Dedup: track recent message_ids (Feishu may redeliver on slow ack)
373
- const _seenMsgIds = new Map(); // message_idtimestamp
374
- const DEDUP_TTL = 60000; // 60s window
375
- function isDuplicate(msgId) {
376
- if (!msgId) return false;
377
- const now = Date.now();
378
- // Cleanup old entries
379
- if (_seenMsgIds.size > 200) {
380
- for (const [k, t] of _seenMsgIds) {
381
- if (now - t > DEDUP_TTL) _seenMsgIds.delete(k);
382
- }
373
+ startReceiving(onMessage, opts = {}) {
374
+ const _log = opts.log || ((lvl, msg) => console.log(`[feishu] [${lvl}] ${msg}`));
375
+ let stopped = false;
376
+ let currentWs = null;
377
+ let healthTimer = null;
378
+ let reconnectTimer = null;
379
+ let reconnectDelay = 5000; // start 5s, doubles up to 60s
380
+ const MAX_RECONNECT_DELAY = 60000;
381
+ const HEALTH_CHECK_INTERVAL = 90000; // check every 90s
382
+ const SILENT_THRESHOLD = 300000; // 5 min no SDK activity suspect dead
383
+
384
+ // Track last SDK activity (any event received = alive)
385
+ let _lastActivityAt = Date.now();
386
+ function touchActivity() { _lastActivityAt = Date.now(); }
387
+
388
+ // Dedup: track recent message_ids (Feishu may redeliver on slow ack)
389
+ const _seenMsgIds = new Map(); // message_id → timestamp
390
+ const DEDUP_TTL = 60000; // 60s window
391
+ function isDuplicate(msgId) {
392
+ if (!msgId) return false;
393
+ const now = Date.now();
394
+ if (_seenMsgIds.size > 200) {
395
+ for (const [k, t] of _seenMsgIds) {
396
+ if (now - t > DEDUP_TTL) _seenMsgIds.delete(k);
383
397
  }
384
- if (_seenMsgIds.has(msgId)) return true;
385
- _seenMsgIds.set(msgId, now);
386
- return false;
387
398
  }
399
+ if (_seenMsgIds.has(msgId)) return true;
400
+ _seenMsgIds.set(msgId, now);
401
+ return false;
402
+ }
388
403
 
389
- const eventDispatcher = new Lark.EventDispatcher({}).register({
404
+ function buildDispatcher() {
405
+ return new Lark.EventDispatcher({}).register({
390
406
  'im.message.receive_v1': async (data) => {
407
+ touchActivity();
391
408
  try {
392
409
  const msg = data.message;
393
410
  if (!msg) return;
394
-
395
- // Dedup by message_id
396
411
  if (isDuplicate(msg.message_id)) return;
397
412
 
398
413
  const chatId = msg.chat_id;
@@ -408,35 +423,30 @@ function createBot(config) {
408
423
  text = msg.content || '';
409
424
  }
410
425
  } else if (msg.message_type === 'file' || msg.message_type === 'image' || msg.message_type === 'media') {
411
- // File, image or media (video) message
412
426
  try {
413
427
  const content = JSON.parse(msg.content);
414
428
  fileInfo = {
415
429
  messageId: msg.message_id,
416
430
  fileKey: content.file_key || content.image_key,
417
431
  fileName: content.file_name || (content.image_key ? `image_${Date.now()}.png` : `file_${Date.now()}`),
418
- msgType: msg.message_type, // 'file', 'image', or 'media'
432
+ msgType: msg.message_type,
419
433
  };
420
434
  } catch {}
421
435
  }
422
436
 
423
- // Strip @mention prefix if present
424
437
  text = text.replace(/@_user_\d+\s*/g, '').trim();
425
438
 
426
439
  if (text || fileInfo) {
427
- // Fire-and-forget: don't block the event loop (SDK needs fast ack)
428
440
  Promise.resolve().then(() => onMessage(chatId, text, data, fileInfo, senderId)).catch((err) => {
429
441
  try { console.error(`[feishu-adapter] onMessage error: ${err && err.message || err}`); } catch { }
430
442
  });
431
443
  }
432
- } catch (e) {
433
- // Non-fatal
434
- }
444
+ } catch (e) { /* Non-fatal */ }
435
445
  },
436
446
  'card.action.trigger': async (data) => {
447
+ touchActivity();
437
448
  try {
438
449
  const action = data.action;
439
- // Try multiple possible chatId fields
440
450
  const chatId = data.open_chat_id || data.chat_id
441
451
  || (data.context && data.context.open_chat_id)
442
452
  || (data.event && data.event.open_chat_id);
@@ -449,27 +459,86 @@ function createBot(config) {
449
459
  const cmd = action.value && action.value.cmd;
450
460
  if (cmd) {
451
461
  Promise.resolve().then(() => onMessage(chatId, cmd, data, null, senderId)).catch((err) => {
452
- try { console.error(`[feishu-adapter] card action error: ${err && err.message || err}`); } catch { }
453
- });
462
+ try { console.error(`[feishu-adapter] card action error: ${err && err.message || err}`); } catch { }
463
+ });
454
464
  }
455
465
  }
456
- } catch (e) {
457
- // Non-fatal
458
- }
466
+ } catch (e) { /* Non-fatal */ }
459
467
  return {};
460
468
  },
461
469
  });
470
+ }
462
471
 
463
- wsClient.start({ eventDispatcher });
472
+ function connect() {
473
+ if (stopped) return;
474
+ try {
475
+ currentWs = new Lark.WSClient({
476
+ appId: app_id,
477
+ appSecret: app_secret,
478
+ loggerLevel: Lark.LoggerLevel.info,
479
+ });
480
+ const eventDispatcher = buildDispatcher();
481
+ currentWs.start({ eventDispatcher });
482
+ touchActivity();
483
+ reconnectDelay = 5000; // reset backoff on successful start
484
+ _log('INFO', 'Feishu WebSocket connecting...');
485
+ } catch (err) {
486
+ _log('ERROR', `Feishu WSClient.start failed: ${err.message}`);
487
+ scheduleReconnect();
488
+ }
489
+ }
464
490
 
465
- // SDK doesn't provide a clean "connected" callback,
466
- // resolve immediately — errors will show in logs
467
- resolve({
468
- stop() {
469
- // SDK doesn't expose a clean shutdown method
470
- // Process exit will clean up
471
- },
472
- });
491
+ function scheduleReconnect() {
492
+ if (stopped) return;
493
+ clearTimeout(reconnectTimer);
494
+ _log('INFO', `Feishu reconnecting in ${reconnectDelay / 1000}s...`);
495
+ reconnectTimer = setTimeout(() => {
496
+ _log('INFO', 'Feishu reconnecting now...');
497
+ connect();
498
+ }, reconnectDelay);
499
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
500
+ }
501
+
502
+ // Health check: detect silent WebSocket death via API probe
503
+ function startHealthCheck() {
504
+ clearInterval(healthTimer);
505
+ healthTimer = setInterval(async () => {
506
+ if (stopped) return;
507
+ const silentMs = Date.now() - _lastActivityAt;
508
+ if (silentMs < SILENT_THRESHOLD) return; // recently active, skip
509
+ // Probe: try a lightweight API call to verify token + connectivity
510
+ try {
511
+ await withTimeout(client.im.chat.list({ params: { page_size: 1 } }), 10000);
512
+ // API works — connection might still be alive, just quiet. Reset activity.
513
+ touchActivity();
514
+ } catch (err) {
515
+ _log('WARN', `Feishu health check failed after ${Math.round(silentMs / 1000)}s silence: ${err.message} — reconnecting`);
516
+ try { currentWs?.stop?.(); } catch { /* ignore */ }
517
+ currentWs = null;
518
+ connect();
519
+ }
520
+ }, HEALTH_CHECK_INTERVAL);
521
+ }
522
+
523
+ // Initial connect
524
+ connect();
525
+ startHealthCheck();
526
+
527
+ return Promise.resolve({
528
+ stop() {
529
+ stopped = true;
530
+ clearTimeout(reconnectTimer);
531
+ clearInterval(healthTimer);
532
+ currentWs = null;
533
+ },
534
+ reconnect() {
535
+ _log('INFO', 'Feishu manual reconnect triggered');
536
+ reconnectDelay = 5000;
537
+ connect();
538
+ },
539
+ isAlive() {
540
+ return !stopped && (Date.now() - _lastActivityAt) < SILENT_THRESHOLD;
541
+ },
473
542
  });
474
543
  },
475
544