lark-relay 0.2.2 → 0.3.0
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 +17 -17
- package/lib/card.js +38 -195
- package/lib/cot.js +9 -26
- package/lib/dispatch.js +35 -50
- package/lib/help.js +9 -9
- package/lib/tasks.js +14 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,30 +73,30 @@ chats: [oc_xxx]
|
|
|
73
73
|
dirs: [/path/to/repo] # 首个 = 主工作目录
|
|
74
74
|
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
75
75
|
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
76
|
-
#
|
|
77
|
-
# display: card 默认;card | cot | final(见下)
|
|
76
|
+
# mode: work 默认;work | chat(见下)
|
|
78
77
|
# model: <名称> 默认走终端同一套默认路由
|
|
79
78
|
```
|
|
80
79
|
|
|
81
|
-
`
|
|
80
|
+
`mode` 就两个场景 —— 一个键定死全部行为,不用拼组合:
|
|
82
81
|
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
|
|
|
82
|
+
| | `work`(默认) | `chat` |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| 用途 | 处理工作 | 日常闲聊 |
|
|
85
|
+
| 回复位置 | 话题内(首问开话题) | 直接发群里 |
|
|
86
|
+
| 过程展示 | COT 消息(实时流式) | 无 |
|
|
87
|
+
| 结论 | 卡片(markdown 渲染) | 纯文本 |
|
|
88
|
+
| session 隔离 | 按 `thread_id`(话题即边界,不滚动) | 按 (群, epoch),空闲超 `idle_gap` 开新世代 |
|
|
89
|
+
| 防抖 | 1s(一问一答要跟手) | 15s(等人打完多行) |
|
|
88
90
|
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
与 `card` 档只显示工具名+参数摘要一致。
|
|
91
|
+
`work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
|
|
92
|
+
结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
|
|
92
93
|
|
|
93
|
-
⚠️
|
|
94
|
-
|
|
95
|
-
结论那条普通文本不受影响 —— 这也是「结论单独发」的价值。
|
|
94
|
+
⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
|
|
95
|
+
「Completed」(不会崩),结论卡片不受影响 —— 这也是「结论单独发」的价值。
|
|
96
96
|
|
|
97
|
-
环境变量:`LR_COT_BATCH_MS`(攒批窗口,默认 1000)、
|
|
98
|
-
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text
|
|
99
|
-
实测两者渲染一致,`text` 每段少 2 个事件
|
|
97
|
+
环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
|
|
98
|
+
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
|
|
99
|
+
实测两者渲染一致,`text` 每段少 2 个事件)。
|
|
100
100
|
|
|
101
101
|
## 存储布局
|
|
102
102
|
|
package/lib/card.js
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 结论卡片:一条非流式 interactive 消息。
|
|
4
|
+
// work 模式用它发最终结论 —— 过程由 COT 消息承担(lib/cot.js),这里只放结论。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 传标量会 10002 unmarshal 报错
|
|
6
|
+
// 比纯文本好在:markdown 正常渲染、不受 reply 的 3500 字符分片限制。
|
|
7
|
+
// **不需要 card 实体**:直接 msg_type=interactive 一步发出(实测),
|
|
8
|
+
// cardkit 那套「建实体 → 拿 card_id → 流式 PUT → 关流式」只有流式打字机才需要
|
|
10
9
|
const larkcli = require('./larkcli')
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
const THROTTLE_MS = Number(process.env.LR_CARD_THROTTLE_MS || 300)
|
|
14
|
-
const PRINT_FREQ_MS = Number(process.env.LR_CARD_PRINT_FREQ_MS || 10)
|
|
15
|
-
const PRINT_STEP = Number(process.env.LR_CARD_PRINT_STEP || 20)
|
|
16
|
-
const PLACEHOLDER = process.env.LR_CARD_PLACEHOLDER || '🤔 正在思考…'
|
|
10
|
+
const { errText, tail } = larkcli
|
|
17
11
|
|
|
18
12
|
// 飞书卡片按标题字号渲染 #,窄卡片里过大突兀 → ATX 标题降级为加粗。
|
|
19
|
-
// 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)
|
|
13
|
+
// 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)绝不能被误降级
|
|
20
14
|
function mdfix(text) {
|
|
21
15
|
const out = []
|
|
22
16
|
let inCode = false
|
|
@@ -38,196 +32,45 @@ function mdfix(text) {
|
|
|
38
32
|
return out.join('\n')
|
|
39
33
|
}
|
|
40
34
|
|
|
41
|
-
function
|
|
42
|
-
const perDevice = (v) => ({ default: v, android: v, ios: v, pc: v })
|
|
35
|
+
function buildCard(text) {
|
|
43
36
|
return {
|
|
44
37
|
schema: '2.0',
|
|
45
|
-
config: {
|
|
46
|
-
|
|
47
|
-
update_multi: true,
|
|
48
|
-
streaming_config: {
|
|
49
|
-
print_frequency_ms: perDevice(PRINT_FREQ_MS),
|
|
50
|
-
print_step: perDevice(PRINT_STEP),
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
// 不设 header:固定「🤖 Claude」标题头太突兀,去掉后卡片只剩正文更自然
|
|
54
|
-
body: {
|
|
55
|
-
elements: [{ tag: 'markdown', element_id: 'body', content: PLACEHOLDER }],
|
|
56
|
-
},
|
|
38
|
+
config: {},
|
|
39
|
+
body: { elements: [{ tag: 'markdown', content: mdfix(text) }] },
|
|
57
40
|
}
|
|
58
41
|
}
|
|
59
42
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.proc = ''
|
|
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()
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
next() {
|
|
83
|
-
return ++this.seq
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 把卡片写操作排进串行队列。seq 在**真正执行时**才取,保证发出顺序 == seq 顺序
|
|
87
|
-
_enqueue(fn) {
|
|
88
|
-
this.q = this.q.then(fn, fn)
|
|
89
|
-
return this.q
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// 步骤 ①:建卡片实体 → card_id。data 是**字符串**(双层编码)
|
|
93
|
-
async create() {
|
|
94
|
-
const body = JSON.stringify({ type: 'card_json', data: JSON.stringify(initCard()) })
|
|
95
|
-
const { stdout } = await larkcli.run([
|
|
96
|
-
'--profile', this.profile, 'api', 'POST', '/open-apis/cardkit/v1/cards',
|
|
97
|
-
'--as', 'bot', '--data', body,
|
|
98
|
-
])
|
|
99
|
-
const j = JSON.parse(stripToJson(stdout))
|
|
100
|
-
this.cardId = j?.data?.card_id || null
|
|
101
|
-
return this.cardId
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 步骤 ②:发一条引用该 card_id 的消息
|
|
105
|
-
async post() {
|
|
106
|
-
const content = JSON.stringify({ type: 'card', data: { card_id: this.cardId } })
|
|
107
|
-
let out
|
|
108
|
-
if (this.replyInThread && this.lastMsg && this.lastMsg !== '-') {
|
|
109
|
-
const data = { msg_type: 'interactive', content, reply_in_thread: true }
|
|
110
|
-
out = await larkcli.run([
|
|
111
|
-
'--profile', this.profile, 'api', 'POST',
|
|
112
|
-
`/open-apis/im/v1/messages/${this.lastMsg}/reply`,
|
|
113
|
-
'--as', 'bot', '--data', JSON.stringify(data),
|
|
114
|
-
])
|
|
115
|
-
} else {
|
|
116
|
-
out = await larkcli.run([
|
|
117
|
-
'--profile', this.profile, 'im', '+messages-send',
|
|
118
|
-
'--params', JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
43
|
+
/**
|
|
44
|
+
* 发一张结论卡片。失败返回 false —— 调用方必须降级发纯文本,保证结论必达。
|
|
45
|
+
* @param {object} opts {profile, chatId, replyTo, inThread}
|
|
46
|
+
*/
|
|
47
|
+
async function sendCard(text, { profile, chatId, replyTo, inThread } = {}) {
|
|
48
|
+
const content = JSON.stringify(buildCard(text))
|
|
49
|
+
const args = replyTo && replyTo !== '-'
|
|
50
|
+
? [
|
|
51
|
+
'--profile', profile, 'api', 'POST',
|
|
52
|
+
`/open-apis/im/v1/messages/${replyTo}/reply`,
|
|
53
|
+
'--as', 'bot',
|
|
119
54
|
'--data', JSON.stringify({
|
|
120
|
-
|
|
55
|
+
msg_type: 'interactive',
|
|
56
|
+
content,
|
|
57
|
+
...(inThread ? { reply_in_thread: true } : {}),
|
|
121
58
|
}),
|
|
59
|
+
]
|
|
60
|
+
: [
|
|
61
|
+
'--profile', profile, 'im', '+messages-send',
|
|
62
|
+
'--params', JSON.stringify({ receive_id_type: 'chat_id' }),
|
|
63
|
+
'--data', JSON.stringify({ receive_id: chatId, msg_type: 'interactive', content }),
|
|
122
64
|
'--as', 'bot',
|
|
123
|
-
]
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 步骤 ③:流式期单 body 元素全量替换(打字机)。
|
|
131
|
-
// 全量替换故本地要维护累积串;单次失败可容忍(下次 PUT 是全量,自愈)。
|
|
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
|
-
})
|
|
148
|
-
}
|
|
149
|
-
|
|
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(),
|
|
174
|
-
})
|
|
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
|
-
}
|
|
186
|
-
})
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 步骤 ⑤:关流式定格。settings 的值是**字符串化的 JSON**(双层转义)
|
|
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
|
-
})
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// 流式事件三种 kind。pendingSay 延迟一段:每轮最后一段 say 就是最终结论
|
|
207
|
-
// (会作为 result 再来一次),不能进过程区否则结论重复。
|
|
208
|
-
// 用「延迟一段」而非内容比较 —— 最稳。
|
|
209
|
-
onSay(text) {
|
|
210
|
-
this.acc += (this.acc ? '\n\n' : '') + text
|
|
211
|
-
this._flushPending()
|
|
212
|
-
this.pendingSay = text
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
onTool(text) {
|
|
216
|
-
this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
|
|
217
|
-
this._flushPending()
|
|
218
|
-
this.proc += `🔧 ${text}\n`
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
onFinal() {
|
|
222
|
-
this.pendingSay = null // 手里那段 pending(=结论)直接丢弃,不 flush
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
_flushPending() {
|
|
226
|
-
if (this.pendingSay) {
|
|
227
|
-
this.proc += `💬 ${this.pendingSay}\n\n`
|
|
228
|
-
this.pendingSay = null
|
|
229
|
-
}
|
|
65
|
+
]
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await larkcli.run(args)
|
|
68
|
+
if (/"ok":\s*true/.test(stdout)) return true
|
|
69
|
+
process.stderr.write(`card: 未 ok chat=${chatId} → ${tail(stdout)}\n`)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(`card: 发送失败 chat=${chatId} → ${tail(errText(err))}\n`)
|
|
230
72
|
}
|
|
73
|
+
return false
|
|
231
74
|
}
|
|
232
75
|
|
|
233
|
-
module.exports = {
|
|
76
|
+
module.exports = { sendCard, mdfix, buildCard }
|
package/lib/cot.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
// cot display:消息上挂载「思考过程」,事件流对齐 AG-UI 协议。
|
|
4
4
|
// 三步流:POST 建 COT 消息 → PUT 攒批写事件(可反复)→ RUN_FINISHED 自动完结。
|
|
5
|
-
// 接口与 CardSession 同构,让 dispatch 里两档只差一个 new。
|
|
6
5
|
// 接口细节与实测边界见 docs/cot-message-api.md
|
|
7
6
|
//
|
|
8
7
|
// **COT 只承载过程,结论必须单独发一条普通消息** —— 接口的设计前提,不是我们的选择。
|
|
@@ -100,16 +99,12 @@ class CotSession {
|
|
|
100
99
|
this.cotId = null
|
|
101
100
|
this.messageId = null
|
|
102
101
|
this.done = false // 已写 RUN_FINISHED/RUN_ERROR;之后写入必被拒
|
|
103
|
-
this.
|
|
104
|
-
// 只用来生成本地唯一 id,**从不发给服务端**。
|
|
105
|
-
// ⚠️ 与 CardSession.seq 同名但无关:那个是服务端排序号,小了会被拒
|
|
106
|
-
this.seq = 0
|
|
102
|
+
this.seq = 0 // 只用来生成本地唯一的 messageId/toolCallId,**从不发给服务端**
|
|
107
103
|
this.pending = [] // 待发事件队列
|
|
108
104
|
this.lastStamp = 0 // 事件 timestamp 的单调下界(见 _stamp)
|
|
109
105
|
this.pendingSay = null // 延迟一段的 say(见 onSay);结论不能进过程区
|
|
110
106
|
this.timer = null
|
|
111
|
-
// 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta
|
|
112
|
-
// 与 CardSession 的 q 同一理由(那边是 sequence 被服务端拒,这边是文本错位)。
|
|
107
|
+
// 串行队列:PUT 必须按写入顺序到达,晚发先到会让 delta 乱序拼接成错乱文本
|
|
113
108
|
this.q = Promise.resolve()
|
|
114
109
|
}
|
|
115
110
|
|
|
@@ -139,11 +134,6 @@ class CotSession {
|
|
|
139
134
|
return this.cotId
|
|
140
135
|
}
|
|
141
136
|
|
|
142
|
-
// 与 CardSession 接口对齐:COT 一步建好,没有「另发一条消息引用它」的步骤
|
|
143
|
-
async post() {
|
|
144
|
-
return this.messageId
|
|
145
|
-
}
|
|
146
|
-
|
|
147
137
|
// 编码单条事件:content 序列化 + 超 4096 字节时截断自由文本字段。
|
|
148
138
|
// 返回事件对象,null = 截不下来只能丢弃(整批被服务端拒比丢一条更糟)
|
|
149
139
|
_encode(eventType, content) {
|
|
@@ -266,11 +256,10 @@ class CotSession {
|
|
|
266
256
|
// 中间文本。每段一个独立 messageId —— 同 id 的多条 CONTENT 会被客户端拼成一段,
|
|
267
257
|
// 而两次 say 之间通常隔着工具调用,拼起来读不通。
|
|
268
258
|
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// 用「延迟一段」而非内容比较 —— 最稳
|
|
259
|
+
// **延迟一段**:最后一段 say 就是结论,它会作为独立消息再发一次 ——
|
|
260
|
+
// 也写进过程区群里就看到两遍。故压着最新一段,onFinal 时丢弃。
|
|
261
|
+
// 用「延迟一段」而非内容比较 —— 最稳
|
|
272
262
|
onSay(text) {
|
|
273
|
-
this.acc += (this.acc ? '\n\n' : '') + text
|
|
274
263
|
this._flushPending()
|
|
275
264
|
this.pendingSay = text
|
|
276
265
|
}
|
|
@@ -300,7 +289,6 @@ class CotSession {
|
|
|
300
289
|
// 工具调用。**不写 TOOL_CALL_RESULT**:输出动辄几千字符且可能含不宜进群的仓库内容,
|
|
301
290
|
// 与 card 档只显示工具名+参数摘要一致。text 形如 "Bash: ls -la"(claude.js 拼的)
|
|
302
291
|
onTool(text) {
|
|
303
|
-
this.acc += (this.acc ? '\n\n' : '') + `_🔧 ${text}_`
|
|
304
292
|
// 工具调用到来 = 前面那段 say 不是结论,可以放行了
|
|
305
293
|
this._flushPending()
|
|
306
294
|
const s = String(text)
|
|
@@ -320,8 +308,8 @@ class CotSession {
|
|
|
320
308
|
)
|
|
321
309
|
}
|
|
322
310
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
311
|
+
// 手里那段 pending(= 结论)直接丢弃,不放行 —— 它会作为独立消息发出,
|
|
312
|
+
// 写进过程区就成了群里的第二遍
|
|
325
313
|
onFinal() {
|
|
326
314
|
this.pendingSay = null
|
|
327
315
|
}
|
|
@@ -374,13 +362,8 @@ class CotSession {
|
|
|
374
362
|
})
|
|
375
363
|
}
|
|
376
364
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
return this._settled()
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// 与 CardSession 接口对齐。finalize 已完结,这里只兜「从没 finalize 成功」的情况:
|
|
383
|
-
// 超时被 SIGTERM、异常中断 —— 不调 complete 的话 COT 会一直显示「在跑」
|
|
365
|
+
// 兜「从没 finalize 成功」的情况:超时被 SIGTERM、异常中断 ——
|
|
366
|
+
// 不调 complete 的话 COT 会一直显示「在跑」
|
|
384
367
|
async stop() {
|
|
385
368
|
if (this.done || !this.cotId) return
|
|
386
369
|
// 从没 finalize 就走到这里 = 异常中断,压着的那段不是结论 → 放行
|
package/lib/dispatch.js
CHANGED
|
@@ -13,7 +13,7 @@ const { acquireBlocking } = require('./lock')
|
|
|
13
13
|
const { paths, ensureDir } = require('./paths')
|
|
14
14
|
const store = require('./store')
|
|
15
15
|
const claude = require('./claude')
|
|
16
|
-
const {
|
|
16
|
+
const { sendCard } = require('./card')
|
|
17
17
|
const { CotSession } = require('./cot')
|
|
18
18
|
const { reply } = require('./reply')
|
|
19
19
|
|
|
@@ -76,7 +76,7 @@ function ledgerAppend(task, chatId, epoch, trigger, result) {
|
|
|
76
76
|
|
|
77
77
|
// 处理一个 (chat_id, thread_id) 组:派一轮活
|
|
78
78
|
async function handleGroup(t, group, opts = {}) {
|
|
79
|
-
const { name: task, taskDir, insPath, dirs,
|
|
79
|
+
const { name: task, taskDir, insPath, dirs, mode } = t
|
|
80
80
|
const cfg = t.config
|
|
81
81
|
const profile = cfg.app
|
|
82
82
|
const chatId = group.chatId
|
|
@@ -97,31 +97,26 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
try {
|
|
100
|
-
// session 派生:
|
|
101
|
-
//
|
|
100
|
+
// session 派生:work 模式按 thread_id 隔离(话题即边界,不滚动,epoch 恒 0);
|
|
101
|
+
// chat 模式按 (群, epoch),空闲超 idle_gap 就开新世代
|
|
102
102
|
let uuid
|
|
103
103
|
let epoch = 0
|
|
104
|
-
if (
|
|
104
|
+
if (mode.thread && threadId) {
|
|
105
105
|
uuid = claude.deriveUuid(task, `${chatId}|thread:${threadId}`)
|
|
106
106
|
} else {
|
|
107
107
|
epoch = claude.sessionEpoch(paths.root, task, chatId, Number(cfg.idle_gap) || 86400)
|
|
108
108
|
uuid = claude.deriveUuid(task, `${chatId}|${epoch}`)
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let view = null
|
|
116
|
-
|
|
117
|
-
if (Session) {
|
|
118
|
-
view = new Session({ profile, chatId, lastMsg, replyInThread })
|
|
111
|
+
// 过程展示:work 模式挂 COT 消息。建不起来就静默降级(结论照发)
|
|
112
|
+
let cot = null
|
|
113
|
+
if (mode.cot) {
|
|
114
|
+
cot = new CotSession({ profile, chatId, lastMsg, replyInThread: mode.thread })
|
|
119
115
|
try {
|
|
120
|
-
if (!(await
|
|
121
|
-
if (!(await view.post())) throw new Error('发展示消息失败')
|
|
116
|
+
if (!(await cot.create())) throw new Error('建 COT 消息失败')
|
|
122
117
|
} catch (err) {
|
|
123
|
-
process.stderr.write(
|
|
124
|
-
|
|
118
|
+
process.stderr.write(`cot: ${err.message},本轮无过程展示\n`)
|
|
119
|
+
cot = null
|
|
125
120
|
}
|
|
126
121
|
}
|
|
127
122
|
|
|
@@ -134,13 +129,12 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
134
129
|
allowedTools: cfg.allowed || 'Read Edit Write Bash',
|
|
135
130
|
model: cfg.model,
|
|
136
131
|
timeoutSec: Number(cfg.timeout) || 1800,
|
|
137
|
-
stream: !!
|
|
138
|
-
onEvent:
|
|
132
|
+
stream: !!cot,
|
|
133
|
+
onEvent: cot
|
|
139
134
|
? (kind, text) => {
|
|
140
|
-
if (kind === 'say')
|
|
141
|
-
else if (kind === 'tool')
|
|
142
|
-
else if (kind === 'final')
|
|
143
|
-
if (kind !== 'final') view.update(view.acc)
|
|
135
|
+
if (kind === 'say') cot.onSay(text)
|
|
136
|
+
else if (kind === 'tool') cot.onTool(text)
|
|
137
|
+
else if (kind === 'final') cot.onFinal()
|
|
144
138
|
}
|
|
145
139
|
: null,
|
|
146
140
|
})
|
|
@@ -155,34 +149,25 @@ async function handleGroup(t, group, opts = {}) {
|
|
|
155
149
|
.slice(-300)}`
|
|
156
150
|
}
|
|
157
151
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
// 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
|
|
153
|
+
// 收尾一抛就会跳过下面的结论投递 + ledgerAppend,而 take 游标已推
|
|
154
|
+
// → 结论既不进群也不进台账,这批消息永不重来
|
|
155
|
+
if (cot) {
|
|
162
156
|
try {
|
|
163
|
-
|
|
164
|
-
await
|
|
157
|
+
await cot.finalize({ rc: res.rc, error: res.err })
|
|
158
|
+
await cot.stop()
|
|
165
159
|
} catch (err) {
|
|
166
|
-
process.stderr.write(`
|
|
160
|
+
process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
|
|
167
161
|
}
|
|
168
|
-
if (!ok) await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
|
|
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
|
-
}
|
|
183
|
-
await reply(profile, chatId, lastMsg, final, { inThread: replyInThread })
|
|
184
162
|
}
|
|
185
163
|
|
|
164
|
+
// 结论**总是单独一条**:work 发卡片(markdown 渲染、不受 3500 字符分片限制),
|
|
165
|
+
// chat 发纯文本。卡片失败降级纯文本 —— 保证结论必达
|
|
166
|
+
const sent = mode.card
|
|
167
|
+
? await sendCard(final, { profile, chatId, replyTo: lastMsg, inThread: mode.thread })
|
|
168
|
+
: false
|
|
169
|
+
if (!sent) await reply(profile, chatId, lastMsg, final, { inThread: mode.thread })
|
|
170
|
+
|
|
186
171
|
ledgerAppend(task, chatId, epoch, batch, final)
|
|
187
172
|
process.stderr.write(
|
|
188
173
|
`dispatch: ${task}/${chatId}${threadId ? `/${threadId}` : ''} 完成 rc=${res.rc} ` +
|
|
@@ -210,12 +195,12 @@ async function run(task, opts = {}) {
|
|
|
210
195
|
|
|
211
196
|
const cfg = t.config
|
|
212
197
|
const chats = cfg.chats || []
|
|
213
|
-
//
|
|
214
|
-
const debounceMs = (Number(cfg.debounce) ||
|
|
198
|
+
// work=话题内一问一答要跟手 → 1s;chat=群里多行需求 → 15s 让人打完
|
|
199
|
+
const debounceMs = (Number(cfg.debounce) || t.mode.debounce) * 1000
|
|
215
200
|
|
|
216
201
|
process.stderr.write(
|
|
217
|
-
`dispatch ${task}: app=${cfg.app} ${chats.length} 群
|
|
218
|
-
`
|
|
202
|
+
`dispatch ${task}: app=${cfg.app} ${chats.length} 群 mode=${t.modeName} ` +
|
|
203
|
+
`model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
|
|
219
204
|
)
|
|
220
205
|
|
|
221
206
|
const consumer = `dispatch-${task}`
|
package/lib/help.js
CHANGED
|
@@ -83,22 +83,22 @@ const DISPATCH_HELP = `常驻:等消息 → 唤起 claude 干活 → 结果回
|
|
|
83
83
|
任务发现:扫 $LR_ROOT/*/dispatch.yaml —— 有文件即是任务,无需注册表。
|
|
84
84
|
LR_ROOT 在单元里指定(唯一的目录绑定,与代码无关)。
|
|
85
85
|
|
|
86
|
-
dispatch.yaml(
|
|
86
|
+
dispatch.yaml(4 必填 + 3 可选)
|
|
87
87
|
app: <profile 名>
|
|
88
88
|
chats: [oc_xxx]
|
|
89
89
|
dirs: [/path/to/repo] # 首个 = 主工作目录
|
|
90
90
|
instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
|
|
91
91
|
filter: '.mentions[]?.id == "ou_xxx"' # 可选
|
|
92
|
-
#
|
|
93
|
-
# display: card 默认;card | cot | final
|
|
92
|
+
# mode: work 默认;work | chat(见下)
|
|
94
93
|
# model: <名称> 默认走终端同一套默认路由
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
mode 两个场景(一个键定死全部行为,不用拼组合)
|
|
96
|
+
work 处理工作:话题内回复 + 过程挂 COT 消息 + 结论发卡片
|
|
97
|
+
+ 按 thread_id 隔离 session + 防抖 1s(一问一答要跟手)
|
|
98
|
+
COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74;老客户端那条过程消息显示为
|
|
99
|
+
「Completed」(不崩),结论卡片不受影响
|
|
100
|
+
chat 日常闲聊:直发群里 + 纯文本 + 按(群, epoch)隔离 session
|
|
101
|
+
+ 防抖 15s(等人打完多行)
|
|
102
102
|
|
|
103
103
|
最佳实践
|
|
104
104
|
· 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
|
package/lib/tasks.js
CHANGED
|
@@ -106,6 +106,16 @@ function scalar(s) {
|
|
|
106
106
|
|
|
107
107
|
const REQUIRED = ['app', 'chats', 'dirs', 'instructions']
|
|
108
108
|
|
|
109
|
+
// 两个场景,不是几个正交开关的组合 —— 一个 mode 键定死全部行为。
|
|
110
|
+
// 此前是 session(thread|idle) × display(card|cot|final) 交叉出 6 种组合,
|
|
111
|
+
// 而 session 一个键实际控制三件事(隔离方式、是否开话题、防抖默认值)。
|
|
112
|
+
const MODES = {
|
|
113
|
+
// 日常闲聊:群里直接对话,不开话题
|
|
114
|
+
chat: { thread: false, cot: false, card: false, debounce: 15 },
|
|
115
|
+
// 处理工作:话题内一问一答,过程挂 COT,结论发卡片
|
|
116
|
+
work: { thread: true, cot: true, card: true, debounce: 1 },
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
function validate(name, cfg, taskDir) {
|
|
110
120
|
const errs = []
|
|
111
121
|
for (const k of REQUIRED) {
|
|
@@ -116,12 +126,9 @@ function validate(name, cfg, taskDir) {
|
|
|
116
126
|
for (const c of cfg.chats || []) {
|
|
117
127
|
if (typeof c !== 'string' || !c.startsWith('oc_')) errs.push(`chats 项不是 oc_ 开头:${c}`)
|
|
118
128
|
}
|
|
119
|
-
const
|
|
120
|
-
if (![
|
|
121
|
-
const
|
|
122
|
-
if (!['card', 'cot', 'final'].includes(display)) {
|
|
123
|
-
errs.push(`display 只能 card|cot|final,给的是 ${display}`)
|
|
124
|
-
}
|
|
129
|
+
const modeName = cfg.mode || 'work'
|
|
130
|
+
if (!MODES[modeName]) errs.push(`mode 只能 ${Object.keys(MODES).join('|')},给的是 ${modeName}`)
|
|
131
|
+
const mode = MODES[modeName] || MODES.work
|
|
125
132
|
|
|
126
133
|
// instructions 相对任务目录解析 —— 执行偏好是资产,必须留仓库
|
|
127
134
|
let insPath = null
|
|
@@ -133,7 +140,7 @@ function validate(name, cfg, taskDir) {
|
|
|
133
140
|
for (const d of dirs) {
|
|
134
141
|
if (!fs.existsSync(d)) errs.push(`dirs 目录不存在:${d}`)
|
|
135
142
|
}
|
|
136
|
-
return { errs, insPath, dirs,
|
|
143
|
+
return { errs, insPath, dirs, modeName, mode }
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
function loadTask(name, root = lrRoot()) {
|