lark-relay 0.1.1 → 0.2.1
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 +21 -1
- package/lib/card.js +81 -66
- package/lib/claude.js +23 -21
- package/lib/collect.js +38 -8
- package/lib/cot.js +363 -0
- package/lib/dedup.js +1 -1
- package/lib/dispatch.js +74 -32
- package/lib/filter.js +9 -4
- package/lib/help.js +8 -1
- package/lib/larkcli.js +22 -108
- package/lib/lock.js +82 -25
- package/lib/paths.js +12 -6
- package/lib/render.js +0 -0
- package/lib/reply.js +1 -8
- package/lib/store.js +24 -12
- package/lib/take.js +5 -0
- package/lib/tasks.js +3 -1
- package/package.json +1 -1
package/lib/cot.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// cot display:消息上挂载「思考过程」,事件流对齐 AG-UI 协议。
|
|
4
|
+
// 三步流:POST 建 COT 消息 → PUT 攒批写事件(可反复)→ RUN_FINISHED 自动完结。
|
|
5
|
+
// 接口与 CardSession 同构,让 dispatch 里两档只差一个 new。
|
|
6
|
+
// 接口细节与实测边界见 docs/cot-message-api.md
|
|
7
|
+
//
|
|
8
|
+
// **COT 只承载过程,结论必须单独发一条普通消息** —— 接口的设计前提,不是我们的选择。
|
|
9
|
+
// 故本类没有 card.finalize(result) 那种「把结论写进载体」的能力。
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ content 是**字符串化的 JSON**(双层编码),_encode 统一处理
|
|
12
|
+
// ⚠️ RUN_FINISHED / RUN_ERROR 后再写事件被硬拒("COT already in terminal state"),
|
|
13
|
+
// 故 finalize 之后必须闭嘴 —— 用 this.done 守
|
|
14
|
+
const larkcli = require('./larkcli')
|
|
15
|
+
const { stripToJson, errText, tail } = larkcli
|
|
16
|
+
|
|
17
|
+
const BATCH_MS = Number(process.env.LR_COT_BATCH_MS || 1000)
|
|
18
|
+
const MAX_EVENTS = 50 // 接口硬上限:events[] 每次 1~50 条
|
|
19
|
+
// 单条 content 上限是**序列化后 4096 字节**,不是字符(文档写「字符」)。
|
|
20
|
+
// 中文每字 3 字节 → 按字符截会稳定超限,且一条超长会让同批全部事件被拒
|
|
21
|
+
const MAX_CONTENT_BYTES = 4096
|
|
22
|
+
|
|
23
|
+
// say 映射到哪类事件。**实测两者渲染完全一致**,故按事件量取舍:
|
|
24
|
+
// text 每段 3 个事件,reasoning 每段 5 个(多一对外包裹)。
|
|
25
|
+
// 留开关是因为渲染一致不保证永远一致
|
|
26
|
+
const SAY_AS = process.env.LR_COT_SAY_AS === 'reasoning' ? 'reasoning' : 'text'
|
|
27
|
+
|
|
28
|
+
// claude 工具名 → COT 内置 icon 枚举。
|
|
29
|
+
// 枚举只有 search/bash/read/write/doc/calendar/task/meeting/default,
|
|
30
|
+
// 不在表里的一律 default —— 传非枚举值的渲染行为未验证,不赌。
|
|
31
|
+
const ICONS = {
|
|
32
|
+
Bash: 'bash',
|
|
33
|
+
BashOutput: 'bash',
|
|
34
|
+
Read: 'read',
|
|
35
|
+
NotebookRead: 'read',
|
|
36
|
+
Edit: 'write',
|
|
37
|
+
Write: 'write',
|
|
38
|
+
NotebookEdit: 'write',
|
|
39
|
+
Grep: 'search',
|
|
40
|
+
Glob: 'search',
|
|
41
|
+
WebSearch: 'search',
|
|
42
|
+
WebFetch: 'search',
|
|
43
|
+
Task: 'task',
|
|
44
|
+
TodoWrite: 'task',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function iconFor(tool) {
|
|
48
|
+
return ICONS[tool] || 'default'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 按 UTF-8 字节截断,且不切坏字符(码点边界对齐)。
|
|
52
|
+
// 不能按码点数截 —— 上限是字节,中文每字 3 字节(见 MAX_CONTENT_BYTES)
|
|
53
|
+
function clipBytes(s, maxBytes) {
|
|
54
|
+
const str = String(s)
|
|
55
|
+
if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str
|
|
56
|
+
const ell = '…'
|
|
57
|
+
const ellBytes = Buffer.byteLength(ell, 'utf8')
|
|
58
|
+
// 连省略号都装不下 → 返回空串。否则 '' + '…' 会**超过** maxBytes,
|
|
59
|
+
// 破坏「返回值 ≤ maxBytes」这个契约(本函数是导出的)
|
|
60
|
+
if (maxBytes < ellBytes) return ''
|
|
61
|
+
const budget = maxBytes - ellBytes
|
|
62
|
+
// Buffer 直切会把多字节字符切成半个 → 用码点逐个累加,天然对齐边界。
|
|
63
|
+
// 逐码点而非逐 UTF-16 单元:emoji 等代理对不能拆
|
|
64
|
+
let bytes = 0
|
|
65
|
+
let out = ''
|
|
66
|
+
for (const ch of str) {
|
|
67
|
+
const b = Buffer.byteLength(ch, 'utf8')
|
|
68
|
+
if (bytes + b > budget) break
|
|
69
|
+
out += ch
|
|
70
|
+
bytes += b
|
|
71
|
+
}
|
|
72
|
+
return out + ell
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class CotSession {
|
|
76
|
+
constructor({ profile, chatId, lastMsg, replyInThread }) {
|
|
77
|
+
this.profile = profile
|
|
78
|
+
this.chatId = chatId
|
|
79
|
+
this.lastMsg = lastMsg
|
|
80
|
+
this.replyInThread = replyInThread
|
|
81
|
+
this.cotId = null
|
|
82
|
+
this.messageId = null
|
|
83
|
+
this.done = false // 已写 RUN_FINISHED/RUN_ERROR;之后写入必被拒
|
|
84
|
+
this.acc = '' // **只写不读**,纯为与 CardSession 接口同构;无消费者,别维护它的格式
|
|
85
|
+
// 只用来生成本地唯一 id,**从不发给服务端**。
|
|
86
|
+
// ⚠️ 与 CardSession.seq 同名但无关:那个是服务端排序号,小了会被拒
|
|
87
|
+
this.seq = 0
|
|
88
|
+
this.pending = [] // 待发事件队列
|
|
89
|
+
this.pendingSay = null // 延迟一段的 say(见 onSay);结论不能进过程区
|
|
90
|
+
this.timer = null
|
|
91
|
+
// 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接。
|
|
92
|
+
// 与 CardSession 的 q 同一理由(那边是 sequence 被服务端拒,这边是文本错位)。
|
|
93
|
+
this.q = Promise.resolve()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_enqueue(fn) {
|
|
97
|
+
this.q = this.q.then(fn, fn)
|
|
98
|
+
return this.q
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 步骤 ①:建 COT 消息 → {cot_id, message_id}。两者后续都要带。
|
|
102
|
+
// origin_message_id 让 COT 挂在触发它的那条用户消息上。
|
|
103
|
+
async create() {
|
|
104
|
+
const data = { receive_id: this.chatId }
|
|
105
|
+
if (this.lastMsg && this.lastMsg !== '-') data.origin_message_id = this.lastMsg
|
|
106
|
+
if (this.replyInThread) data.reply_in_thread = true
|
|
107
|
+
const { stdout } = await larkcli.run([
|
|
108
|
+
'--profile', this.profile, 'api', 'POST', '/open-apis/im/v1/message_cot',
|
|
109
|
+
'--params', JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
110
|
+
'--as', 'bot', '--data', JSON.stringify(data),
|
|
111
|
+
])
|
|
112
|
+
const j = JSON.parse(stripToJson(stdout))
|
|
113
|
+
this.cotId = j?.data?.cot_id || null
|
|
114
|
+
this.messageId = j?.data?.message_id || null
|
|
115
|
+
if (!this.cotId || !this.messageId) return null
|
|
116
|
+
// RUN_STARTED 立刻发,让消息一出现就是「在跑」而不是空壳
|
|
117
|
+
this._push(['RUN_STARTED', { runId: this.cotId, threadId: this.cotId }])
|
|
118
|
+
await this.flush()
|
|
119
|
+
return this.cotId
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 与 CardSession 接口对齐:COT 一步建好,没有「另发一条消息引用它」的步骤
|
|
123
|
+
async post() {
|
|
124
|
+
return this.messageId
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 编码单条事件:content 序列化 + 超 4096 字节时截断自由文本字段。
|
|
128
|
+
// 返回事件对象,null = 截不下来只能丢弃(整批被服务端拒比丢一条更糟)
|
|
129
|
+
_encode(eventType, content) {
|
|
130
|
+
let s = JSON.stringify(content)
|
|
131
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) {
|
|
132
|
+
// 超长只可能来自 delta/title/message 这类自由文本
|
|
133
|
+
const key = ['delta', 'title', 'message'].find((k) => typeof content[k] === 'string')
|
|
134
|
+
if (!key) return null // 没有可截的字段 → 丢这条,过程事件不值得冒整批失败
|
|
135
|
+
// JSON 转义会膨胀(换行 2 字节、控制字符 6 字节),故截完再验、按实测比例收敛。
|
|
136
|
+
// 别用固定系数退让 —— 0.8 退 8 次才到 0.17,覆盖不了 6 倍膨胀(ANSI 日志会撞上)
|
|
137
|
+
const overhead = Buffer.byteLength(JSON.stringify({ ...content, [key]: '' }), 'utf8')
|
|
138
|
+
let budget = MAX_CONTENT_BYTES - overhead
|
|
139
|
+
for (let i = 0; i < 8 && budget > 0; i++) {
|
|
140
|
+
const clipped = { ...content, [key]: clipBytes(content[key], budget) }
|
|
141
|
+
s = JSON.stringify(clipped)
|
|
142
|
+
const actual = Buffer.byteLength(s, 'utf8')
|
|
143
|
+
if (actual <= MAX_CONTENT_BYTES) break
|
|
144
|
+
// 按本轮实测膨胀率反推能装多少,留 2% 余量
|
|
145
|
+
const ratio = (MAX_CONTENT_BYTES - overhead) / (actual - overhead)
|
|
146
|
+
const next = Math.floor(budget * ratio * 0.98)
|
|
147
|
+
budget = next < budget ? next : budget - 1 // 保证严格递减,不会死循环
|
|
148
|
+
}
|
|
149
|
+
// 截不下来就丢;配对的 START/END 由 _push 的成组语义一起丢,不留空气泡
|
|
150
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) return null
|
|
151
|
+
}
|
|
152
|
+
return { event_type: eventType, content: s, timestamp: String(Date.now()) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 成组入队:**同一组事件保证同批发出**。
|
|
156
|
+
// 一段 say 是 3 件套(reasoning 档 5 件)、一次工具调用是 2 件套 —— 若被切批拆散,
|
|
157
|
+
// 前半批失败就会让客户端收到没有 START 的孤立 CONTENT/END(渲染行为未验证)。
|
|
158
|
+
// 组内任一条因超长被丢 → 整组丢,不留「有气泡无内容」
|
|
159
|
+
_push(...events) {
|
|
160
|
+
if (this.done) return
|
|
161
|
+
const encoded = []
|
|
162
|
+
for (const [type, content] of events) {
|
|
163
|
+
const e = this._encode(type, content)
|
|
164
|
+
if (!e) return // 组内有一条编码不出来 → 整组不发
|
|
165
|
+
encoded.push(e)
|
|
166
|
+
}
|
|
167
|
+
// 装不下整组就先把已攒的发走,让这一组完整落进下一批 —— 这是「不跨批」的实现
|
|
168
|
+
if (this.pending.length && this.pending.length + encoded.length > MAX_EVENTS) this.flush()
|
|
169
|
+
this.pending.push(...encoded)
|
|
170
|
+
if (this.pending.length >= MAX_EVENTS) {
|
|
171
|
+
this.flush()
|
|
172
|
+
} else if (!this.timer) {
|
|
173
|
+
this.timer = setTimeout(() => this.flush(), BATCH_MS)
|
|
174
|
+
if (this.timer.unref) this.timer.unref() // 别让攒批定时器吊住进程退出
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 等队列排空后返回 v,且**吞掉 rejection** —— flush/update 返回的就是 this.q,
|
|
179
|
+
// 而它们被 fire-and-forget 调用,裸传 rejected 的 q 会终结常驻进程
|
|
180
|
+
_settled(v) {
|
|
181
|
+
return this.q.then(
|
|
182
|
+
() => v,
|
|
183
|
+
() => v,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 步骤 ②:PUT 写事件。返回 bool —— **调用方靠它判断是否要兜底**
|
|
188
|
+
// (RUN_FINISHED 写失败则 COT 永久卡在「在跑」)。
|
|
189
|
+
// 整个 fn 体裹 try:flush() 绝不能抛(理由同 _settled);连日志的 EPIPE 也要吞
|
|
190
|
+
flush() {
|
|
191
|
+
if (this.timer) {
|
|
192
|
+
clearTimeout(this.timer)
|
|
193
|
+
this.timer = null
|
|
194
|
+
}
|
|
195
|
+
// 已封口:pending 里的事件注定被拒,清掉省下每批一次的失败请求 + 日志
|
|
196
|
+
if (this.done) {
|
|
197
|
+
this.pending = []
|
|
198
|
+
return this._settled(false)
|
|
199
|
+
}
|
|
200
|
+
// cotId 为 null 时**不清空 pending** —— create 还没成功,事件要活到它成功
|
|
201
|
+
if (!this.pending.length || !this.cotId) return this._settled(true)
|
|
202
|
+
const batch = this.pending
|
|
203
|
+
this.pending = []
|
|
204
|
+
return this._enqueue(async () => {
|
|
205
|
+
let ok = true
|
|
206
|
+
try {
|
|
207
|
+
// 切片非冗余:create 之前攒的事件可远超 50 条(那时 flush 提前返回且不清空)。
|
|
208
|
+
// 切点不会落在一段 say 中间 —— _push 的成组语义保证
|
|
209
|
+
for (let i = 0; i < batch.length; i += MAX_EVENTS) {
|
|
210
|
+
const events = batch.slice(i, i + MAX_EVENTS)
|
|
211
|
+
try {
|
|
212
|
+
await larkcli.run([
|
|
213
|
+
'--profile', this.profile, 'api', 'PUT', '/open-apis/im/v1/message_cot',
|
|
214
|
+
'--as', 'bot',
|
|
215
|
+
'--data', JSON.stringify({
|
|
216
|
+
events, message_id: this.messageId, cot_id: this.cotId,
|
|
217
|
+
}),
|
|
218
|
+
])
|
|
219
|
+
} catch (err) {
|
|
220
|
+
ok = false
|
|
221
|
+
const t = tail(errText(err), 200)
|
|
222
|
+
process.stderr.write(`cot: 写事件失败(${events.length} 条)→ ${t}\n`)
|
|
223
|
+
// terminal state 之后的写入全是徒劳,直接封口
|
|
224
|
+
if (/terminal state/.test(t)) this.done = true
|
|
225
|
+
break // 一片失败,后续片多半同因,别刷屏
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
ok = false // 含 stderr.write 抛 EPIPE 这类极端情况
|
|
230
|
+
}
|
|
231
|
+
return ok
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 中间文本。每段一个独立 messageId —— 同 id 的多条 CONTENT 会被客户端拼成一段,
|
|
236
|
+
// 而两次 say 之间通常隔着工具调用,拼起来读不通。
|
|
237
|
+
//
|
|
238
|
+
// **延迟一段**(与 CardSession 同机制):最后一段 say 就是结论,它会作为独立消息
|
|
239
|
+
// 再发一次 —— 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
|
|
240
|
+
// 用「延迟一段」而非内容比较 —— 最稳(card.js 踩出来的)
|
|
241
|
+
onSay(text) {
|
|
242
|
+
this.acc += (this.acc ? '\n\n' : '') + text
|
|
243
|
+
this._flushPending()
|
|
244
|
+
this.pendingSay = text
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_flushPending() {
|
|
248
|
+
if (this.pendingSay === null) return
|
|
249
|
+
const text = this.pendingSay
|
|
250
|
+
this.pendingSay = null
|
|
251
|
+
const id = `say-${++this.seq}`
|
|
252
|
+
if (SAY_AS === 'text') {
|
|
253
|
+
this._push(
|
|
254
|
+
['TEXT_MESSAGE_START', { messageId: id, role: 'assistant' }],
|
|
255
|
+
['TEXT_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
256
|
+
['TEXT_MESSAGE_END', { messageId: id }],
|
|
257
|
+
)
|
|
258
|
+
} else {
|
|
259
|
+
this._push(
|
|
260
|
+
['REASONING_START', { messageId: id }],
|
|
261
|
+
['REASONING_MESSAGE_START', { messageId: id }],
|
|
262
|
+
['REASONING_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
263
|
+
['REASONING_MESSAGE_END', { messageId: id }],
|
|
264
|
+
['REASONING_END', { messageId: id }],
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 工具调用。**不写 TOOL_CALL_RESULT**:输出动辄几千字符且可能含不宜进群的仓库内容,
|
|
270
|
+
// 与 card 档只显示工具名+参数摘要一致。text 形如 "Bash: ls -la"(claude.js 拼的)
|
|
271
|
+
onTool(text) {
|
|
272
|
+
this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
|
|
273
|
+
// 工具调用到来 = 前面那段 say 不是结论,可以放行了
|
|
274
|
+
this._flushPending()
|
|
275
|
+
const s = String(text)
|
|
276
|
+
const tool = s.split(':', 1)[0].trim()
|
|
277
|
+
const id = `tool-${++this.seq}`
|
|
278
|
+
this._push(
|
|
279
|
+
[
|
|
280
|
+
'TOOL_CALL_START',
|
|
281
|
+
{
|
|
282
|
+
toolCallId: id,
|
|
283
|
+
icon: iconFor(tool),
|
|
284
|
+
title: s,
|
|
285
|
+
toolCallName: tool || 'tool',
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
['TOOL_CALL_END', { toolCallId: id }],
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 与 CardSession 同签名同语义:手里那段 pending(= 结论)直接丢弃,不放行 ——
|
|
293
|
+
// 它会作为独立消息发出,写进过程区就成了群里的第二遍
|
|
294
|
+
onFinal() {
|
|
295
|
+
this.pendingSay = null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 步骤 ③:RUN_FINISHED 自动完结 —— **但只在写入成功时**。写失败(500/频控/抖动)
|
|
299
|
+
// 那条终结事件就没到服务端,COT 永久卡在「在跑」→ 靠 complete 兜底(幂等,不守 done)。
|
|
300
|
+
// rc != 0 走 RUN_ERROR + 无条件 complete:RUN_ERROR 是否自动流转未验证,补一刀不亏
|
|
301
|
+
async finalize({ rc = 0, error = '' } = {}) {
|
|
302
|
+
if (!this.cotId || this.done) return false
|
|
303
|
+
if (rc === 0) {
|
|
304
|
+
this._push(['RUN_FINISHED', { runId: this.cotId, threadId: this.cotId }])
|
|
305
|
+
const ok = await this.flush()
|
|
306
|
+
this.done = true
|
|
307
|
+
if (!ok) await this.complete('done') // 它和最多 49 条事件同批,该批被拒就一起没了
|
|
308
|
+
return true
|
|
309
|
+
}
|
|
310
|
+
// rc != 0:没有「结论」这回事,压着的那段 say 是仅有的线索,放行
|
|
311
|
+
this._flushPending()
|
|
312
|
+
this._push([
|
|
313
|
+
'RUN_ERROR',
|
|
314
|
+
{
|
|
315
|
+
runId: this.cotId,
|
|
316
|
+
threadId: this.cotId,
|
|
317
|
+
message: clipBytes(error || `rc=${rc}`, 500),
|
|
318
|
+
code: String(rc),
|
|
319
|
+
},
|
|
320
|
+
])
|
|
321
|
+
await this.flush()
|
|
322
|
+
this.done = true
|
|
323
|
+
await this.complete('error')
|
|
324
|
+
return true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 手动关写入态。正常路径不需要(RUN_FINISHED 已流转),
|
|
328
|
+
// 留给异常中断 —— 不调的话 COT 会一直显示「在跑」
|
|
329
|
+
async complete(reason = 'done') {
|
|
330
|
+
if (!this.cotId) return false
|
|
331
|
+
return this._enqueue(async () => {
|
|
332
|
+
try {
|
|
333
|
+
await larkcli.run([
|
|
334
|
+
'--profile', this.profile, 'api', 'POST',
|
|
335
|
+
`/open-apis/im/v1/message_cot/complete/${this.cotId}`,
|
|
336
|
+
'--params', JSON.stringify({ message_id: this.messageId, reason }),
|
|
337
|
+
'--as', 'bot',
|
|
338
|
+
])
|
|
339
|
+
return true
|
|
340
|
+
} catch {
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 与 CardSession 接口对齐:onEvent 里 fire-and-forget 调,cot 侧攒批已自带节流
|
|
347
|
+
update() {
|
|
348
|
+
return this._settled()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 与 CardSession 接口对齐。finalize 已完结,这里只兜「从没 finalize 成功」的情况:
|
|
352
|
+
// 超时被 SIGTERM、异常中断 —— 不调 complete 的话 COT 会一直显示「在跑」
|
|
353
|
+
async stop() {
|
|
354
|
+
if (this.done || !this.cotId) return
|
|
355
|
+
// 从没 finalize 就走到这里 = 异常中断,压着的那段不是结论 → 放行
|
|
356
|
+
this._flushPending()
|
|
357
|
+
await this.flush()
|
|
358
|
+
await this.complete('timeout')
|
|
359
|
+
this.done = true
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = { CotSession, iconFor, clipBytes, SAY_AS, MAX_EVENTS, MAX_CONTENT_BYTES }
|
package/lib/dedup.js
CHANGED
package/lib/dispatch.js
CHANGED
|
@@ -14,8 +14,8 @@ const { paths, ensureDir } = require('./paths')
|
|
|
14
14
|
const store = require('./store')
|
|
15
15
|
const claude = require('./claude')
|
|
16
16
|
const { CardSession } = require('./card')
|
|
17
|
+
const { CotSession } = require('./cot')
|
|
17
18
|
const { reply } = require('./reply')
|
|
18
|
-
const larkcli = require('./larkcli')
|
|
19
19
|
|
|
20
20
|
function cmdList() {
|
|
21
21
|
const tasks = listTasks()
|
|
@@ -109,17 +109,19 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const replyInThread = session === 'thread'
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
114
116
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
+
if (Session) {
|
|
118
|
+
view = new Session({ profile, chatId, lastMsg, replyInThread })
|
|
117
119
|
try {
|
|
118
|
-
if (!(await
|
|
119
|
-
if (!(await
|
|
120
|
+
if (!(await view.create())) throw new Error('建展示载体失败')
|
|
121
|
+
if (!(await view.post())) throw new Error('发展示消息失败')
|
|
120
122
|
} catch (err) {
|
|
121
|
-
process.stderr.write(
|
|
122
|
-
|
|
123
|
+
process.stderr.write(`${display}: ${err.message},回退 final\n`)
|
|
124
|
+
view = null
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
|
|
@@ -132,13 +134,13 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
132
134
|
allowedTools: cfg.allowed || 'Read Edit Write Bash',
|
|
133
135
|
model: cfg.model,
|
|
134
136
|
timeoutSec: Number(cfg.timeout) || 1800,
|
|
135
|
-
stream: !!
|
|
136
|
-
onEvent:
|
|
137
|
+
stream: !!view,
|
|
138
|
+
onEvent: view
|
|
137
139
|
? (kind, text) => {
|
|
138
|
-
if (kind === 'say')
|
|
139
|
-
else if (kind === 'tool')
|
|
140
|
-
else if (kind === 'final')
|
|
141
|
-
if (kind !== 'final')
|
|
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)
|
|
142
144
|
}
|
|
143
145
|
: null,
|
|
144
146
|
})
|
|
@@ -153,12 +155,31 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
153
155
|
.slice(-300)}`
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
if (card) {
|
|
157
|
-
// 全卡 PUT
|
|
158
|
-
|
|
158
|
+
if (view && display === 'card') {
|
|
159
|
+
// 全卡 PUT 失败必须降级发纯文本,保证结论必达。
|
|
160
|
+
// 抛出时也当失败处理 —— 不依赖「CardSession 内部不抛」这个远处的性质
|
|
161
|
+
let ok = false
|
|
162
|
+
try {
|
|
163
|
+
ok = await view.finalize(final)
|
|
164
|
+
await view.stop()
|
|
165
|
+
} catch (err) {
|
|
166
|
+
process.stderr.write(`card: 收尾异常 → ${err.stack || err}\n`)
|
|
167
|
+
}
|
|
159
168
|
if (!ok) await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
|
|
160
|
-
await card.stop()
|
|
161
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
|
+
}
|
|
162
183
|
await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
|
|
163
184
|
}
|
|
164
185
|
|
|
@@ -210,32 +231,53 @@ async function run(task, opts = {}) {
|
|
|
210
231
|
process.on('SIGINT', onSig)
|
|
211
232
|
|
|
212
233
|
for (;;) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
let res
|
|
235
|
+
try {
|
|
236
|
+
res = await takeBatch({
|
|
237
|
+
app: cfg.app,
|
|
238
|
+
chats,
|
|
239
|
+
name: consumer,
|
|
240
|
+
filter: cfg.filter,
|
|
241
|
+
debounceMs,
|
|
242
|
+
maxWaitMs: 75_000,
|
|
243
|
+
timeoutMs: opts.once ? 300_000 : 12 * 3600 * 1000,
|
|
244
|
+
signal: ac.signal,
|
|
245
|
+
})
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// 取批阶段的异常绝不能让进程退出:Restart=on-failure 会变成每 5s 崩一次,
|
|
248
|
+
// 而卡住的那条消息永远在队首 → 任务彻底停摆且看不出原因
|
|
249
|
+
process.stderr.write(`dispatch: ${task} 取批失败,15s 后重试 → ${err.stack || err}\n`)
|
|
250
|
+
if (opts.once) return 1
|
|
251
|
+
await new Promise((r) => setTimeout(r, 15_000))
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
223
254
|
|
|
224
255
|
if (res.events.length) {
|
|
225
256
|
// 一个防抖窗口会吐同群多话题的消息,只按 chat_id 分组会**串话题**
|
|
226
|
-
|
|
257
|
+
const groups = groupByChatThread(res.events)
|
|
258
|
+
for (let i = 0; i < groups.length; i++) {
|
|
259
|
+
// 收到停止信号后不再开新一轮 claude:单元 TimeoutStopSec=30,
|
|
260
|
+
// 而一轮最长 1800s → 会被 SIGKILL,卡片停在占位符、结论不回群。
|
|
261
|
+
// 游标此时也没推(take 的 abort 分支只在有批次时推),故剩余组下次重新取到
|
|
262
|
+
if (stopping) {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`dispatch: ${task} 正在停止,剩余 ${groups.length - i} 组留到下次\n`,
|
|
265
|
+
)
|
|
266
|
+
break
|
|
267
|
+
}
|
|
227
268
|
try {
|
|
228
|
-
await handleGroup(t,
|
|
269
|
+
await handleGroup(t, groups[i])
|
|
229
270
|
} catch (err) {
|
|
230
271
|
process.stderr.write(`dispatch: ${task} 处理组失败 ${err.stack || err}\n`)
|
|
231
272
|
}
|
|
232
273
|
}
|
|
233
274
|
} else if (opts.once) {
|
|
234
275
|
process.stderr.write(`dispatch: 没等到消息(${res.reason})\n`)
|
|
276
|
+
return res.reason === 'timeout' ? 4 : 0 // 与 take 对齐:4 = 等超时,0 = 正常
|
|
235
277
|
}
|
|
236
278
|
|
|
237
279
|
if (opts.once || stopping) return 0
|
|
238
280
|
}
|
|
239
281
|
}
|
|
240
282
|
|
|
241
|
-
module.exports = { run, cmdList,
|
|
283
|
+
module.exports = { run, cmdList, handleGroup }
|
package/lib/filter.js
CHANGED
|
@@ -37,11 +37,16 @@ function applyFilter(events, expr) {
|
|
|
37
37
|
maxBuffer: 64 * 1024 * 1024,
|
|
38
38
|
})
|
|
39
39
|
} catch (err) {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// 保守放行 + warn,不抛 —— 抛出去会让 dispatch 崩溃循环:
|
|
41
|
+
// 表达式对某类事件报错(如 `.mentions[].id` 遇到没有 mentions 的消息)时,
|
|
42
|
+
// 那条消息永远卡在队首,游标推不动,systemd 每 5s 重启一次,该任务彻底停摆。
|
|
43
|
+
// 放行的代价是模型多看几条本该滤掉的消息,远小于停摆
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`warn: --filter 求值失败,本批不过滤(${events.length} 条放行)。表达式:${expr}\n` +
|
|
46
|
+
` ${(err.stderr || err.message || '').trim().split('\n')[0]}\n` +
|
|
47
|
+
` 提示:字段可能不存在,用 ? 和 // 兜底,如 '(.mentions // []) | any(.id == "ou_xxx")'\n`,
|
|
42
48
|
)
|
|
43
|
-
|
|
44
|
-
throw e
|
|
49
|
+
return events
|
|
45
50
|
}
|
|
46
51
|
const verdicts = out.split('\n').filter((l) => l.length > 0)
|
|
47
52
|
// 求值行数应与事件数一致;不一致说明表达式产出多值,保守放行全部并 warn
|
package/lib/help.js
CHANGED
|
@@ -90,9 +90,16 @@ dispatch.yaml(5 必填 + 3 可选)
|
|
|
90
90
|
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
91
91
|
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
92
92
|
# session: thread 默认;thread=按 thread_id 隔离 | idle=一群一 session
|
|
93
|
-
# display: card 默认;card | final
|
|
93
|
+
# display: card 默认;card | cot | final
|
|
94
94
|
# model: <名称> 默认走终端同一套默认路由
|
|
95
95
|
|
|
96
|
+
display 三档
|
|
97
|
+
card CardKit 流式打字机,单卡原地更新,结论写进卡片(收尾失败回退纯文本)
|
|
98
|
+
cot 思考过程挂在 COT 消息上,**结论总是单独发一条**(接口设计前提,非降级)
|
|
99
|
+
客户端门槛 PC ≥ 7.70 / 移动 ≥ 7.74;老客户端只看到一条「Completed」
|
|
100
|
+
(不崩,但过程不可见 → 群里有老客户端就选 card);不写工具结果
|
|
101
|
+
final 只发最终结论,不展示过程
|
|
102
|
+
|
|
96
103
|
最佳实践
|
|
97
104
|
· 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
|
|
98
105
|
· 改完 yaml 先 --once 跑一轮验证,再 enable
|