lark-relay 0.0.0 → 0.1.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 CHANGED
@@ -1,3 +1,111 @@
1
1
  # lark-relay
2
2
 
3
- Placeholder name reserved for future development. Not functional yet.
3
+ Lark(飞书)事件中继站。三个命令:**收下来** / **我来取** / **派给别人**。
4
+
5
+ 依赖 [`lark-cli`](https://www.npmjs.com/package/@lark-base-open/lark-cli) 提供认证与
6
+ 事件总线;本包只负责缓冲、批次与派活,零 npm 依赖。
7
+
8
+ ```bash
9
+ npm i -g lark-relay
10
+ lark-relay guide # 装完先读这个
11
+ ```
12
+
13
+ ## 为什么需要它
14
+
15
+ Lark 事件是**流式**的 —— 进程不在的时刻,消息**永久丢失**,不是延迟送达
16
+ (实测:消息发出 8 秒后才起 consumer,收到 0 条)。
17
+
18
+ 所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
19
+ 会话关了几小时,都不丢消息。这就是 `collect`。
20
+
21
+ ## 两个场景(不可混用)
22
+
23
+ | | 在场取用 `take` | 托管派活 `dispatch` |
24
+ | --- | --- | --- |
25
+ | 长命的 | AI 会话(人设、上下文连续) | 消息(等在队列里) |
26
+ | 短命的 | 消息(来一批处理一批) | AI(每轮新起,跑完就没) |
27
+ | 谁在等谁 | AI 等消息 | 消息等 AI |
28
+ | 控制权 | AI 手里(自己决定何时再取) | 服务手里(决定何时拉起 AI) |
29
+ | 谁能干 | 任何 agent(Claude Code / Codex / 手敲) | 需内置唤起知识 |
30
+ | 配置 | 纯参数,无文件 | `dispatch.yaml` |
31
+
32
+ 你(AI)在会话里盯群 → `take`。无人在场也要干活 → `dispatch`。
33
+
34
+ ## 命令
35
+
36
+ ```bash
37
+ lark-relay collect # 底座:全部 profile 各起 consume → 原子落盘(systemd 常驻)
38
+ lark-relay take … # 场景1:阻塞等一批 → 输出 → 退出
39
+ lark-relay dispatch <task> # 场景2:常驻循环 = take + 唤起 claude
40
+ lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
41
+ lark-relay guide # 一页用法
42
+ ```
43
+
44
+ ### collect
45
+
46
+ 零参数、零配置。app 列表实时读 `lark-cli profile list`,新增 profile 自动纳入。
47
+ 未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
48
+
49
+ 每 app 一个子进程,某个挂了单独重启不影响其他。回收在本进程内每小时自查,
50
+ 删超期的整个日期目录 —— 不另起 gc 单元/timer。
51
+
52
+ ### take
53
+
54
+ 只有一种形态:**阻塞等 → 有一批就输出 → 退出**。循环交给 harness ——
55
+ Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
56
+
57
+ ```bash
58
+ lark-relay take --app <app> --chats oc_xxx --render text
59
+ ```
60
+
61
+ 空参数跑一下会输出完整的照做指导(查群 ID → 验 bot 在群 → 监听三步)。
62
+
63
+ 业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
64
+
65
+ ### dispatch
66
+
67
+ 任务发现:扫 `$LR_ROOT/*/dispatch.yaml` —— 有文件即是任务,无需注册表。
68
+ `dispatch` 内部就是调 `take`,保证两场景不实现分叉。
69
+
70
+ ```yaml
71
+ app: <profile 名>
72
+ chats: [oc_xxx]
73
+ dirs: [/path/to/repo] # 首个 = 主工作目录
74
+ instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
75
+ filter: '.mentions[]?.id == "ou_xxx"' # 可选
76
+ # session: thread 默认;thread=按 thread_id 隔离 | idle=一群一 session
77
+ # display: card 默认;card=CardKit 流式打字机 | final
78
+ # model: <名称> 默认走终端同一套默认路由
79
+ ```
80
+
81
+ ## 存储布局
82
+
83
+ ```
84
+ ~/.lark-relay/
85
+ store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json 原始事件(可随时删,丢了自愈)
86
+ cursors/<name>/<app> 各消费者游标
87
+ ledger/<task>__<chat>.ndjson dispatch 结论台账
88
+ ```
89
+
90
+ 判据:能随时删掉重建的才放这里。`ledger` 是唯一不能重建的东西
91
+ (session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
92
+
93
+ 多消费者**共享 store、各持游标**,不做「处理完即删」—— 谁都不能替别人删。
94
+ 盯同一 app 请用不同 `--name` 隔离游标。
95
+
96
+ ## 设计取舍
97
+
98
+ - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp → rename`(同目录原子)。
99
+ 不用 `--output-dir` —— 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
100
+ - **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
101
+ 两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
102
+ 要按大小滚动 + 保留 N 个文件,反而比整目录删复杂
103
+ - **两层去重**:cursor 管进度,`message_id` 管幂等(TTL 6h)。
104
+ collect 重启或 bus 重投时同一条消息会落成**新文件**,游标拦不住
105
+ - **`--chats` 只接 `oc_` 开头的 chat_id,不接群名**:群名可能匹配多个或匹配错群,
106
+ 而监听错群是**静默失败**(一直等一个永不来的消息)
107
+ - **`--since` 默认 `now`**:不重放历史,消灭「预热游标」这一步
108
+
109
+ ## 许可
110
+
111
+ MIT
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // UTF-8 兜底:systemd 默认 C locale。虽然 Node 的字符串处理不像 bash 那样
5
+ // 按字节切片,但子进程(lark-cli / claude)会继承 locale,中文仍会被切坏。
6
+ if (!/utf-?8/i.test(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '')) {
7
+ process.env.LANG = 'C.UTF-8'
8
+ process.env.LC_ALL = 'C.UTF-8'
9
+ }
10
+
11
+ const { parseArgs, num, list } = require('../lib/args')
12
+ const help = require('../lib/help')
13
+
14
+ const VERSION = require('../package.json').version
15
+
16
+ const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站
17
+
18
+ lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(systemd 常驻)
19
+ lark-relay take … 场景1 在场取用:阻塞等一批 → 输出 → 退出
20
+ lark-relay dispatch <task> 场景2 托管派活:常驻循环 = take + 唤起 claude
21
+ lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
22
+ lark-relay guide 一页用法(装完先读这个)
23
+
24
+ 第一次用 → lark-relay guide
25
+ 各命令详情 → lark-relay <命令> --help`
26
+
27
+ async function main() {
28
+ const argv = process.argv.slice(2)
29
+ const cmd = argv[0]
30
+ const rest = argv.slice(1)
31
+
32
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
33
+ process.stdout.write(`${USAGE}\n`)
34
+ return 0
35
+ }
36
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
37
+ process.stdout.write(`${VERSION}\n`)
38
+ return 0
39
+ }
40
+
41
+ switch (cmd) {
42
+ case 'guide':
43
+ process.stdout.write(`${help.GUIDE}\n`)
44
+ return 0
45
+ case 'collect':
46
+ return await cmdCollect(rest)
47
+ case 'take':
48
+ return await cmdTake(rest)
49
+ case 'status':
50
+ return await cmdStatus(rest)
51
+ case 'dispatch':
52
+ return await cmdDispatch(rest)
53
+ default:
54
+ process.stderr.write(`未知命令:${cmd}\n\n${USAGE}\n`)
55
+ return 2
56
+ }
57
+ }
58
+
59
+ async function cmdCollect(argv) {
60
+ const a = parseArgs(argv, { flags: ['help'] })
61
+ if (a.help) {
62
+ process.stdout.write(`${help.COLLECT_HELP}\n`)
63
+ return 0
64
+ }
65
+ const { runCollect } = require('../lib/collect')
66
+ return await runCollect({
67
+ exclude: list(a.exclude),
68
+ retain: num(a.retain, 3),
69
+ ledgerRetain: num(a['ledger-retain'], 90),
70
+ })
71
+ }
72
+
73
+ async function cmdTake(argv) {
74
+ const a = parseArgs(argv, { flags: ['help'] })
75
+ const larkcli = require('../lib/larkcli')
76
+
77
+ // 空参数输出完整照做指导,而非报错 —— AI 的真实行为是「先空参数跑一下看看」
78
+ if (a.help || (!a.app && !a.chats)) {
79
+ let apps = []
80
+ try {
81
+ apps = (await larkcli.listProfiles()).filter((p) => p.usable).map((p) => p.name)
82
+ } catch {}
83
+ process.stdout.write(`${help.takeHelp(apps)}\n`)
84
+ return a.help ? 0 : 2
85
+ }
86
+
87
+ const store = require('../lib/store')
88
+ const { takeBatch, deriveName, parseChats } = require('../lib/take')
89
+ const { render } = require('../lib/render')
90
+ const { acquireExclusive } = require('../lib/lock')
91
+
92
+ if (!a.app || a.app === true) {
93
+ process.stderr.write('需要 --app <name>。跑 `lark-relay take` 看完整用法\n')
94
+ return 2
95
+ }
96
+ const chats = parseChats(a.chats === true ? '' : a.chats)
97
+ if (!chats.length) {
98
+ process.stderr.write('需要 --chats oc_xxx。跑 `lark-relay take` 看完整用法\n')
99
+ return 2
100
+ }
101
+ const bad = chats.filter((c) => !c.startsWith('oc_'))
102
+ if (bad.length) {
103
+ process.stderr.write(
104
+ `--chats 只接受 oc_ 开头的 chat_id,这些不是:${bad.join(' ')}\n` +
105
+ `群名会匹配错群,而监听错群是静默失败(你会一直等一个永不来的消息)。\n` +
106
+ `查 ID:lark-cli --profile ${a.app} im +chat-search --as user --query "<群名>"\n`,
107
+ )
108
+ return 2
109
+ }
110
+
111
+ const name = a.name && a.name !== true ? a.name : deriveName(a.app, chats)
112
+
113
+ // 同一 --name 不该有两个 take 在跑:两个进程互相推游标会撕批次
114
+ const lock = acquireExclusive(`take-${name}`)
115
+ if (!lock.ok) {
116
+ process.stderr.write(
117
+ `已有一个 take 在用游标身份 "${name}"(pid ${lock.holder})。\n` +
118
+ `要并行盯同一 app 请用不同 --name,否则两个进程会互相推游标撕批次。\n`,
119
+ )
120
+ return 3
121
+ }
122
+
123
+ const since = a.since === true ? 'now' : a.since || 'now'
124
+ if (since === 'now' && store.readCursor(name, a.app) === null) {
125
+ store.seedCursorNow(name, a.app) // 默认不重放历史,消灭「预热游标」这一步
126
+ }
127
+
128
+ const debounceMs = num(a.debounce, 5) * 1000
129
+ const maxWaitMs = num(a['max-wait'], 25) * 1000
130
+ const timeoutMs = num(a.timeout, 12 * 3600) * 1000
131
+
132
+ const ac = new AbortController()
133
+ const onSig = () => ac.abort()
134
+ process.on('SIGTERM', onSig)
135
+ process.on('SIGINT', onSig)
136
+
137
+ const res = await takeBatch({
138
+ app: a.app,
139
+ chats,
140
+ name,
141
+ filter: a.filter === true ? null : a.filter,
142
+ debounceMs,
143
+ maxWaitMs,
144
+ timeoutMs,
145
+ signal: ac.signal,
146
+ })
147
+
148
+ if (!res.events.length) {
149
+ process.stderr.write(`没等到消息(${res.reason})。游标身份 ${name}\n`)
150
+ return res.reason === 'timeout' ? 4 : 0
151
+ }
152
+
153
+ const mode = a.render === true ? 'text' : a.render
154
+ process.stdout.write(`${render(res.events, mode)}\n`)
155
+ process.stderr.write(`${res.events.length} 条,游标身份 ${name}\n`)
156
+ return 0
157
+ }
158
+
159
+ async function cmdStatus(argv) {
160
+ const a = parseArgs(argv, { flags: ['json', 'help'] })
161
+ if (a.help) {
162
+ process.stdout.write(`${help.STATUS_HELP}\n`)
163
+ return 0
164
+ }
165
+ const { buildStatus, formatStatus } = require('../lib/status')
166
+ const s = await buildStatus()
167
+ if (a.json) process.stdout.write(`${JSON.stringify(s, null, 2)}\n`)
168
+ else process.stdout.write(`${formatStatus(s)}\n`)
169
+ return 0
170
+ }
171
+
172
+ async function cmdDispatch(argv) {
173
+ const a = parseArgs(argv, { flags: ['list', 'once', 'help'] })
174
+ if (a.help) {
175
+ process.stdout.write(`${help.DISPATCH_HELP}\n`)
176
+ return 0
177
+ }
178
+ const dispatch = require('../lib/dispatch')
179
+ if (a.list) return dispatch.cmdList()
180
+ const task = a._[0]
181
+ if (!task) {
182
+ process.stdout.write(`${help.DISPATCH_HELP}\n`)
183
+ return 2
184
+ }
185
+ return await dispatch.run(task, { once: !!a.once })
186
+ }
187
+
188
+ main()
189
+ .then((code) => process.exit(code || 0))
190
+ .catch((err) => {
191
+ if (err && err.userFacing) process.stderr.write(`${err.message}\n`)
192
+ else process.stderr.write(`lark-relay: ${(err && err.stack) || err}\n`)
193
+ process.exit(1)
194
+ })
package/lib/args.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use strict'
2
+
3
+ // 零依赖 argv 解析。只支持 --key value / --key=value / --flag。
4
+ function parseArgs(argv, spec = {}) {
5
+ const flags = new Set(spec.flags || [])
6
+ const out = { _: [] }
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i]
9
+ if (a === '--') {
10
+ out._.push(...argv.slice(i + 1))
11
+ break
12
+ }
13
+ if (!a.startsWith('--')) {
14
+ out._.push(a)
15
+ continue
16
+ }
17
+ const eq = a.indexOf('=')
18
+ if (eq !== -1) {
19
+ out[a.slice(2, eq)] = a.slice(eq + 1)
20
+ continue
21
+ }
22
+ const key = a.slice(2)
23
+ if (flags.has(key)) {
24
+ out[key] = true
25
+ continue
26
+ }
27
+ const next = argv[i + 1]
28
+ if (next === undefined || next.startsWith('--')) {
29
+ out[key] = true // 当成 flag,由调用侧校验
30
+ } else {
31
+ out[key] = next
32
+ i++
33
+ }
34
+ }
35
+ return out
36
+ }
37
+
38
+ function num(v, dflt) {
39
+ if (v === undefined || v === true) return dflt
40
+ const n = Number(v)
41
+ return Number.isFinite(n) ? n : dflt
42
+ }
43
+
44
+ function list(v) {
45
+ if (!v || v === true) return []
46
+ return String(v)
47
+ .split(',')
48
+ .map((s) => s.trim())
49
+ .filter(Boolean)
50
+ }
51
+
52
+ module.exports = { parseArgs, num, list }
package/lib/card.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict'
2
+
3
+ // card display:CardKit 流式打字机,单卡原地更新。ai-pmo 在用的模式。
4
+ // 五步流:建卡实体 → 发引用消息 → 流式 PUT body → 收尾全卡重构 → 关流式定格。
5
+ //
6
+ // ⚠️ 两处双层编码(踩过):
7
+ // 建卡的 data 与关流式的 settings 都是**字符串化的 JSON**,不是对象
8
+ // ⚠️ print_frequency_ms / print_step 必须是按端对象 {default,android,ios,pc},
9
+ // 传标量会 10002 unmarshal 报错
10
+ const larkcli = require('./larkcli')
11
+
12
+ const THROTTLE_MS = Number(process.env.LR_CARD_THROTTLE_MS || 300)
13
+ const PRINT_FREQ_MS = Number(process.env.LR_CARD_PRINT_FREQ_MS || 10)
14
+ const PRINT_STEP = Number(process.env.LR_CARD_PRINT_STEP || 20)
15
+ const PLACEHOLDER = process.env.LR_CARD_PLACEHOLDER || '🤔 正在思考…'
16
+
17
+ // 飞书卡片按标题字号渲染 #,窄卡片里过大突兀 → ATX 标题降级为加粗。
18
+ // 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)绝不能被误降级。
19
+ function mdfix(text) {
20
+ const out = []
21
+ let inCode = false
22
+ for (const line of String(text).split('\n')) {
23
+ if (/^[ \t]*```/.test(line)) {
24
+ inCode = !inCode
25
+ out.push(line)
26
+ continue
27
+ }
28
+ if (!inCode) {
29
+ const m = line.match(/^#+[ \t]+(.*)$/)
30
+ if (m) {
31
+ out.push(m[1] === '' ? line : `**${m[1]}**`)
32
+ continue
33
+ }
34
+ }
35
+ out.push(line)
36
+ }
37
+ return out.join('\n')
38
+ }
39
+
40
+ function initCard() {
41
+ const perDevice = (v) => ({ default: v, android: v, ios: v, pc: v })
42
+ return {
43
+ schema: '2.0',
44
+ config: {
45
+ streaming_mode: true,
46
+ update_multi: true,
47
+ streaming_config: {
48
+ print_frequency_ms: perDevice(PRINT_FREQ_MS),
49
+ print_step: perDevice(PRINT_STEP),
50
+ },
51
+ },
52
+ // 不设 header:固定「🤖 Claude」标题头太突兀,去掉后卡片只剩正文更自然
53
+ body: {
54
+ elements: [{ tag: 'markdown', element_id: 'body', content: PLACEHOLDER }],
55
+ },
56
+ }
57
+ }
58
+
59
+ class CardSession {
60
+ constructor({ profile, chatId, lastMsg, replyInThread }) {
61
+ this.profile = profile
62
+ this.chatId = chatId
63
+ this.lastMsg = lastMsg
64
+ this.replyInThread = replyInThread
65
+ this.cardId = null
66
+ this.messageId = null
67
+ this.seq = 0 // int32 从 1 起,卡片更新串行无并发
68
+ this.lastPutAt = 0
69
+ this.acc = ''
70
+ this.proc = ''
71
+ this.pendingSay = null
72
+ }
73
+
74
+ next() {
75
+ return ++this.seq
76
+ }
77
+
78
+ // 步骤 ①:建卡片实体 → card_id。data 是**字符串**(双层编码)
79
+ async create() {
80
+ const body = JSON.stringify({ type: 'card_json', data: JSON.stringify(initCard()) })
81
+ const { stdout } = await larkcli.run([
82
+ '--profile', this.profile, 'api', 'POST', '/open-apis/cardkit/v1/cards',
83
+ '--as', 'bot', '--data', body,
84
+ ])
85
+ const j = JSON.parse(stripToJson(stdout))
86
+ this.cardId = j?.data?.card_id || null
87
+ return this.cardId
88
+ }
89
+
90
+ // 步骤 ②:发一条引用该 card_id 的消息
91
+ async post() {
92
+ const content = JSON.stringify({ type: 'card', data: { card_id: this.cardId } })
93
+ let out
94
+ if (this.replyInThread && this.lastMsg && this.lastMsg !== '-') {
95
+ const data = { msg_type: 'interactive', content, reply_in_thread: true }
96
+ out = await larkcli.run([
97
+ '--profile', this.profile, 'api', 'POST',
98
+ `/open-apis/im/v1/messages/${this.lastMsg}/reply`,
99
+ '--as', 'bot', '--data', JSON.stringify(data),
100
+ ])
101
+ } else {
102
+ out = await larkcli.run([
103
+ '--profile', this.profile, 'im', '+messages-send',
104
+ '--params', JSON.stringify({ receive_id_type: 'chat_id' }),
105
+ '--data', JSON.stringify({
106
+ receive_id: this.chatId, msg_type: 'interactive', content,
107
+ }),
108
+ '--as', 'bot',
109
+ ])
110
+ }
111
+ const j = JSON.parse(stripToJson(out.stdout))
112
+ this.messageId = j?.data?.message_id || null
113
+ return this.messageId
114
+ }
115
+
116
+ // 步骤 ③:流式期单 body 元素全量替换(打字机)。
117
+ // 全量替换故本地要维护累积串;单次失败可容忍(下次 PUT 是全量,自愈)。
118
+ async update(text) {
119
+ const now = Date.now()
120
+ if (now - this.lastPutAt < THROTTLE_MS) return // 仅防频控,不为攒批
121
+ this.lastPutAt = now
122
+ const content = mdfix(text || '🔧 正在查阅…')
123
+ try {
124
+ await larkcli.run([
125
+ '--profile', this.profile, 'api', 'PUT',
126
+ `/open-apis/cardkit/v1/cards/${this.cardId}/elements/body/content`,
127
+ '--as', 'bot',
128
+ '--data', JSON.stringify({ content, sequence: this.next() }),
129
+ ])
130
+ } catch {}
131
+ }
132
+
133
+ // 步骤 ④:收尾全卡重构。注意外层键是 card(内含 {type,data}),不是 ③ 的 content
134
+ async finalize(result) {
135
+ const elements = []
136
+ if (this.proc.trim()) {
137
+ elements.push({
138
+ tag: 'collapsible_panel',
139
+ expanded: false,
140
+ header: { title: { tag: 'markdown', content: '💭 思考与执行过程(点击展开)' } },
141
+ elements: [{ tag: 'markdown', content: mdfix(this.proc) }],
142
+ })
143
+ elements.push({ tag: 'hr' })
144
+ }
145
+ // 无过程内容时只放结论,不放空面板
146
+ elements.push({ tag: 'markdown', content: mdfix(result) })
147
+ const card = {
148
+ schema: '2.0',
149
+ config: { streaming_mode: false },
150
+ body: { elements },
151
+ }
152
+ const body = JSON.stringify({
153
+ card: { type: 'card_json', data: JSON.stringify(card) },
154
+ sequence: this.next(),
155
+ })
156
+ try {
157
+ const { stdout } = await larkcli.run([
158
+ '--profile', this.profile, 'api', 'PUT',
159
+ `/open-apis/cardkit/v1/cards/${this.cardId}`,
160
+ '--as', 'bot', '--data', body,
161
+ ])
162
+ return /"ok":\s*true/.test(stdout)
163
+ } catch (err) {
164
+ const tail = String(err.stdout || err.stderr || err.message).replace(/\n/g, ' ').slice(0, 200)
165
+ process.stderr.write(`card: 收尾全卡更新失败 → ${tail}\n`)
166
+ return false
167
+ }
168
+ }
169
+
170
+ // 步骤 ⑤:关流式定格。settings 的值是**字符串化的 JSON**(双层转义)
171
+ async stop() {
172
+ try {
173
+ await larkcli.run([
174
+ '--profile', this.profile, 'api', 'PATCH',
175
+ `/open-apis/cardkit/v1/cards/${this.cardId}/settings`,
176
+ '--as', 'bot',
177
+ '--data', JSON.stringify({
178
+ settings: JSON.stringify({ config: { streaming_mode: false } }),
179
+ sequence: this.next(),
180
+ }),
181
+ ])
182
+ } catch {}
183
+ }
184
+
185
+ // 流式事件三种 kind。pendingSay 延迟一段:每轮最后一段 say 就是最终结论
186
+ // (会作为 result 再来一次),不能进过程区否则结论重复。
187
+ // 用「延迟一段」而非内容比较 —— 最稳。
188
+ onSay(text) {
189
+ this.acc += (this.acc ? '\n\n' : '') + text
190
+ this._flushPending()
191
+ this.pendingSay = text
192
+ }
193
+
194
+ onTool(text) {
195
+ this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
196
+ this._flushPending()
197
+ this.proc += `🔧 ${text}\n`
198
+ }
199
+
200
+ onFinal() {
201
+ this.pendingSay = null // 手里那段 pending(=结论)直接丢弃,不 flush
202
+ }
203
+
204
+ _flushPending() {
205
+ if (this.pendingSay) {
206
+ this.proc += `💬 ${this.pendingSay}\n\n`
207
+ this.pendingSay = null
208
+ }
209
+ }
210
+ }
211
+
212
+ // lark-cli 可能有前置非 JSON 行,剥到第一个 { 起
213
+ function stripToJson(s) {
214
+ const i = String(s).indexOf('{')
215
+ return i === -1 ? '{}' : String(s).slice(i)
216
+ }
217
+
218
+ module.exports = { CardSession, mdfix }