lark-relay 0.3.0 → 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 CHANGED
@@ -73,20 +73,29 @@ 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
- # mode: work 默认;work | chat(见下)
76
+ # mode: work 默认;work | chat | agent(见下)
77
77
  # model: <名称> 默认走终端同一套默认路由
78
78
  ```
79
79
 
80
- `mode` 就两个场景 —— 一个键定死全部行为,不用拼组合:
81
-
82
- | | `work`(默认) | `chat` |
83
- |---|---|---|
84
- | 用途 | 处理工作 | 日常闲聊 |
85
- | 回复位置 | 话题内(首问开话题) | 直接发群里 |
86
- | 过程展示 | COT 消息(实时流式) | 无 |
87
- | 结论 | 卡片(markdown 渲染) | 纯文本 |
88
- | session 隔离 | 按 `thread_id`(话题即边界,不滚动) | 按 (群, epoch),空闲超 `idle_gap` 开新世代 |
89
- | 防抖 | 1s(一问一答要跟手) | 15s(等人打完多行) |
80
+ `mode` 三个场景 —— 一个键定死全部行为,不用拼组合:
81
+
82
+ | | `work`(默认) | `chat` | `agent` |
83
+ |---|---|---|---|
84
+ | 用途 | 处理工作 | 简单问答 | 群运营 |
85
+ | 回复位置 | 话题内(首问开话题) | 直接发群里 | **模型自己决定** |
86
+ | 过程展示 | COT 消息(实时流式) | 无 | 无 |
87
+ | 结论投递 | 引擎发卡片 | 引擎发纯文本 | **引擎不发** |
88
+ | session 隔离 | 按 `thread_id` | 按 (群, epoch) | 按 (群, epoch) |
89
+ | 防抖 | 1s | 15s | 15s |
90
+
91
+ `agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
92
+ 而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
93
+ `agent` 模式下引擎只负责拉起模型、传消息批次、记台账 —— 发不发、回哪条、
94
+ 用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
95
+ Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
96
+
97
+ 这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
98
+ 到达模型侧**。
90
99
 
91
100
  `work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
92
101
  结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
package/lib/claude.js CHANGED
@@ -46,12 +46,34 @@ function sessionEpoch(stateDir, task, chatId, idleGap) {
46
46
  return epoch
47
47
  }
48
48
 
49
- const PROMPT_TEMPLATE = (batch) =>
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, PROMPT_TEMPLATE }
231
+ module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPTS }
package/lib/dispatch.js CHANGED
@@ -122,7 +122,7 @@ async function handleGroup(t, group, opts = {}) {
122
122
 
123
123
  const res = await claude.runClaude({
124
124
  cwd,
125
- prompt: claude.PROMPT_TEMPLATE(batch),
125
+ prompt: claude.PROMPTS[mode.prompt](batch),
126
126
  uuid,
127
127
  instructions: insPath,
128
128
  addDirs,
@@ -139,14 +139,19 @@ async function handleGroup(t, group, opts = {}) {
139
139
  : null,
140
140
  })
141
141
 
142
+ // 兜底文案分两套:要发回群的(deliver != none)面向群里的人写,
143
+ // agent 模式的 final 只进台账,不该出现「稍后重试或换个说法」这种对人说的话
142
144
  let final = (res.final || '').trim()
143
145
  if (!final) {
144
- final =
145
- res.rc === 0
146
- ? '(完成,但没拿到最终结论)'
147
- : `⚠️ 处理超时或异常(rc=${res.rc})。稍后重试或换个说法。\n\n错误:${String(res.err)
148
- .replace(/\n/g, ' ')
149
- .slice(-300)}`
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
+ }
150
155
  }
151
156
 
152
157
  // 收尾必须包 try/catch:handleGroup 外层只有 try/finally(无 catch),
@@ -161,12 +166,15 @@ async function handleGroup(t, group, opts = {}) {
161
166
  }
162
167
  }
163
168
 
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 })
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 })
177
+ }
170
178
 
171
179
  ledgerAppend(task, chatId, epoch, batch, final)
172
180
  process.stderr.write(
package/lib/help.js CHANGED
@@ -89,16 +89,20 @@ dispatch.yaml(4 必填 + 3 可选)
89
89
  dirs: [/path/to/repo] # 首个 = 主工作目录
90
90
  instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
91
91
  filter: '.mentions[]?.id == "ou_xxx"' # 可选
92
- # mode: work 默认;work | chat(见下)
92
+ # mode: work 默认;work | chat | agent(见下)
93
93
  # model: <名称> 默认走终端同一套默认路由
94
94
 
95
- mode 两个场景(一个键定死全部行为,不用拼组合)
95
+ mode 三个场景(一个键定死全部行为,不用拼组合)
96
96
  work 处理工作:话题内回复 + 过程挂 COT 消息 + 结论发卡片
97
97
  + 按 thread_id 隔离 session + 防抖 1s(一问一答要跟手)
98
98
  COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74;老客户端那条过程消息显示为
99
99
  「Completed」(不崩),结论卡片不受影响
100
- chat 日常闲聊:直发群里 + 纯文本 + 按(群, epoch)隔离 session
100
+ chat 简单问答:直发群里 + 纯文本 + 按(群, epoch)隔离 session
101
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,14 +106,17 @@ 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 一个键实际控制三件事(隔离方式、是否开话题、防抖默认值)。
109
+ // 三个场景,不是几个正交开关的组合 —— 一个 mode 键定死全部行为。
110
+ // deliver 决定「结论由谁发」:card/text = 引擎发,none = 模型自己发。
112
111
  const MODES = {
113
112
  // 日常闲聊:群里直接对话,不开话题
114
- chat: { thread: false, cot: false, card: false, debounce: 15 },
113
+ chat: { thread: false, cot: false, deliver: 'text', prompt: 'task', debounce: 15 },
115
114
  // 处理工作:话题内一问一答,过程挂 COT,结论发卡片
116
- work: { thread: true, cot: true, card: true, debounce: 1 },
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 },
117
120
  }
118
121
 
119
122
  function validate(name, cfg, taskDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it, or dispatch work to an AI agent.",
5
5
  "keywords": [
6
6
  "lark",