lark-relay 0.4.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
@@ -77,7 +77,7 @@ filter: '.mentions[]?.id == "ou_xxx"' # 可选
77
77
  # model: <名称> 默认走终端同一套默认路由
78
78
  ```
79
79
 
80
- `mode` 三个场景 —— 一个键定死全部行为,不用拼组合:
80
+ `mode` 三个场景 -- 一个键定死全部行为,不用拼组合:
81
81
 
82
82
  | | `work`(默认) | `chat` | `agent` |
83
83
  |---|---|---|---|
@@ -90,18 +90,18 @@ filter: '.mentions[]?.id == "ou_xxx"' # 可选
90
90
 
91
91
  `agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
92
92
  而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
93
- `agent` 模式下引擎只负责拉起模型、传消息批次、记台账 —— 发不发、回哪条、
93
+ `agent` 模式下引擎只负责拉起模型、传消息批次、记台账 -- 发不发、回哪条、
94
94
  用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
95
95
  Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
96
96
 
97
97
  这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
98
98
  到达模型侧**。
99
99
 
100
- `work` 的过程与结论是**两条消息** —— COT 消息只承载过程(接口的设计前提),
100
+ `work` 的过程与结论是**两条消息** -- COT 消息只承载过程(接口的设计前提),
101
101
  结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
102
102
 
103
103
  ⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
104
- 「Completed」(不会崩),结论卡片不受影响 —— 这也是「结论单独发」的价值。
104
+ 「Completed」(不会崩),结论卡片不受影响 -- 这也是「结论单独发」的价值。
105
105
 
106
106
  环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
107
107
  `LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
@@ -119,13 +119,13 @@ Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多
119
119
  判据:能随时删掉重建的才放这里。`ledger` 是唯一不能重建的东西
120
120
  (session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
121
121
 
122
- 多消费者**共享 store、各持游标**,不做「处理完即删」—— 谁都不能替别人删。
122
+ 多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
123
123
  盯同一 app 请用不同 `--name` 隔离游标。
124
124
 
125
125
  ## 设计取舍
126
126
 
127
- - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp rename`(同目录原子)。
128
- 不用 `--output-dir` —— 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
127
+ - **原子落盘**:collect 读 stdout NDJSON 后自己 `写 .tmp -> rename`(同目录原子)。
128
+ 不用 `--output-dir` -- 它先建 0 字节再填充,消费侧游标可能跨过半成品导致事件永久丢失
129
129
  - **一事件一文件 + 按天分目录**:不用追加式 NDJSON。因为多消费者共享 store 时
130
130
  两种方案都不能「处理完即删」,NDJSON 最大的优势(删除简单)失效,而它的 gc
131
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.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",