lark-relay 0.4.5 → 0.4.7

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/lib/help.js CHANGED
@@ -52,20 +52,26 @@ function takeHelp(apps) {
52
52
 
53
53
  const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
54
54
 
55
- lark-relay collect # 前台跑(调试)
55
+ lark-relay-collect # 前台跑(调试)
56
56
  常驻:Linux 用 systemd,macOS 用 launchd(单元模板见仓库 systemd//launchd 目录)
57
57
 
58
+ 独立可执行文件而非 \`lark-relay\` 的子命令 —— 它是常驻服务,服务管理器展示的是
59
+ 可执行文件名,与 launchd label(com.adaex.lark-relay-collect)、systemd 单元名同名才认得出。
60
+
58
61
  app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需改配置。
59
62
  每 app 一个子进程;某个挂了单独重启,不影响其他。
60
63
  tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
61
64
  用户重新 login 后下次启动自动纳入。
62
65
 
63
66
  为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
64
- 8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take/dispatch 侧
65
- 崩了、claude 跑 30 分钟、会话关几小时,都不丢消息。
67
+ 8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
68
+ 崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
66
69
 
67
- 装完先 \`lark-relay status\` 确认 -- collect 行应为 running;不在跑看
68
- 服务日志(Linux \`journalctl -u lark-relay-collect\`;macOS \`log show --predicate 'process == "lark-relay"'\`)。
70
+ 装完先 \`lark-relay status\` 确认 -- collect 行应为 running;不在跑看服务日志:
71
+ Linux journalctl -u lark-relay-collect
72
+ macOS tail -f ~/.lark-relay/logs/collect.err.log
73
+ (launchd 把 stderr 直写该文件,**不进统一日志**,log show 查不到)
74
+ 日志行带 ISO 时间戳;超 16MB 自动轮转,旧的在 .1/.2/.3
69
75
 
70
76
  落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
71
77
  回收由 collect 进程内每小时自查一次,删超期的整个日期目录(不另起 gc 单元/timer)。
@@ -73,81 +79,33 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
73
79
  参数
74
80
  --exclude <apps> 排除指定 profile(逗号分隔)
75
81
  --retain N store 保留天数(默认 3)
76
- --ledger-retain N ledger 保留天数(默认 90)`
82
+
83
+ 环境变量
84
+ LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
85
+ LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)`
77
86
 
78
87
  const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪。
79
88
 
80
89
  lark-relay status # 概览
81
90
  lark-relay status --json # 机器可读
82
91
 
83
- 排障入口:某消费者「落后」很多 -> 它的 take/dispatch 没在跑或卡住;
92
+ 排障入口:某消费者「落后」很多 -> 它的 take 没在跑或卡住;
84
93
  「无消费者」= 白采,可考虑 --exclude。
85
- collect 行不是 running -> 看服务日志(systemd journal 或 launchd 统一日志),
94
+ collect 行不是 running -> 看服务日志(Linux journalctl;
95
+ macOS \`tail -f ~/.lark-relay/logs/collect.err.log\` -- 不进统一日志),
86
96
  collect 停摆的每一秒都在丢消息。`
87
97
 
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 目录)
98
+ const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
93
99
 
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 会逼模型每轮都产出一段发回群的话
100
+ \`lark-relay-collect\` 把事件收下来落盘(常驻服务),\`lark-relay take\` 在你需要时取走一批。
101
+ 两者分离是硬需求:消费侧崩了、AI 跑 30 分钟、会话关几小时,采集都不能停 --
102
+ 停摆的每一秒都在丢消息。所以采集是独立的可执行文件,不是本命令的子命令。
119
103
 
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。
146
-
147
- 前置:collect 必须在跑
104
+ 前置:采集必须在跑
148
105
  lark-relay status # collect 行应为 running
149
106
  进程不在的时刻消息永久丢失,不是延迟送达。
150
- 不在跑 -> 起常驻服务(Linux systemd / macOS launchd,模板见仓库;或 \`lark-relay collect\` 前台调试)
107
+ 不在跑 -> 起常驻服务(Linux systemd / macOS launchd,模板见仓库;
108
+ 或 \`lark-relay-collect\` 前台调试)
151
109
 
152
110
  take 三步(照做)
153
111
  1. 查群 ID lark-cli --profile <app> im +chat-search --as user --query "<群名>"
@@ -159,11 +117,6 @@ take 三步(照做)
159
117
  一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
160
118
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
161
119
 
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
120
  常见错误
168
121
  · 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
169
122
  bot 不在群时用 bot 身份查不到,「查询失败」不能推断「不在群」
@@ -176,4 +129,4 @@ take 三步(照做)
176
129
 
177
130
  各命令详情:lark-relay <命令> --help`
178
131
 
179
- module.exports = { takeHelp, COLLECT_HELP, STATUS_HELP, DISPATCH_HELP, GUIDE }
132
+ 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 }
package/lib/paths.js CHANGED
@@ -11,7 +11,6 @@ const paths = {
11
11
  root: ROOT,
12
12
  store: path.join(ROOT, 'store'),
13
13
  cursors: path.join(ROOT, 'cursors'),
14
- ledger: path.join(ROOT, 'ledger'),
15
14
  run: path.join(ROOT, 'run'),
16
15
 
17
16
  appDay(app, day) {
@@ -23,9 +22,6 @@ const paths = {
23
22
  cursor(name, app) {
24
23
  return path.join(ROOT, 'cursors', name, app)
25
24
  },
26
- ledgerFile(task, chat) {
27
- return path.join(ROOT, 'ledger', `${task}__${chat}.ndjson`)
28
- },
29
25
  }
30
26
 
31
27
  function ensureDir(dir) {
package/lib/status.js CHANGED
@@ -7,7 +7,6 @@ const { execFileSync } = require('child_process')
7
7
  const store = require('./store')
8
8
  const { paths } = require('./paths')
9
9
  const larkcli = require('./larkcli')
10
- const { listTasks } = require('./tasks')
11
10
 
12
11
  function ago(ms) {
13
12
  if (!ms) return '—'
@@ -41,9 +40,8 @@ function unitMainPid(unit) {
41
40
 
42
41
  // 服务管理器按平台分治:Linux systemd / macOS launchd。
43
42
  // 单元名/label 是部署约定,与 systemd 单元、launchd plist 同源;
44
- // LR_COLLECT_LABEL / LR_DISPATCH_LABEL_PREFIX 是改名部署的逃生舱。
43
+ // LR_COLLECT_LABEL 是改名部署的逃生舱。
45
44
  const COLLECT_LABEL = process.env.LR_COLLECT_LABEL || 'com.adaex.lark-relay-collect'
46
- const DISPATCH_LABEL_PREFIX = process.env.LR_DISPATCH_LABEL_PREFIX || 'com.adaex.lark-relay-dispatch'
47
45
 
48
46
  function launchctlState(label) {
49
47
  try {
@@ -53,21 +51,25 @@ function launchctlState(label) {
53
51
  encoding: 'utf8',
54
52
  stdio: ['ignore', 'pipe', 'ignore'],
55
53
  })
56
- const state = (out.match(/^\s*state\s*=\s*(\S+)/m) || [])[1]
54
+ // ⚠️ 取值必须到行尾再 trim,不能用 (\S+) —— launchd 对已加载但没在跑的服务
55
+ // 输出 `state = not running`,(\S+) 只吃到 "not",status 会显示成
56
+ // 「collect ○ not」。而「collect 没在跑」正是最要命的状态,偏偏这时不可读
57
+ //
58
+ // 只把 running 归一成 active(供上层判活),其余保留 launchd 原话不映射成
59
+ // systemd 的 inactive —— 用户接着要跑 `launchctl print`,看到的就是
60
+ // `not running`,术语一致比跨平台统一更有用
61
+ const state = (out.match(/^\s*state\s*=\s*(.+)$/m) || [])[1]
57
62
  const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
58
- return { state: state === 'running' ? 'active' : state || 'inactive', pid }
63
+ const s = (state || '').trim()
64
+ return { state: s === 'running' ? 'active' : s || 'inactive', pid }
59
65
  } catch {
60
66
  return { state: 'inactive', pid: null }
61
67
  }
62
68
  }
63
69
 
64
- // kind: 'collect' | 'dispatch';dispatch 带任务名。
65
- function serviceState(kind, name) {
66
- if (process.platform === 'darwin') {
67
- const label = kind === 'collect' ? COLLECT_LABEL : `${DISPATCH_LABEL_PREFIX}.${name}`
68
- return launchctlState(label)
69
- }
70
- const unit = kind === 'collect' ? 'lark-relay-collect.service' : `lark-relay-dispatch@${name}.service`
70
+ function serviceState() {
71
+ if (process.platform === 'darwin') return launchctlState(COLLECT_LABEL)
72
+ const unit = 'lark-relay-collect.service'
71
73
  return { state: unitState(unit), pid: unitMainPid(unit) }
72
74
  }
73
75
 
@@ -113,7 +115,7 @@ function lagOf(app, cursor) {
113
115
  }
114
116
 
115
117
  async function buildStatus() {
116
- const { state, pid } = serviceState('collect')
118
+ const { state, pid } = serviceState()
117
119
 
118
120
  let profiles = []
119
121
  try {
@@ -145,25 +147,7 @@ async function buildStatus() {
145
147
  rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
146
148
  }
147
149
 
148
- const tasks = []
149
- for (const t of listTasks()) {
150
- let lastLedgerMs = null
151
- for (const chat of t.config.chats || []) {
152
- try {
153
- const st = fs.statSync(paths.ledgerFile(t.name, chat))
154
- if (!lastLedgerMs || st.mtimeMs > lastLedgerMs) lastLedgerMs = st.mtimeMs
155
- } catch {}
156
- }
157
- tasks.push({
158
- name: t.name,
159
- app: t.config.app,
160
- chats: (t.config.chats || []).length,
161
- state: serviceState('dispatch', t.name).state,
162
- lastLedgerMs,
163
- })
164
- }
165
-
166
- return { collect: { state, pid, usable, expired, hint: collectHint() }, apps: rows, tasks }
150
+ return { collect: { state, pid, usable, expired, hint: collectHint() }, apps: rows }
167
151
  }
168
152
 
169
153
  function formatStatus(s) {
@@ -201,16 +185,6 @@ function formatStatus(s) {
201
185
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}${cs}`)
202
186
  }
203
187
 
204
- if (s.tasks.length) {
205
- out.push('')
206
- out.push('dispatch 任务')
207
- for (const t of s.tasks) {
208
- const d = t.state === 'active' ? '●' : '○'
209
- out.push(
210
- `${w(t.name, 9)}${d} ${w(t.state, 9)}${w(`${t.chats} 群`, 7)}上次派活 ${ago(t.lastLedgerMs)}`,
211
- )
212
- }
213
- }
214
188
  return out.join('\n')
215
189
  }
216
190
 
package/lib/store.js CHANGED
@@ -27,7 +27,7 @@ function writeEvent(app, obj, seq) {
27
27
 
28
28
  // 排序键:stamp → pid → seq,数值比较(不靠字典序,避免位宽变化时错序)
29
29
  // ⚠️ 必须容忍非法文件名:任何手工放进 store 的 .json(notes.json / 备份 / README.json)
30
- // 都会进到这里。曾用 BigInt 裸解析 → SyntaxError 让 take/status/dispatch 全线崩溃
30
+ // 都会进到这里。曾用 BigInt 裸解析 → SyntaxError 让 take/status 全线崩溃
31
31
  // (且需目录里 ≥2 个文件才触发,因为单元素 sort 不调比较器 —— 攒够两个突然全崩)
32
32
  const NAME_RE = /^(\d+)_(\d+)_(\d+)$/
33
33
 
@@ -175,28 +175,6 @@ function gcStore(retainDays) {
175
175
  return removed
176
176
  }
177
177
 
178
- // ledger 是 ~/.lark-relay/ 里唯一不能重建的东西,按天回收(默认 90 天)
179
- function gcLedger(retainDays) {
180
- const cutoff = Date.now() - retainDays * 86400 * 1000
181
- const removed = []
182
- let names
183
- try {
184
- names = fs.readdirSync(paths.ledger)
185
- } catch {
186
- return removed
187
- }
188
- for (const name of names) {
189
- const file = path.join(paths.ledger, name)
190
- try {
191
- if (fs.statSync(file).mtimeMs < cutoff) {
192
- fs.rmSync(file, { force: true })
193
- removed.push(name)
194
- }
195
- } catch {}
196
- }
197
- return removed
198
- }
199
-
200
178
  module.exports = {
201
179
  writeEvent,
202
180
  scanWindow,
@@ -212,5 +190,4 @@ module.exports = {
212
190
  latestEntry,
213
191
  listApps,
214
192
  gcStore,
215
- gcLedger,
216
193
  }
package/lib/take.js CHANGED
@@ -77,8 +77,8 @@ async function takeBatch(opts) {
77
77
 
78
78
  for (;;) {
79
79
  // abort(SIGTERM/SIGINT)时必须和正常返回一样落盘 —— 否则批次已吐给调用方
80
- // (AI 已看到、dispatch 已跑完 claude 并回群),游标却没推、dedup 没落盘,
81
- // 重启后同一批会**重跑一遍**(实测:重复回群 + ledger 两行)
80
+ // (AI 已看到、可能已回群),游标却没推、dedup 没落盘,
81
+ // 重启后同一批会**重跑一遍**(实测:重复回群)
82
82
  if (signal && signal.aborted) {
83
83
  if (batch.length && commit) store.writeCursor(name, app, commit)
84
84
  dedup.flush()
package/lib/utf8.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ // UTF-8 兜底:systemd / launchd 环境都无 LANG -> C locale。虽然 Node 的字符串处理
4
+ // 不像 bash 那样按字节切片,但子进程(lark-cli)会继承 locale,中文仍会被切坏。
5
+ //
6
+ // 两个 bin(lark-relay / lark-relay-collect)都要,故抽成模块 —— 复制两份会漂移。
7
+ // 必须在任何 spawn 之前执行,所以两个入口都在最顶上 require 它。
8
+ function ensureUtf8(env = process.env) {
9
+ if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '')) {
10
+ env.LANG = 'C.UTF-8'
11
+ env.LC_ALL = 'C.UTF-8'
12
+ }
13
+ }
14
+
15
+ module.exports = { ensureUtf8 }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.5",
4
- "description": "Lark event relay: collect events to disk, take a batch when you need it, or dispatch work to an AI agent.",
3
+ "version": "0.4.7",
4
+ "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",
7
7
  "feishu",
@@ -17,7 +17,8 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "bin": {
20
- "lark-relay": "bin/lark-relay.js"
20
+ "lark-relay": "bin/lark-relay.js",
21
+ "lark-relay-collect": "bin/lark-relay-collect.js"
21
22
  },
22
23
  "files": [
23
24
  "bin",
package/lib/card.js DELETED
@@ -1,76 +0,0 @@
1
- 'use strict'
2
-
3
- // 结论卡片:一条非流式 interactive 消息。
4
- // work 模式用它发最终结论 —— 过程由 COT 消息承担(lib/cot.js),这里只放结论。
5
- //
6
- // 比纯文本好在:markdown 正常渲染、不受 reply 的 3500 字符分片限制。
7
- // **不需要 card 实体**:直接 msg_type=interactive 一步发出(实测),
8
- // cardkit 那套「建实体 → 拿 card_id → 流式 PUT → 关流式」只有流式打字机才需要
9
- const larkcli = require('./larkcli')
10
- const { errText, tail } = larkcli
11
-
12
- // 飞书卡片按标题字号渲染 #,窄卡片里过大突兀 → ATX 标题降级为加粗。
13
- // 必须状态机跳过 ``` 代码块:块内的 `# 注释`(shell/python)绝不能被误降级
14
- function mdfix(text) {
15
- const out = []
16
- let inCode = false
17
- for (const line of String(text).split('\n')) {
18
- if (/^[ \t]*```/.test(line)) {
19
- inCode = !inCode
20
- out.push(line)
21
- continue
22
- }
23
- if (!inCode) {
24
- const m = line.match(/^#+[ \t]+(.*)$/)
25
- if (m) {
26
- out.push(m[1] === '' ? line : `**${m[1]}**`)
27
- continue
28
- }
29
- }
30
- out.push(line)
31
- }
32
- return out.join('\n')
33
- }
34
-
35
- function buildCard(text) {
36
- return {
37
- schema: '2.0',
38
- config: {},
39
- body: { elements: [{ tag: 'markdown', content: mdfix(text) }] },
40
- }
41
- }
42
-
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',
54
- '--data', JSON.stringify({
55
- msg_type: 'interactive',
56
- content,
57
- ...(inThread ? { reply_in_thread: true } : {}),
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 }),
64
- '--as', 'bot',
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`)
72
- }
73
- return false
74
- }
75
-
76
- module.exports = { sendCard, mdfix, buildCard }