lark-relay 0.2.2 → 0.3.0

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.
package/README.md CHANGED
@@ -73,30 +73,30 @@ chats: [oc_xxx]
73
73
  dirs: [/path/to/repo] # 首个 = 主工作目录
74
74
  instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
75
75
  filter: '.mentions[]?.id == "ou_xxx"' # 可选
76
- # session: thread 默认;thread=按 thread_id 隔离 | idle=一群一 session
77
- # display: card 默认;card | cot | final(见下)
76
+ # mode: work 默认;work | chat(见下)
78
77
  # model: <名称> 默认走终端同一套默认路由
79
78
  ```
80
79
 
81
- `display` 三档:
80
+ `mode` 就两个场景 —— 一个键定死全部行为,不用拼组合:
82
81
 
83
- | | 过程展示 | 结论 | 备注 |
84
- | --- | --- | --- | --- |
85
- | `card` | CardKit 流式打字机,单卡原地更新 | 写进同一张卡片 | 收尾 PUT 失败自动回退纯文本 |
86
- | `cot` | 思考过程挂在 COT 消息上(AG-UI 事件流) | **总是单独发一条** | 客户端 PC ≥ 7.70 / 移动 ≥ 7.74 |
87
- | `final` | | 单独发一条 | 最省事,不流式 |
82
+ | | `work`(默认) | `chat` |
83
+ |---|---|---|
84
+ | 用途 | 处理工作 | 日常闲聊 |
85
+ | 回复位置 | 话题内(首问开话题) | 直接发群里 |
86
+ | 过程展示 | COT 消息(实时流式) | |
87
+ | 结论 | 卡片(markdown 渲染) | 纯文本 |
88
+ | session 隔离 | 按 `thread_id`(话题即边界,不滚动) | 按 (群, epoch),空闲超 `idle_gap` 开新世代 |
89
+ | 防抖 | 1s(一问一答要跟手) | 15s(等人打完多行) |
88
90
 
89
- `cot` 档的「结论单独发」不是降级路径,而是 COT 接口的设计前提 —— COT 消息只承载
90
- 过程。它也**不写工具结果**:工具输出动辄几千字符且可能含不宜进群的仓库内容,
91
- 与 `card` 档只显示工具名+参数摘要一致。
91
+ `work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
92
+ 结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
92
93
 
93
- ⚠️ **老客户端(< 7.70)看不到过程**:COT 消息底层是 `msg_type: post`,不支持的客户端
94
- 只会看到一条内容为「Completed」的消息(实测,不会崩)。群里有老客户端用户时选 `card`。
95
- 结论那条普通文本不受影响 —— 这也是「结论单独发」的价值。
94
+ ⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
95
+ Completed(不会崩),结论卡片不受影响 —— 这也是「结论单独发」的价值。
96
96
 
97
- 环境变量:`LR_COT_BATCH_MS`(攒批窗口,默认 1000)、
98
- `LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`)。
99
- 实测两者渲染一致,`text` 每段少 2 个事件(reasoning 多一对 START/END 包裹)。
97
+ 环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
98
+ `LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
99
+ 实测两者渲染一致,`text` 每段少 2 个事件)。
100
100
 
101
101
  ## 存储布局
102
102
 
package/lib/card.js CHANGED
@@ -1,22 +1,16 @@
1
1
  'use strict'
2
2
 
3
- // card display:CardKit 流式打字机,单卡原地更新。
4
- // 五步流:建卡实体 发引用消息 流式 PUT body → 收尾全卡重构 → 关流式定格。
3
+ // 结论卡片:一条非流式 interactive 消息。
4
+ // work 模式用它发最终结论 —— 过程由 COT 消息承担(lib/cot.js),这里只放结论。
5
5
  //
6
- // ⚠️ 两处双层编码(踩过):
7
- // 建卡的 data 与关流式的 settings 都是**字符串化的 JSON**,不是对象
8
- // ⚠️ print_frequency_ms / print_step 必须是按端对象 {default,android,ios,pc},
9
- // 传标量会 10002 unmarshal 报错
6
+ // 比纯文本好在:markdown 正常渲染、不受 reply 的 3500 字符分片限制。
7
+ // **不需要 card 实体**:直接 msg_type=interactive 一步发出(实测),
8
+ // cardkit 那套「建实体 card_id → 流式 PUT → 关流式」只有流式打字机才需要
10
9
  const larkcli = require('./larkcli')
11
- const { stripToJson, errText, tail } = larkcli
12
-
13
- const THROTTLE_MS = Number(process.env.LR_CARD_THROTTLE_MS || 300)
14
- const PRINT_FREQ_MS = Number(process.env.LR_CARD_PRINT_FREQ_MS || 10)
15
- const PRINT_STEP = Number(process.env.LR_CARD_PRINT_STEP || 20)
16
- const PLACEHOLDER = process.env.LR_CARD_PLACEHOLDER || '🤔 正在思考…'
10
+ const { errText, tail } = larkcli
17
11
 
18
12
  // 飞书卡片按标题字号渲染 #,窄卡片里过大突兀 → ATX 标题降级为加粗。
19
- // 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)绝不能被误降级。
13
+ // 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)绝不能被误降级
20
14
  function mdfix(text) {
21
15
  const out = []
22
16
  let inCode = false
@@ -38,196 +32,45 @@ function mdfix(text) {
38
32
  return out.join('\n')
39
33
  }
40
34
 
41
- function initCard() {
42
- const perDevice = (v) => ({ default: v, android: v, ios: v, pc: v })
35
+ function buildCard(text) {
43
36
  return {
44
37
  schema: '2.0',
45
- config: {
46
- streaming_mode: true,
47
- update_multi: true,
48
- streaming_config: {
49
- print_frequency_ms: perDevice(PRINT_FREQ_MS),
50
- print_step: perDevice(PRINT_STEP),
51
- },
52
- },
53
- // 不设 header:固定「🤖 Claude」标题头太突兀,去掉后卡片只剩正文更自然
54
- body: {
55
- elements: [{ tag: 'markdown', element_id: 'body', content: PLACEHOLDER }],
56
- },
38
+ config: {},
39
+ body: { elements: [{ tag: 'markdown', content: mdfix(text) }] },
57
40
  }
58
41
  }
59
42
 
60
- class CardSession {
61
- constructor({ profile, chatId, lastMsg, replyInThread }) {
62
- this.profile = profile
63
- this.chatId = chatId
64
- this.lastMsg = lastMsg
65
- this.replyInThread = replyInThread
66
- this.cardId = null
67
- this.messageId = null
68
- this.seq = 0 // int32 从 1 起
69
- this.lastPutAt = 0
70
- this.acc = ''
71
- this.proc = ''
72
- this.pendingSay = null
73
- // 串行队列:所有卡片写操作排队执行。
74
- // ⚠️ onEvent 里的 update() 是 fire-and-forget(不 await),若并发发出,
75
- // 晚发的可能先到 → 服务端拒较小 sequence;更糟的是 in-flight 的 update
76
- // (seq 小、body 大所以慢)在 finalize/stop 之后才到达 →
77
- // **卡片从最终结论退回中间过程文本并定格在那里**(流式已关,不会再刷新)。
78
- // 实测到达顺序曾是 finalize(2) → stop(3) → update(1)
79
- this.q = Promise.resolve()
80
- }
81
-
82
- next() {
83
- return ++this.seq
84
- }
85
-
86
- // 把卡片写操作排进串行队列。seq 在**真正执行时**才取,保证发出顺序 == seq 顺序
87
- _enqueue(fn) {
88
- this.q = this.q.then(fn, fn)
89
- return this.q
90
- }
91
-
92
- // 步骤 ①:建卡片实体 → card_id。data 是**字符串**(双层编码)
93
- async create() {
94
- const body = JSON.stringify({ type: 'card_json', data: JSON.stringify(initCard()) })
95
- const { stdout } = await larkcli.run([
96
- '--profile', this.profile, 'api', 'POST', '/open-apis/cardkit/v1/cards',
97
- '--as', 'bot', '--data', body,
98
- ])
99
- const j = JSON.parse(stripToJson(stdout))
100
- this.cardId = j?.data?.card_id || null
101
- return this.cardId
102
- }
103
-
104
- // 步骤 ②:发一条引用该 card_id 的消息
105
- async post() {
106
- const content = JSON.stringify({ type: 'card', data: { card_id: this.cardId } })
107
- let out
108
- if (this.replyInThread && this.lastMsg && this.lastMsg !== '-') {
109
- const data = { msg_type: 'interactive', content, reply_in_thread: true }
110
- out = await larkcli.run([
111
- '--profile', this.profile, 'api', 'POST',
112
- `/open-apis/im/v1/messages/${this.lastMsg}/reply`,
113
- '--as', 'bot', '--data', JSON.stringify(data),
114
- ])
115
- } else {
116
- out = await larkcli.run([
117
- '--profile', this.profile, 'im', '+messages-send',
118
- '--params', JSON.stringify({ receive_id_type: 'chat_id' }),
43
+ /**
44
+ * 发一张结论卡片。失败返回 false —— 调用方必须降级发纯文本,保证结论必达。
45
+ * @param {object} opts {profile, chatId, replyTo, inThread}
46
+ */
47
+ async function sendCard(text, { profile, chatId, replyTo, inThread } = {}) {
48
+ const content = JSON.stringify(buildCard(text))
49
+ const args = replyTo && replyTo !== '-'
50
+ ? [
51
+ '--profile', profile, 'api', 'POST',
52
+ `/open-apis/im/v1/messages/${replyTo}/reply`,
53
+ '--as', 'bot',
119
54
  '--data', JSON.stringify({
120
- receive_id: this.chatId, msg_type: 'interactive', content,
55
+ msg_type: 'interactive',
56
+ content,
57
+ ...(inThread ? { reply_in_thread: true } : {}),
121
58
  }),
59
+ ]
60
+ : [
61
+ '--profile', profile, 'im', '+messages-send',
62
+ '--params', JSON.stringify({ receive_id_type: 'chat_id' }),
63
+ '--data', JSON.stringify({ receive_id: chatId, msg_type: 'interactive', content }),
122
64
  '--as', 'bot',
123
- ])
124
- }
125
- const j = JSON.parse(stripToJson(out.stdout))
126
- this.messageId = j?.data?.message_id || null
127
- return this.messageId
128
- }
129
-
130
- // 步骤 ③:流式期单 body 元素全量替换(打字机)。
131
- // 全量替换故本地要维护累积串;单次失败可容忍(下次 PUT 是全量,自愈)。
132
- // 走串行队列:调用方 fire-and-forget 也不会乱序
133
- update(text) {
134
- return this._enqueue(async () => {
135
- const now = Date.now()
136
- if (now - this.lastPutAt < THROTTLE_MS) return // 仅防频控,不为攒批
137
- this.lastPutAt = now
138
- const content = mdfix(text || '🔧 正在查阅…')
139
- try {
140
- await larkcli.run([
141
- '--profile', this.profile, 'api', 'PUT',
142
- `/open-apis/cardkit/v1/cards/${this.cardId}/elements/body/content`,
143
- '--as', 'bot',
144
- '--data', JSON.stringify({ content, sequence: this.next() }),
145
- ])
146
- } catch {}
147
- })
148
- }
149
-
150
- // 步骤 ④:收尾全卡重构。注意外层键是 card(内含 {type,data}),不是 ③ 的 content。
151
- // 排在队尾执行 —— 必须等所有 in-flight update 落地,否则会被它们覆盖
152
- finalize(result) {
153
- return this._enqueue(async () => {
154
- const elements = []
155
- if (this.proc.trim()) {
156
- elements.push({
157
- tag: 'collapsible_panel',
158
- expanded: false,
159
- header: { title: { tag: 'markdown', content: '💭 思考与执行过程(点击展开)' } },
160
- elements: [{ tag: 'markdown', content: mdfix(this.proc) }],
161
- })
162
- elements.push({ tag: 'hr' })
163
- }
164
- // 无过程内容时只放结论,不放空面板
165
- elements.push({ tag: 'markdown', content: mdfix(result) })
166
- const card = {
167
- schema: '2.0',
168
- config: { streaming_mode: false },
169
- body: { elements },
170
- }
171
- const body = JSON.stringify({
172
- card: { type: 'card_json', data: JSON.stringify(card) },
173
- sequence: this.next(),
174
- })
175
- try {
176
- const { stdout } = await larkcli.run([
177
- '--profile', this.profile, 'api', 'PUT',
178
- `/open-apis/cardkit/v1/cards/${this.cardId}`,
179
- '--as', 'bot', '--data', body,
180
- ])
181
- return /"ok":\s*true/.test(stdout)
182
- } catch (err) {
183
- process.stderr.write(`card: 收尾全卡更新失败 → ${tail(errText(err), 200)}\n`)
184
- return false
185
- }
186
- })
187
- }
188
-
189
- // 步骤 ⑤:关流式定格。settings 的值是**字符串化的 JSON**(双层转义)
190
- stop() {
191
- return this._enqueue(async () => {
192
- try {
193
- await larkcli.run([
194
- '--profile', this.profile, 'api', 'PATCH',
195
- `/open-apis/cardkit/v1/cards/${this.cardId}/settings`,
196
- '--as', 'bot',
197
- '--data', JSON.stringify({
198
- settings: JSON.stringify({ config: { streaming_mode: false } }),
199
- sequence: this.next(),
200
- }),
201
- ])
202
- } catch {}
203
- })
204
- }
205
-
206
- // 流式事件三种 kind。pendingSay 延迟一段:每轮最后一段 say 就是最终结论
207
- // (会作为 result 再来一次),不能进过程区否则结论重复。
208
- // 用「延迟一段」而非内容比较 —— 最稳。
209
- onSay(text) {
210
- this.acc += (this.acc ? '\n\n' : '') + text
211
- this._flushPending()
212
- this.pendingSay = text
213
- }
214
-
215
- onTool(text) {
216
- this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
217
- this._flushPending()
218
- this.proc += `🔧 ${text}\n`
219
- }
220
-
221
- onFinal() {
222
- this.pendingSay = null // 手里那段 pending(=结论)直接丢弃,不 flush
223
- }
224
-
225
- _flushPending() {
226
- if (this.pendingSay) {
227
- this.proc += `💬 ${this.pendingSay}\n\n`
228
- this.pendingSay = null
229
- }
65
+ ]
66
+ try {
67
+ const { stdout } = await larkcli.run(args)
68
+ if (/"ok":\s*true/.test(stdout)) return true
69
+ process.stderr.write(`card: 未 ok chat=${chatId} → ${tail(stdout)}\n`)
70
+ } catch (err) {
71
+ process.stderr.write(`card: 发送失败 chat=${chatId} → ${tail(errText(err))}\n`)
230
72
  }
73
+ return false
231
74
  }
232
75
 
233
- module.exports = { CardSession, mdfix }
76
+ module.exports = { sendCard, mdfix, buildCard }
package/lib/cot.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  // cot display:消息上挂载「思考过程」,事件流对齐 AG-UI 协议。
4
4
  // 三步流:POST 建 COT 消息 → PUT 攒批写事件(可反复)→ RUN_FINISHED 自动完结。
5
- // 接口与 CardSession 同构,让 dispatch 里两档只差一个 new。
6
5
  // 接口细节与实测边界见 docs/cot-message-api.md
7
6
  //
8
7
  // **COT 只承载过程,结论必须单独发一条普通消息** —— 接口的设计前提,不是我们的选择。
@@ -100,16 +99,12 @@ class CotSession {
100
99
  this.cotId = null
101
100
  this.messageId = null
102
101
  this.done = false // 已写 RUN_FINISHED/RUN_ERROR;之后写入必被拒
103
- this.acc = '' // **只写不读**,纯为与 CardSession 接口同构;无消费者,别维护它的格式
104
- // 只用来生成本地唯一 id,**从不发给服务端**。
105
- // ⚠️ 与 CardSession.seq 同名但无关:那个是服务端排序号,小了会被拒
106
- this.seq = 0
102
+ this.seq = 0 // 只用来生成本地唯一的 messageId/toolCallId,**从不发给服务端**
107
103
  this.pending = [] // 待发事件队列
108
104
  this.lastStamp = 0 // 事件 timestamp 的单调下界(见 _stamp)
109
105
  this.pendingSay = null // 延迟一段的 say(见 onSay);结论不能进过程区
110
106
  this.timer = null
111
- // 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接。
112
- // 与 CardSession 的 q 同一理由(那边是 sequence 被服务端拒,这边是文本错位)。
107
+ // 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接成错乱文本
113
108
  this.q = Promise.resolve()
114
109
  }
115
110
 
@@ -139,11 +134,6 @@ class CotSession {
139
134
  return this.cotId
140
135
  }
141
136
 
142
- // 与 CardSession 接口对齐:COT 一步建好,没有「另发一条消息引用它」的步骤
143
- async post() {
144
- return this.messageId
145
- }
146
-
147
137
  // 编码单条事件:content 序列化 + 超 4096 字节时截断自由文本字段。
148
138
  // 返回事件对象,null = 截不下来只能丢弃(整批被服务端拒比丢一条更糟)
149
139
  _encode(eventType, content) {
@@ -266,11 +256,10 @@ class CotSession {
266
256
  // 中间文本。每段一个独立 messageId —— 同 id 的多条 CONTENT 会被客户端拼成一段,
267
257
  // 而两次 say 之间通常隔着工具调用,拼起来读不通。
268
258
  //
269
- // **延迟一段**(与 CardSession 同机制):最后一段 say 就是结论,它会作为独立消息
270
- // 再发一次 —— 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
271
- // 用「延迟一段」而非内容比较 —— 最稳(card.js 踩出来的)
259
+ // **延迟一段**:最后一段 say 就是结论,它会作为独立消息再发一次 ——
260
+ // 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
261
+ // 用「延迟一段」而非内容比较 —— 最稳
272
262
  onSay(text) {
273
- this.acc += (this.acc ? '\n\n' : '') + text
274
263
  this._flushPending()
275
264
  this.pendingSay = text
276
265
  }
@@ -300,7 +289,6 @@ class CotSession {
300
289
  // 工具调用。**不写 TOOL_CALL_RESULT**:输出动辄几千字符且可能含不宜进群的仓库内容,
301
290
  // 与 card 档只显示工具名+参数摘要一致。text 形如 "Bash: ls -la"(claude.js 拼的)
302
291
  onTool(text) {
303
- this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
304
292
  // 工具调用到来 = 前面那段 say 不是结论,可以放行了
305
293
  this._flushPending()
306
294
  const s = String(text)
@@ -320,8 +308,8 @@ class CotSession {
320
308
  )
321
309
  }
322
310
 
323
- // CardSession 同签名同语义:手里那段 pending(= 结论)直接丢弃,不放行 ——
324
- // 它会作为独立消息发出,写进过程区就成了群里的第二遍
311
+ // 手里那段 pending(= 结论)直接丢弃,不放行 —— 它会作为独立消息发出,
312
+ // 写进过程区就成了群里的第二遍
325
313
  onFinal() {
326
314
  this.pendingSay = null
327
315
  }
@@ -374,13 +362,8 @@ class CotSession {
374
362
  })
375
363
  }
376
364
 
377
- // CardSession 接口对齐:onEvent fire-and-forget 调,cot 侧攒批已自带节流
378
- update() {
379
- return this._settled()
380
- }
381
-
382
- // 与 CardSession 接口对齐。finalize 已完结,这里只兜「从没 finalize 成功」的情况:
383
- // 超时被 SIGTERM、异常中断 —— 不调 complete 的话 COT 会一直显示「在跑」
365
+ // 兜「从没 finalize 成功」的情况:超时被 SIGTERM、异常中断 ——
366
+ // 不调 complete 的话 COT 会一直显示「在跑」
384
367
  async stop() {
385
368
  if (this.done || !this.cotId) return
386
369
  // 从没 finalize 就走到这里 = 异常中断,压着的那段不是结论 → 放行
package/lib/dispatch.js CHANGED
@@ -13,7 +13,7 @@ const { acquireBlocking } = require('./lock')
13
13
  const { paths, ensureDir } = require('./paths')
14
14
  const store = require('./store')
15
15
  const claude = require('./claude')
16
- const { CardSession } = require('./card')
16
+ const { sendCard } = require('./card')
17
17
  const { CotSession } = require('./cot')
18
18
  const { reply } = require('./reply')
19
19
 
@@ -76,7 +76,7 @@ function ledgerAppend(task, chatId, epoch, trigger, result) {
76
76
 
77
77
  // 处理一个 (chat_id, thread_id) 组:派一轮活
78
78
  async function handleGroup(t, group, opts = {}) {
79
- const { name: task, taskDir, insPath, dirs, session, display } = t
79
+ const { name: task, taskDir, insPath, dirs, mode } = t
80
80
  const cfg = t.config
81
81
  const profile = cfg.app
82
82
  const chatId = group.chatId
@@ -97,31 +97,26 @@ async function handleGroup(t, group, opts = {}) {
97
97
  }
98
98
 
99
99
  try {
100
- // session 派生:thread 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
101
- // 否则按 (群, epoch),空闲超 idle_gap 就开新世代
100
+ // session 派生:work 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
101
+ // chat 模式按 (群, epoch),空闲超 idle_gap 就开新世代
102
102
  let uuid
103
103
  let epoch = 0
104
- if (session === 'thread' && threadId) {
104
+ if (mode.thread && threadId) {
105
105
  uuid = claude.deriveUuid(task, `${chatId}|thread:${threadId}`)
106
106
  } else {
107
107
  epoch = claude.sessionEpoch(paths.root, task, chatId, Number(cfg.idle_gap) || 86400)
108
108
  uuid = claude.deriveUuid(task, `${chatId}|${epoch}`)
109
109
  }
110
110
 
111
- const replyInThread = session === 'thread'
112
- // card cot 同构(create/post/onSay/onTool/onFinal/update),只差一个构造器。
113
- // 差别只在收尾:card 把结论写进卡片,cot 的结论**必须**单独发(接口设计前提)
114
- const Session = display === 'card' ? CardSession : display === 'cot' ? CotSession : null
115
- let view = null
116
-
117
- if (Session) {
118
- view = new Session({ profile, chatId, lastMsg, replyInThread })
111
+ // 过程展示:work 模式挂 COT 消息。建不起来就静默降级(结论照发)
112
+ let cot = null
113
+ if (mode.cot) {
114
+ cot = new CotSession({ profile, chatId, lastMsg, replyInThread: mode.thread })
119
115
  try {
120
- if (!(await view.create())) throw new Error('建展示载体失败')
121
- if (!(await view.post())) throw new Error('发展示消息失败')
116
+ if (!(await cot.create())) throw new Error('建 COT 消息失败')
122
117
  } catch (err) {
123
- process.stderr.write(`${display}: ${err.message},回退 final\n`)
124
- view = null
118
+ process.stderr.write(`cot: ${err.message},本轮无过程展示\n`)
119
+ cot = null
125
120
  }
126
121
  }
127
122
 
@@ -134,13 +129,12 @@ async function handleGroup(t, group, opts = {}) {
134
129
  allowedTools: cfg.allowed || 'Read Edit Write Bash',
135
130
  model: cfg.model,
136
131
  timeoutSec: Number(cfg.timeout) || 1800,
137
- stream: !!view,
138
- onEvent: view
132
+ stream: !!cot,
133
+ onEvent: cot
139
134
  ? (kind, text) => {
140
- if (kind === 'say') view.onSay(text)
141
- else if (kind === 'tool') view.onTool(text)
142
- else if (kind === 'final') view.onFinal()
143
- if (kind !== 'final') view.update(view.acc)
135
+ if (kind === 'say') cot.onSay(text)
136
+ else if (kind === 'tool') cot.onTool(text)
137
+ else if (kind === 'final') cot.onFinal()
144
138
  }
145
139
  : null,
146
140
  })
@@ -155,34 +149,25 @@ async function handleGroup(t, group, opts = {}) {
155
149
  .slice(-300)}`
156
150
  }
157
151
 
158
- if (view && display === 'card') {
159
- // 全卡 PUT 失败必须降级发纯文本,保证结论必达。
160
- // 抛出时也当失败处理 —— 不依赖「CardSession 内部不抛」这个远处的性质
161
- let ok = false
152
+ // 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
153
+ // 收尾一抛就会跳过下面的结论投递 + ledgerAppend,而 take 游标已推
154
+ // 结论既不进群也不进台账,这批消息永不重来
155
+ if (cot) {
162
156
  try {
163
- ok = await view.finalize(final)
164
- await view.stop()
157
+ await cot.finalize({ rc: res.rc, error: res.err })
158
+ await cot.stop()
165
159
  } catch (err) {
166
- process.stderr.write(`card: 收尾异常 → ${err.stack || err}\n`)
160
+ process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
167
161
  }
168
- if (!ok) await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
169
- } else {
170
- // cot 档:过程在 COT 消息里,结论**总是**单独发一条 —— 不是降级,是设计。
171
- // ⚠️ 收尾必须包 try/catch 且排在 reply 之前不能挡住它:handleGroup 外层只有
172
- // try/finally(无 catch),收尾一抛就会跳过 reply + ledgerAppend,
173
- // 而 take 游标已推 → 结论既不进群也不进台账,这批消息永不重来。
174
- // 「过程展示是锦上添花,绝不能因它让整轮派活失败」—— 靠这里兜住
175
- if (view) {
176
- try {
177
- await view.finalize({ rc: res.rc, error: res.err })
178
- await view.stop()
179
- } catch (err) {
180
- process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
181
- }
182
- }
183
- await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
184
162
  }
185
163
 
164
+ // 结论**总是单独一条**:work 发卡片(markdown 渲染、不受 3500 字符分片限制),
165
+ // chat 发纯文本。卡片失败降级纯文本 —— 保证结论必达
166
+ const sent = mode.card
167
+ ? await sendCard(final, { profile, chatId, replyTo: lastMsg, inThread: mode.thread })
168
+ : false
169
+ if (!sent) await reply(profile, chatId, lastMsg, final, { inThread: mode.thread })
170
+
186
171
  ledgerAppend(task, chatId, epoch, batch, final)
187
172
  process.stderr.write(
188
173
  `dispatch: ${task}/${chatId}${threadId ? `/${threadId}` : ''} 完成 rc=${res.rc} ` +
@@ -210,12 +195,12 @@ async function run(task, opts = {}) {
210
195
 
211
196
  const cfg = t.config
212
197
  const chats = cfg.chats || []
213
- // thread=话题回复,交互式一问一答要跟手 → 1s;否则群任务多行需求 → 15s 让人打完
214
- const debounceMs = (Number(cfg.debounce) || (t.session === 'thread' ? 1 : 15)) * 1000
198
+ // work=话题内一问一答要跟手 → 1s;chat=群里多行需求 → 15s 让人打完
199
+ const debounceMs = (Number(cfg.debounce) || t.mode.debounce) * 1000
215
200
 
216
201
  process.stderr.write(
217
- `dispatch ${task}: app=${cfg.app} ${chats.length} 群 session=${t.session} ` +
218
- `display=${t.display} model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
202
+ `dispatch ${task}: app=${cfg.app} ${chats.length} 群 mode=${t.modeName} ` +
203
+ `model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
219
204
  )
220
205
 
221
206
  const consumer = `dispatch-${task}`
package/lib/help.js CHANGED
@@ -83,22 +83,22 @@ const DISPATCH_HELP = `常驻:等消息 → 唤起 claude 干活 → 结果回
83
83
  任务发现:扫 $LR_ROOT/*/dispatch.yaml —— 有文件即是任务,无需注册表。
84
84
  LR_ROOT 在单元里指定(唯一的目录绑定,与代码无关)。
85
85
 
86
- dispatch.yaml(5 必填 + 3 可选)
86
+ dispatch.yaml(4 必填 + 3 可选)
87
87
  app: <profile 名>
88
88
  chats: [oc_xxx]
89
89
  dirs: [/path/to/repo] # 首个 = 主工作目录
90
90
  instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
91
91
  filter: '.mentions[]?.id == "ou_xxx"' # 可选
92
- # session: thread 默认;thread=按 thread_id 隔离 | idle=一群一 session
93
- # display: card 默认;card | cot | final
92
+ # mode: work 默认;work | chat(见下)
94
93
  # model: <名称> 默认走终端同一套默认路由
95
94
 
96
- display 三档
97
- card CardKit 流式打字机,单卡原地更新,结论写进卡片(收尾失败回退纯文本)
98
- cot 思考过程挂在 COT 消息上,**结论总是单独发一条**(接口设计前提,非降级)
99
- 客户端门槛 PC ≥ 7.70 / 移动 ≥ 7.74;老客户端只看到一条「Completed」
100
- (不崩,但过程不可见 → 群里有老客户端就选 card);不写工具结果
101
- final 只发最终结论,不展示过程
95
+ mode 两个场景(一个键定死全部行为,不用拼组合)
96
+ work 处理工作:话题内回复 + 过程挂 COT 消息 + 结论发卡片
97
+ + thread_id 隔离 session + 防抖 1s(一问一答要跟手)
98
+ COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74;老客户端那条过程消息显示为
99
+ 「Completed」(不崩),结论卡片不受影响
100
+ chat 日常闲聊:直发群里 + 纯文本 + 按(群, epoch)隔离 session
101
+ + 防抖 15s(等人打完多行)
102
102
 
103
103
  最佳实践
104
104
  · 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
package/lib/tasks.js CHANGED
@@ -106,6 +106,16 @@ function scalar(s) {
106
106
 
107
107
  const REQUIRED = ['app', 'chats', 'dirs', 'instructions']
108
108
 
109
+ // 两个场景,不是几个正交开关的组合 —— 一个 mode 键定死全部行为。
110
+ // 此前是 session(thread|idle) × display(card|cot|final) 交叉出 6 种组合,
111
+ // 而 session 一个键实际控制三件事(隔离方式、是否开话题、防抖默认值)。
112
+ const MODES = {
113
+ // 日常闲聊:群里直接对话,不开话题
114
+ chat: { thread: false, cot: false, card: false, debounce: 15 },
115
+ // 处理工作:话题内一问一答,过程挂 COT,结论发卡片
116
+ work: { thread: true, cot: true, card: true, debounce: 1 },
117
+ }
118
+
109
119
  function validate(name, cfg, taskDir) {
110
120
  const errs = []
111
121
  for (const k of REQUIRED) {
@@ -116,12 +126,9 @@ function validate(name, cfg, taskDir) {
116
126
  for (const c of cfg.chats || []) {
117
127
  if (typeof c !== 'string' || !c.startsWith('oc_')) errs.push(`chats 项不是 oc_ 开头:${c}`)
118
128
  }
119
- const session = cfg.session || 'thread'
120
- if (!['thread', 'idle'].includes(session)) errs.push(`session 只能 thread|idle,给的是 ${session}`)
121
- const display = cfg.display || 'card'
122
- if (!['card', 'cot', 'final'].includes(display)) {
123
- errs.push(`display 只能 card|cot|final,给的是 ${display}`)
124
- }
129
+ const modeName = cfg.mode || 'work'
130
+ if (!MODES[modeName]) errs.push(`mode 只能 ${Object.keys(MODES).join('|')},给的是 ${modeName}`)
131
+ const mode = MODES[modeName] || MODES.work
125
132
 
126
133
  // instructions 相对任务目录解析 —— 执行偏好是资产,必须留仓库
127
134
  let insPath = null
@@ -133,7 +140,7 @@ function validate(name, cfg, taskDir) {
133
140
  for (const d of dirs) {
134
141
  if (!fs.existsSync(d)) errs.push(`dirs 目录不存在:${d}`)
135
142
  }
136
- return { errs, insPath, dirs, session, display }
143
+ return { errs, insPath, dirs, modeName, mode }
137
144
  }
138
145
 
139
146
  function loadTask(name, root = lrRoot()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it, or dispatch work to an AI agent.",
5
5
  "keywords": [
6
6
  "lark",