lark-relay 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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=CardKit 流式打字机 | final
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
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- // card display:CardKit 流式打字机,单卡原地更新。ai-pmo 在用的模式。
3
+ // card display:CardKit 流式打字机,单卡原地更新。
4
4
  // 五步流:建卡实体 → 发引用消息 → 流式 PUT body → 收尾全卡重构 → 关流式定格。
5
5
  //
6
6
  // ⚠️ 两处双层编码(踩过):
@@ -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)
@@ -64,17 +65,30 @@ class CardSession {
64
65
  this.replyInThread = replyInThread
65
66
  this.cardId = null
66
67
  this.messageId = null
67
- this.seq = 0 // int32 从 1 起,卡片更新串行无并发
68
+ this.seq = 0 // int32 从 1
68
69
  this.lastPutAt = 0
69
70
  this.acc = ''
70
71
  this.proc = ''
71
72
  this.pendingSay = null
73
+ // 串行队列:所有卡片写操作排队执行。
74
+ // ⚠️ onEvent 里的 update() 是 fire-and-forget(不 await),若并发发出,
75
+ // 晚发的可能先到 → 服务端拒较小 sequence;更糟的是 in-flight 的 update
76
+ // (seq 小、body 大所以慢)在 finalize/stop 之后才到达 →
77
+ // **卡片从最终结论退回中间过程文本并定格在那里**(流式已关,不会再刷新)。
78
+ // 实测到达顺序曾是 finalize(2) → stop(3) → update(1)
79
+ this.q = Promise.resolve()
72
80
  }
73
81
 
74
82
  next() {
75
83
  return ++this.seq
76
84
  }
77
85
 
86
+ // 把卡片写操作排进串行队列。seq 在**真正执行时**才取,保证发出顺序 == seq 顺序
87
+ _enqueue(fn) {
88
+ this.q = this.q.then(fn, fn)
89
+ return this.q
90
+ }
91
+
78
92
  // 步骤 ①:建卡片实体 → card_id。data 是**字符串**(双层编码)
79
93
  async create() {
80
94
  const body = JSON.stringify({ type: 'card_json', data: JSON.stringify(initCard()) })
@@ -115,71 +129,78 @@ class CardSession {
115
129
 
116
130
  // 步骤 ③:流式期单 body 元素全量替换(打字机)。
117
131
  // 全量替换故本地要维护累积串;单次失败可容忍(下次 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 {}
132
+ // 走串行队列:调用方 fire-and-forget 也不会乱序
133
+ update(text) {
134
+ return this._enqueue(async () => {
135
+ const now = Date.now()
136
+ if (now - this.lastPutAt < THROTTLE_MS) return // 仅防频控,不为攒批
137
+ this.lastPutAt = now
138
+ const content = mdfix(text || '🔧 正在查阅…')
139
+ try {
140
+ await larkcli.run([
141
+ '--profile', this.profile, 'api', 'PUT',
142
+ `/open-apis/cardkit/v1/cards/${this.cardId}/elements/body/content`,
143
+ '--as', 'bot',
144
+ '--data', JSON.stringify({ content, sequence: this.next() }),
145
+ ])
146
+ } catch {}
147
+ })
131
148
  }
132
149
 
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) }],
150
+ // 步骤 ④:收尾全卡重构。注意外层键是 card(内含 {type,data}),不是 ③ 的 content
151
+ // 排在队尾执行 —— 必须等所有 in-flight update 落地,否则会被它们覆盖
152
+ finalize(result) {
153
+ return this._enqueue(async () => {
154
+ const elements = []
155
+ if (this.proc.trim()) {
156
+ elements.push({
157
+ tag: 'collapsible_panel',
158
+ expanded: false,
159
+ header: { title: { tag: 'markdown', content: '💭 思考与执行过程(点击展开)' } },
160
+ elements: [{ tag: 'markdown', content: mdfix(this.proc) }],
161
+ })
162
+ elements.push({ tag: 'hr' })
163
+ }
164
+ // 无过程内容时只放结论,不放空面板
165
+ elements.push({ tag: 'markdown', content: mdfix(result) })
166
+ const card = {
167
+ schema: '2.0',
168
+ config: { streaming_mode: false },
169
+ body: { elements },
170
+ }
171
+ const body = JSON.stringify({
172
+ card: { type: 'card_json', data: JSON.stringify(card) },
173
+ sequence: this.next(),
142
174
  })
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(),
175
+ try {
176
+ const { stdout } = await larkcli.run([
177
+ '--profile', this.profile, 'api', 'PUT',
178
+ `/open-apis/cardkit/v1/cards/${this.cardId}`,
179
+ '--as', 'bot', '--data', body,
180
+ ])
181
+ return /"ok":\s*true/.test(stdout)
182
+ } catch (err) {
183
+ process.stderr.write(`card: 收尾全卡更新失败 → ${tail(errText(err), 200)}\n`)
184
+ return false
185
+ }
155
186
  })
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
187
  }
169
188
 
170
189
  // 步骤 ⑤:关流式定格。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 {}
190
+ stop() {
191
+ return this._enqueue(async () => {
192
+ try {
193
+ await larkcli.run([
194
+ '--profile', this.profile, 'api', 'PATCH',
195
+ `/open-apis/cardkit/v1/cards/${this.cardId}/settings`,
196
+ '--as', 'bot',
197
+ '--data', JSON.stringify({
198
+ settings: JSON.stringify({ config: { streaming_mode: false } }),
199
+ sequence: this.next(),
200
+ }),
201
+ ])
202
+ } catch {}
203
+ })
183
204
  }
184
205
 
185
206
  // 流式事件三种 kind。pendingSay 延迟一段:每轮最后一段 say 就是最终结论
@@ -209,10 +230,4 @@ class CardSession {
209
230
  }
210
231
  }
211
232
 
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
233
  module.exports = { CardSession, mdfix }
package/lib/claude.js CHANGED
@@ -7,10 +7,12 @@ const os = require('os')
7
7
  const path = require('path')
8
8
  const { spawn } = require('child_process')
9
9
 
10
- // 模型分发交给 dotfiles 里的 claude() 函数 —— 它有白名单校验,是单一事实源。
11
- // 旧架构存的是内部函数名(route: cc-48,实测早已不存在),改存模型名后
12
- // 新增后端零改动,写错也会被分发器 exit 2 挡住。
13
- const CC_RC = process.env.LR_CC_RC || path.join(os.homedir(), 'space/dotfiles/rc.d/claude.sh')
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')
14
16
 
15
17
  // sha1(task|scope) 前 32 位切成 8-4-4-4-12
16
18
  function deriveUuid(task, scope) {
@@ -86,15 +88,23 @@ function runClaude(opts) {
86
88
  args.push('--output-format', stream ? 'stream-json' : 'json')
87
89
  if (stream) args.push('--verbose')
88
90
 
89
- // 经 bash -lc source claude.sh 后调 claude() —— 模型分发与白名单校验都在那里
91
+ // 经 bash -lc source <rc> 后调 claude() —— 模型分发与白名单校验都在那里。
92
+ // rc 不存在时**不当致命错误**:降级到 PATH 里的 claude,只在指定了 --model 时才必须有 rc
93
+ // (否则 --model 传给原生 claude 会是另一套语义)。
94
+ // ⚠️ CC_RC 必须 shellQuote:曾直接内插进双引号里的 echo,
95
+ // LR_CC_RC='/x$(命令)' 会被执行(实测)。所有插进 script 的值一律走 shellQuote
90
96
  const quoted = args.map(shellQuote).join(' ')
91
- const modelArg = model ? `--model ${shellQuote(model)} ` : ''
92
- const script =
93
- `set -o pipefail\n` +
94
- `if ! source ${shellQuote(CC_RC)} 2>/dev/null; then\n` +
95
- ` echo "cc: 无法 source ${CC_RC}" >&2; exit 3\n` +
96
- `fi\n` +
97
- `claude ${modelArg}${quoted}\n`
97
+ const rcQ = shellQuote(CC_RC)
98
+ const script = model
99
+ ? `set -o pipefail\n` +
100
+ `if ! source ${rcQ} 2>/dev/null; then\n` +
101
+ ` printf '%s\\n' "cc: 指定了 --model 但 source 失败,设 LR_CC_RC 指向定义 claude() 的文件" >&2\n` +
102
+ ` exit 3\n` +
103
+ `fi\n` +
104
+ `claude --model ${shellQuote(model)} ${quoted}\n`
105
+ : `set -o pipefail\n` +
106
+ `source ${rcQ} 2>/dev/null || true\n` +
107
+ `claude ${quoted}\n`
98
108
 
99
109
  return new Promise((resolve) => {
100
110
  const child = spawn('bash', ['-lc', script], {
@@ -196,12 +206,4 @@ function shellQuote(s) {
196
206
  return `'${String(s).replace(/'/g, `'\\''`)}'`
197
207
  }
198
208
 
199
- module.exports = {
200
- runClaude,
201
- deriveUuid,
202
- sessionEpoch,
203
- sessionFile,
204
- encCwd,
205
- PROMPT_TEMPLATE,
206
- CC_RC,
207
- }
209
+ module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPT_TEMPLATE }
package/lib/collect.js CHANGED
@@ -24,6 +24,7 @@ class AppCollector {
24
24
  this.stopped = false
25
25
  this.backoffMs = 1000
26
26
  this.restarts = 0
27
+ this.healthyTimer = null
27
28
  }
28
29
 
29
30
  start() {
@@ -50,39 +51,55 @@ class AppCollector {
50
51
  try {
51
52
  store.writeEvent(this.app, obj, this.seq++)
52
53
  this.received++
53
- // 收到第一条就说明这个 app 通了,重置退避
54
- this.backoffMs = 1000
55
54
  } catch (err) {
56
55
  process.stderr.write(`[${this.app}] error: 落盘失败 ${err.message}\n`)
57
56
  }
58
57
  })
59
58
 
59
+ // 退避重置的判据是「子进程活够久」而非「收到消息」:
60
+ // 某 app 长期没消息但连接正常时,退避会一路顶到 60s 上限再也不降,
61
+ // 占空比掉到 50% 以下 —— 而那段时间的消息是永久丢的
62
+ this.healthyTimer = setTimeout(() => {
63
+ this.backoffMs = 1000
64
+ }, 30_000)
65
+
60
66
  // 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断进 journald
61
67
  child.stderr.on('data', (buf) => {
62
68
  const text = buf.toString().trimEnd()
63
69
  if (text) process.stderr.write(`[${this.app}] ${text}\n`)
64
70
  })
65
71
 
66
- child.on('exit', (code, signal) => {
72
+ // spawn 失败(lark-cli 被卸载 / 升级中途 / PATH 变化)只发 error+close,**没有 exit**。
73
+ // 把重启逻辑挂在 close 上,否则该 app 会永久停摆而主进程看着还 active
74
+ let restarted = false
75
+ const scheduleRestart = (why) => {
76
+ if (restarted || this.stopped) return
77
+ restarted = true
78
+ clearTimeout(this.healthyTimer)
67
79
  this.child = null
68
- if (this.stopped) return
69
80
  this.restarts++
70
- const why = signal ? `signal ${signal}` : `code ${code}`
71
81
  process.stderr.write(
72
- `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启\n`,
82
+ `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
83
+ `—— 这段时间该 app 的消息会永久丢失\n`,
73
84
  )
74
85
  // 每 app 一个子进程;某个挂了单独重启,不影响其他
75
86
  setTimeout(() => this.start(), this.backoffMs)
76
87
  this.backoffMs = Math.min(this.backoffMs * 2, 60_000)
88
+ }
89
+
90
+ child.on('close', (code, signal) => {
91
+ scheduleRestart(signal ? `signal ${signal}` : `code ${code}`)
77
92
  })
78
93
 
79
94
  child.on('error', (err) => {
80
95
  process.stderr.write(`[${this.app}] spawn 失败:${err.message}\n`)
96
+ scheduleRestart(`spawn error ${err.code || err.message}`)
81
97
  })
82
98
  }
83
99
 
84
100
  stop() {
85
101
  this.stopped = true
102
+ clearTimeout(this.healthyTimer)
86
103
  // 优雅停止:SIGTERM。禁 SIGKILL —— 否则漏卸载服务端订阅
87
104
  if (this.child) this.child.kill('SIGTERM')
88
105
  }
@@ -94,7 +111,20 @@ async function runCollect(opts) {
94
111
  ensureDir(paths.ledger)
95
112
 
96
113
  const exclude = new Set(opts.exclude || [])
97
- const profiles = await larkcli.listProfiles()
114
+ let profiles
115
+ try {
116
+ profiles = await larkcli.listProfiles()
117
+ } catch (err) {
118
+ const e = new Error(
119
+ `读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
120
+ `collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
121
+ ` which ${larkcli.CLI} # 装了吗、在 PATH 里吗\n` +
122
+ ` ${larkcli.CLI} profile list # 能跑吗\n` +
123
+ `systemd 单元里 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
124
+ )
125
+ e.userFacing = true
126
+ throw e
127
+ }
98
128
 
99
129
  const chosen = []
100
130
  const skipped = []
@@ -164,4 +194,4 @@ async function runCollect(opts) {
164
194
  })
165
195
  }
166
196
 
167
- module.exports = { runCollect, EVENT_KEY }
197
+ module.exports = { runCollect }