lark-relay 0.4.5 → 0.4.7

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/lib/claude.js DELETED
@@ -1,231 +0,0 @@
1
- 'use strict'
2
-
3
- // 唤起 headless claude。session 确定性派生,不落配置、不需人填。
4
- const crypto = require('crypto')
5
- const fs = require('fs')
6
- const os = require('os')
7
- const path = require('path')
8
- const { spawn } = require('child_process')
9
-
10
- // 模型分发交给一个 shell 函数 `claude()` —— 由它做白名单校验与后端路由,
11
- // 是模型名的单一事实源。包不自己维护模型清单(那样新增后端要改两处)。
12
- //
13
- // LR_CC_RC 指向定义了 `claude()` 的 rc 文件。默认值只是个约定俗成的位置,
14
- // 部署时按实际环境设置:Environment=LR_CC_RC=%h/path/to/claude.sh
15
- const CC_RC = process.env.LR_CC_RC || path.join(os.homedir(), '.claude-route.sh')
16
-
17
- // sha1(task|scope) 前 32 位切成 8-4-4-4-12
18
- function deriveUuid(task, scope) {
19
- const h = crypto.createHash('sha1').update(`${task}|${scope}`).digest('hex')
20
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`
21
- }
22
-
23
- // Claude Code 按 cwd 分目录存 session:/ 和 . 都换成 -
24
- function encCwd(cwd) {
25
- return String(cwd).replace(/[/.]/g, '-')
26
- }
27
-
28
- function sessionFile(cwd, uuid) {
29
- return path.join(os.homedir(), '.claude', 'projects', encCwd(cwd), `${uuid}.jsonl`)
30
- }
31
-
32
- // 会话世代号:超过 idleGap 没动静就开新会话。thread 模式不调用(话题即边界,恒 0)。
33
- function sessionEpoch(stateDir, task, chatId, idleGap) {
34
- const dir = path.join(stateDir, 'state')
35
- fs.mkdirSync(dir, { recursive: true })
36
- const file = path.join(dir, `${task}__${chatId}.tsv`)
37
- const now = Math.floor(Date.now() / 1000)
38
- let epoch = 0
39
- try {
40
- const [lastStr, epStr] = fs.readFileSync(file, 'utf8').trim().split('\t')
41
- const last = Number(lastStr) || 0
42
- epoch = Number(epStr) || 0
43
- if (idleGap > 0 && now - last > idleGap) epoch += 1
44
- } catch {}
45
- fs.writeFileSync(file, `${now}\t${epoch}\n`)
46
- return epoch
47
- }
48
-
49
- // 提示词按 mode 分流 —— 两类场景对模型的要求根本不同。
50
- // work/chat:模型产出一段结论,由 dispatch 发回群(模型不碰发送)。
51
- // agent:模型自己决定发不发、发给谁、用谁的身份、发文字还是表情 ——
52
- // 引擎不做回复决策,和「引擎不做权限管控」是同一条思路:边界靠 instructions 到达模型侧。
53
- const PROMPTS = {
54
- task: (batch) =>
55
- `下面是飞书群里最新一批消息。请把它们当作针对本仓库(当前工作目录)的任务来处理:定位/分析/修复/测试。处理完后,你的最终回复会被原样发回该群,请直接面向群里的人简洁说明你做了什么、发现了什么、需要什么确认。
56
-
57
- ===== 群消息 =====
58
- ${batch}
59
- ===== 结束 =====`,
60
-
61
- agent: (batch) =>
62
- `下面是飞书群里最新一批消息。
63
-
64
- **本轮的发送完全由你负责** —— 没有任何东西会被自动发回群。按你的职责文档判断:
65
- 该不该回应、回应哪一条、用哪个身份、发文字还是表情回应、要不要执行既定任务。
66
- 需要发送时自己用 lark-cli 完成。
67
-
68
- **多数情况下正确的选择是什么都不做** —— 不要为了有所动作而发言。
69
-
70
- 处理完请用一句话说明你做了什么(如「用可莉回复了接梗」「无 @ 我方,沉默」),
71
- 这句话只进台账供事后回溯,不会发到群里。
72
-
73
- ===== 群消息 =====
74
- ${batch}
75
- ===== 结束 =====`,
76
- }
77
-
78
- /**
79
- * 起一轮 headless claude。
80
- * @returns {Promise<{rc:number, final:string, err:string}>}
81
- */
82
- function runClaude(opts) {
83
- const {
84
- cwd,
85
- prompt,
86
- uuid,
87
- instructions,
88
- addDirs = [],
89
- allowedTools = 'Read Edit Write Bash',
90
- model,
91
- timeoutSec = 1800,
92
- stream = false,
93
- onEvent,
94
- } = opts
95
-
96
- const resume = fs.existsSync(sessionFile(cwd, uuid))
97
- const args = ['-p', prompt]
98
- // 首次用 --session-id 建、之后用 --resume 续,判据就是 jsonl 是否存在
99
- args.push(resume ? '--resume' : '--session-id', uuid)
100
- args.push('--add-dir', cwd) // 主工作目录自己也加一次
101
- args.push('--setting-sources', 'user,project,local') // 含 project 才加载主目录 CLAUDE.md
102
- args.push('--permission-mode', 'acceptEdits')
103
- args.push('--allowedTools', allowedTools)
104
- for (const d of addDirs) args.push('--add-dir', d)
105
- // 职责/边界必须走这里:CLAUDE.md 只自动加载主工作目录+父链,
106
- // --add-dir 的目录启动不加载,AGENTS.md 也不被当 memory
107
- if (instructions && fs.existsSync(instructions)) {
108
- args.push('--append-system-prompt-file', instructions)
109
- }
110
- args.push('--output-format', stream ? 'stream-json' : 'json')
111
- if (stream) args.push('--verbose')
112
-
113
- // 经 bash -lc source <rc> 后调 claude() —— 模型分发与白名单校验都在那里。
114
- // rc 不存在时**不当致命错误**:降级到 PATH 里的 claude,只在指定了 --model 时才必须有 rc
115
- // (否则 --model 传给原生 claude 会是另一套语义)。
116
- // ⚠️ CC_RC 必须 shellQuote:曾直接内插进双引号里的 echo,
117
- // LR_CC_RC='/x$(命令)' 会被执行(实测)。所有插进 script 的值一律走 shellQuote
118
- const quoted = args.map(shellQuote).join(' ')
119
- const rcQ = shellQuote(CC_RC)
120
- const script = model
121
- ? `set -o pipefail\n` +
122
- `if ! source ${rcQ} 2>/dev/null; then\n` +
123
- ` printf '%s\\n' "cc: 指定了 --model 但 source 失败,设 LR_CC_RC 指向定义 claude() 的文件" >&2\n` +
124
- ` exit 3\n` +
125
- `fi\n` +
126
- `claude --model ${shellQuote(model)} ${quoted}\n`
127
- : `set -o pipefail\n` +
128
- `source ${rcQ} 2>/dev/null || true\n` +
129
- `claude ${quoted}\n`
130
-
131
- return new Promise((resolve) => {
132
- const child = spawn('bash', ['-lc', script], {
133
- cwd,
134
- env: {
135
- ...process.env,
136
- LANG: 'C.UTF-8',
137
- LC_ALL: 'C.UTF-8',
138
- // headless 里 Bash 工具层单命令超时 10min(非整体超时)
139
- BASH_MAX_TIMEOUT_MS: process.env.BASH_MAX_TIMEOUT_MS || '600000',
140
- },
141
- stdio: ['ignore', 'pipe', 'pipe'],
142
- })
143
-
144
- let out = ''
145
- let err = ''
146
- let done = false
147
- // 挂钟超时兜底:包住整轮
148
- const timer = setTimeout(() => {
149
- if (!done) {
150
- err += `\n[timeout ${timeoutSec}s]`
151
- child.kill('SIGTERM')
152
- setTimeout(() => child.kill('SIGKILL'), 5000)
153
- }
154
- }, timeoutSec * 1000)
155
-
156
- if (stream) {
157
- let buf = ''
158
- child.stdout.on('data', (d) => {
159
- buf += d.toString()
160
- const lines = buf.split('\n')
161
- buf = lines.pop()
162
- for (const line of lines) handleStreamLine(line, onEvent, (t) => (out = t))
163
- })
164
- child.stdout.on('end', () => {
165
- if (buf.trim()) handleStreamLine(buf, onEvent, (t) => (out = t))
166
- })
167
- } else {
168
- child.stdout.on('data', (d) => (out += d.toString()))
169
- }
170
- child.stderr.on('data', (d) => (err += d.toString()))
171
-
172
- child.on('close', (code) => {
173
- done = true
174
- clearTimeout(timer)
175
- let final = ''
176
- if (stream) {
177
- final = out
178
- } else {
179
- try {
180
- final = JSON.parse(stripToJson(out))?.result || ''
181
- } catch {
182
- final = out.trim()
183
- }
184
- }
185
- resolve({ rc: code === null ? 124 : code, final, err })
186
- })
187
- child.on('error', (e) => {
188
- done = true
189
- clearTimeout(timer)
190
- resolve({ rc: 1, final: '', err: String(e.message) })
191
- })
192
- })
193
- }
194
-
195
- // stream-json 事件 → say / tool / final
196
- function handleStreamLine(line, onEvent, setFinal) {
197
- const s = line.trim()
198
- if (!s || s[0] !== '{') return
199
- let j
200
- try {
201
- j = JSON.parse(s)
202
- } catch {
203
- return
204
- }
205
- if (j.type === 'assistant') {
206
- for (const c of j.message?.content || []) {
207
- if (c.type === 'text' && c.text) onEvent?.('say', c.text)
208
- else if (c.type === 'tool_use') {
209
- const detail =
210
- c.input?.command ?? c.input?.file_path ?? c.input?.pattern ?? c.input?.description ?? ''
211
- const str = String(detail)
212
- onEvent?.('tool', `${c.name}: ${str.length > 300 ? `${str.slice(0, 300)}…` : str}`)
213
- }
214
- }
215
- } else if (j.type === 'result') {
216
- const r = j.result || ''
217
- setFinal(r)
218
- onEvent?.('final', r)
219
- }
220
- }
221
-
222
- function stripToJson(s) {
223
- const i = String(s).indexOf('{')
224
- return i === -1 ? '{}' : String(s).slice(i)
225
- }
226
-
227
- function shellQuote(s) {
228
- return `'${String(s).replace(/'/g, `'\\''`)}'`
229
- }
230
-
231
- module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPTS }
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 }