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 +21 -1
- package/lib/card.js +81 -66
- package/lib/claude.js +23 -21
- package/lib/collect.js +38 -8
- package/lib/cot.js +363 -0
- package/lib/dedup.js +1 -1
- package/lib/dispatch.js +74 -32
- package/lib/filter.js +9 -4
- package/lib/help.js +8 -1
- package/lib/larkcli.js +22 -108
- package/lib/lock.js +82 -25
- package/lib/paths.js +12 -6
- package/lib/render.js +0 -0
- package/lib/reply.js +1 -8
- package/lib/store.js +24 -12
- package/lib/take.js +5 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// card display:CardKit 流式打字机,单卡原地更新。
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
elements
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
|
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
|
|
92
|
-
const script =
|
|
93
|
-
`set -o pipefail\n` +
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
197
|
+
module.exports = { runCollect }
|