lark-relay 0.4.4 → 0.4.6

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
@@ -18,25 +18,26 @@ Lark 事件是**流式**的 -- 进程不在的时刻,消息**永久丢失**,不
18
18
  所以要有一个常驻进程只管把事件收下来落盘,消费侧崩了、AI 跑了半小时、
19
19
  会话关了几小时,都不丢消息。这就是 `collect`。
20
20
 
21
- ## 两个场景(不可混用)
21
+ ## 两段分离
22
22
 
23
- | | 在场取用 `take` | 托管派活 `dispatch` |
24
- | --- | --- | --- |
25
- | 长命的 | AI 会话(人设、上下文连续) | 消息(等在队列里) |
26
- | 短命的 | 消息(来一批处理一批) | AI(每轮新起,跑完就没) |
27
- | 谁在等谁 | AI 等消息 | 消息等 AI |
28
- | 控制权 | AI 手里(自己决定何时再取) | 服务手里(决定何时拉起 AI) |
29
- | 谁能干 | 任何 agent(Claude Code / Codex / 手敲) | 需内置唤起知识 |
30
- | 配置 | 纯参数,无文件 | `dispatch.yaml` |
23
+ 采集与消费必须分离:`collect` 只管把事件收下来落盘,`take` 在你需要时取走一批。
24
+ 消费侧崩了、AI 跑了半小时、会话关了几小时,采集都不能停 --
25
+ 停摆的每一秒都在丢消息。
31
26
 
32
- 你(AI)在会话里盯群 -> `take`。无人在场也要干活 -> `dispatch`。
27
+ | | |
28
+ | --- | --- |
29
+ | 长命的 | AI 会话(人设、上下文连续) |
30
+ | 短命的 | 消息(来一批处理一批) |
31
+ | 谁在等谁 | AI 等消息 |
32
+ | 控制权 | AI 手里(自己决定何时再取) |
33
+ | 谁能干 | 任何 agent(Claude Code / Codex / 手敲) |
34
+ | 配置 | 纯参数,无文件 |
33
35
 
34
36
  ## 命令
35
37
 
36
38
  ```bash
37
39
  lark-relay collect # 底座:全部 profile 各起 consume -> 原子落盘(常驻服务)
38
- lark-relay take … # 场景1:阻塞等一批 -> 输出 -> 退出
39
- lark-relay dispatch <task> # 场景2:常驻循环 = take + 唤起 claude
40
+ lark-relay take … # 阻塞等一批 -> 输出 -> 退出
40
41
  lark-relay status # 谁在跑 / 各 app 积压 / 各游标位置
41
42
  lark-relay guide # 一页用法
42
43
  ```
@@ -65,62 +66,15 @@ lark-relay take --app <app> --chats oc_xxx --render text
65
66
 
66
67
  业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
67
68
 
68
- ### dispatch
69
-
70
- 任务发现:扫 `$LR_ROOT/*/dispatch.yaml` -- 有文件即是任务,无需注册表。
71
- `dispatch` 内部就是调 `take`,保证两场景不实现分叉。
72
-
73
- ```yaml
74
- app: <profile 名>
75
- chats: [oc_xxx]
76
- dirs: [/path/to/repo] # 首个 = 主工作目录
77
- instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
78
- filter: '.mentions[]?.id == "ou_xxx"' # 可选
79
- # mode: work 默认;work | chat | agent(见下)
80
- # model: <名称> 默认走终端同一套默认路由
81
- ```
82
-
83
- `mode` 三个场景 -- 一个键定死全部行为,不用拼组合:
84
-
85
- | | `work`(默认) | `chat` | `agent` |
86
- |---|---|---|---|
87
- | 用途 | 处理工作 | 简单问答 | 群运营 |
88
- | 回复位置 | 话题内(首问开话题) | 直接发群里 | **模型自己决定** |
89
- | 过程展示 | COT 消息(实时流式) | 无 | 无 |
90
- | 结论投递 | 引擎发卡片 | 引擎发纯文本 | **引擎不发** |
91
- | session 隔离 | 按 `thread_id` | 按 (群, epoch) | 按 (群, epoch) |
92
- | 防抖 | 1s | 15s | 15s |
93
-
94
- `agent` 是给「**多数轮该沉默**」的场景准备的:群运营里绝大多数消息不需要回应,
95
- 而 `work`/`chat` 的提示词都承诺「你的最终回复会被发回群」,等于逼模型每轮说话。
96
- `agent` 模式下引擎只负责拉起模型、传消息批次、记台账 -- 发不发、回哪条、
97
- 用哪个 bot 身份、发文字还是表情回应,全由模型按 `instructions` 决定(它有
98
- Bash + lark-cli)。沉默的轮也会进台账,便于事后看它到底判了多少次。
99
-
100
- 这与「引擎不做权限管控」是同一条思路:**引擎不做回复决策,边界靠 instructions
101
- 到达模型侧**。
102
-
103
- `work` 的过程与结论是**两条消息** -- COT 消息只承载过程(接口的设计前提),
104
- 结论另发一条卡片。卡片发送失败会自动降级纯文本,保证结论必达。
105
-
106
- ⚠️ **COT 要求客户端 PC ≥ 7.70 / 移动 ≥ 7.74**:老客户端上那条过程消息显示为
107
- 「Completed」(不会崩),结论卡片不受影响 -- 这也是「结论单独发」的价值。
108
-
109
- 环境变量:`LR_COT_BATCH_MS`(COT 攒批窗口,默认 1000)、
110
- `LR_COT_SAY_AS=text|reasoning`(中间文本走正式文本流还是思考流,默认 `text`;
111
- 实测两者渲染一致,`text` 每段少 2 个事件)。
112
-
113
69
  ## 存储布局
114
70
 
115
71
  ```
116
72
  ~/.lark-relay/
117
73
  store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json 原始事件(可随时删,丢了自愈)
118
74
  cursors/<name>/<app> 各消费者游标
119
- ledger/<task>__<chat>.ndjson dispatch 结论台账
120
75
  ```
121
76
 
122
- 判据:能随时删掉重建的才放这里。`ledger` 是唯一不能重建的东西
123
- (session 会因空闲滚动/过期丢上下文,结论不能丢),走文件系统级备份。
77
+ 判据:能随时删掉重建的才放这里 -- 上面两样都能。
124
78
 
125
79
  多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
126
80
  盯同一 app 请用不同 `--name` 隔离游标。
package/bin/lark-relay.js CHANGED
@@ -17,7 +17,7 @@ const VERSION = require('../package.json').version
17
17
  // 不在白名单的 --xxx 一律硬报错 —— 拼错参数静默吞掉是实测事故:
18
18
  // --chat-id 拼错 -> 打印用法退出,看着像「正常退出但没消息」,漏看 bot 第一条 Working
19
19
  const TAKE_KEYS = ['app', 'chats', 'name', 'filter', 'debounce', 'max-wait', 'timeout', 'render', 'since']
20
- const COLLECT_KEYS = ['exclude', 'retain', 'ledger-retain']
20
+ const COLLECT_KEYS = ['exclude', 'retain']
21
21
 
22
22
  // 未知参数 -> stderr 短错误 + 候选提示 + 改写后的命令,exit 2。
23
23
  // 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
@@ -78,9 +78,8 @@ function rewriteArgv(argv, unknown, allowed) {
78
78
 
79
79
  const USAGE = `lark-relay ${VERSION} —— Lark 事件中继站
80
80
 
81
- lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(systemd 常驻)
82
- lark-relay take … 场景1 在场取用:阻塞等一批 → 输出 → 退出
83
- lark-relay dispatch <task> 场景2 托管派活:常驻循环 = take + 唤起 claude
81
+ lark-relay collect 底座:全部 profile 各起 consume → 原子落盘(常驻服务)
82
+ lark-relay take … 在场取用:阻塞等一批 → 输出 → 退出
84
83
  lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
85
84
  lark-relay guide 一页用法(装完先读这个)
86
85
 
@@ -111,10 +110,8 @@ async function main() {
111
110
  return await cmdTake(rest)
112
111
  case 'status':
113
112
  return await cmdStatus(rest)
114
- case 'dispatch':
115
- return await cmdDispatch(rest)
116
113
  default: {
117
- const s = suggest(cmd, ['collect', 'take', 'dispatch', 'status', 'guide'])
114
+ const s = suggest(cmd, ['collect', 'take', 'status', 'guide'])
118
115
  process.stderr.write(`未知命令:${cmd}${s ? `。你是不是想写 ${s}?` : ''}\n\n${USAGE}\n`)
119
116
  return 2
120
117
  }
@@ -132,7 +129,6 @@ async function cmdCollect(argv) {
132
129
  return await runCollect({
133
130
  exclude: list(a.exclude),
134
131
  retain: num(a.retain, 3),
135
- ledgerRetain: num(a['ledger-retain'], 90),
136
132
  })
137
133
  }
138
134
 
@@ -247,28 +243,6 @@ async function cmdStatus(argv) {
247
243
  return 0
248
244
  }
249
245
 
250
- async function cmdDispatch(argv) {
251
- const a = parseArgs(argv, { flags: ['list', 'once', 'help'], keys: [] })
252
- if (a._unknown.length) return rejectUnknown('dispatch', argv, a._unknown, ['list', 'once'])
253
- if (a.help) {
254
- process.stdout.write(`${help.DISPATCH_HELP}\n`)
255
- return 0
256
- }
257
- const dispatch = require('../lib/dispatch')
258
- if (a.list) return dispatch.cmdList()
259
- const task = a._[0]
260
- if (!task) {
261
- // 空参 = 求教,和 take 一致(exit 0);带了 flag 却没任务名是写错,短错误
262
- if (argv.length === 0) {
263
- process.stdout.write(`${help.DISPATCH_HELP}\n`)
264
- return 0
265
- }
266
- process.stderr.write('需要任务名($LR_ROOT/<任务名>/dispatch.yaml)。看现有任务:`lark-relay dispatch --list`\n')
267
- return 2
268
- }
269
- return await dispatch.run(task, { once: !!a.once })
270
- }
271
-
272
246
  main()
273
247
  .then((code) => process.exit(code || 0))
274
248
  .catch((err) => {
package/lib/collect.js CHANGED
@@ -5,11 +5,12 @@
5
5
  //
6
6
  // 为什么必须独立常驻:lark 事件是流式的,进程不在的时刻消息永久丢失
7
7
  // (实测:消息发出 8s 后才起 consumer,收到 0 条)。collect 独立常驻,
8
- // 才能让 take/dispatch 侧崩了、claude 跑 30 分钟、会话关几小时,都不丢消息。
8
+ // 才能让 take 侧崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
9
9
  const readline = require('readline')
10
10
  const larkcli = require('./larkcli')
11
11
  const store = require('./store')
12
12
  const { paths, ensureDir } = require('./paths')
13
+ const { init: logInit, logLine } = require('./log')
13
14
 
14
15
  const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
15
16
  const GC_INTERVAL_MS = 3600_000
@@ -45,14 +46,14 @@ class AppCollector {
45
46
  try {
46
47
  obj = JSON.parse(s)
47
48
  } catch {
48
- process.stderr.write(`[${this.app}] warn: 非 JSON 行,已跳过\n`)
49
+ logLine(`[${this.app}] warn: 非 JSON 行,已跳过`)
49
50
  return
50
51
  }
51
52
  try {
52
53
  store.writeEvent(this.app, obj, this.seq++)
53
54
  this.received++
54
55
  } catch (err) {
55
- process.stderr.write(`[${this.app}] error: 落盘失败 ${err.message}\n`)
56
+ logLine(`[${this.app}] error: 落盘失败 ${err.message}`)
56
57
  }
57
58
  })
58
59
 
@@ -63,10 +64,17 @@ class AppCollector {
63
64
  this.backoffMs = 1000
64
65
  }, 30_000)
65
66
 
66
- // 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断进 journald
67
- child.stderr.on('data', (buf) => {
68
- const text = buf.toString().trimEnd()
69
- if (text) process.stderr.write(`[${this.app}] ${text}\n`)
67
+ // 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断要留下。
68
+ //
69
+ // ⚠️ 必须走 readline 按行读,不能裸接 'data' 事件(改回去会同时带回三个 bug):
70
+ // ① 一个 chunk 常含多行 -> 只有块首能拿到 [app] 前缀与时间戳,其余行无法归属
71
+ // ② chunk 边界任意切,一条诊断可能被拆成两次 write -> 变成两条独立记录
72
+ // ③ UTF-8 多字节被 chunk 边界切断 -> 中文诊断出现替换字符
73
+ // readline 内部用 StringDecoder 处理跨块残字节,故 stdout 路径从来没这些问题。
74
+ const erl = readline.createInterface({ input: child.stderr, crlfDelay: Infinity })
75
+ erl.on('line', (line) => {
76
+ const text = line.trimEnd()
77
+ if (text) logLine(`[${this.app}] ${text}`)
70
78
  })
71
79
 
72
80
  // spawn 失败(lark-cli 被卸载 / 升级中途 / PATH 变化)只发 error+close,**没有 exit**。
@@ -78,9 +86,9 @@ class AppCollector {
78
86
  clearTimeout(this.healthyTimer)
79
87
  this.child = null
80
88
  this.restarts++
81
- process.stderr.write(
89
+ logLine(
82
90
  `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
83
- `—— 这段时间该 app 的消息会永久丢失\n`,
91
+ `—— 这段时间该 app 的消息会永久丢失`,
84
92
  )
85
93
  // 每 app 一个子进程;某个挂了单独重启,不影响其他
86
94
  setTimeout(() => this.start(), this.backoffMs)
@@ -92,7 +100,7 @@ class AppCollector {
92
100
  })
93
101
 
94
102
  child.on('error', (err) => {
95
- process.stderr.write(`[${this.app}] spawn 失败:${err.message}\n`)
103
+ logLine(`[${this.app}] spawn 失败:${err.message}`)
96
104
  scheduleRestart(`spawn error ${err.code || err.message}`)
97
105
  })
98
106
  }
@@ -108,22 +116,25 @@ class AppCollector {
108
116
  async function runCollect(opts) {
109
117
  ensureDir(paths.store)
110
118
  ensureDir(paths.cursors)
111
- ensureDir(paths.ledger)
119
+ // 日志接管要最早做 —— 下面的启动横幅、profile 报错都该带上时间戳。
120
+ // 非 launchd 场景(journald / 前台)是 no-op,logLine 退化成裸 stderr 直写
121
+ logInit()
112
122
 
113
123
  const exclude = new Set(opts.exclude || [])
114
124
  let profiles
115
125
  try {
116
126
  profiles = await larkcli.listProfiles()
117
127
  } catch (err) {
118
- const e = new Error(
119
- `读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
128
+ // 不抛给 bin 的顶层 catch —— 那行是所有命令共用的裸 stderr,
129
+ // 会让「collect 崩了」这条最关键的记录反而没有时间戳
130
+ logLine(
131
+ `error: 读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
120
132
  `collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
121
133
  ` which ${larkcli.CLI} # 装了吗、在 PATH 里吗\n` +
122
134
  ` ${larkcli.CLI} profile list # 能跑吗\n` +
123
135
  `常驻服务(systemd 单元/launchd plist)的 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
124
136
  )
125
- e.userFacing = true
126
- throw e
137
+ return 1
127
138
  }
128
139
 
129
140
  const chosen = []
@@ -143,17 +154,17 @@ async function runCollect(opts) {
143
154
  }
144
155
 
145
156
  if (skipped.length) {
146
- process.stderr.write(`warn: 跳过 ${skipped.join(' ')}\n`)
157
+ logLine(`warn: 跳过 ${skipped.join(' ')}`)
147
158
  }
148
159
  if (!chosen.length) {
149
- process.stderr.write('error: 没有可用 profile。先 `lark-cli auth login`\n')
160
+ logLine('error: 没有可用 profile。先 `lark-cli auth login`')
150
161
  return 1
151
162
  }
152
163
 
153
- process.stderr.write(
164
+ logLine(
154
165
  `lark-relay collect: ${chosen.length}/${profiles.length} profiles → ${paths.store}\n` +
155
166
  ` ${chosen.map((p) => p.name).join(' ')}\n` +
156
- ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天 ledger 保留 ${opts.ledgerRetain}天\n`,
167
+ ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
157
168
  )
158
169
 
159
170
  const collectors = chosen.map((p) => new AppCollector(p.name, opts))
@@ -164,18 +175,16 @@ async function runCollect(opts) {
164
175
  const gcTimer = setInterval(() => {
165
176
  try {
166
177
  const s = store.gcStore(opts.retain)
167
- const l = store.gcLedger(opts.ledgerRetain)
168
- if (s.length || l.length) {
169
- process.stderr.write(`gc: 删 store ${s.length} 个日期目录, ledger ${l.length} 个文件\n`)
178
+ if (s.length) {
179
+ logLine(`gc: 删 store ${s.length} 个日期目录`)
170
180
  }
171
181
  } catch (err) {
172
- process.stderr.write(`gc: 失败 ${err.message}\n`)
182
+ logLine(`gc: 失败 ${err.message}`)
173
183
  }
174
184
  }, GC_INTERVAL_MS)
175
185
  // 启动时先跑一次,别等一小时
176
186
  try {
177
187
  store.gcStore(opts.retain)
178
- store.gcLedger(opts.ledgerRetain)
179
188
  } catch {}
180
189
 
181
190
  return await new Promise((resolve) => {
@@ -183,7 +192,7 @@ async function runCollect(opts) {
183
192
  const shutdown = (sig) => {
184
193
  if (shuttingDown) return
185
194
  shuttingDown = true
186
- process.stderr.write(`收到 ${sig},停止 ${collectors.length} 个 consumer\n`)
195
+ logLine(`收到 ${sig},停止 ${collectors.length} 个 consumer`)
187
196
  clearInterval(gcTimer)
188
197
  for (const c of collectors) c.stop()
189
198
  // 给子进程时间卸载服务端订阅
package/lib/filter.js CHANGED
@@ -37,7 +37,7 @@ function applyFilter(events, expr) {
37
37
  maxBuffer: 64 * 1024 * 1024,
38
38
  })
39
39
  } catch (err) {
40
- // 保守放行 + warn,不抛 —— 抛出去会让 dispatch 崩溃循环:
40
+ // 保守放行 + warn,不抛 —— 抛出去会让常驻消费者崩溃循环:
41
41
  // 表达式对某类事件报错(如 `.mentions[].id` 遇到没有 mentions 的消息)时,
42
42
  // 那条消息永远卡在队首,游标推不动,systemd 每 5s 重启一次,该任务彻底停摆。
43
43
  // 放行的代价是模型多看几条本该滤掉的消息,远小于停摆
package/lib/help.js CHANGED
@@ -61,11 +61,14 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
61
61
  用户重新 login 后下次启动自动纳入。
62
62
 
63
63
  为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
64
- 8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take/dispatch
65
- 崩了、claude 跑 30 分钟、会话关几小时,都不丢消息。
64
+ 8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
65
+ 崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
66
66
 
67
- 装完先 \`lark-relay status\` 确认 -- collect 行应为 running;不在跑看
68
- 服务日志(Linux \`journalctl -u lark-relay-collect\`;macOS \`log show --predicate 'process == "lark-relay"'\`)。
67
+ 装完先 \`lark-relay status\` 确认 -- collect 行应为 running;不在跑看服务日志:
68
+ Linux journalctl -u lark-relay-collect
69
+ macOS tail -f ~/.lark-relay/logs/collect.err.log
70
+ (launchd 把 stderr 直写该文件,**不进统一日志**,log show 查不到)
71
+ 日志行带 ISO 时间戳;超 16MB 自动轮转,旧的在 .1/.2/.3
69
72
 
70
73
  落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
71
74
  回收由 collect 进程内每小时自查一次,删超期的整个日期目录(不另起 gc 单元/timer)。
@@ -73,76 +76,26 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
73
76
  参数
74
77
  --exclude <apps> 排除指定 profile(逗号分隔)
75
78
  --retain N store 保留天数(默认 3)
76
- --ledger-retain N ledger 保留天数(默认 90)`
79
+
80
+ 环境变量
81
+ LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
82
+ LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)`
77
83
 
78
84
  const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪。
79
85
 
80
86
  lark-relay status # 概览
81
87
  lark-relay status --json # 机器可读
82
88
 
83
- 排障入口:某消费者「落后」很多 -> 它的 take/dispatch 没在跑或卡住;
89
+ 排障入口:某消费者「落后」很多 -> 它的 take 没在跑或卡住;
84
90
  「无消费者」= 白采,可考虑 --exclude。
85
- collect 行不是 running -> 看服务日志(systemd journal 或 launchd 统一日志),
91
+ collect 行不是 running -> 看服务日志(Linux journalctl;
92
+ macOS \`tail -f ~/.lark-relay/logs/collect.err.log\` -- 不进统一日志),
86
93
  collect 停摆的每一秒都在丢消息。`
87
94
 
88
- const DISPATCH_HELP = `常驻:等消息 -> 唤起 claude 干活 -> 结果回群。无人在场也跑。
89
-
90
- lark-relay dispatch --list # 列出所有 dispatch 任务及状态
91
- lark-relay dispatch <task> --once # 前台跑一轮(调试,不进常驻服务)
92
- 常驻:Linux 用 systemd,macOS 用 launchd(单元模板见仓库 systemd//launchd 目录)
93
-
94
- --list 输出:任务名 / app / 群数 / 常驻服务是否 active / 上次 ledger 时间
95
-
96
- 任务发现:扫 $LR_ROOT/*/dispatch.yaml -- 有文件即是任务,无需注册表。
97
- LR_ROOT 在单元里指定(唯一的目录绑定,与代码无关)。
98
-
99
- dispatch.yaml(4 必填 + 3 可选)
100
- app: <profile 名>
101
- chats: [oc_xxx]
102
- dirs: [/path/to/repo] # 首个 = 主工作目录
103
- instructions: ./instructions.md # 职责/边界,--append-system-prompt-file 注入
104
- filter: '.mentions[]?.id == "ou_xxx"' # 可选
105
- # mode: work 默认;work | chat | agent(见下)
106
- # model: <名称> 默认走终端同一套默认路由
107
-
108
- mode 三个场景(一个键定死全部行为,不用拼组合)
109
- work 处理工作:话题内回复 + 过程挂 COT 消息 + 结论发卡片
110
- + 按 thread_id 隔离 session + 防抖 1s(一问一答要跟手)
111
- COT 要求客户端 PC >= 7.70 / 移动 >= 7.74;老客户端那条过程消息显示为
112
- 「Completed」(不崩),结论卡片不受影响
113
- chat 简单问答:直发群里 + 纯文本 + 按(群, epoch)隔离 session
114
- + 防抖 15s(等人打完多行)
115
- agent 模型自己当运营者:引擎只拉起它 + 传消息 + 记台账,**一条消息都不发**。
116
- 发不发 / 回哪条 / 用哪个 bot 身份 / 文字还是表情,全由模型按
117
- instructions 决定(它有 Bash + lark-cli)。适合「多数轮该沉默」的
118
- 群运营 -- work/chat 会逼模型每轮都产出一段发回群的话
95
+ const GUIDE = `lark-relay -- Lark 事件中继站。两个命令:收下来 / 我来取。
119
96
 
120
- 最佳实践
121
- · 边界写 instructions,别指望 --add-dir 目录的 CLAUDE.md(启动不加载)
122
- · 改完 yaml 先 --once 跑一轮验证,再 enable
123
- · 高风险发布仍建议人工执行
124
-
125
- 退出码(--once)
126
- 0 跑了一轮,或没等到消息但正常结束
127
- 1 取批异常(看 stderr)
128
- 2 任务不存在或配置错
129
- 4 等消息超时(5min 没消息)`
130
-
131
- const GUIDE = `lark-relay -- Lark 事件中继站。三个命令:收下来 / 我来取 / 派给别人。
132
-
133
- 先判场景(两者不可混用)
134
- ┌──────────┬────────────────────────┬────────────────────────┐
135
- │ │ 在场取用 take │ 托管派活 dispatch │
136
- ├──────────┼────────────────────────┼────────────────────────┤
137
- │ 长命的 │ AI 会话(人设、上下文) │ 消息(等在队列里) │
138
- │ 短命的 │ 消息(来一批处理一批) │ AI(每轮新起,跑完就没)│
139
- │ 谁等谁 │ AI 等消息 │ 消息等 AI │
140
- │ 控制权 │ AI 手里 │ 服务手里 │
141
- │ 谁能干 │ 任何 agent │ 需内置唤起知识 │
142
- │ 配置 │ 纯参数,无文件 │ dispatch.yaml │
143
- └──────────┴────────────────────────┴────────────────────────┘
144
-
145
- 你(AI)在会话里盯群 -> take。无人在场也要干活 -> dispatch。
97
+ collect 把事件收下来落盘,take 在你需要时取走一批。两者分离是硬需求:
98
+ 消费侧崩了、AI 30 分钟、会话关几小时,采集都不能停 -- 停摆的每一秒都在丢消息。
146
99
 
147
100
  前置:collect 必须在跑
148
101
  lark-relay status # collect 行应为 running
@@ -159,11 +112,6 @@ take 三步(照做)
159
112
  一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
160
113
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
161
114
 
162
- 建 dispatch 任务
163
- 1. 在任务目录写 dispatch.yaml(\`lark-relay dispatch --help\` 有模板)
164
- 2. lark-relay dispatch <task> --once # 前台验一轮
165
- 3. 起常驻服务(Linux systemd / macOS launchd,模板见仓库)
166
-
167
115
  常见错误
168
116
  · 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
169
117
  bot 不在群时用 bot 身份查不到,「查询失败」不能推断「不在群」
@@ -176,4 +124,4 @@ take 三步(照做)
176
124
 
177
125
  各命令详情:lark-relay <命令> --help`
178
126
 
179
- module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP, DISPATCH_HELP, GUIDE }
127
+ module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP, GUIDE }
package/lib/lock.js CHANGED
@@ -1,10 +1,7 @@
1
1
  'use strict'
2
2
 
3
- // 两种锁,语义不同,别混:
4
- // - 消费者锁(非阻塞):同一 --name 不该有两个 take/dispatch 在跑,抢不到直接退出并说清楚。
5
- // 否则两个进程互相推游标,批次会被撕成两半。
6
- // - (任务,群)锁(阻塞 -w 1800):dispatch 每群串行。并发会写坏 append-only ledger,
7
- // 且同群两轮 claude 会互相打断。超时视为「别人正在干」,静默跳过。
3
+ // 消费者锁(非阻塞):同一 --name 不该有两个 take 在跑,抢不到直接退出并说清楚。
4
+ // 否则两个进程互相推游标,批次会被撕成两半。
8
5
  const fs = require('fs')
9
6
  const path = require('path')
10
7
  const { paths, ensureDir } = require('./paths')
@@ -72,19 +69,6 @@ function acquireExclusive(key) {
72
69
  return { ok: false, file: dir, holder: readPid(pidFile) }
73
70
  }
74
71
 
75
- // 阻塞版:等到拿到锁或超时。dispatch 每(任务,群)一把。
76
- async function acquireBlocking(key, timeoutMs = 1800_000, pollMs = 500) {
77
- const deadline = Date.now() + timeoutMs
78
- for (;;) {
79
- const lock = acquireExclusive(key)
80
- if (lock.ok) return lock
81
- if (Date.now() >= deadline) {
82
- return { ok: false, file: lock.file, holder: lock.holder, timedOut: true }
83
- }
84
- await new Promise((r) => setTimeout(r, pollMs))
85
- }
86
- }
87
-
88
72
  function mkHandle(dir, pidFile, me) {
89
73
  let released = false
90
74
  const release = () => {
@@ -130,4 +114,4 @@ function sanitize(s) {
130
114
  return String(s).replace(/[^A-Za-z0-9._-]/g, '_')
131
115
  }
132
116
 
133
- module.exports = { acquireExclusive, acquireBlocking }
117
+ module.exports = { acquireExclusive }
package/lib/log.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict'
2
+
3
+ // 常驻日志:时间戳前缀 + 按大小轮转。**只给 collect 这类常驻服务用**,
4
+ // CLI 的用户可见输出(take 的 NDJSON 载荷、status --json)绝不能过这里 --
5
+ // 加前缀会破坏下游解析契约。
6
+ //
7
+ // 为什么需要:Linux 有 journald 免费给时间戳和轮转,macOS 的 launchd 只有
8
+ // StandardErrorPath 直写文件 -- 无时间戳、无轮转、无分级。而本项目的核心排查
9
+ // 问题恰是「**什么时候**断线、断了多久、那个窗口丢了几条」,时间戳是唯一依据。
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { paths } = require('./paths')
13
+
14
+ // 激活判据:stderr 是普通文件 = launchd 直写场景。
15
+ // 实测 fs.fstatSync(2):pipe -> isFile=false/isFIFO=true;重定向到文件 -> isFile=true。
16
+ // 这一个判据同时分开了三种场景:
17
+ // launchd 直写文件 -> 要时间戳、要轮转(本模块接管)
18
+ // systemd journald -> 不要(journald 自带时间戳,加了会重复;socket 不是 file)
19
+ // 前台调试 tty/pipe -> 不要(人眼看,时间戳是噪音)
20
+ // 所以不需要按平台分支,也不需要环境变量开关 -- 判据天然对齐。
21
+ function stderrIsFile() {
22
+ try {
23
+ return fs.fstatSync(2).isFile()
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ // 轮转阈值。默认 16MB。
30
+ // ⚠️ 下界只防 0/负数/NaN,不能写 Math.max(1, MB) —— 那会把 0.001 这种
31
+ // 小阈值抬成 1MB,测试就没法用小文件验轮转(踩过:930 字节都没触发)
32
+ function envBytes() {
33
+ const mb = Number(process.env.LARK_RELAY_LOG_MAX_MB)
34
+ const v = Number.isFinite(mb) && mb > 0 ? mb : 16
35
+ return Math.max(1024, Math.round(v * 1024 * 1024))
36
+ }
37
+ const MAX_BYTES = envBytes()
38
+ // 保留代数。⚠️ 必须 >1:实测只留 1 代时,高频写入会让第二次轮转覆盖第一次的 .1,
39
+ // 中间整段永久丢失 -- 那正是加时间戳要回溯的东西,不能自拆台脚。
40
+ const KEEP = (() => {
41
+ const n = Number(process.env.LARK_RELAY_LOG_KEEP)
42
+ return Number.isInteger(n) && n >= 1 ? n : 3
43
+ })()
44
+
45
+ let fd = null
46
+ let target = null
47
+ let size = 0
48
+ let active = false
49
+
50
+ // 拿 stderr 指向的真实路径。
51
+ // ⚠️ 不能用 realpathSync('/dev/fd/2') —— macOS 实测**不解引用**,原样返回
52
+ // '/dev/fd/2'(Linux 才解得开)。lsof 能拿到,但为写日志 spawn 外部进程太重。
53
+ //
54
+ // 正解:日志路径是我们自己在 plist 里定的约定路径,直接用它,再用 dev/ino
55
+ // 核对「它确实就是 fd 2 指向的那个文件」-- 校验通过才接管,不匹配就退回直写。
56
+ // 这样既不 spawn,也不会在别人自定义了 StandardErrorPath 时写错对象。
57
+ //
58
+ // LARK_RELAY_LOG_FILE 是显式指定的逃生舱(也是测试入口):给了就直接用,
59
+ // 跳过 dev/ino 校验 -- 调用方明确知道自己要写哪儿。
60
+ function resolveTarget() {
61
+ if (process.env.LARK_RELAY_LOG_FILE) return process.env.LARK_RELAY_LOG_FILE
62
+ const guess = path.join(paths.root, 'logs', 'collect.err.log')
63
+ try {
64
+ const a = fs.fstatSync(2)
65
+ const b = fs.statSync(guess)
66
+ if (a.dev === b.dev && a.ino === b.ino) return guess
67
+ } catch {}
68
+ return null
69
+ }
70
+
71
+ function open() {
72
+ fd = fs.openSync(target, 'a')
73
+ try {
74
+ size = fs.fstatSync(fd).size
75
+ } catch {
76
+ size = 0
77
+ }
78
+ }
79
+
80
+ // 轮转:KEEP=3 时 .2 -> .3、.1 -> .2、当前 -> .1,然后重开。
81
+ // 必须由本进程 reopen -- launchd 持有 fd 2,外部 rename(newsyslog)后
82
+ // 进程仍写旧 inode,日志看着「停了」。newsyslog 要配 SIGHUP 且是系统级配置,
83
+ // 与「服务不绑代码目录」不自洽,故不用。
84
+ function rotate() {
85
+ try {
86
+ if (fd !== null) fs.closeSync(fd)
87
+ } catch {}
88
+ // 先把带编号的整体后移(从大到小,避免覆盖),最旧的一代自然被 rename 挤掉。
89
+ // ⚠️ 编号档位是 .1 ~ .KEEP,循环必须到 1 为止(曾写成把当前文件直接
90
+ // rename 成 .2,导致 .1 永不存在 —— 实测文件跳号)
91
+ for (let i = KEEP - 1; i >= 1; i--) {
92
+ try {
93
+ if (fs.existsSync(`${target}.${i}`)) fs.renameSync(`${target}.${i}`, `${target}.${i + 1}`)
94
+ } catch {}
95
+ }
96
+ // 当前文件永远进 .1
97
+ try {
98
+ if (fs.existsSync(target)) fs.renameSync(target, `${target}.1`)
99
+ } catch {}
100
+ open()
101
+ }
102
+
103
+ function stamp() {
104
+ return new Date().toISOString()
105
+ }
106
+
107
+ function writeRaw(s) {
108
+ if (fd === null) return process.stderr.write(s)
109
+ const buf = Buffer.from(s)
110
+ if (size + buf.length > MAX_BYTES) rotate()
111
+ try {
112
+ fs.writeSync(fd, buf)
113
+ size += buf.length
114
+ } catch {
115
+ // 写失败不能让常驻进程崩 -- 日志是辅助,采集是本职
116
+ try {
117
+ process.stderr.write(s)
118
+ } catch {}
119
+ }
120
+ }
121
+
122
+ // 逐行加前缀。⚠️ 必须按 \n 拆分,不能只在整块前面拼一个时间戳:
123
+ // 调用方存在单次 write 输出多行的情况(启动横幅 3 行、子进程转发的多行诊断),
124
+ // 只给首行加前缀会让后续行无法归属到时间点,轮转/grep 时尤其致命。
125
+ function logLine(text) {
126
+ const s = String(text)
127
+ if (!active) return void process.stderr.write(s.endsWith('\n') ? s : `${s}\n`)
128
+ const body = s.endsWith('\n') ? s.slice(0, -1) : s
129
+ const ts = stamp()
130
+ let out = ''
131
+ for (const line of body.split('\n')) out += `${ts} ${line}\n`
132
+ writeRaw(out)
133
+ }
134
+
135
+ // 常驻入口调一次。非 launchd 场景是 no-op,logLine 退化成裸 stderr 直写。
136
+ function init() {
137
+ if (active) return active
138
+ // 显式指定优先于场景探测 -- 调用方明确要写文件时不该被 isFile 判据挡住
139
+ if (!process.env.LARK_RELAY_LOG_FILE && !stderrIsFile()) return false
140
+ target = resolveTarget()
141
+ if (!target) return false
142
+ try {
143
+ fs.mkdirSync(path.dirname(target), { recursive: true })
144
+ open()
145
+ active = true
146
+ } catch {
147
+ fd = null
148
+ active = false
149
+ }
150
+ return active
151
+ }
152
+
153
+ // 测试用:重置模块态。生产代码不该调
154
+ function _reset() {
155
+ try {
156
+ if (fd !== null) fs.closeSync(fd)
157
+ } catch {}
158
+ fd = null
159
+ target = null
160
+ size = 0
161
+ active = false
162
+ }
163
+
164
+ module.exports = { init, logLine, stderrIsFile, _reset }