lark-relay 0.2.0 → 0.2.2
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 +2 -10
- package/lib/claude.js +1 -9
- package/lib/collect.js +1 -1
- package/lib/cot.js +394 -0
- package/lib/dedup.js +1 -1
- package/lib/dispatch.js +41 -20
- package/lib/help.js +8 -1
- package/lib/larkcli.js +22 -108
- package/lib/render.js +0 -0
- package/lib/reply.js +1 -8
- package/lib/tasks.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,10 +74,30 @@ dirs: [/path/to/repo] # 首个 = 主工作目录
|
|
|
74
74
|
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
75
75
|
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
76
76
|
# session: thread 默认;thread=按 thread_id 隔离 | idle=一群一 session
|
|
77
|
-
# display: card 默认;card
|
|
77
|
+
# display: card 默认;card | cot | final(见下)
|
|
78
78
|
# model: <名称> 默认走终端同一套默认路由
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
`display` 三档:
|
|
82
|
+
|
|
83
|
+
| 档 | 过程展示 | 结论 | 备注 |
|
|
84
|
+
| --- | --- | --- | --- |
|
|
85
|
+
| `card` | CardKit 流式打字机,单卡原地更新 | 写进同一张卡片 | 收尾 PUT 失败自动回退纯文本 |
|
|
86
|
+
| `cot` | 思考过程挂在 COT 消息上(AG-UI 事件流) | **总是单独发一条** | 客户端 PC ≥ 7.70 / 移动 ≥ 7.74 |
|
|
87
|
+
| `final` | 无 | 单独发一条 | 最省事,不流式 |
|
|
88
|
+
|
|
89
|
+
`cot` 档的「结论单独发」不是降级路径,而是 COT 接口的设计前提 —— COT 消息只承载
|
|
90
|
+
过程。它也**不写工具结果**:工具输出动辄几千字符且可能含不宜进群的仓库内容,
|
|
91
|
+
与 `card` 档只显示工具名+参数摘要一致。
|
|
92
|
+
|
|
93
|
+
⚠️ **老客户端(< 7.70)看不到过程**:COT 消息底层是 `msg_type: post`,不支持的客户端
|
|
94
|
+
只会看到一条内容为「Completed」的消息(实测,不会崩)。群里有老客户端用户时选 `card`。
|
|
95
|
+
结论那条普通文本不受影响 —— 这也是「结论单独发」的价值。
|
|
96
|
+
|
|
97
|
+
环境变量:`LR_COT_BATCH_MS`(攒批窗口,默认 1000)、
|
|
98
|
+
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`)。
|
|
99
|
+
实测两者渲染一致,`text` 每段少 2 个事件(reasoning 多一对 START/END 包裹)。
|
|
100
|
+
|
|
81
101
|
## 存储布局
|
|
82
102
|
|
|
83
103
|
```
|
package/lib/card.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// ⚠️ print_frequency_ms / print_step 必须是按端对象 {default,android,ios,pc},
|
|
9
9
|
// 传标量会 10002 unmarshal 报错
|
|
10
10
|
const larkcli = require('./larkcli')
|
|
11
|
+
const { stripToJson, errText, tail } = larkcli
|
|
11
12
|
|
|
12
13
|
const THROTTLE_MS = Number(process.env.LR_CARD_THROTTLE_MS || 300)
|
|
13
14
|
const PRINT_FREQ_MS = Number(process.env.LR_CARD_PRINT_FREQ_MS || 10)
|
|
@@ -179,10 +180,7 @@ class CardSession {
|
|
|
179
180
|
])
|
|
180
181
|
return /"ok":\s*true/.test(stdout)
|
|
181
182
|
} catch (err) {
|
|
182
|
-
|
|
183
|
-
.replace(/\n/g, ' ')
|
|
184
|
-
.slice(0, 200)
|
|
185
|
-
process.stderr.write(`card: 收尾全卡更新失败 → ${tail}\n`)
|
|
183
|
+
process.stderr.write(`card: 收尾全卡更新失败 → ${tail(errText(err), 200)}\n`)
|
|
186
184
|
return false
|
|
187
185
|
}
|
|
188
186
|
})
|
|
@@ -232,10 +230,4 @@ class CardSession {
|
|
|
232
230
|
}
|
|
233
231
|
}
|
|
234
232
|
|
|
235
|
-
// lark-cli 可能有前置非 JSON 行,剥到第一个 { 起
|
|
236
|
-
function stripToJson(s) {
|
|
237
|
-
const i = String(s).indexOf('{')
|
|
238
|
-
return i === -1 ? '{}' : String(s).slice(i)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
233
|
module.exports = { CardSession, mdfix }
|
package/lib/claude.js
CHANGED
|
@@ -206,12 +206,4 @@ function shellQuote(s) {
|
|
|
206
206
|
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
module.exports = {
|
|
210
|
-
runClaude,
|
|
211
|
-
deriveUuid,
|
|
212
|
-
sessionEpoch,
|
|
213
|
-
sessionFile,
|
|
214
|
-
encCwd,
|
|
215
|
-
PROMPT_TEMPLATE,
|
|
216
|
-
CC_RC,
|
|
217
|
-
}
|
|
209
|
+
module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPT_TEMPLATE }
|
package/lib/collect.js
CHANGED
package/lib/cot.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
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
|
+
// 实测 9 个枚举全部有专属图标(search 🔍 / bash </> / read 📖 / write ✏️ / doc 📄 /
|
|
30
|
+
// calendar 📅 / task ☑️ / meeting 📹 / default ●)。文档外的值多数回落成 default 的圆点,
|
|
31
|
+
// 少数(shell/edit/web)生效但只是已有图标的别名 —— 没有新增表达力且无兼容承诺,不用
|
|
32
|
+
const ICONS = {
|
|
33
|
+
Bash: 'bash',
|
|
34
|
+
BashOutput: 'bash',
|
|
35
|
+
Read: 'read',
|
|
36
|
+
NotebookRead: 'read',
|
|
37
|
+
Edit: 'write',
|
|
38
|
+
Write: 'write',
|
|
39
|
+
NotebookEdit: 'write',
|
|
40
|
+
Grep: 'search',
|
|
41
|
+
Glob: 'search',
|
|
42
|
+
WebSearch: 'search',
|
|
43
|
+
WebFetch: 'search',
|
|
44
|
+
Task: 'task',
|
|
45
|
+
TodoWrite: 'task',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MCP 工具名(mcp__<server>__<action>)按名字猜 —— 它们在 dispatch 场景很常见,
|
|
49
|
+
// 一律给 default 的圆点等于没信息。命名约定不保证,故只在猜中时用。
|
|
50
|
+
// **顺序即优先级**:域名词(doc/task/calendar)比动词更能说明工具属于哪个域,故排在前
|
|
51
|
+
const MCP_HINTS = [
|
|
52
|
+
[/meeting|minutes|\bvc\b/, 'meeting'],
|
|
53
|
+
[/calendar|schedule/, 'calendar'],
|
|
54
|
+
[/task|todo/, 'task'],
|
|
55
|
+
[/doc|sheet|base|wiki|file/, 'doc'],
|
|
56
|
+
[/write|create|update|edit|send|post|delete/, 'write'],
|
|
57
|
+
[/read|get|fetch|view/, 'read'],
|
|
58
|
+
[/search|find|query|list/, 'search'],
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
function iconFor(tool) {
|
|
62
|
+
if (ICONS[tool]) return ICONS[tool]
|
|
63
|
+
const s = String(tool).toLowerCase()
|
|
64
|
+
if (s.startsWith('mcp__')) {
|
|
65
|
+
for (const [re, icon] of MCP_HINTS) if (re.test(s)) return icon
|
|
66
|
+
}
|
|
67
|
+
return 'default'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 按 UTF-8 字节截断,且不切坏字符(码点边界对齐)。
|
|
71
|
+
// 不能按码点数截 —— 上限是字节,中文每字 3 字节(见 MAX_CONTENT_BYTES)
|
|
72
|
+
function clipBytes(s, maxBytes) {
|
|
73
|
+
const str = String(s)
|
|
74
|
+
if (Buffer.byteLength(str, 'utf8') <= maxBytes) return str
|
|
75
|
+
const ell = '…'
|
|
76
|
+
const ellBytes = Buffer.byteLength(ell, 'utf8')
|
|
77
|
+
// 连省略号都装不下 → 返回空串。否则 '' + '…' 会**超过** maxBytes,
|
|
78
|
+
// 破坏「返回值 ≤ maxBytes」这个契约(本函数是导出的)
|
|
79
|
+
if (maxBytes < ellBytes) return ''
|
|
80
|
+
const budget = maxBytes - ellBytes
|
|
81
|
+
// Buffer 直切会把多字节字符切成半个 → 用码点逐个累加,天然对齐边界。
|
|
82
|
+
// 逐码点而非逐 UTF-16 单元:emoji 等代理对不能拆
|
|
83
|
+
let bytes = 0
|
|
84
|
+
let out = ''
|
|
85
|
+
for (const ch of str) {
|
|
86
|
+
const b = Buffer.byteLength(ch, 'utf8')
|
|
87
|
+
if (bytes + b > budget) break
|
|
88
|
+
out += ch
|
|
89
|
+
bytes += b
|
|
90
|
+
}
|
|
91
|
+
return out + ell
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class CotSession {
|
|
95
|
+
constructor({ profile, chatId, lastMsg, replyInThread }) {
|
|
96
|
+
this.profile = profile
|
|
97
|
+
this.chatId = chatId
|
|
98
|
+
this.lastMsg = lastMsg
|
|
99
|
+
this.replyInThread = replyInThread
|
|
100
|
+
this.cotId = null
|
|
101
|
+
this.messageId = null
|
|
102
|
+
this.done = false // 已写 RUN_FINISHED/RUN_ERROR;之后写入必被拒
|
|
103
|
+
this.acc = '' // **只写不读**,纯为与 CardSession 接口同构;无消费者,别维护它的格式
|
|
104
|
+
// 只用来生成本地唯一 id,**从不发给服务端**。
|
|
105
|
+
// ⚠️ 与 CardSession.seq 同名但无关:那个是服务端排序号,小了会被拒
|
|
106
|
+
this.seq = 0
|
|
107
|
+
this.pending = [] // 待发事件队列
|
|
108
|
+
this.lastStamp = 0 // 事件 timestamp 的单调下界(见 _stamp)
|
|
109
|
+
this.pendingSay = null // 延迟一段的 say(见 onSay);结论不能进过程区
|
|
110
|
+
this.timer = null
|
|
111
|
+
// 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接。
|
|
112
|
+
// 与 CardSession 的 q 同一理由(那边是 sequence 被服务端拒,这边是文本错位)。
|
|
113
|
+
this.q = Promise.resolve()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_enqueue(fn) {
|
|
117
|
+
this.q = this.q.then(fn, fn)
|
|
118
|
+
return this.q
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 步骤 ①:建 COT 消息 → {cot_id, message_id}。两者后续都要带。
|
|
122
|
+
// origin_message_id 让 COT 挂在触发它的那条用户消息上。
|
|
123
|
+
async create() {
|
|
124
|
+
const data = { receive_id: this.chatId }
|
|
125
|
+
if (this.lastMsg && this.lastMsg !== '-') data.origin_message_id = this.lastMsg
|
|
126
|
+
if (this.replyInThread) data.reply_in_thread = true
|
|
127
|
+
const { stdout } = await larkcli.run([
|
|
128
|
+
'--profile', this.profile, 'api', 'POST', '/open-apis/im/v1/message_cot',
|
|
129
|
+
'--params', JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
130
|
+
'--as', 'bot', '--data', JSON.stringify(data),
|
|
131
|
+
])
|
|
132
|
+
const j = JSON.parse(stripToJson(stdout))
|
|
133
|
+
this.cotId = j?.data?.cot_id || null
|
|
134
|
+
this.messageId = j?.data?.message_id || null
|
|
135
|
+
if (!this.cotId || !this.messageId) return null
|
|
136
|
+
// RUN_STARTED 立刻发,让消息一出现就是「在跑」而不是空壳
|
|
137
|
+
this._push(['RUN_STARTED', { runId: this.cotId, threadId: this.cotId }])
|
|
138
|
+
await this.flush()
|
|
139
|
+
return this.cotId
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 与 CardSession 接口对齐:COT 一步建好,没有「另发一条消息引用它」的步骤
|
|
143
|
+
async post() {
|
|
144
|
+
return this.messageId
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 编码单条事件:content 序列化 + 超 4096 字节时截断自由文本字段。
|
|
148
|
+
// 返回事件对象,null = 截不下来只能丢弃(整批被服务端拒比丢一条更糟)
|
|
149
|
+
_encode(eventType, content) {
|
|
150
|
+
let s = JSON.stringify(content)
|
|
151
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) {
|
|
152
|
+
// 超长只可能来自 delta/title/message 这类自由文本
|
|
153
|
+
const key = ['delta', 'title', 'message'].find((k) => typeof content[k] === 'string')
|
|
154
|
+
if (!key) return null // 没有可截的字段 → 丢这条,过程事件不值得冒整批失败
|
|
155
|
+
// JSON 转义会膨胀(换行 2 字节、控制字符 6 字节),故截完再验、按实测比例收敛。
|
|
156
|
+
// 别用固定系数退让 —— 0.8 退 8 次才到 0.17,覆盖不了 6 倍膨胀(ANSI 日志会撞上)
|
|
157
|
+
const overhead = Buffer.byteLength(JSON.stringify({ ...content, [key]: '' }), 'utf8')
|
|
158
|
+
let budget = MAX_CONTENT_BYTES - overhead
|
|
159
|
+
for (let i = 0; i < 8 && budget > 0; i++) {
|
|
160
|
+
const clipped = { ...content, [key]: clipBytes(content[key], budget) }
|
|
161
|
+
s = JSON.stringify(clipped)
|
|
162
|
+
const actual = Buffer.byteLength(s, 'utf8')
|
|
163
|
+
if (actual <= MAX_CONTENT_BYTES) break
|
|
164
|
+
// 按本轮实测膨胀率反推能装多少,留 2% 余量
|
|
165
|
+
const ratio = (MAX_CONTENT_BYTES - overhead) / (actual - overhead)
|
|
166
|
+
const next = Math.floor(budget * ratio * 0.98)
|
|
167
|
+
budget = next < budget ? next : budget - 1 // 保证严格递减,不会死循环
|
|
168
|
+
}
|
|
169
|
+
// 截不下来就丢;配对的 START/END 由 _push 的成组语义一起丢,不留空气泡
|
|
170
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_CONTENT_BYTES) return null
|
|
171
|
+
}
|
|
172
|
+
return { event_type: eventType, content: s, timestamp: String(this._stamp()) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 事件 timestamp 必须**严格单调递增**。
|
|
176
|
+
// 实测(2026-08-20):同一批 8 个 delta 用相同 timestamp 时,客户端渲染成
|
|
177
|
+
// 「3 2 6 8 7 5 4 1」—— 不是反转而是完全打乱,说明它按 timestamp 排序且排序不稳定。
|
|
178
|
+
// 而一段 say 的 START/CONTENT/END 是同步生成的,Date.now() 必然相同 → 文字错乱。
|
|
179
|
+
// 墙上时间只作下界,计数器保证单调(同 paths.js 的 stamp,时钟回拨也不倒退)
|
|
180
|
+
_stamp() {
|
|
181
|
+
const now = Date.now()
|
|
182
|
+
this.lastStamp = now > this.lastStamp ? now : this.lastStamp + 1
|
|
183
|
+
return this.lastStamp
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 成组入队:**同一组事件保证同批发出**。
|
|
187
|
+
// 一段 say 是 3 件套(reasoning 档 5 件)、一次工具调用是 2 件套 —— 若被切批拆散,
|
|
188
|
+
// 前半批失败就会让客户端收到没有 START 的孤立 CONTENT/END(渲染行为未验证)。
|
|
189
|
+
// 组内任一条因超长被丢 → 整组丢,不留「有气泡无内容」
|
|
190
|
+
_push(...events) {
|
|
191
|
+
if (this.done) return
|
|
192
|
+
const encoded = []
|
|
193
|
+
for (const [type, content] of events) {
|
|
194
|
+
const e = this._encode(type, content)
|
|
195
|
+
if (!e) return // 组内有一条编码不出来 → 整组不发
|
|
196
|
+
encoded.push(e)
|
|
197
|
+
}
|
|
198
|
+
// 装不下整组就先把已攒的发走,让这一组完整落进下一批 —— 这是「不跨批」的实现
|
|
199
|
+
if (this.pending.length && this.pending.length + encoded.length > MAX_EVENTS) this.flush()
|
|
200
|
+
this.pending.push(...encoded)
|
|
201
|
+
if (this.pending.length >= MAX_EVENTS) {
|
|
202
|
+
this.flush()
|
|
203
|
+
} else if (!this.timer) {
|
|
204
|
+
this.timer = setTimeout(() => this.flush(), BATCH_MS)
|
|
205
|
+
if (this.timer.unref) this.timer.unref() // 别让攒批定时器吊住进程退出
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 等队列排空后返回 v,且**吞掉 rejection** —— flush/update 返回的就是 this.q,
|
|
210
|
+
// 而它们被 fire-and-forget 调用,裸传 rejected 的 q 会终结常驻进程
|
|
211
|
+
_settled(v) {
|
|
212
|
+
return this.q.then(
|
|
213
|
+
() => v,
|
|
214
|
+
() => v,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 步骤 ②:PUT 写事件。返回 bool —— **调用方靠它判断是否要兜底**
|
|
219
|
+
// (RUN_FINISHED 写失败则 COT 永久卡在「在跑」)。
|
|
220
|
+
// 整个 fn 体裹 try:flush() 绝不能抛(理由同 _settled);连日志的 EPIPE 也要吞
|
|
221
|
+
flush() {
|
|
222
|
+
if (this.timer) {
|
|
223
|
+
clearTimeout(this.timer)
|
|
224
|
+
this.timer = null
|
|
225
|
+
}
|
|
226
|
+
// 已封口:pending 里的事件注定被拒,清掉省下每批一次的失败请求 + 日志
|
|
227
|
+
if (this.done) {
|
|
228
|
+
this.pending = []
|
|
229
|
+
return this._settled(false)
|
|
230
|
+
}
|
|
231
|
+
// cotId 为 null 时**不清空 pending** —— create 还没成功,事件要活到它成功
|
|
232
|
+
if (!this.pending.length || !this.cotId) return this._settled(true)
|
|
233
|
+
const batch = this.pending
|
|
234
|
+
this.pending = []
|
|
235
|
+
return this._enqueue(async () => {
|
|
236
|
+
let ok = true
|
|
237
|
+
try {
|
|
238
|
+
// 切片非冗余:create 之前攒的事件可远超 50 条(那时 flush 提前返回且不清空)。
|
|
239
|
+
// 切点不会落在一段 say 中间 —— _push 的成组语义保证
|
|
240
|
+
for (let i = 0; i < batch.length; i += MAX_EVENTS) {
|
|
241
|
+
const events = batch.slice(i, i + MAX_EVENTS)
|
|
242
|
+
try {
|
|
243
|
+
await larkcli.run([
|
|
244
|
+
'--profile', this.profile, 'api', 'PUT', '/open-apis/im/v1/message_cot',
|
|
245
|
+
'--as', 'bot',
|
|
246
|
+
'--data', JSON.stringify({
|
|
247
|
+
events, message_id: this.messageId, cot_id: this.cotId,
|
|
248
|
+
}),
|
|
249
|
+
])
|
|
250
|
+
} catch (err) {
|
|
251
|
+
ok = false
|
|
252
|
+
const t = tail(errText(err), 200)
|
|
253
|
+
process.stderr.write(`cot: 写事件失败(${events.length} 条)→ ${t}\n`)
|
|
254
|
+
// terminal state 之后的写入全是徒劳,直接封口
|
|
255
|
+
if (/terminal state/.test(t)) this.done = true
|
|
256
|
+
break // 一片失败,后续片多半同因,别刷屏
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
ok = false // 含 stderr.write 抛 EPIPE 这类极端情况
|
|
261
|
+
}
|
|
262
|
+
return ok
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 中间文本。每段一个独立 messageId —— 同 id 的多条 CONTENT 会被客户端拼成一段,
|
|
267
|
+
// 而两次 say 之间通常隔着工具调用,拼起来读不通。
|
|
268
|
+
//
|
|
269
|
+
// **延迟一段**(与 CardSession 同机制):最后一段 say 就是结论,它会作为独立消息
|
|
270
|
+
// 再发一次 —— 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
|
|
271
|
+
// 用「延迟一段」而非内容比较 —— 最稳(card.js 踩出来的)
|
|
272
|
+
onSay(text) {
|
|
273
|
+
this.acc += (this.acc ? '\n\n' : '') + text
|
|
274
|
+
this._flushPending()
|
|
275
|
+
this.pendingSay = text
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_flushPending() {
|
|
279
|
+
if (this.pendingSay === null) return
|
|
280
|
+
const text = this.pendingSay
|
|
281
|
+
this.pendingSay = null
|
|
282
|
+
const id = `say-${++this.seq}`
|
|
283
|
+
if (SAY_AS === 'text') {
|
|
284
|
+
this._push(
|
|
285
|
+
['TEXT_MESSAGE_START', { messageId: id, role: 'assistant' }],
|
|
286
|
+
['TEXT_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
287
|
+
['TEXT_MESSAGE_END', { messageId: id }],
|
|
288
|
+
)
|
|
289
|
+
} else {
|
|
290
|
+
this._push(
|
|
291
|
+
['REASONING_START', { messageId: id }],
|
|
292
|
+
['REASONING_MESSAGE_START', { messageId: id }],
|
|
293
|
+
['REASONING_MESSAGE_CONTENT', { messageId: id, delta: String(text) }],
|
|
294
|
+
['REASONING_MESSAGE_END', { messageId: id }],
|
|
295
|
+
['REASONING_END', { messageId: id }],
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 工具调用。**不写 TOOL_CALL_RESULT**:输出动辄几千字符且可能含不宜进群的仓库内容,
|
|
301
|
+
// 与 card 档只显示工具名+参数摘要一致。text 形如 "Bash: ls -la"(claude.js 拼的)
|
|
302
|
+
onTool(text) {
|
|
303
|
+
this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
|
|
304
|
+
// 工具调用到来 = 前面那段 say 不是结论,可以放行了
|
|
305
|
+
this._flushPending()
|
|
306
|
+
const s = String(text)
|
|
307
|
+
const tool = s.split(':', 1)[0].trim()
|
|
308
|
+
const id = `tool-${++this.seq}`
|
|
309
|
+
this._push(
|
|
310
|
+
[
|
|
311
|
+
'TOOL_CALL_START',
|
|
312
|
+
{
|
|
313
|
+
toolCallId: id,
|
|
314
|
+
icon: iconFor(tool),
|
|
315
|
+
title: s,
|
|
316
|
+
toolCallName: tool || 'tool',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
['TOOL_CALL_END', { toolCallId: id }],
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 与 CardSession 同签名同语义:手里那段 pending(= 结论)直接丢弃,不放行 ——
|
|
324
|
+
// 它会作为独立消息发出,写进过程区就成了群里的第二遍
|
|
325
|
+
onFinal() {
|
|
326
|
+
this.pendingSay = null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 步骤 ③:RUN_FINISHED 自动完结 —— **但只在写入成功时**。写失败(500/频控/抖动)
|
|
330
|
+
// 那条终结事件就没到服务端,COT 永久卡在「在跑」→ 靠 complete 兜底(幂等,不守 done)。
|
|
331
|
+
// rc != 0 走 RUN_ERROR + 无条件 complete:RUN_ERROR 是否自动流转未验证,补一刀不亏
|
|
332
|
+
async finalize({ rc = 0, error = '' } = {}) {
|
|
333
|
+
if (!this.cotId || this.done) return false
|
|
334
|
+
if (rc === 0) {
|
|
335
|
+
this._push(['RUN_FINISHED', { runId: this.cotId, threadId: this.cotId }])
|
|
336
|
+
const ok = await this.flush()
|
|
337
|
+
this.done = true
|
|
338
|
+
if (!ok) await this.complete('done') // 它和最多 49 条事件同批,该批被拒就一起没了
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
// rc != 0:没有「结论」这回事,压着的那段 say 是仅有的线索,放行
|
|
342
|
+
this._flushPending()
|
|
343
|
+
this._push([
|
|
344
|
+
'RUN_ERROR',
|
|
345
|
+
{
|
|
346
|
+
runId: this.cotId,
|
|
347
|
+
threadId: this.cotId,
|
|
348
|
+
message: clipBytes(error || `rc=${rc}`, 500),
|
|
349
|
+
code: String(rc),
|
|
350
|
+
},
|
|
351
|
+
])
|
|
352
|
+
await this.flush()
|
|
353
|
+
this.done = true
|
|
354
|
+
await this.complete('error')
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 手动关写入态。正常路径不需要(RUN_FINISHED 已流转),
|
|
359
|
+
// 留给异常中断 —— 不调的话 COT 会一直显示「在跑」
|
|
360
|
+
async complete(reason = 'done') {
|
|
361
|
+
if (!this.cotId) return false
|
|
362
|
+
return this._enqueue(async () => {
|
|
363
|
+
try {
|
|
364
|
+
await larkcli.run([
|
|
365
|
+
'--profile', this.profile, 'api', 'POST',
|
|
366
|
+
`/open-apis/im/v1/message_cot/complete/${this.cotId}`,
|
|
367
|
+
'--params', JSON.stringify({ message_id: this.messageId, reason }),
|
|
368
|
+
'--as', 'bot',
|
|
369
|
+
])
|
|
370
|
+
return true
|
|
371
|
+
} catch {
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 与 CardSession 接口对齐:onEvent 里 fire-and-forget 调,cot 侧攒批已自带节流
|
|
378
|
+
update() {
|
|
379
|
+
return this._settled()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 与 CardSession 接口对齐。finalize 已完结,这里只兜「从没 finalize 成功」的情况:
|
|
383
|
+
// 超时被 SIGTERM、异常中断 —— 不调 complete 的话 COT 会一直显示「在跑」
|
|
384
|
+
async stop() {
|
|
385
|
+
if (this.done || !this.cotId) return
|
|
386
|
+
// 从没 finalize 就走到这里 = 异常中断,压着的那段不是结论 → 放行
|
|
387
|
+
this._flushPending()
|
|
388
|
+
await this.flush()
|
|
389
|
+
await this.complete('timeout')
|
|
390
|
+
this.done = true
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
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
|
|
|
@@ -259,4 +280,4 @@ async function run(task, opts = {}) {
|
|
|
259
280
|
}
|
|
260
281
|
}
|
|
261
282
|
|
|
262
|
-
module.exports = { run, cmdList,
|
|
283
|
+
module.exports = { run, cmdList, handleGroup }
|
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
|
package/lib/larkcli.js
CHANGED
|
@@ -23,6 +23,27 @@ function run(args, opts = {}) {
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// lark-cli 的 stdout 可能有前置非 JSON 行(如「installed successfully」自更新提示),
|
|
27
|
+
// 剥到第一个 { 起。这是 lark-cli 的输出契约,故与 run 同处一地。
|
|
28
|
+
//
|
|
29
|
+
// ⚠️ **只适用于对象响应**:数组响应会被切坏 ——
|
|
30
|
+
// stripToJson('[{"a":1}]') → '{"a":1}]' → JSON.parse 抛错。
|
|
31
|
+
// listProfiles 解析的正是数组,所以它不能用这个(它直接 JSON.parse)
|
|
32
|
+
function stripToJson(s) {
|
|
33
|
+
const i = String(s).indexOf('{')
|
|
34
|
+
return i === -1 ? '{}' : String(s).slice(i)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// execFile 失败时错误信息散在三个字段里,取第一个有内容的
|
|
38
|
+
function errText(err) {
|
|
39
|
+
return String(err.stdout || err.stderr || err.message || err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 压成单行并截断,给 stderr 日志用。行首那段才是错误码/原因所在
|
|
43
|
+
function tail(s, n = 300) {
|
|
44
|
+
return String(s).replace(/\n/g, ' ').slice(0, n)
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
// profile list 默认输出 JSON,无需 --json。
|
|
27
48
|
// 两类不可用,都要跳过,否则 consume 一直失败重试(死循环):
|
|
28
49
|
// - tokenStatus 为 expired:授权过期,永久失败直到用户重新 login
|
|
@@ -57,111 +78,4 @@ function spawnConsume(profile, eventKey, extraArgs = []) {
|
|
|
57
78
|
return spawn(CLI, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
|
|
61
|
-
const params = { receive_id_type: 'chat_id' }
|
|
62
|
-
const data = {
|
|
63
|
-
receive_id: chatId,
|
|
64
|
-
msg_type: 'text',
|
|
65
|
-
content: JSON.stringify({ text }),
|
|
66
|
-
}
|
|
67
|
-
const args = [
|
|
68
|
-
'--profile',
|
|
69
|
-
profile,
|
|
70
|
-
'im',
|
|
71
|
-
'+messages-send',
|
|
72
|
-
'--params',
|
|
73
|
-
JSON.stringify(params),
|
|
74
|
-
'--data',
|
|
75
|
-
JSON.stringify(data),
|
|
76
|
-
'--as',
|
|
77
|
-
'bot',
|
|
78
|
-
]
|
|
79
|
-
if (opts.replyTo) return replyText(profile, opts.replyTo, text, opts)
|
|
80
|
-
const { stdout } = await run(args)
|
|
81
|
-
return JSON.parse(stdout)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function replyText(profile, messageId, text, opts = {}) {
|
|
85
|
-
const data = {
|
|
86
|
-
msg_type: 'text',
|
|
87
|
-
content: JSON.stringify({ text }),
|
|
88
|
-
}
|
|
89
|
-
if (opts.inThread) data.reply_in_thread = true
|
|
90
|
-
const args = [
|
|
91
|
-
'--profile',
|
|
92
|
-
profile,
|
|
93
|
-
'api',
|
|
94
|
-
'POST',
|
|
95
|
-
`/open-apis/im/v1/messages/${messageId}/reply`,
|
|
96
|
-
'--data',
|
|
97
|
-
JSON.stringify(data),
|
|
98
|
-
'--as',
|
|
99
|
-
'bot',
|
|
100
|
-
]
|
|
101
|
-
const { stdout } = await run(args)
|
|
102
|
-
return JSON.parse(stdout)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function sendCard(profile, chatId, card, opts = {}) {
|
|
106
|
-
const content = JSON.stringify(card)
|
|
107
|
-
if (opts.replyTo) {
|
|
108
|
-
const data = { msg_type: 'interactive', content }
|
|
109
|
-
if (opts.inThread) data.reply_in_thread = true
|
|
110
|
-
const { stdout } = await run([
|
|
111
|
-
'--profile',
|
|
112
|
-
profile,
|
|
113
|
-
'api',
|
|
114
|
-
'POST',
|
|
115
|
-
`/open-apis/im/v1/messages/${opts.replyTo}/reply`,
|
|
116
|
-
'--data',
|
|
117
|
-
JSON.stringify(data),
|
|
118
|
-
'--as',
|
|
119
|
-
'bot',
|
|
120
|
-
])
|
|
121
|
-
return JSON.parse(stdout)
|
|
122
|
-
}
|
|
123
|
-
const { stdout } = await run([
|
|
124
|
-
'--profile',
|
|
125
|
-
profile,
|
|
126
|
-
'im',
|
|
127
|
-
'+messages-send',
|
|
128
|
-
'--params',
|
|
129
|
-
JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
130
|
-
'--data',
|
|
131
|
-
JSON.stringify({ receive_id: chatId, msg_type: 'interactive', content }),
|
|
132
|
-
'--as',
|
|
133
|
-
'bot',
|
|
134
|
-
])
|
|
135
|
-
return JSON.parse(stdout)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// reply 返回体的 thread_id 是 null,要单独查消息详情才拿得到(实测)。
|
|
139
|
-
async function getMessageThreadId(profile, messageId) {
|
|
140
|
-
try {
|
|
141
|
-
const { stdout } = await run([
|
|
142
|
-
'--profile',
|
|
143
|
-
profile,
|
|
144
|
-
'api',
|
|
145
|
-
'GET',
|
|
146
|
-
`/open-apis/im/v1/messages/${messageId}`,
|
|
147
|
-
'--as',
|
|
148
|
-
'bot',
|
|
149
|
-
])
|
|
150
|
-
const j = JSON.parse(stdout)
|
|
151
|
-
const items = j?.data?.items || j?.items || []
|
|
152
|
-
return items[0]?.thread_id || null
|
|
153
|
-
} catch {
|
|
154
|
-
return null
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
module.exports = {
|
|
159
|
-
CLI,
|
|
160
|
-
run,
|
|
161
|
-
listProfiles,
|
|
162
|
-
spawnConsume,
|
|
163
|
-
sendText,
|
|
164
|
-
replyText,
|
|
165
|
-
sendCard,
|
|
166
|
-
getMessageThreadId,
|
|
167
|
-
}
|
|
81
|
+
module.exports = { CLI, run, stripToJson, errText, tail, listProfiles, spawnConsume }
|
package/lib/render.js
CHANGED
|
Binary file
|
package/lib/reply.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// 回群:超长按**字符**分片。飞书单条 text 有长度上限,超长会整条发送失败
|
|
4
4
|
// (用户收不到任何东西),故必须分片而非截断。
|
|
5
5
|
const larkcli = require('./larkcli')
|
|
6
|
+
const { errText, tail } = larkcli
|
|
6
7
|
|
|
7
8
|
const CHUNK = Number(process.env.LR_REPLY_CHUNK || 3500)
|
|
8
9
|
|
|
@@ -65,12 +66,4 @@ async function replyOne(profile, chatId, msgId, text, inThread) {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function errText(err) {
|
|
69
|
-
return String(err.stdout || err.stderr || err.message || err)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function tail(s) {
|
|
73
|
-
return String(s).replace(/\n/g, ' ').slice(0, 300)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
69
|
module.exports = { reply, CHUNK }
|
package/lib/tasks.js
CHANGED
|
@@ -119,7 +119,9 @@ function validate(name, cfg, taskDir) {
|
|
|
119
119
|
const session = cfg.session || 'thread'
|
|
120
120
|
if (!['thread', 'idle'].includes(session)) errs.push(`session 只能 thread|idle,给的是 ${session}`)
|
|
121
121
|
const display = cfg.display || 'card'
|
|
122
|
-
if (!['card', 'final'].includes(display))
|
|
122
|
+
if (!['card', 'cot', 'final'].includes(display)) {
|
|
123
|
+
errs.push(`display 只能 card|cot|final,给的是 ${display}`)
|
|
124
|
+
}
|
|
123
125
|
|
|
124
126
|
// instructions 相对任务目录解析 —— 执行偏好是资产,必须留仓库
|
|
125
127
|
let insPath = null
|