lark-relay 0.4.4 → 0.4.6
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 +14 -60
- package/bin/lark-relay.js +4 -30
- package/lib/collect.js +34 -25
- package/lib/filter.js +1 -1
- package/lib/help.js +18 -70
- package/lib/lock.js +3 -19
- package/lib/log.js +164 -0
- package/lib/paths.js +0 -4
- package/lib/status.js +22 -43
- package/lib/store.js +1 -24
- package/lib/take.js +2 -2
- package/package.json +2 -2
- package/lib/card.js +0 -76
- package/lib/claude.js +0 -231
- package/lib/cot.js +0 -377
- package/lib/dispatch.js +0 -276
- package/lib/reply.js +0 -69
- package/lib/tasks.js +0 -181
package/lib/cot.js
DELETED
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// cot display:消息上挂载「思考过程」,事件流对齐 AG-UI 协议。
|
|
4
|
-
// 三步流:POST 建 COT 消息 → PUT 攒批写事件(可反复)→ RUN_FINISHED 自动完结。
|
|
5
|
-
// 接口细节与实测边界见 docs/cot-message-api.md
|
|
6
|
-
//
|
|
7
|
-
// **COT 只承载过程,结论必须单独发一条普通消息** —— 接口的设计前提,不是我们的选择。
|
|
8
|
-
// 故本类没有 card.finalize(result) 那种「把结论写进载体」的能力。
|
|
9
|
-
//
|
|
10
|
-
// ⚠️ content 是**字符串化的 JSON**(双层编码),_encode 统一处理
|
|
11
|
-
// ⚠️ RUN_FINISHED / RUN_ERROR 后再写事件被硬拒("COT already in terminal state"),
|
|
12
|
-
// 故 finalize 之后必须闭嘴 —— 用 this.done 守
|
|
13
|
-
const larkcli = require('./larkcli')
|
|
14
|
-
const { stripToJson, errText, tail } = larkcli
|
|
15
|
-
|
|
16
|
-
const BATCH_MS = Number(process.env.LR_COT_BATCH_MS || 1000)
|
|
17
|
-
const MAX_EVENTS = 50 // 接口硬上限:events[] 每次 1~50 条
|
|
18
|
-
// 单条 content 上限是**序列化后 4096 字节**,不是字符(文档写「字符」)。
|
|
19
|
-
// 中文每字 3 字节 → 按字符截会稳定超限,且一条超长会让同批全部事件被拒
|
|
20
|
-
const MAX_CONTENT_BYTES = 4096
|
|
21
|
-
|
|
22
|
-
// say 映射到哪类事件。**实测两者渲染完全一致**,故按事件量取舍:
|
|
23
|
-
// text 每段 3 个事件,reasoning 每段 5 个(多一对外包裹)。
|
|
24
|
-
// 留开关是因为渲染一致不保证永远一致
|
|
25
|
-
const SAY_AS = process.env.LR_COT_SAY_AS === 'reasoning' ? 'reasoning' : 'text'
|
|
26
|
-
|
|
27
|
-
// claude 工具名 → COT 内置 icon 枚举。
|
|
28
|
-
// 实测 9 个枚举全部有专属图标(search 🔍 / bash </> / read 📖 / write ✏️ / doc 📄 /
|
|
29
|
-
// calendar 📅 / task ☑️ / meeting 📹 / default ●)。文档外的值多数回落成 default 的圆点,
|
|
30
|
-
// 少数(shell/edit/web)生效但只是已有图标的别名 —— 没有新增表达力且无兼容承诺,不用
|
|
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
|
-
// MCP 工具名(mcp__<server>__<action>)按名字猜 —— 它们在 dispatch 场景很常见,
|
|
48
|
-
// 一律给 default 的圆点等于没信息。命名约定不保证,故只在猜中时用。
|
|
49
|
-
// **顺序即优先级**:域名词(doc/task/calendar)比动词更能说明工具属于哪个域,故排在前
|
|
50
|
-
const MCP_HINTS = [
|
|
51
|
-
[/meeting|minutes|\bvc\b/, 'meeting'],
|
|
52
|
-
[/calendar|schedule/, 'calendar'],
|
|
53
|
-
[/task|todo/, 'task'],
|
|
54
|
-
[/doc|sheet|base|wiki|file/, 'doc'],
|
|
55
|
-
[/write|create|update|edit|send|post|delete/, 'write'],
|
|
56
|
-
[/read|get|fetch|view/, 'read'],
|
|
57
|
-
[/search|find|query|list/, 'search'],
|
|
58
|
-
]
|
|
59
|
-
|
|
60
|
-
function iconFor(tool) {
|
|
61
|
-
if (ICONS[tool]) return ICONS[tool]
|
|
62
|
-
const s = String(tool).toLowerCase()
|
|
63
|
-
if (s.startsWith('mcp__')) {
|
|
64
|
-
for (const [re, icon] of MCP_HINTS) if (re.test(s)) return icon
|
|
65
|
-
}
|
|
66
|
-
return 'default'
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// 按 UTF-8 字节截断,且不切坏字符(码点边界对齐)。
|
|
70
|
-
// 不能按码点数截 —— 上限是字节,中文每字 3 字节(见 MAX_CONTENT_BYTES)
|
|
71
|
-
function clipBytes(s, maxBytes) {
|
|
72
|
-
const str = String(s)
|
|
73
|
-
if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str
|
|
74
|
-
const ell = '…'
|
|
75
|
-
const ellBytes = Buffer.byteLength(ell, 'utf8')
|
|
76
|
-
// 连省略号都装不下 → 返回空串。否则 '' + '…' 会**超过** maxBytes,
|
|
77
|
-
// 破坏「返回值 ≤ maxBytes」这个契约(本函数是导出的)
|
|
78
|
-
if (maxBytes < ellBytes) return ''
|
|
79
|
-
const budget = maxBytes - ellBytes
|
|
80
|
-
// Buffer 直切会把多字节字符切成半个 → 用码点逐个累加,天然对齐边界。
|
|
81
|
-
// 逐码点而非逐 UTF-16 单元:emoji 等代理对不能拆
|
|
82
|
-
let bytes = 0
|
|
83
|
-
let out = ''
|
|
84
|
-
for (const ch of str) {
|
|
85
|
-
const b = Buffer.byteLength(ch, 'utf8')
|
|
86
|
-
if (bytes + b > budget) break
|
|
87
|
-
out += ch
|
|
88
|
-
bytes += b
|
|
89
|
-
}
|
|
90
|
-
return out + ell
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
class CotSession {
|
|
94
|
-
constructor({ profile, chatId, lastMsg, replyInThread }) {
|
|
95
|
-
this.profile = profile
|
|
96
|
-
this.chatId = chatId
|
|
97
|
-
this.lastMsg = lastMsg
|
|
98
|
-
this.replyInThread = replyInThread
|
|
99
|
-
this.cotId = null
|
|
100
|
-
this.messageId = null
|
|
101
|
-
this.done = false // 已写 RUN_FINISHED/RUN_ERROR;之后写入必被拒
|
|
102
|
-
this.seq = 0 // 只用来生成本地唯一的 messageId/toolCallId,**从不发给服务端**
|
|
103
|
-
this.pending = [] // 待发事件队列
|
|
104
|
-
this.lastStamp = 0 // 事件 timestamp 的单调下界(见 _stamp)
|
|
105
|
-
this.pendingSay = null // 延迟一段的 say(见 onSay);结论不能进过程区
|
|
106
|
-
this.timer = null
|
|
107
|
-
// 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接成错乱文本
|
|
108
|
-
this.q = Promise.resolve()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
_enqueue(fn) {
|
|
112
|
-
this.q = this.q.then(fn, fn)
|
|
113
|
-
return this.q
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 步骤 ①:建 COT 消息 → {cot_id, message_id}。两者后续都要带。
|
|
117
|
-
// origin_message_id 让 COT 挂在触发它的那条用户消息上。
|
|
118
|
-
async create() {
|
|
119
|
-
const data = { receive_id: this.chatId }
|
|
120
|
-
if (this.lastMsg && this.lastMsg !== '-') data.origin_message_id = this.lastMsg
|
|
121
|
-
if (this.replyInThread) data.reply_in_thread = true
|
|
122
|
-
const { stdout } = await larkcli.run([
|
|
123
|
-
'--profile', this.profile, 'api', 'POST', '/open-apis/im/v1/message_cot',
|
|
124
|
-
'--params', JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
125
|
-
'--as', 'bot', '--data', JSON.stringify(data),
|
|
126
|
-
])
|
|
127
|
-
const j = JSON.parse(stripToJson(stdout))
|
|
128
|
-
this.cotId = j?.data?.cot_id || null
|
|
129
|
-
this.messageId = j?.data?.message_id || null
|
|
130
|
-
if (!this.cotId || !this.messageId) return null
|
|
131
|
-
// RUN_STARTED 立刻发,让消息一出现就是「在跑」而不是空壳
|
|
132
|
-
this._push(['RUN_STARTED', { runId: this.cotId, threadId: this.cotId }])
|
|
133
|
-
await this.flush()
|
|
134
|
-
return this.cotId
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 编码单条事件:content 序列化 + 超 4096 字节时截断自由文本字段。
|
|
138
|
-
// 返回事件对象,null = 截不下来只能丢弃(整批被服务端拒比丢一条更糟)
|
|
139
|
-
_encode(eventType, content) {
|
|
140
|
-
let s = JSON.stringify(content)
|
|
141
|
-
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) {
|
|
142
|
-
// 超长只可能来自 delta/title/message 这类自由文本
|
|
143
|
-
const key = ['delta', 'title', 'message'].find((k) => typeof content[k] === 'string')
|
|
144
|
-
if (!key) return null // 没有可截的字段 → 丢这条,过程事件不值得冒整批失败
|
|
145
|
-
// JSON 转义会膨胀(换行 2 字节、控制字符 6 字节),故截完再验、按实测比例收敛。
|
|
146
|
-
// 别用固定系数退让 —— 0.8 退 8 次才到 0.17,覆盖不了 6 倍膨胀(ANSI 日志会撞上)
|
|
147
|
-
const overhead = Buffer.byteLength(JSON.stringify({ ...content, [key]: '' }), 'utf8')
|
|
148
|
-
let budget = MAX_CONTENT_BYTES - overhead
|
|
149
|
-
for (let i = 0; i < 8 && budget > 0; i++) {
|
|
150
|
-
const clipped = { ...content, [key]: clipBytes(content[key], budget) }
|
|
151
|
-
s = JSON.stringify(clipped)
|
|
152
|
-
const actual = Buffer.byteLength(s, 'utf8')
|
|
153
|
-
if (actual <= MAX_CONTENT_BYTES) break
|
|
154
|
-
// 按本轮实测膨胀率反推能装多少,留 2% 余量
|
|
155
|
-
const ratio = (MAX_CONTENT_BYTES - overhead) / (actual - overhead)
|
|
156
|
-
const next = Math.floor(budget * ratio * 0.98)
|
|
157
|
-
budget = next < budget ? next : budget - 1 // 保证严格递减,不会死循环
|
|
158
|
-
}
|
|
159
|
-
// 截不下来就丢;配对的 START/END 由 _push 的成组语义一起丢,不留空气泡
|
|
160
|
-
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) return null
|
|
161
|
-
}
|
|
162
|
-
return { event_type: eventType, content: s, timestamp: String(this._stamp()) }
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 事件 timestamp 必须**严格单调递增**。
|
|
166
|
-
// 实测(2026-08-20):同一批 8 个 delta 用相同 timestamp 时,客户端渲染成
|
|
167
|
-
// 「3 2 6 8 7 5 4 1」—— 不是反转而是完全打乱,说明它按 timestamp 排序且排序不稳定。
|
|
168
|
-
// 而一段 say 的 START/CONTENT/END 是同步生成的,Date.now() 必然相同 → 文字错乱。
|
|
169
|
-
// 墙上时间只作下界,计数器保证单调(同 paths.js 的 stamp,时钟回拨也不倒退)
|
|
170
|
-
_stamp() {
|
|
171
|
-
const now = Date.now()
|
|
172
|
-
this.lastStamp = now > this.lastStamp ? now : this.lastStamp + 1
|
|
173
|
-
return this.lastStamp
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// 成组入队:**同一组事件保证同批发出**。
|
|
177
|
-
// 一段 say 是 3 件套(reasoning 档 5 件)、一次工具调用是 2 件套 —— 若被切批拆散,
|
|
178
|
-
// 前半批失败就会让客户端收到没有 START 的孤立 CONTENT/END(渲染行为未验证)。
|
|
179
|
-
// 组内任一条因超长被丢 → 整组丢,不留「有气泡无内容」
|
|
180
|
-
_push(...events) {
|
|
181
|
-
if (this.done) return
|
|
182
|
-
const encoded = []
|
|
183
|
-
for (const [type, content] of events) {
|
|
184
|
-
const e = this._encode(type, content)
|
|
185
|
-
if (!e) return // 组内有一条编码不出来 → 整组不发
|
|
186
|
-
encoded.push(e)
|
|
187
|
-
}
|
|
188
|
-
// 装不下整组就先把已攒的发走,让这一组完整落进下一批 —— 这是「不跨批」的实现
|
|
189
|
-
if (this.pending.length && this.pending.length + encoded.length > MAX_EVENTS) this.flush()
|
|
190
|
-
this.pending.push(...encoded)
|
|
191
|
-
if (this.pending.length >= MAX_EVENTS) {
|
|
192
|
-
this.flush()
|
|
193
|
-
} else if (!this.timer) {
|
|
194
|
-
this.timer = setTimeout(() => this.flush(), BATCH_MS)
|
|
195
|
-
if (this.timer.unref) this.timer.unref() // 别让攒批定时器吊住进程退出
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// 等队列排空后返回 v,且**吞掉 rejection** —— flush/update 返回的就是 this.q,
|
|
200
|
-
// 而它们被 fire-and-forget 调用,裸传 rejected 的 q 会终结常驻进程
|
|
201
|
-
_settled(v) {
|
|
202
|
-
return this.q.then(
|
|
203
|
-
() => v,
|
|
204
|
-
() => v,
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 步骤 ②:PUT 写事件。返回 bool —— **调用方靠它判断是否要兜底**
|
|
209
|
-
// (RUN_FINISHED 写失败则 COT 永久卡在「在跑」)。
|
|
210
|
-
// 整个 fn 体裹 try:flush() 绝不能抛(理由同 _settled);连日志的 EPIPE 也要吞
|
|
211
|
-
flush() {
|
|
212
|
-
if (this.timer) {
|
|
213
|
-
clearTimeout(this.timer)
|
|
214
|
-
this.timer = null
|
|
215
|
-
}
|
|
216
|
-
// 已封口:pending 里的事件注定被拒,清掉省下每批一次的失败请求 + 日志
|
|
217
|
-
if (this.done) {
|
|
218
|
-
this.pending = []
|
|
219
|
-
return this._settled(false)
|
|
220
|
-
}
|
|
221
|
-
// cotId 为 null 时**不清空 pending** —— create 还没成功,事件要活到它成功
|
|
222
|
-
if (!this.pending.length || !this.cotId) return this._settled(true)
|
|
223
|
-
const batch = this.pending
|
|
224
|
-
this.pending = []
|
|
225
|
-
return this._enqueue(async () => {
|
|
226
|
-
let ok = true
|
|
227
|
-
try {
|
|
228
|
-
// 切片非冗余:create 之前攒的事件可远超 50 条(那时 flush 提前返回且不清空)。
|
|
229
|
-
// 切点不会落在一段 say 中间 —— _push 的成组语义保证
|
|
230
|
-
for (let i = 0; i < batch.length; i += MAX_EVENTS) {
|
|
231
|
-
const events = batch.slice(i, i + MAX_EVENTS)
|
|
232
|
-
try {
|
|
233
|
-
await larkcli.run([
|
|
234
|
-
'--profile', this.profile, 'api', 'PUT', '/open-apis/im/v1/message_cot',
|
|
235
|
-
'--as', 'bot',
|
|
236
|
-
'--data', JSON.stringify({
|
|
237
|
-
events, message_id: this.messageId, cot_id: this.cotId,
|
|
238
|
-
}),
|
|
239
|
-
])
|
|
240
|
-
} catch (err) {
|
|
241
|
-
ok = false
|
|
242
|
-
const t = tail(errText(err), 200)
|
|
243
|
-
process.stderr.write(`cot: 写事件失败(${events.length} 条)→ ${t}\n`)
|
|
244
|
-
// terminal state 之后的写入全是徒劳,直接封口
|
|
245
|
-
if (/terminal state/.test(t)) this.done = true
|
|
246
|
-
break // 一片失败,后续片多半同因,别刷屏
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
} catch {
|
|
250
|
-
ok = false // 含 stderr.write 抛 EPIPE 这类极端情况
|
|
251
|
-
}
|
|
252
|
-
return ok
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 中间文本。每段一个独立 messageId —— 同 id 的多条 CONTENT 会被客户端拼成一段,
|
|
257
|
-
// 而两次 say 之间通常隔着工具调用,拼起来读不通。
|
|
258
|
-
//
|
|
259
|
-
// **延迟一段**:最后一段 say 就是结论,它会作为独立消息再发一次 ——
|
|
260
|
-
// 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
|
|
261
|
-
// 用「延迟一段」而非内容比较 —— 最稳
|
|
262
|
-
onSay(text) {
|
|
263
|
-
this._flushPending()
|
|
264
|
-
this.pendingSay = text
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
_flushPending() {
|
|
268
|
-
if (this.pendingSay === null) return
|
|
269
|
-
const text = this.pendingSay
|
|
270
|
-
this.pendingSay = null
|
|
271
|
-
const id = `say-${++this.seq}`
|
|
272
|
-
if (SAY_AS === 'text') {
|
|
273
|
-
this._push(
|
|
274
|
-
['TEXT_MESSAGE_START', { messageId: id, role: 'assistant' }],
|
|
275
|
-
['TEXT_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
276
|
-
['TEXT_MESSAGE_END', { messageId: id }],
|
|
277
|
-
)
|
|
278
|
-
} else {
|
|
279
|
-
this._push(
|
|
280
|
-
['REASONING_START', { messageId: id }],
|
|
281
|
-
['REASONING_MESSAGE_START', { messageId: id }],
|
|
282
|
-
['REASONING_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
283
|
-
['REASONING_MESSAGE_END', { messageId: id }],
|
|
284
|
-
['REASONING_END', { messageId: id }],
|
|
285
|
-
)
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// 工具调用。**不写 TOOL_CALL_RESULT**:输出动辄几千字符且可能含不宜进群的仓库内容,
|
|
290
|
-
// 与 card 档只显示工具名+参数摘要一致。text 形如 "Bash: ls -la"(claude.js 拼的)
|
|
291
|
-
onTool(text) {
|
|
292
|
-
// 工具调用到来 = 前面那段 say 不是结论,可以放行了
|
|
293
|
-
this._flushPending()
|
|
294
|
-
const s = String(text)
|
|
295
|
-
const tool = s.split(':', 1)[0].trim()
|
|
296
|
-
const id = `tool-${++this.seq}`
|
|
297
|
-
this._push(
|
|
298
|
-
[
|
|
299
|
-
'TOOL_CALL_START',
|
|
300
|
-
{
|
|
301
|
-
toolCallId: id,
|
|
302
|
-
icon: iconFor(tool),
|
|
303
|
-
title: s,
|
|
304
|
-
toolCallName: tool || 'tool',
|
|
305
|
-
},
|
|
306
|
-
],
|
|
307
|
-
['TOOL_CALL_END', { toolCallId: id }],
|
|
308
|
-
)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// 手里那段 pending(= 结论)直接丢弃,不放行 —— 它会作为独立消息发出,
|
|
312
|
-
// 写进过程区就成了群里的第二遍
|
|
313
|
-
onFinal() {
|
|
314
|
-
this.pendingSay = null
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 步骤 ③:RUN_FINISHED 自动完结 —— **但只在写入成功时**。写失败(500/频控/抖动)
|
|
318
|
-
// 那条终结事件就没到服务端,COT 永久卡在「在跑」→ 靠 complete 兜底(幂等,不守 done)。
|
|
319
|
-
// rc != 0 走 RUN_ERROR + 无条件 complete:RUN_ERROR 是否自动流转未验证,补一刀不亏
|
|
320
|
-
async finalize({ rc = 0, error = '' } = {}) {
|
|
321
|
-
if (!this.cotId || this.done) return false
|
|
322
|
-
if (rc === 0) {
|
|
323
|
-
this._push(['RUN_FINISHED', { runId: this.cotId, threadId: this.cotId }])
|
|
324
|
-
const ok = await this.flush()
|
|
325
|
-
this.done = true
|
|
326
|
-
if (!ok) await this.complete('done') // 它和最多 49 条事件同批,该批被拒就一起没了
|
|
327
|
-
return true
|
|
328
|
-
}
|
|
329
|
-
// rc != 0:没有「结论」这回事,压着的那段 say 是仅有的线索,放行
|
|
330
|
-
this._flushPending()
|
|
331
|
-
this._push([
|
|
332
|
-
'RUN_ERROR',
|
|
333
|
-
{
|
|
334
|
-
runId: this.cotId,
|
|
335
|
-
threadId: this.cotId,
|
|
336
|
-
message: clipBytes(error || `rc=${rc}`, 500),
|
|
337
|
-
code: String(rc),
|
|
338
|
-
},
|
|
339
|
-
])
|
|
340
|
-
await this.flush()
|
|
341
|
-
this.done = true
|
|
342
|
-
await this.complete('error')
|
|
343
|
-
return true
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// 手动关写入态。正常路径不需要(RUN_FINISHED 已流转),
|
|
347
|
-
// 留给异常中断 —— 不调的话 COT 会一直显示「在跑」
|
|
348
|
-
async complete(reason = 'done') {
|
|
349
|
-
if (!this.cotId) return false
|
|
350
|
-
return this._enqueue(async () => {
|
|
351
|
-
try {
|
|
352
|
-
await larkcli.run([
|
|
353
|
-
'--profile', this.profile, 'api', 'POST',
|
|
354
|
-
`/open-apis/im/v1/message_cot/complete/${this.cotId}`,
|
|
355
|
-
'--params', JSON.stringify({ message_id: this.messageId, reason }),
|
|
356
|
-
'--as', 'bot',
|
|
357
|
-
])
|
|
358
|
-
return true
|
|
359
|
-
} catch {
|
|
360
|
-
return false
|
|
361
|
-
}
|
|
362
|
-
})
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 兜「从没 finalize 成功」的情况:超时被 SIGTERM、异常中断 ——
|
|
366
|
-
// 不调 complete 的话 COT 会一直显示「在跑」
|
|
367
|
-
async stop() {
|
|
368
|
-
if (this.done || !this.cotId) return
|
|
369
|
-
// 从没 finalize 就走到这里 = 异常中断,压着的那段不是结论 → 放行
|
|
370
|
-
this._flushPending()
|
|
371
|
-
await this.flush()
|
|
372
|
-
await this.complete('timeout')
|
|
373
|
-
this.done = true
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
module.exports = { CotSession, iconFor, clipBytes, SAY_AS, MAX_EVENTS, MAX_CONTENT_BYTES }
|
package/lib/dispatch.js
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// dispatch:常驻循环 = take + 唤起 claude。无人在场也跑。
|
|
4
|
-
// **内部就是调 take** —— 保证两场景不实现分叉(旧架构三份 wrapper 各自演化出
|
|
5
|
-
// 253 行就是分叉的教训)。
|
|
6
|
-
const fs = require('fs')
|
|
7
|
-
const path = require('path')
|
|
8
|
-
const { execFileSync } = require('child_process')
|
|
9
|
-
const { loadTask, listTasks, lrRoot } = require('./tasks')
|
|
10
|
-
const { takeBatch } = require('./take')
|
|
11
|
-
const { groupByChatThread, renderText } = require('./render')
|
|
12
|
-
const { acquireBlocking } = require('./lock')
|
|
13
|
-
const { paths, ensureDir } = require('./paths')
|
|
14
|
-
const store = require('./store')
|
|
15
|
-
const claude = require('./claude')
|
|
16
|
-
const { sendCard } = require('./card')
|
|
17
|
-
const { CotSession } = require('./cot')
|
|
18
|
-
const { reply } = require('./reply')
|
|
19
|
-
|
|
20
|
-
function cmdList() {
|
|
21
|
-
const tasks = listTasks()
|
|
22
|
-
if (!tasks.length) {
|
|
23
|
-
process.stdout.write(
|
|
24
|
-
`没有 dispatch 任务。LR_ROOT=${lrRoot()}\n` +
|
|
25
|
-
`建任务:在 $LR_ROOT/<任务名>/dispatch.yaml 写配置(模板见 lark-relay dispatch --help)\n`,
|
|
26
|
-
)
|
|
27
|
-
return 0
|
|
28
|
-
}
|
|
29
|
-
const rows = [['任务', 'app', '群数', '单元', '上次派活']]
|
|
30
|
-
for (const t of tasks) {
|
|
31
|
-
const unit = `lark-relay-dispatch@${t.name}.service`
|
|
32
|
-
let state = 'inactive'
|
|
33
|
-
try {
|
|
34
|
-
state = execFileSync('systemctl', ['is-active', unit], { encoding: 'utf8' }).trim()
|
|
35
|
-
} catch (err) {
|
|
36
|
-
state = (err.stdout || '').trim() || 'inactive'
|
|
37
|
-
}
|
|
38
|
-
let last = '—'
|
|
39
|
-
for (const chat of t.config.chats || []) {
|
|
40
|
-
try {
|
|
41
|
-
const st = fs.statSync(paths.ledgerFile(t.name, chat))
|
|
42
|
-
const m = Math.round((Date.now() - st.mtimeMs) / 60000)
|
|
43
|
-
last = m < 60 ? `${m}m ago` : `${Math.round(m / 60)}h ago`
|
|
44
|
-
} catch {}
|
|
45
|
-
}
|
|
46
|
-
rows.push([
|
|
47
|
-
t.name,
|
|
48
|
-
String(t.config.app || '?'),
|
|
49
|
-
String((t.config.chats || []).length),
|
|
50
|
-
state,
|
|
51
|
-
last,
|
|
52
|
-
])
|
|
53
|
-
if (t.errs.length) rows.push([` ⚠ ${t.errs.join('; ')}`, '', '', '', ''])
|
|
54
|
-
}
|
|
55
|
-
const wid = [0, 1, 2, 3, 4].map((i) => Math.max(...rows.map((r) => (r[i] || '').length)))
|
|
56
|
-
for (const r of rows) {
|
|
57
|
-
process.stdout.write(r.map((c, i) => String(c || '').padEnd(wid[i] + 2)).join('').trimEnd() + '\n')
|
|
58
|
-
}
|
|
59
|
-
return 0
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ledger:dispatch 每轮追加一条,四个字段。session 会因空闲滚动/过期丢上下文,
|
|
63
|
-
// 结论不能丢,供跨会话回溯。
|
|
64
|
-
function ledgerAppend(task, chatId, epoch, trigger, result) {
|
|
65
|
-
try {
|
|
66
|
-
ensureDir(paths.ledger)
|
|
67
|
-
const line = JSON.stringify({
|
|
68
|
-
ts: new Date().toISOString(),
|
|
69
|
-
epoch: Number(epoch) || 0,
|
|
70
|
-
trigger: String(trigger).slice(0, 500),
|
|
71
|
-
result: String(result).slice(0, 4000),
|
|
72
|
-
})
|
|
73
|
-
fs.appendFileSync(paths.ledgerFile(task, chatId), `${line}\n`)
|
|
74
|
-
} catch {}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// 处理一个 (chat_id, thread_id) 组:派一轮活
|
|
78
|
-
async function handleGroup(t, group, opts = {}) {
|
|
79
|
-
const { name: task, taskDir, insPath, dirs, mode } = t
|
|
80
|
-
const cfg = t.config
|
|
81
|
-
const profile = cfg.app
|
|
82
|
-
const chatId = group.chatId
|
|
83
|
-
const threadId = group.threadId
|
|
84
|
-
const events = group.events
|
|
85
|
-
|
|
86
|
-
const cwd = dirs[0] || taskDir
|
|
87
|
-
const addDirs = dirs.slice(1)
|
|
88
|
-
const batch = renderText(events)
|
|
89
|
-
const lastMsg = events[events.length - 1]?.message_id || '-'
|
|
90
|
-
|
|
91
|
-
// 每(任务,群)一把锁串行:并发会写坏 append-only ledger、错算 epoch,
|
|
92
|
-
// 且同群两轮 claude 会互相打断。粒度是群而非话题 —— session 与 epoch 状态按群竞争。
|
|
93
|
-
const lock = await acquireBlocking(`dispatch-${task}__${chatId}`, 1800_000)
|
|
94
|
-
if (!lock.ok) {
|
|
95
|
-
process.stderr.write(`dispatch: ${task}/${chatId} 获取锁超时,跳过本批\n`)
|
|
96
|
-
return 0
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
// session 派生:work 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
|
|
101
|
-
// chat 模式按 (群, epoch),空闲超 idle_gap 就开新世代
|
|
102
|
-
let uuid
|
|
103
|
-
let epoch = 0
|
|
104
|
-
if (mode.thread && threadId) {
|
|
105
|
-
uuid = claude.deriveUuid(task, `${chatId}|thread:${threadId}`)
|
|
106
|
-
} else {
|
|
107
|
-
epoch = claude.sessionEpoch(paths.root, task, chatId, Number(cfg.idle_gap) || 86400)
|
|
108
|
-
uuid = claude.deriveUuid(task, `${chatId}|${epoch}`)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 过程展示:work 模式挂 COT 消息。建不起来就静默降级(结论照发)
|
|
112
|
-
let cot = null
|
|
113
|
-
if (mode.cot) {
|
|
114
|
-
cot = new CotSession({ profile, chatId, lastMsg, replyInThread: mode.thread })
|
|
115
|
-
try {
|
|
116
|
-
if (!(await cot.create())) throw new Error('建 COT 消息失败')
|
|
117
|
-
} catch (err) {
|
|
118
|
-
process.stderr.write(`cot: ${err.message},本轮无过程展示\n`)
|
|
119
|
-
cot = null
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const res = await claude.runClaude({
|
|
124
|
-
cwd,
|
|
125
|
-
prompt: claude.PROMPTS[mode.prompt](batch),
|
|
126
|
-
uuid,
|
|
127
|
-
instructions: insPath,
|
|
128
|
-
addDirs,
|
|
129
|
-
allowedTools: cfg.allowed || 'Read Edit Write Bash',
|
|
130
|
-
model: cfg.model,
|
|
131
|
-
timeoutSec: Number(cfg.timeout) || 1800,
|
|
132
|
-
stream: !!cot,
|
|
133
|
-
onEvent: cot
|
|
134
|
-
? (kind, text) => {
|
|
135
|
-
if (kind === 'say') cot.onSay(text)
|
|
136
|
-
else if (kind === 'tool') cot.onTool(text)
|
|
137
|
-
else if (kind === 'final') cot.onFinal()
|
|
138
|
-
}
|
|
139
|
-
: null,
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// 兜底文案分两套:要发回群的(deliver != none)面向群里的人写,
|
|
143
|
-
// agent 模式的 final 只进台账,不该出现「稍后重试或换个说法」这种对人说的话
|
|
144
|
-
let final = (res.final || '').trim()
|
|
145
|
-
if (!final) {
|
|
146
|
-
const errTail = String(res.err).replace(/\n/g, ' ').slice(-300)
|
|
147
|
-
if (mode.deliver === 'none') {
|
|
148
|
-
final = res.rc === 0 ? '(本轮无自述)' : `(异常 rc=${res.rc}) ${errTail}`
|
|
149
|
-
} else {
|
|
150
|
-
final =
|
|
151
|
-
res.rc === 0
|
|
152
|
-
? '(完成,但没拿到最终结论)'
|
|
153
|
-
: `⚠️ 处理超时或异常(rc=${res.rc})。稍后重试或换个说法。\n\n错误:${errTail}`
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
|
|
158
|
-
// 收尾一抛就会跳过下面的结论投递 + ledgerAppend,而 take 游标已推
|
|
159
|
-
// → 结论既不进群也不进台账,这批消息永不重来
|
|
160
|
-
if (cot) {
|
|
161
|
-
try {
|
|
162
|
-
await cot.finalize({ rc: res.rc, error: res.err })
|
|
163
|
-
await cot.stop()
|
|
164
|
-
} catch (err) {
|
|
165
|
-
process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// 结论投递:card = 卡片(markdown 渲染、不受 3500 字符分片限制),失败降级纯文本;
|
|
170
|
-
// text = 纯文本;none = **引擎不发**,agent 模式下模型已自己发过了,
|
|
171
|
-
// final 只是它的一句自述(进台账供回溯)
|
|
172
|
-
if (mode.deliver !== 'none') {
|
|
173
|
-
const sent = mode.deliver === 'card'
|
|
174
|
-
? await sendCard(final, { profile, chatId, replyTo: lastMsg, inThread: mode.thread })
|
|
175
|
-
: false
|
|
176
|
-
if (!sent) await reply(profile, chatId, lastMsg, final, { inThread: mode.thread })
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
ledgerAppend(task, chatId, epoch, batch, final)
|
|
180
|
-
process.stderr.write(
|
|
181
|
-
`dispatch: ${task}/${chatId}${threadId ? `/${threadId}` : ''} 完成 rc=${res.rc} ` +
|
|
182
|
-
`${events.length} 条 → ${final.length} 字符\n`,
|
|
183
|
-
)
|
|
184
|
-
return res.rc === 0 ? 0 : 1
|
|
185
|
-
} finally {
|
|
186
|
-
lock.release()
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function run(task, opts = {}) {
|
|
191
|
-
const t = loadTask(task)
|
|
192
|
-
if (!t) {
|
|
193
|
-
process.stderr.write(
|
|
194
|
-
`找不到任务 ${task}:${path.join(lrRoot(), task, 'dispatch.yaml')} 不存在\n` +
|
|
195
|
-
`LR_ROOT=${lrRoot()};看现有任务:lark-relay dispatch --list\n`,
|
|
196
|
-
)
|
|
197
|
-
return 2
|
|
198
|
-
}
|
|
199
|
-
if (t.errs.length) {
|
|
200
|
-
process.stderr.write(`任务 ${task} 配置有问题:\n ${t.errs.join('\n ')}\n`)
|
|
201
|
-
return 2
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const cfg = t.config
|
|
205
|
-
const chats = cfg.chats || []
|
|
206
|
-
// work=话题内一问一答要跟手 → 1s;chat=群里多行需求 → 15s 让人打完
|
|
207
|
-
const debounceMs = (Number(cfg.debounce) || t.mode.debounce) * 1000
|
|
208
|
-
|
|
209
|
-
process.stderr.write(
|
|
210
|
-
`dispatch ${task}: app=${cfg.app} ${chats.length} 群 mode=${t.modeName} ` +
|
|
211
|
-
`model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
const consumer = `dispatch-${task}`
|
|
215
|
-
if (store.readCursor(consumer, cfg.app) === null) store.seedCursorNow(consumer, cfg.app)
|
|
216
|
-
|
|
217
|
-
const ac = new AbortController()
|
|
218
|
-
let stopping = false
|
|
219
|
-
const onSig = () => {
|
|
220
|
-
stopping = true
|
|
221
|
-
ac.abort()
|
|
222
|
-
}
|
|
223
|
-
process.on('SIGTERM', onSig)
|
|
224
|
-
process.on('SIGINT', onSig)
|
|
225
|
-
|
|
226
|
-
for (;;) {
|
|
227
|
-
let res
|
|
228
|
-
try {
|
|
229
|
-
res = await takeBatch({
|
|
230
|
-
app: cfg.app,
|
|
231
|
-
chats,
|
|
232
|
-
name: consumer,
|
|
233
|
-
filter: cfg.filter,
|
|
234
|
-
debounceMs,
|
|
235
|
-
maxWaitMs: 75_000,
|
|
236
|
-
timeoutMs: opts.once ? 300_000 : 12 * 3600 * 1000,
|
|
237
|
-
signal: ac.signal,
|
|
238
|
-
})
|
|
239
|
-
} catch (err) {
|
|
240
|
-
// 取批阶段的异常绝不能让进程退出:Restart=on-failure 会变成每 5s 崩一次,
|
|
241
|
-
// 而卡住的那条消息永远在队首 → 任务彻底停摆且看不出原因
|
|
242
|
-
process.stderr.write(`dispatch: ${task} 取批失败,15s 后重试 → ${err.stack || err}\n`)
|
|
243
|
-
if (opts.once) return 1
|
|
244
|
-
await new Promise((r) => setTimeout(r, 15_000))
|
|
245
|
-
continue
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (res.events.length) {
|
|
249
|
-
// 一个防抖窗口会吐同群多话题的消息,只按 chat_id 分组会**串话题**
|
|
250
|
-
const groups = groupByChatThread(res.events)
|
|
251
|
-
for (let i = 0; i < groups.length; i++) {
|
|
252
|
-
// 收到停止信号后不再开新一轮 claude:单元 TimeoutStopSec=30,
|
|
253
|
-
// 而一轮最长 1800s → 会被 SIGKILL,卡片停在占位符、结论不回群。
|
|
254
|
-
// 游标此时也没推(take 的 abort 分支只在有批次时推),故剩余组下次重新取到
|
|
255
|
-
if (stopping) {
|
|
256
|
-
process.stderr.write(
|
|
257
|
-
`dispatch: ${task} 正在停止,剩余 ${groups.length - i} 组留到下次\n`,
|
|
258
|
-
)
|
|
259
|
-
break
|
|
260
|
-
}
|
|
261
|
-
try {
|
|
262
|
-
await handleGroup(t, groups[i])
|
|
263
|
-
} catch (err) {
|
|
264
|
-
process.stderr.write(`dispatch: ${task} 处理组失败 ${err.stack || err}\n`)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
} else if (opts.once) {
|
|
268
|
-
process.stderr.write(`dispatch: 没等到消息(${res.reason})\n`)
|
|
269
|
-
return res.reason === 'timeout' ? 4 : 0 // 与 take 对齐:4 = 等超时,0 = 正常
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (opts.once || stopping) return 0
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
module.exports = { run, cmdList, handleGroup }
|