lark-relay 0.2.2 → 0.4.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 +26 -17
- package/lib/card.js +38 -195
- package/lib/claude.js +26 -4
- package/lib/cot.js +9 -26
- package/lib/dispatch.js +50 -57
- package/lib/help.js +13 -9
- package/lib/tasks.js +17 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,30 +73,39 @@ 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 | agent(见下)
|
|
78
77
|
# model: <名称> 默认走终端同一套默认路由
|
|
79
78
|
```
|
|
80
79
|
|
|
81
|
-
`
|
|
80
|
+
`mode` 三个场景 —— 一个键定死全部行为,不用拼组合:
|
|
82
81
|
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
|
|
|
82
|
+
| | `work`(默认) | `chat` | `agent` |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| 用途 | 处理工作 | 简单问答 | 群运营 |
|
|
85
|
+
| 回复位置 | 话题内(首问开话题) | 直接发群里 | **模型自己决定** |
|
|
86
|
+
| 过程展示 | COT 消息(实时流式) | 无 | 无 |
|
|
87
|
+
| 结论投递 | 引擎发卡片 | 引擎发纯文本 | **引擎不发** |
|
|
88
|
+
| session 隔离 | 按 `thread_id` | 按 (群, epoch) | 按 (群, epoch) |
|
|
89
|
+
| 防抖 | 1s | 15s | 15s |
|
|
88
90
|
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
`agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
|
|
92
|
+
而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
|
|
93
|
+
`agent` 模式下引擎只负责拉起模型、传消息批次、记台账 —— 发不发、回哪条、
|
|
94
|
+
用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
|
|
95
|
+
Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
结论那条普通文本不受影响 —— 这也是「结论单独发」的价值。
|
|
97
|
+
这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
|
|
98
|
+
到达模型侧**。
|
|
96
99
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
`work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
|
|
101
|
+
结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
|
|
102
|
+
|
|
103
|
+
⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
|
|
104
|
+
「Completed」(不会崩),结论卡片不受影响 —— 这也是「结论单独发」的价值。
|
|
105
|
+
|
|
106
|
+
环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
|
|
107
|
+
`LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
|
|
108
|
+
实测两者渲染一致,`text` 每段少 2 个事件)。
|
|
100
109
|
|
|
101
110
|
## 存储布局
|
|
102
111
|
|
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/claude.js
CHANGED
|
@@ -46,12 +46,34 @@ function sessionEpoch(stateDir, task, chatId, idleGap) {
|
|
|
46
46
|
return epoch
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// 提示词按 mode 分流 —— 两类场景对模型的要求根本不同。
|
|
50
|
+
// work/chat:模型产出一段结论,由 dispatch 发回群(模型不碰发送)。
|
|
51
|
+
// agent:模型自己决定发不发、发给谁、用谁的身份、发文字还是表情 ——
|
|
52
|
+
// 引擎不做回复决策,和「引擎不做权限管控」是同一条思路:边界靠 instructions 到达模型侧。
|
|
53
|
+
const PROMPTS = {
|
|
54
|
+
task: (batch) =>
|
|
55
|
+
`下面是飞书群里最新一批消息。请把它们当作针对本仓库(当前工作目录)的任务来处理:定位/分析/修复/测试。处理完后,你的最终回复会被原样发回该群,请直接面向群里的人简洁说明你做了什么、发现了什么、需要什么确认。
|
|
51
56
|
|
|
52
57
|
===== 群消息 =====
|
|
53
58
|
${batch}
|
|
54
|
-
===== 结束
|
|
59
|
+
===== 结束 =====`,
|
|
60
|
+
|
|
61
|
+
agent: (batch) =>
|
|
62
|
+
`下面是飞书群里最新一批消息。
|
|
63
|
+
|
|
64
|
+
**本轮的发送完全由你负责** —— 没有任何东西会被自动发回群。按你的职责文档判断:
|
|
65
|
+
该不该回应、回应哪一条、用哪个身份、发文字还是表情回应、要不要执行既定任务。
|
|
66
|
+
需要发送时自己用 lark-cli 完成。
|
|
67
|
+
|
|
68
|
+
**多数情况下正确的选择是什么都不做** —— 不要为了有所动作而发言。
|
|
69
|
+
|
|
70
|
+
处理完请用一句话说明你做了什么(如「用可莉回复了接梗」「无 @ 我方,沉默」),
|
|
71
|
+
这句话只进台账供事后回溯,不会发到群里。
|
|
72
|
+
|
|
73
|
+
===== 群消息 =====
|
|
74
|
+
${batch}
|
|
75
|
+
===== 结束 =====`,
|
|
76
|
+
}
|
|
55
77
|
|
|
56
78
|
/**
|
|
57
79
|
* 起一轮 headless claude。
|
|
@@ -206,4 +228,4 @@ function shellQuote(s) {
|
|
|
206
228
|
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
module.exports = { runClaude, deriveUuid, sessionEpoch,
|
|
231
|
+
module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPTS }
|
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,90 +97,83 @@ 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
|
|
|
128
123
|
const res = await claude.runClaude({
|
|
129
124
|
cwd,
|
|
130
|
-
prompt: claude.
|
|
125
|
+
prompt: claude.PROMPTS[mode.prompt](batch),
|
|
131
126
|
uuid,
|
|
132
127
|
instructions: insPath,
|
|
133
128
|
addDirs,
|
|
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
|
})
|
|
147
141
|
|
|
142
|
+
// 兜底文案分两套:要发回群的(deliver != none)面向群里的人写,
|
|
143
|
+
// agent 模式的 final 只进台账,不该出现「稍后重试或换个说法」这种对人说的话
|
|
148
144
|
let final = (res.final || '').trim()
|
|
149
145
|
if (!final) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
const errTail = String(res.err).replace(/\n/g, ' ').slice(-300)
|
|
147
|
+
if (mode.deliver === 'none') {
|
|
148
|
+
final = res.rc === 0 ? '(本轮无自述)' : `(异常 rc=${res.rc}) ${errTail}`
|
|
149
|
+
} else {
|
|
150
|
+
final =
|
|
151
|
+
res.rc === 0
|
|
152
|
+
? '(完成,但没拿到最终结论)'
|
|
153
|
+
: `⚠️ 处理超时或异常(rc=${res.rc})。稍后重试或换个说法。\n\n错误:${errTail}`
|
|
154
|
+
}
|
|
156
155
|
}
|
|
157
156
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
// 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
|
|
158
|
+
// 收尾一抛就会跳过下面的结论投递 + ledgerAppend,而 take 游标已推
|
|
159
|
+
// → 结论既不进群也不进台账,这批消息永不重来
|
|
160
|
+
if (cot) {
|
|
162
161
|
try {
|
|
163
|
-
|
|
164
|
-
await
|
|
162
|
+
await cot.finalize({ rc: res.rc, error: res.err })
|
|
163
|
+
await cot.stop()
|
|
165
164
|
} catch (err) {
|
|
166
|
-
process.stderr.write(`
|
|
165
|
+
process.stderr.write(`cot: 收尾异常(不影响结论)→ ${err.stack || err}\n`)
|
|
167
166
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 结论投递:card = 卡片(markdown 渲染、不受 3500 字符分片限制),失败降级纯文本;
|
|
170
|
+
// text = 纯文本;none = **引擎不发**,agent 模式下模型已自己发过了,
|
|
171
|
+
// final 只是它的一句自述(进台账供回溯)
|
|
172
|
+
if (mode.deliver !== 'none') {
|
|
173
|
+
const sent = mode.deliver === 'card'
|
|
174
|
+
? await sendCard(final, { profile, chatId, replyTo: lastMsg, inThread: mode.thread })
|
|
175
|
+
: false
|
|
176
|
+
if (!sent) await reply(profile, chatId, lastMsg, final, { inThread: mode.thread })
|
|
184
177
|
}
|
|
185
178
|
|
|
186
179
|
ledgerAppend(task, chatId, epoch, batch, final)
|
|
@@ -210,12 +203,12 @@ async function run(task, opts = {}) {
|
|
|
210
203
|
|
|
211
204
|
const cfg = t.config
|
|
212
205
|
const chats = cfg.chats || []
|
|
213
|
-
//
|
|
214
|
-
const debounceMs = (Number(cfg.debounce) ||
|
|
206
|
+
// work=话题内一问一答要跟手 → 1s;chat=群里多行需求 → 15s 让人打完
|
|
207
|
+
const debounceMs = (Number(cfg.debounce) || t.mode.debounce) * 1000
|
|
215
208
|
|
|
216
209
|
process.stderr.write(
|
|
217
|
-
`dispatch ${task}: app=${cfg.app} ${chats.length} 群
|
|
218
|
-
`
|
|
210
|
+
`dispatch ${task}: app=${cfg.app} ${chats.length} 群 mode=${t.modeName} ` +
|
|
211
|
+
`model=${cfg.model || '(默认路由)'} 防抖 ${debounceMs / 1000}s\n`,
|
|
219
212
|
)
|
|
220
213
|
|
|
221
214
|
const consumer = `dispatch-${task}`
|
package/lib/help.js
CHANGED
|
@@ -83,22 +83,26 @@ 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 | agent(见下)
|
|
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
|
+
agent 模型自己当运营者:引擎只拉起它 + 传消息 + 记台账,**一条消息都不发**。
|
|
103
|
+
发不发 / 回哪条 / 用哪个 bot 身份 / 文字还是表情,全由模型按
|
|
104
|
+
instructions 决定(它有 Bash + lark-cli)。适合「多数轮该沉默」的
|
|
105
|
+
群运营 —— work/chat 会逼模型每轮都产出一段发回群的话
|
|
102
106
|
|
|
103
107
|
最佳实践
|
|
104
108
|
· 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
|
package/lib/tasks.js
CHANGED
|
@@ -106,6 +106,19 @@ function scalar(s) {
|
|
|
106
106
|
|
|
107
107
|
const REQUIRED = ['app', 'chats', 'dirs', 'instructions']
|
|
108
108
|
|
|
109
|
+
// 三个场景,不是几个正交开关的组合 —— 一个 mode 键定死全部行为。
|
|
110
|
+
// deliver 决定「结论由谁发」:card/text = 引擎发,none = 模型自己发。
|
|
111
|
+
const MODES = {
|
|
112
|
+
// 日常闲聊:群里直接对话,不开话题
|
|
113
|
+
chat: { thread: false, cot: false, deliver: 'text', prompt: 'task', debounce: 15 },
|
|
114
|
+
// 处理工作:话题内一问一答,过程挂 COT,结论发卡片
|
|
115
|
+
work: { thread: true, cot: true, deliver: 'card', prompt: 'task', debounce: 1 },
|
|
116
|
+
// 模型自己当运营者:引擎只拉起它 + 传消息 + 记台账,**不发任何消息**。
|
|
117
|
+
// 发不发、回哪条、用谁的身份、文字还是表情,全由模型按 instructions 决定
|
|
118
|
+
// (它有 Bash + lark-cli)。多数轮的正解是沉默 —— 引擎不该逼它每轮都说话
|
|
119
|
+
agent: { thread: false, cot: false, deliver: 'none', prompt: 'agent', debounce: 15 },
|
|
120
|
+
}
|
|
121
|
+
|
|
109
122
|
function validate(name, cfg, taskDir) {
|
|
110
123
|
const errs = []
|
|
111
124
|
for (const k of REQUIRED) {
|
|
@@ -116,12 +129,9 @@ function validate(name, cfg, taskDir) {
|
|
|
116
129
|
for (const c of cfg.chats || []) {
|
|
117
130
|
if (typeof c !== 'string' || !c.startsWith('oc_')) errs.push(`chats 项不是 oc_ 开头:${c}`)
|
|
118
131
|
}
|
|
119
|
-
const
|
|
120
|
-
if (![
|
|
121
|
-
const
|
|
122
|
-
if (!['card', 'cot', 'final'].includes(display)) {
|
|
123
|
-
errs.push(`display 只能 card|cot|final,给的是 ${display}`)
|
|
124
|
-
}
|
|
132
|
+
const modeName = cfg.mode || 'work'
|
|
133
|
+
if (!MODES[modeName]) errs.push(`mode 只能 ${Object.keys(MODES).join('|')},给的是 ${modeName}`)
|
|
134
|
+
const mode = MODES[modeName] || MODES.work
|
|
125
135
|
|
|
126
136
|
// instructions 相对任务目录解析 —— 执行偏好是资产,必须留仓库
|
|
127
137
|
let insPath = null
|
|
@@ -133,7 +143,7 @@ function validate(name, cfg, taskDir) {
|
|
|
133
143
|
for (const d of dirs) {
|
|
134
144
|
if (!fs.existsSync(d)) errs.push(`dirs 目录不存在:${d}`)
|
|
135
145
|
}
|
|
136
|
-
return { errs, insPath, dirs,
|
|
146
|
+
return { errs, insPath, dirs, modeName, mode }
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
function loadTask(name, root = lrRoot()) {
|