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/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,28 +40,36 @@ 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 {
50
- const out = execFileSync('launchctl', ['print', `system/${label}`], { encoding: 'utf8' })
51
- const state = (out.match(/^\s*state\s*=\s*(\S+)/m) || [])[1]
48
+ // stderr 必须 ignore:launchctl 对不存在的服务把 "Bad request" 写到 stderr,
49
+ // 实测 execFileSync 失败时这串会泄漏到终端,污染 status 输出
50
+ const out = execFileSync('launchctl', ['print', `system/${label}`], {
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ })
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]
52
62
  const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
53
- return { state: state === 'running' ? 'active' : state || 'inactive', pid }
63
+ const s = (state || '').trim()
64
+ return { state: s === 'running' ? 'active' : s || 'inactive', pid }
54
65
  } catch {
55
66
  return { state: 'inactive', pid: null }
56
67
  }
57
68
  }
58
69
 
59
- // kind: 'collect' | 'dispatch';dispatch 带任务名。
60
- function serviceState(kind, name) {
61
- if (process.platform === 'darwin') {
62
- const label = kind === 'collect' ? COLLECT_LABEL : `${DISPATCH_LABEL_PREFIX}.${name}`
63
- return launchctlState(label)
64
- }
65
- 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'
66
73
  return { state: unitState(unit), pid: unitMainPid(unit) }
67
74
  }
68
75
 
@@ -108,7 +115,7 @@ function lagOf(app, cursor) {
108
115
  }
109
116
 
110
117
  async function buildStatus() {
111
- const { state, pid } = serviceState('collect')
118
+ const { state, pid } = serviceState()
112
119
 
113
120
  let profiles = []
114
121
  try {
@@ -140,25 +147,7 @@ async function buildStatus() {
140
147
  rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
141
148
  }
142
149
 
143
- const tasks = []
144
- for (const t of listTasks()) {
145
- let lastLedgerMs = null
146
- for (const chat of t.config.chats || []) {
147
- try {
148
- const st = fs.statSync(paths.ledgerFile(t.name, chat))
149
- if (!lastLedgerMs || st.mtimeMs > lastLedgerMs) lastLedgerMs = st.mtimeMs
150
- } catch {}
151
- }
152
- tasks.push({
153
- name: t.name,
154
- app: t.config.app,
155
- chats: (t.config.chats || []).length,
156
- state: serviceState('dispatch', t.name).state,
157
- lastLedgerMs,
158
- })
159
- }
160
-
161
- return { collect: { state, pid, usable, expired, hint: collectHint() }, apps: rows, tasks }
150
+ return { collect: { state, pid, usable, expired, hint: collectHint() }, apps: rows }
162
151
  }
163
152
 
164
153
  function formatStatus(s) {
@@ -196,16 +185,6 @@ function formatStatus(s) {
196
185
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}${cs}`)
197
186
  }
198
187
 
199
- if (s.tasks.length) {
200
- out.push('')
201
- out.push('dispatch 任务')
202
- for (const t of s.tasks) {
203
- const d = t.state === 'active' ? '●' : '○'
204
- out.push(
205
- `${w(t.name, 9)}${d} ${w(t.state, 9)}${w(`${t.chats} 群`, 7)}上次派活 ${ago(t.lastLedgerMs)}`,
206
- )
207
- }
208
- }
209
188
  return out.join('\n')
210
189
  }
211
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.4",
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.6",
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",
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 }
package/lib/claude.js DELETED
@@ -1,231 +0,0 @@
1
- 'use strict'
2
-
3
- // 唤起 headless claude。session 确定性派生,不落配置、不需人填。
4
- const crypto = require('crypto')
5
- const fs = require('fs')
6
- const os = require('os')
7
- const path = require('path')
8
- const { spawn } = require('child_process')
9
-
10
- // 模型分发交给一个 shell 函数 `claude()` —— 由它做白名单校验与后端路由,
11
- // 是模型名的单一事实源。包不自己维护模型清单(那样新增后端要改两处)。
12
- //
13
- // LR_CC_RC 指向定义了 `claude()` 的 rc 文件。默认值只是个约定俗成的位置,
14
- // 部署时按实际环境设置:Environment=LR_CC_RC=%h/path/to/claude.sh
15
- const CC_RC = process.env.LR_CC_RC || path.join(os.homedir(), '.claude-route.sh')
16
-
17
- // sha1(task|scope) 前 32 位切成 8-4-4-4-12
18
- function deriveUuid(task, scope) {
19
- const h = crypto.createHash('sha1').update(`${task}|${scope}`).digest('hex')
20
- return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`
21
- }
22
-
23
- // Claude Code 按 cwd 分目录存 session:/ 和 . 都换成 -
24
- function encCwd(cwd) {
25
- return String(cwd).replace(/[/.]/g, '-')
26
- }
27
-
28
- function sessionFile(cwd, uuid) {
29
- return path.join(os.homedir(), '.claude', 'projects', encCwd(cwd), `${uuid}.jsonl`)
30
- }
31
-
32
- // 会话世代号:超过 idleGap 没动静就开新会话。thread 模式不调用(话题即边界,恒 0)。
33
- function sessionEpoch(stateDir, task, chatId, idleGap) {
34
- const dir = path.join(stateDir, 'state')
35
- fs.mkdirSync(dir, { recursive: true })
36
- const file = path.join(dir, `${task}__${chatId}.tsv`)
37
- const now = Math.floor(Date.now() / 1000)
38
- let epoch = 0
39
- try {
40
- const [lastStr, epStr] = fs.readFileSync(file, 'utf8').trim().split('\t')
41
- const last = Number(lastStr) || 0
42
- epoch = Number(epStr) || 0
43
- if (idleGap > 0 && now - last > idleGap) epoch += 1
44
- } catch {}
45
- fs.writeFileSync(file, `${now}\t${epoch}\n`)
46
- return epoch
47
- }
48
-
49
- // 提示词按 mode 分流 —— 两类场景对模型的要求根本不同。
50
- // work/chat:模型产出一段结论,由 dispatch 发回群(模型不碰发送)。
51
- // agent:模型自己决定发不发、发给谁、用谁的身份、发文字还是表情 ——
52
- // 引擎不做回复决策,和「引擎不做权限管控」是同一条思路:边界靠 instructions 到达模型侧。
53
- const PROMPTS = {
54
- task: (batch) =>
55
- `下面是飞书群里最新一批消息。请把它们当作针对本仓库(当前工作目录)的任务来处理:定位/分析/修复/测试。处理完后,你的最终回复会被原样发回该群,请直接面向群里的人简洁说明你做了什么、发现了什么、需要什么确认。
56
-
57
- ===== 群消息 =====
58
- ${batch}
59
- ===== 结束 =====`,
60
-
61
- agent: (batch) =>
62
- `下面是飞书群里最新一批消息。
63
-
64
- **本轮的发送完全由你负责** —— 没有任何东西会被自动发回群。按你的职责文档判断:
65
- 该不该回应、回应哪一条、用哪个身份、发文字还是表情回应、要不要执行既定任务。
66
- 需要发送时自己用 lark-cli 完成。
67
-
68
- **多数情况下正确的选择是什么都不做** —— 不要为了有所动作而发言。
69
-
70
- 处理完请用一句话说明你做了什么(如「用可莉回复了接梗」「无 @ 我方,沉默」),
71
- 这句话只进台账供事后回溯,不会发到群里。
72
-
73
- ===== 群消息 =====
74
- ${batch}
75
- ===== 结束 =====`,
76
- }
77
-
78
- /**
79
- * 起一轮 headless claude。
80
- * @returns {Promise<{rc:number, final:string, err:string}>}
81
- */
82
- function runClaude(opts) {
83
- const {
84
- cwd,
85
- prompt,
86
- uuid,
87
- instructions,
88
- addDirs = [],
89
- allowedTools = 'Read Edit Write Bash',
90
- model,
91
- timeoutSec = 1800,
92
- stream = false,
93
- onEvent,
94
- } = opts
95
-
96
- const resume = fs.existsSync(sessionFile(cwd, uuid))
97
- const args = ['-p', prompt]
98
- // 首次用 --session-id 建、之后用 --resume 续,判据就是 jsonl 是否存在
99
- args.push(resume ? '--resume' : '--session-id', uuid)
100
- args.push('--add-dir', cwd) // 主工作目录自己也加一次
101
- args.push('--setting-sources', 'user,project,local') // 含 project 才加载主目录 CLAUDE.md
102
- args.push('--permission-mode', 'acceptEdits')
103
- args.push('--allowedTools', allowedTools)
104
- for (const d of addDirs) args.push('--add-dir', d)
105
- // 职责/边界必须走这里:CLAUDE.md 只自动加载主工作目录+父链,
106
- // --add-dir 的目录启动不加载,AGENTS.md 也不被当 memory
107
- if (instructions && fs.existsSync(instructions)) {
108
- args.push('--append-system-prompt-file', instructions)
109
- }
110
- args.push('--output-format', stream ? 'stream-json' : 'json')
111
- if (stream) args.push('--verbose')
112
-
113
- // 经 bash -lc source <rc> 后调 claude() —— 模型分发与白名单校验都在那里。
114
- // rc 不存在时**不当致命错误**:降级到 PATH 里的 claude,只在指定了 --model 时才必须有 rc
115
- // (否则 --model 传给原生 claude 会是另一套语义)。
116
- // ⚠️ CC_RC 必须 shellQuote:曾直接内插进双引号里的 echo,
117
- // LR_CC_RC='/x$(命令)' 会被执行(实测)。所有插进 script 的值一律走 shellQuote
118
- const quoted = args.map(shellQuote).join(' ')
119
- const rcQ = shellQuote(CC_RC)
120
- const script = model
121
- ? `set -o pipefail\n` +
122
- `if ! source ${rcQ} 2>/dev/null; then\n` +
123
- ` printf '%s\\n' "cc: 指定了 --model 但 source 失败,设 LR_CC_RC 指向定义 claude() 的文件" >&2\n` +
124
- ` exit 3\n` +
125
- `fi\n` +
126
- `claude --model ${shellQuote(model)} ${quoted}\n`
127
- : `set -o pipefail\n` +
128
- `source ${rcQ} 2>/dev/null || true\n` +
129
- `claude ${quoted}\n`
130
-
131
- return new Promise((resolve) => {
132
- const child = spawn('bash', ['-lc', script], {
133
- cwd,
134
- env: {
135
- ...process.env,
136
- LANG: 'C.UTF-8',
137
- LC_ALL: 'C.UTF-8',
138
- // headless 里 Bash 工具层单命令超时 10min(非整体超时)
139
- BASH_MAX_TIMEOUT_MS: process.env.BASH_MAX_TIMEOUT_MS || '600000',
140
- },
141
- stdio: ['ignore', 'pipe', 'pipe'],
142
- })
143
-
144
- let out = ''
145
- let err = ''
146
- let done = false
147
- // 挂钟超时兜底:包住整轮
148
- const timer = setTimeout(() => {
149
- if (!done) {
150
- err += `\n[timeout ${timeoutSec}s]`
151
- child.kill('SIGTERM')
152
- setTimeout(() => child.kill('SIGKILL'), 5000)
153
- }
154
- }, timeoutSec * 1000)
155
-
156
- if (stream) {
157
- let buf = ''
158
- child.stdout.on('data', (d) => {
159
- buf += d.toString()
160
- const lines = buf.split('\n')
161
- buf = lines.pop()
162
- for (const line of lines) handleStreamLine(line, onEvent, (t) => (out = t))
163
- })
164
- child.stdout.on('end', () => {
165
- if (buf.trim()) handleStreamLine(buf, onEvent, (t) => (out = t))
166
- })
167
- } else {
168
- child.stdout.on('data', (d) => (out += d.toString()))
169
- }
170
- child.stderr.on('data', (d) => (err += d.toString()))
171
-
172
- child.on('close', (code) => {
173
- done = true
174
- clearTimeout(timer)
175
- let final = ''
176
- if (stream) {
177
- final = out
178
- } else {
179
- try {
180
- final = JSON.parse(stripToJson(out))?.result || ''
181
- } catch {
182
- final = out.trim()
183
- }
184
- }
185
- resolve({ rc: code === null ? 124 : code, final, err })
186
- })
187
- child.on('error', (e) => {
188
- done = true
189
- clearTimeout(timer)
190
- resolve({ rc: 1, final: '', err: String(e.message) })
191
- })
192
- })
193
- }
194
-
195
- // stream-json 事件 → say / tool / final
196
- function handleStreamLine(line, onEvent, setFinal) {
197
- const s = line.trim()
198
- if (!s || s[0] !== '{') return
199
- let j
200
- try {
201
- j = JSON.parse(s)
202
- } catch {
203
- return
204
- }
205
- if (j.type === 'assistant') {
206
- for (const c of j.message?.content || []) {
207
- if (c.type === 'text' && c.text) onEvent?.('say', c.text)
208
- else if (c.type === 'tool_use') {
209
- const detail =
210
- c.input?.command ?? c.input?.file_path ?? c.input?.pattern ?? c.input?.description ?? ''
211
- const str = String(detail)
212
- onEvent?.('tool', `${c.name}: ${str.length > 300 ? `${str.slice(0, 300)}…` : str}`)
213
- }
214
- }
215
- } else if (j.type === 'result') {
216
- const r = j.result || ''
217
- setFinal(r)
218
- onEvent?.('final', r)
219
- }
220
- }
221
-
222
- function stripToJson(s) {
223
- const i = String(s).indexOf('{')
224
- return i === -1 ? '{}' : String(s).slice(i)
225
- }
226
-
227
- function shellQuote(s) {
228
- return `'${String(s).replace(/'/g, `'\\''`)}'`
229
- }
230
-
231
- module.exports = { runClaude, deriveUuid, sessionEpoch, PROMPTS }