lark-relay 0.3.0 → 0.4.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
@@ -12,7 +12,7 @@ lark-relay guide # 装完先读这个
12
12
 
13
13
  ## 为什么需要它
14
14
 
15
- Lark 事件是**流式**的 —— 进程不在的时刻,消息**永久丢失**,不是延迟送达
15
+ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不是延迟送达
16
16
  (实测:消息发出 8 秒后才起 consumer,收到 0 条)。
17
17
 
18
18
  所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
@@ -29,13 +29,13 @@ Lark 事件是**流式**的 —— 进程不在的时刻,消息**永久丢失**,
29
29
  | 谁能干 | 任何 agent(Claude Code / Codex / 手敲) | 需内置唤起知识 |
30
30
  | 配置 | 纯参数,无文件 | `dispatch.yaml` |
31
31
 
32
- 你(AI)在会话里盯群 `take`。无人在场也要干活 `dispatch`。
32
+ 你(AI)在会话里盯群 -> `take`。无人在场也要干活 -> `dispatch`。
33
33
 
34
34
  ## 命令
35
35
 
36
36
  ```bash
37
- lark-relay collect # 底座:全部 profile 各起 consume 原子落盘(systemd 常驻)
38
- lark-relay take … # 场景1:阻塞等一批 输出 退出
37
+ lark-relay collect # 底座:全部 profile 各起 consume -> 原子落盘(systemd 常驻)
38
+ lark-relay take … # 场景1:阻塞等一批 -> 输出 -> 退出
39
39
  lark-relay dispatch <task> # 场景2:常驻循环 = take + 唤起 claude
40
40
  lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
41
41
  lark-relay guide # 一页用法
@@ -47,24 +47,24 @@ lark-relay guide # 一页用法
47
47
  未授权或授权过期的 profile 自动跳过并 warn(永久失败,重试是死循环)。
48
48
 
49
49
  每 app 一个子进程,某个挂了单独重启不影响其他。回收在本进程内每小时自查,
50
- 删超期的整个日期目录 —— 不另起 gc 单元/timer。
50
+ 删超期的整个日期目录 -- 不另起 gc 单元/timer。
51
51
 
52
52
  ### take
53
53
 
54
- 只有一种形态:**阻塞等 有一批就输出 退出**。循环交给 harness ——
54
+ 只有一种形态:**阻塞等 -> 有一批就输出 -> 退出**。循环交给 harness --
55
55
  Claude Code 用 `run_in_background` 起,进程退出会主动通知 AI,这才是事件驱动。
56
56
 
57
57
  ```bash
58
58
  lark-relay take --app <app> --chats oc_xxx --render text
59
59
  ```
60
60
 
61
- 空参数跑一下会输出完整的照做指导(查群 ID 验 bot 在群 监听三步)。
61
+ 空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步)。
62
62
 
63
63
  业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
64
64
 
65
65
  ### dispatch
66
66
 
67
- 任务发现:扫 `$LR_ROOT/*/dispatch.yaml` —— 有文件即是任务,无需注册表。
67
+ 任务发现:扫 `$LR_ROOT/*/dispatch.yaml` -- 有文件即是任务,无需注册表。
68
68
  `dispatch` 内部就是调 `take`,保证两场景不实现分叉。
69
69
 
70
70
  ```yaml
@@ -73,26 +73,35 @@ 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` 就两个场景 —— 一个键定死全部行为,不用拼组合:
80
+ `mode` 三个场景 -- 一个键定死全部行为,不用拼组合:
81
81
 
82
- | | `work`(默认) | `chat` |
83
- |---|---|---|
84
- | 用途 | 处理工作 | 日常闲聊 |
85
- | 回复位置 | 话题内(首问开话题) | 直接发群里 |
86
- | 过程展示 | COT 消息(实时流式) | 无 |
87
- | 结论 | 卡片(markdown 渲染) | 纯文本 |
88
- | session 隔离 | 按 `thread_id`(话题即边界,不滚动) | 按 (群, epoch),空闲超 `idle_gap` 开新世代 |
89
- | 防抖 | 1s(一问一答要跟手) | 15s(等人打完多行) |
82
+ | | `work`(默认) | `chat` | `agent` |
83
+ |---|---|---|---|
84
+ | 用途 | 处理工作 | 简单问答 | 群运营 |
85
+ | 回复位置 | 话题内(首问开话题) | 直接发群里 | **模型自己决定** |
86
+ | 过程展示 | COT 消息(实时流式) | 无 | 无 |
87
+ | 结论投递 | 引擎发卡片 | 引擎发纯文本 | **引擎不发** |
88
+ | session 隔离 | 按 `thread_id` | 按 (群, epoch) | 按 (群, epoch) |
89
+ | 防抖 | 1s | 15s | 15s |
90
90
 
91
- `work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
91
+ `agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
92
+ 而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
93
+ `agent` 模式下引擎只负责拉起模型、传消息批次、记台账 -- 发不发、回哪条、
94
+ 用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
95
+ Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
96
+
97
+ 这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
98
+ 到达模型侧**。
99
+
100
+ `work` 的过程与结论是**两条消息** -- COT 消息只承载过程(接口的设计前提),
92
101
  结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
93
102
 
94
103
  ⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
95
- 「Completed」(不会崩),结论卡片不受影响 —— 这也是「结论单独发」的价值。
104
+ 「Completed」(不会崩),结论卡片不受影响 -- 这也是「结论单独发」的价值。
96
105
 
97
106
  环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
98
107
  `LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
@@ -110,13 +119,13 @@ filter: '.mentions[]?.id == "ou_xxx"' # 可选
110
119
  判据:能随时删掉重建的才放这里。`ledger` 是唯一不能重建的东西
111
120
  (session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
112
121
 
113
- 多消费者**共享 store、各持游标**,不做「处理完即删」—— 谁都不能替别人删。
122
+ 多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
114
123
  盯同一 app 请用不同 `--name` 隔离游标。
115
124
 
116
125
  ## 设计取舍
117
126
 
118
- - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp rename`(同目录原子)。
119
- 不用 `--output-dir` —— 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
127
+ - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp -> rename`(同目录原子)。
128
+ 不用 `--output-dir` -- 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
120
129
  - **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
121
130
  两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
122
131
  要按大小滚动 + 保留 N 个文件,反而比整目录删复杂
package/bin/lark-relay.js CHANGED
@@ -8,11 +8,33 @@ if (!/utf-?8/i.test(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LA
8
8
  process.env.LC_ALL = 'C.UTF-8'
9
9
  }
10
10
 
11
- const { parseArgs, num, list } = require('../lib/args')
11
+ const { parseArgs, num, list, suggest } = require('../lib/args')
12
12
  const help = require('../lib/help')
13
13
 
14
14
  const VERSION = require('../package.json').version
15
15
 
16
+ // 各命令的带值参数白名单(布尔开关在 parseArgs 的 flags 里)。
17
+ // 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故:
18
+ // --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
19
+ const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
20
+ const COLLECT_KEYS = ['exclude', 'retain', 'ledger-retain']
21
+
22
+ // 未知参数 -> stderr 短错误 + 候选提示,exit 2。不打印完整用法 —— 那看着像 --help 成功
23
+ function rejectUnknown(cmd, unknown, allowed) {
24
+ const names = [...new Set(unknown)]
25
+ const shown = names.map((k) => `--${k}`).join(', ')
26
+ let hint = ''
27
+ for (const k of names) {
28
+ const s = suggest(k, allowed)
29
+ if (s) {
30
+ hint = `\n你是不是想写 --${s}?`
31
+ break
32
+ }
33
+ }
34
+ process.stderr.write(`未知参数:${shown}${hint}\n跑 \`lark-relay ${cmd}\` 看完整用法\n`)
35
+ return 2
36
+ }
37
+
16
38
  const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站
17
39
 
18
40
  lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(systemd 常驻)
@@ -57,7 +79,8 @@ async function main() {
57
79
  }
58
80
 
59
81
  async function cmdCollect(argv) {
60
- const a = parseArgs(argv, { flags: ['help'] })
82
+ const a = parseArgs(argv, { flags: ['help'], keys: COLLECT_KEYS })
83
+ if (a._unknown.length) return rejectUnknown('collect', a._unknown, COLLECT_KEYS)
61
84
  if (a.help) {
62
85
  process.stdout.write(`${help.COLLECT_HELP}\n`)
63
86
  return 0
@@ -71,11 +94,14 @@ async function cmdCollect(argv) {
71
94
  }
72
95
 
73
96
  async function cmdTake(argv) {
74
- const a = parseArgs(argv, { flags: ['help'] })
97
+ const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
98
+ if (a._unknown.length) return rejectUnknown('take', a._unknown, TAKE_KEYS)
75
99
  const larkcli = require('../lib/larkcli')
76
100
 
77
- // 空参数输出完整照做指导,而非报错 —— AI 的真实行为是「先空参数跑一下看看」
78
- if (a.help || (!a.app && !a.chats)) {
101
+ // 空参数输出完整照做指导,而非报错 —— AI 的真实行为是「先空参数跑一下看看」。
102
+ // 但带了参数又缺 app/chats 的走下面短错误 —— 打印完整用法和 --help 长得一样,
103
+ // 会被当成「正常退出但没消息」(实测漏看 bot 第一条 Working)。
104
+ if (a.help || argv.length === 0) {
79
105
  let apps = []
80
106
  try {
81
107
  apps = (await larkcli.listProfiles()).filter((p) => p.usable).map((p) => p.name)
@@ -157,7 +183,8 @@ async function cmdTake(argv) {
157
183
  }
158
184
 
159
185
  async function cmdStatus(argv) {
160
- const a = parseArgs(argv, { flags: ['json', 'help'] })
186
+ const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
187
+ if (a._unknown.length) return rejectUnknown('status', a._unknown, ['json'])
161
188
  if (a.help) {
162
189
  process.stdout.write(`${help.STATUS_HELP}\n`)
163
190
  return 0
@@ -170,7 +197,8 @@ async function cmdStatus(argv) {
170
197
  }
171
198
 
172
199
  async function cmdDispatch(argv) {
173
- const a = parseArgs(argv, { flags: ['list', 'once', 'help'] })
200
+ const a = parseArgs(argv, { flags: ['list', 'once', 'help'], keys: [] })
201
+ if (a._unknown.length) return rejectUnknown('dispatch', a._unknown, ['list', 'once'])
174
202
  if (a.help) {
175
203
  process.stdout.write(`${help.DISPATCH_HELP}\n`)
176
204
  return 0
package/lib/args.js CHANGED
@@ -1,9 +1,14 @@
1
1
  'use strict'
2
2
 
3
3
  // 零依赖 argv 解析。只支持 --key value / --key=value / --flag。
4
+ // spec.flags:布尔开关;spec.keys:带值选项。不在两者里的 --xxx 进 out._unknown,
5
+ // 由调用侧硬报错 —— 拼错参数必须当场失败,不能静默吞掉
6
+ // (实测:--chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working)
4
7
  function parseArgs(argv, spec = {}) {
5
8
  const flags = new Set(spec.flags || [])
6
- const out = { _: [] }
9
+ const keys = new Set(spec.keys || [])
10
+ const known = (k) => flags.has(k) || keys.has(k)
11
+ const out = { _: [], _unknown: [] }
7
12
  for (let i = 0; i < argv.length; i++) {
8
13
  const a = argv[i]
9
14
  if (a === '--') {
@@ -16,17 +21,26 @@ function parseArgs(argv, spec = {}) {
16
21
  }
17
22
  const eq = a.indexOf('=')
18
23
  if (eq !== -1) {
19
- out[a.slice(2, eq)] = a.slice(eq + 1)
24
+ const k = a.slice(2, eq)
25
+ if (known(k)) out[k] = a.slice(eq + 1)
26
+ else out._unknown.push(k)
20
27
  continue
21
28
  }
22
29
  const key = a.slice(2)
30
+ if (!known(key)) {
31
+ out._unknown.push(key)
32
+ // 吞掉它的值,避免下一个非 -- 参数错位成位置参数
33
+ const next = argv[i + 1]
34
+ if (next !== undefined && !next.startsWith('--')) i++
35
+ continue
36
+ }
23
37
  if (flags.has(key)) {
24
38
  out[key] = true
25
39
  continue
26
40
  }
27
41
  const next = argv[i + 1]
28
42
  if (next === undefined || next.startsWith('--')) {
29
- out[key] = true // 当成 flag,由调用侧校验
43
+ out[key] = true // 缺值,由调用侧校验
30
44
  } else {
31
45
  out[key] = next
32
46
  i++
@@ -49,4 +63,41 @@ function list(v) {
49
63
  .filter(Boolean)
50
64
  }
51
65
 
52
- module.exports = { parseArgs, num, list }
66
+ function levenshtein(a, b) {
67
+ const m = a.length
68
+ const n = b.length
69
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
70
+ for (let j = 0; j <= n; j++) dp[0][j] = j
71
+ for (let i = 1; i <= m; i++) {
72
+ for (let j = 1; j <= n; j++) {
73
+ dp[i][j] = Math.min(
74
+ dp[i - 1][j] + 1,
75
+ dp[i][j - 1] + 1,
76
+ dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
77
+ )
78
+ }
79
+ }
80
+ return dp[m][n]
81
+ }
82
+
83
+ // 拼错参数时给个候选:编辑距离 <= 2,或前 4 字符前缀相同(--chat-id -> --chats)。
84
+ // 都不沾边返回 null,不硬猜。
85
+ function suggest(unknown, allowed) {
86
+ let best = null
87
+ let bestD = Infinity
88
+ for (const k of allowed) {
89
+ const d = levenshtein(unknown, k)
90
+ if (d < bestD) {
91
+ bestD = d
92
+ best = k
93
+ }
94
+ }
95
+ if (bestD <= 2) return best
96
+ if (unknown.length >= 4) {
97
+ const hit = allowed.find((k) => k.startsWith(unknown.slice(0, 4)))
98
+ if (hit) return hit
99
+ }
100
+ return null
101
+ }
102
+
103
+ module.exports = { parseArgs, num, list, suggest }
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.1",
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",