sintbot 1.0.15 → 1.3.0

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.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. package/src/usage.js +0 -144
@@ -1,114 +0,0 @@
1
- // The two contracts the engine talks to. Keeping these explicit (even as
2
- // JSDoc) is what lets the core stay agnostic: it never references "feishu" or
3
- // "claudecode" by name, only these shapes. Adding a platform or an agent =
4
- // a new file that satisfies one of these — engine untouched.
5
- //
6
- // See DESIGN.md §2 (module boundaries) and §4 (subsystem detail).
7
-
8
- /**
9
- * A normalized event emitted by an AgentRunner turn.
10
- * @typedef {Object} AgentEvent
11
- * @property {'system'|'assistant'|'tool_result'|'result'|string} type
12
- * @property {string} [subtype] - e.g. 'init' for system, 'success' for result
13
- * @property {string} [text] - assistant text or final result text
14
- * @property {string} [thinking] - assistant reasoning, when present
15
- * @property {Array<{name:string,input:any}>} [tools] - tool_use blocks on an assistant event
16
- * @property {Array<any>} [results] - tool_result blocks on a user event
17
- * @property {boolean} [isError]
18
- * @property {string} [transient] - 出错结果上的一句群面人话,只在失败是服务端瞬时状况(过载 / 限流 / 网关错)时有;
19
- * 由 runner 判(src/agent/transienterr.js,两个 runner 共用一份签名表),引擎只负责
20
- * 显示它并附「过一会儿发一句「继续」」的下一步。`text` 仍留服务端原话 —— 账本的失败
21
- * 原因靠它对号,人话不能顶掉它。没有这个字段 = 不是瞬时错
22
- * @property {{kind:'quota'|'account', window:'weekly'|'session'|null}} [quota]
23
- * - 出错结果是额度用尽(quota)或账号 / 登录态不可用(account)时有;由 runner 判
24
- * (src/agent/quotaerr.js,两个 runner 共用),引擎据此自动切到 providers[*].fallback
25
- * 指定的备用模型(v0.11.107)。`text` 同样留原话。没有这个字段 = 不是这两类
26
- * @property {string} [sessionId] - agent-side session id (used for --resume)
27
- * @property {any} [usage]
28
- * @property {boolean} [usageCumulative] - result usage is SESSION-cumulative, not per-turn
29
- * (codex reports totals); the engine baselines it into
30
- * a per-turn delta via the store — never ledger it raw
31
- * @property {number} [contextTokens] - size of the LAST request's input side (fresh +
32
- * cache read + cache write) ≈ current context size;
33
- * runners report it when their stream exposes it
34
- * @property {number} [preTokens] - 压缩事件(system · subtype 含 compact)上:压缩前的上下文量,runner 从 CLI 的
35
- * 压缩元数据取。引擎优先用它报「此前上下文约 X」—— contextTokens 是上一条
36
- * assistant 的 usage,尚未回给模型的工具结果不在里面,真机少报过 5 倍
37
- * @property {number} [postTokens] - 同上,压缩后的量;有就一并报
38
- * @property {any} [raw] - unrecognized event passthrough
39
- */
40
-
41
- /**
42
- * AgentRunner — drives an AI coding agent CLI. Two implementations, picked per
43
- * provider (the engine stays agnostic — it only ever calls `runner.runTurn`):
44
- * src/agent/claudecode.js — Claude Code, model swapped via env (default backend).
45
- * src/agent/codexcode.js — OpenAI Codex CLI for GPT (provider.runner === 'codex';
46
- * DESIGN §1). Both spawn-per-turn over stream-json.
47
- *
48
- * @typedef {Object} RunTurnOptions
49
- * @property {string} prompt
50
- * @property {Object} provider - { baseUrl?, apiKey?, model?, env?, runner? } — runner:'codex' routes to codexcode (no baseUrl/apiKey; auth is the machine's `codex login`)
51
- * @property {string} [model] - per-turn override of provider.model
52
- * @property {string} [workdir] - agent working directory (confinement)
53
- * @property {string} [sessionId] - resume an existing agent session
54
- * @property {string} [permissionMode]
55
- * @property {number} [autocompactTokens] - context ceiling: past it the CLI compacts in place (claude --autocompact / codex model_auto_compact_token_limit); 0/absent ⇒ CLI default (window-edge only)
56
- * @property {{mcp?: string[], skills?: string[]}} [plugins] - per-group plugin enablement; absent ⇒ runner default loading
57
- * @property {string} [appendSystemPrompt] - appended to the agent's system prompt (NO_REPLY / sender-tag contract; keep ASCII-safe — travels as argv)
58
- * @property {Object} [extraEnv] - non-provider env merged into the spawn (SBOT_*-namespaced, e.g. SBOT_CHAT_ID for the feishu base skill); both runners apply it
59
- * @property {(ev: AgentEvent) => void} [onEvent]
60
- *
61
- * @typedef {Object} RunTurnResult
62
- * @property {string|null} sessionId
63
- * @property {AgentEvent|null} result
64
- * @property {number} code
65
- *
66
- * interface AgentRunner {
67
- * runTurn(opts: RunTurnOptions): Promise<RunTurnResult>
68
- * }
69
- */
70
-
71
- /**
72
- * Platform — a messaging platform adapter. M2 implementation: src/platform/feishu.js.
73
- *
74
- * interface Platform {
75
- * start(onMessage: (msg: InboundMessage) => void,
76
- * hooks?: { onRecall?: (r: {chatId: string, msgId: string}) => void }): Promise<void>
77
- * // hooks.onRecall: platform saw an inbound message get RECALLED — engine drops it from
78
- * // the queue / aborts the running solo turn (撤回即停). Optional; stubs may ignore it.
79
- * reply(replyCtx, content: string): Promise<void>
80
- * send(chatId: string, content: string): Promise<string|null|void>
81
- * // resolves null when the platform confirmed the message was NOT delivered (API rejected /
82
- * // network down); anything else (message_id / undefined) = delivered or not tracked.
83
- * // Callers that must not lose a message (cron say / script-failure notice) key their retry on null.
84
- * createStreamingCard?(replyCtx): Promise<StreamingCard> // optional
85
- * stop(): Promise<void>
86
- * sendCard?(chatId, card): Promise<any> // 静态卡片(各平台自己的形状;bin 侧按平台用)
87
- * connHealth?(): object // 连接健康快照(控制台用)
88
- * // 平台自描述(可选;engine 据此改写自知清单,core 不认识任何平台名):
89
- * skillCmd?: string // 基础技能命令名,默认 'sbot feishu';清单末尾整体替换
90
- * chatLabel?: string // 「飞书群」/「企业微信群」这类称呼,清单开头与短语里用
91
- * features?: { // 某项 === false ⇒ 清单不教它(教了 agent 就会去调不存在的动作)
92
- * history?, editRecall?, react?, download?, boards?, imagen?, cardPinned?, plainSend?: boolean,
93
- * mediaMarker?: 'path' // 入站图片 / 文件已由平台落盘,标记里给的是本机路径(默认是「先下载」的坐标)
94
- * }
95
- * }
96
- *
97
- * interface StreamingCard {
98
- * update(text: string): void // 累积全量文本(打字机);引擎已把 `>> ` 里程碑行换成 ▶
99
- * finish(text: string, opts?: { milestones?: string[] }): Promise<void>
100
- * // 收尾正文(已剔掉里程碑行)+ 本轮里程碑整批;平台可收进默认收起的折叠区,不认的平台忽略 opts 即可
101
- * recall?(): Promise<boolean> // 撤回整张卡(ack 后转静默);不支持就没有这个方法
102
- * }
103
- *
104
- * @typedef {Object} InboundMessage
105
- * @property {string} chatId
106
- * @property {string} userId
107
- * @property {string} text
108
- * @property {string} msgId
109
- * @property {Array<{key:string,name?:string}>} [images]
110
- * @property {number} createTimeMs
111
- * @property {any} replyCtx - opaque token the platform uses to reply
112
- */
113
-
114
- module.exports = {} // types only; no runtime exports yet
@@ -1,185 +0,0 @@
1
- // 群内反馈本(issue)—— 每台 sbot 一本,存在本机 `~/.sbot/issues.jsonl`。
2
- //
3
- // 为什么要有它:群成员(产品、运营、业务同事)是这套系统真正的使用者,但他们的反馈至今
4
- // 只能散在群消息里,靠 operator 事后翻聊天记录去捞 —— 2026-08-13 就丢过一次(4 条消息
5
- // 静默蒸发,靠用户贴截图才被发现)。给他们一个够得着的入口,反馈才不会蒸发。
6
- // ⚠ 与仓库托管平台上的 issue 无关,不同步、不关联:这是 sbot 自己的本地反馈本。
7
- //
8
- // 形态:**事件流 JSONL**,与 turns.jsonl / audit.jsonl 同族。open/close/reopen 各追加一行,
9
- // 读时按 id 折叠成当前态 —— 从不改写历史行(崩溃安全、无锁读、天然可审计:谁在什么时候
10
- // 关的、备注是什么,全留在文件里)。
11
- //
12
- // id 只在**本机**内递增(#7 这种短号,群里好念好报)。跨机汇总时用 `host` 字段区分,故
13
- // 两台各自编号撞车也不影响 —— 全局标识是 host + id,而群成员只在自己那台的语境里念它。
14
-
15
- const fs = require('node:fs')
16
- const path = require('node:path')
17
- const os = require('node:os')
18
-
19
- // 正文上限。⚠ 不是为了省空间(这本账一天几条),是防「顺手粘一整段日志/一屏报错」把
20
- // 单行撑到兆级 —— 而这本账是要被整本读进来做分析的,一条撑爆等于整本读不动。
21
- // 4000 字远超任何正常反馈的长度,截断处留标记,让人知道后面还有东西、别当成全文。
22
- const MAX_TEXT = 4000
23
- const TRUNC_MARK = '…(过长已截断)'
24
-
25
- // ── 纯逻辑(无 IO,可单测)──────────────────────────────────────────────────
26
-
27
- // 事件流 → 当前态。open 建档,close/reopen 只改状态与备注,其余字段一律以 open 那条为准
28
- // (后续事件不许改写正文 —— 反馈原话是证据,不能被后来的操作悄悄改掉)。
29
- // 未知 op 一律忽略:老版本读新文件时宁可少认几条,也不能整本读不出来。
30
- function foldEvents(events) {
31
- const byId = new Map()
32
- for (const e of events) {
33
- if (!e || e.id == null) continue
34
- if (e.op === 'open') {
35
- byId.set(e.id, {
36
- id: e.id,
37
- ts: e.ts || 0,
38
- chatId: e.chatId || null,
39
- group: e.group || '',
40
- sender: e.sender || '',
41
- text: e.text || '',
42
- version: e.version || '',
43
- host: e.host || '',
44
- turn: e.turn || null,
45
- status: 'open',
46
- note: '',
47
- closedAt: 0,
48
- })
49
- continue
50
- }
51
- const cur = byId.get(e.id)
52
- if (!cur) continue // close 一个不存在的 id:忽略(文件被截断/手工编辑过)
53
- if (e.op === 'close') {
54
- cur.status = 'closed'
55
- cur.closedAt = e.ts || 0
56
- if (e.note) cur.note = e.note
57
- } else if (e.op === 'reopen') {
58
- cur.status = 'open'
59
- cur.closedAt = 0
60
- if (e.note) cur.note = e.note
61
- }
62
- }
63
- return [...byId.values()].sort((a, b) => a.id - b.id)
64
- }
65
-
66
- // 下一个本机序号。⚠ 取「历史最大 + 1」而不是「条数 + 1」:后者在文件被手工删过行之后
67
- // 会发出重复号,而重复号会让 close 打在错的那条上。
68
- function nextId(issues) {
69
- let max = 0
70
- for (const i of issues) if (typeof i.id === 'number' && i.id > max) max = i.id
71
- return max + 1
72
- }
73
-
74
- // 正文规整:去首尾空白 + 封顶。截断只发生在超长时,正常反馈逐字保留(原话是证据)。
75
- function clampText(text) {
76
- const s = String(text || '').trim()
77
- return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + TRUNC_MARK : s
78
- }
79
-
80
- // 解析一行 JSONL,坏行返回 null(整本不能因为一行坏掉就读不出来)。
81
- function parseLines(text) {
82
- const out = []
83
- for (const line of String(text || '').split('\n')) {
84
- const s = line.trim()
85
- if (!s) continue
86
- try { out.push(JSON.parse(s)) } catch { /* 坏行跳过 */ }
87
- }
88
- return out
89
- }
90
-
91
- // 群里回执 / CLI 列表共用的单行摘要。⚠ 正文压成一行并截断 —— 反馈里常有换行,
92
- // 直接进列表会把一屏冲掉。
93
- function summarize(issue, { width = 60 } = {}) {
94
- const t = String(issue.text || '').replace(/\s+/g, ' ').trim()
95
- return t.length > width ? t.slice(0, width) + '…' : t
96
- }
97
-
98
- // 出问题那一轮的技术指纹 —— 这是整个反馈本最值钱的一个字段。
99
- // 群成员的原话通常是「它刚才没回我」,单靠这句没法查;带上那一轮的 status/耗时/token,
100
- // 一眼就能分辨「空轮」「超时」「模型报错」还是「它其实答了但没看见」。
101
- // ⚠ 只带指标不带**正常正文**:正文可能含群里的业务数据,而反馈本是要被跨机收集、集中分析的。
102
- // ⚠ **失败原因是例外,而且必须显示**:它不是业务正文,是「为什么没成」——「模型报错」这一类
103
- // 光看 status 只知道错了、不知道错在哪,拿到反馈的人还得再猜一遍(这正是本字段存在的理由)。
104
- // ⭐ 只有在**上游保证 err 里装的是错误文本、不是模型的正常回答**时这条才成立(见 engine 的
105
- // turnErrText:第一版就是拿正常正文冒充原因,那样带出去才真的破了上面那条规矩)。
106
- // 指纹是给人扫一眼的,故这里再收一次长度;全文在 `issue show` 里。
107
- function fingerprint(turn) {
108
- if (!turn) return ''
109
- const bits = []
110
- if (turn.status) bits.push(turn.status)
111
- if (turn.ms >= 0) bits.push(Math.round(turn.ms / 1000) + 's')
112
- const tk = turn.tokens
113
- if (tk) bits.push(`tokens{in:${tk.in ?? 0},out:${tk.out ?? 0}}`)
114
- if (turn.provider) bits.push(turn.provider)
115
- if (turn.err) bits.push(String(turn.err).replace(/\s+/g, ' ').trim().slice(0, 120))
116
- return bits.join(' · ')
117
- }
118
-
119
- // ── 落盘层(薄壳)────────────────────────────────────────────────────────
120
- // withFileLock 从 store 注入而不是这里 require:core 里已有一份跨进程写锁实现,
121
- // 两份锁 = 两把不同的锁,等于没锁。
122
-
123
- function createIssues(file, { log = () => {}, withFileLock = (f, fn) => fn(), host = os.hostname().split('.')[0] } = {}) {
124
- const readAll = () => {
125
- try { return foldEvents(parseLines(fs.readFileSync(file, 'utf8'))) } catch { return [] }
126
- }
127
- const appendEvent = (ev) => {
128
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
129
- fs.appendFileSync(file, JSON.stringify(ev) + '\n', { mode: 0o600 }) // 反馈原文含群成员的话,同账本只给自己读
130
- }
131
- return {
132
- file,
133
- host,
134
- // 新建。⚠ 「算号 + 追加」必须在同一把锁里:两个群同时提,分头算出的会是同一个号。
135
- add({ chatId, group, sender, text, version, turn } = {}) {
136
- let created = null
137
- withFileLock(file, () => {
138
- const id = nextId(readAll())
139
- const ev = {
140
- op: 'open',
141
- id,
142
- ts: Date.now(),
143
- chatId: chatId || null,
144
- group: group || '',
145
- sender: sender || '',
146
- text: clampText(text),
147
- version: version || '',
148
- host,
149
- turn: turn || null,
150
- }
151
- appendEvent(ev)
152
- created = { ...ev, status: 'open', note: '', closedAt: 0 }
153
- })
154
- return created
155
- },
156
- list({ all = false } = {}) {
157
- const arr = readAll()
158
- return all ? arr : arr.filter((i) => i.status === 'open')
159
- },
160
- get(id) {
161
- return readAll().find((i) => i.id === Number(id)) || null
162
- },
163
- // close/reopen 返回**变更后**的那条(调用方要拿它的 chatId 去原群回一句)。
164
- // 找不到 / 状态已经是目标态 ⇒ 返回 null,让调用方如实说「没这条」或「本来就是开着的」。
165
- setStatus(id, op, note) {
166
- const n = Number(id)
167
- // 备注先验形再走 clampText:到得了这里的两个入口(CLI / 控制台 API)都是 operator 侧,
168
- // 但 API 传来的可能不是字符串 —— 不拦就会以对象形态写进事件行,前端渲染时炸整页
169
- // (整本账连坐);拦成 '[object Object]' 也一样是脏数据。非字符串一律当没写。
170
- const clean = typeof note === 'string' ? clampText(note) : ''
171
- let out = null
172
- withFileLock(file, () => {
173
- const cur = readAll().find((i) => i.id === n)
174
- if (!cur) return
175
- if (op === 'close' && cur.status === 'closed') return
176
- if (op === 'reopen' && cur.status === 'open') return
177
- appendEvent({ op, id: n, ts: Date.now(), note: clean })
178
- out = { ...cur, status: op === 'close' ? 'closed' : 'open', note: clean || cur.note }
179
- })
180
- return out
181
- },
182
- }
183
- }
184
-
185
- module.exports = { createIssues, foldEvents, nextId, parseLines, summarize, fingerprint, clampText, MAX_TEXT }
@@ -1,30 +0,0 @@
1
- // Turn ledger — one JSONL line per finished turn, appended to disk:
2
- // { ts, chatId, provider, sender, text, status, ms, tokens: { in, out, cr?, cw? }, err? }
3
- // err 只在 status 为 error/failed 时出现(v0.11.61):此前失败只记 status、一个字的原因都没有,
4
- // 而日志那边又没时间戳 ⇒ 线上失败事后无从查起。**字段缺席 = 旧行**,不是「这次没原因」。
5
- // tokens 是**本轮增量**(cr=缓存读 cw=缓存写,engine._settleUsage 归一;v0.11.17 前的旧行
6
- // claude 漏缓存、codex 记的是会话累计 —— 读历史数据时注意口径分界)。
7
- // The in-memory activity feed is just a view; this file is the durable record
8
- // and the data source for usage / cost accounting.
9
- // Best-effort by design: a ledger failure must never break a turn.
10
-
11
- const fs = require('node:fs')
12
- const path = require('node:path')
13
-
14
- function createLedger(file, log = () => {}) {
15
- let dirReady = false
16
- return {
17
- file,
18
- append(rec) {
19
- try {
20
- if (!dirReady) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); dirReady = true }
21
- // 账本里有发送者和消息正文:新建时只给自己读写(mode 只在创建时生效,旧文件由 core/datadir 启动时收紧)
22
- fs.appendFile(file, JSON.stringify(rec) + '\n', { mode: 0o600 }, (e) => { if (e) log('ledger append failed: ' + e.message) })
23
- } catch (e) {
24
- log('ledger append failed: ' + (e && e.message))
25
- }
26
- },
27
- }
28
- }
29
-
30
- module.exports = { createLedger }
@@ -1,214 +0,0 @@
1
- // 运维告警(operator alert)—— 模型账号 / 额度类故障发到专用告警群,让 operator 第一时间知道。
2
- //
3
- // 为什么要做:账号被停 / 登录态刷新失败 / 额度用尽,此前只在**出事的那个群**里说一句(错误卡或自动切换的
4
- // 通知),operator 不在那个群就一无所知。2026-09-25 OAuth 过期 6 轮静默失败 10 小时、09-27 账号 on hold、
5
- // 10-05 周额度被别人用光 —— 三次都是事后翻日志才知道。账号类异常更是「会不会被封」的最早信号,必须有人立刻看到。
6
- //
7
- // 分工(与 quotaalert 同款):本模块只做**文案 + 去重判定**,不碰 IO、不认平台;引擎在事发点吐结构化事件
8
- // (onAlert 钩子),bin 接线把文案发到 `alerts.chatId`。core 不认 runner 名、不碰错误原话以外的东西。
9
- //
10
- // 事件形状(引擎吐的):
11
- // { kind:'provider-down', provider, cause:'account'|'quota', window:'weekly'|'session'|null, until:ms|null,
12
- // chatId?, source:'turn'|'probe', detail? } —— 某模型不可用(出错轮判出来的 / 额度探针判出来的)
13
- // { kind:'provider-up', provider } —— 探针确认恢复(之前标过不可用)
14
- // { kind:'failover', chatId, from, to, home, cause, until, back } —— 某群自动切换(back=true 是切回)
15
- // { kind:'platform-down', platform, downMs, detail } —— 某平台长连接断开超过宽限期(createConnWatch 吐)
16
- // { kind:'platform-up', platform, downMs } —— 报过中断的平台重连上了
17
- // { kind:'platform-issue', platform, issue:'kicked'|'auth-expired'|'tool-gated', detail } —— 平台侧需要人处理的事:被另一台机器挤掉已停连 / 工具能力授权过期 / 读群历史被企业规模门控(853006)
18
- //
19
- // 去重:账号 / 额度类故障每个模型每小时最多一条(每轮都失败就每轮都报 = 刷屏);「已恢复」只在报过故障之后才报;
20
- // 切换按群 + 目标 10 分钟一条(两边都没额度时来回切,别把告警群也刷满);平台中断按平台一小时一条,恢复同模型的规矩。
21
- // ⚠ 文案是群面文字:零工具名、零机器路径(规则见 CONTRIBUTING.md「用户面零工具名」),单测钉死。
22
- // ⚠ core 不认平台名:platform 只是个字串,中文名(飞书 / 企业微信)由 bin 经 createSink 的 platformLabels 注入。
23
-
24
- const DOWN_COOLDOWN_MS = 60 * 60_000
25
- const FAILOVER_COOLDOWN_MS = 10 * 60_000
26
- const CONN_GRACE_MS = 5 * 60_000
27
- const DETAIL_MAX = 160
28
-
29
- // 去重闸:同键在冷却期内只放行一次。内存态即可 —— daemon 重启后同一故障再报一次反而是对的(故障还在)。
30
- function createGate(now = () => Date.now()) {
31
- const last = new Map() // key → 上次放行时刻
32
- const downSent = new Set() // 报过故障的模型:只有它们的「已恢复」才值得说
33
- function allow(ev) {
34
- if (!ev || !ev.kind) return false
35
- const t = now()
36
- if (ev.kind === 'provider-down') {
37
- const k = `down:${ev.provider}:${ev.cause}`
38
- if (last.has(k) && t - last.get(k) < DOWN_COOLDOWN_MS) return false // ⚠ 没发过要用 has 判,不能拿 0 当「很久以前」(小时钟下会把首条压掉)
39
- last.set(k, t); downSent.add(ev.provider); return true
40
- }
41
- if (ev.kind === 'provider-up') {
42
- if (!downSent.has(ev.provider)) return false
43
- downSent.delete(ev.provider)
44
- for (const k of [...last.keys()]) if (k.startsWith(`down:${ev.provider}:`)) last.delete(k) // 恢复后再出事要立刻能报
45
- return true
46
- }
47
- if (ev.kind === 'failover') {
48
- const k = `failover:${ev.chatId}:${ev.to}`
49
- if (last.has(k) && t - last.get(k) < FAILOVER_COOLDOWN_MS) return false
50
- last.set(k, t); return true
51
- }
52
- // 平台连接中断:键带 `conn:` 前缀 —— 与模型的 `down:` 分开,名字撞上(某 provider 恰好叫 feishu)也不会互相压掉。
53
- if (ev.kind === 'platform-down') {
54
- const k = `conn:${ev.platform}`
55
- if (last.has(k) && t - last.get(k) < DOWN_COOLDOWN_MS) return false
56
- last.set(k, t); downSent.add(`conn:${ev.platform}`); return true
57
- }
58
- if (ev.kind === 'platform-up') {
59
- if (!downSent.has(`conn:${ev.platform}`)) return false
60
- downSent.delete(`conn:${ev.platform}`)
61
- last.delete(`conn:${ev.platform}`) // 恢复后再断、再过宽限期,要能立刻报,不受上一次的小时冷却影响
62
- return true
63
- }
64
- // 平台侧要人处理的事(被挤掉已停连 / 工具授权过期):按平台 + 事由每小时一条 —— 授权过期时每次轮询都会撞到,不压就是刷屏
65
- if (ev.kind === 'platform-issue') {
66
- const k = `issue:${ev.platform}:${ev.issue}`
67
- if (last.has(k) && t - last.get(k) < DOWN_COOLDOWN_MS) return false
68
- last.set(k, t); return true
69
- }
70
- return false
71
- }
72
- return { allow }
73
- }
74
-
75
- // 时长人话:「不到 1 分钟」/「约 N 分钟」/「约 H 小时 M 分钟」。自带「约」,拼文案时别再加。
76
- function humanDur(ms) {
77
- const m = Math.round(Math.max(0, Number(ms) || 0) / 60_000)
78
- if (m < 1) return '不到 1 分钟'
79
- if (m < 60) return `约 ${m} 分钟`
80
- const h = Math.floor(m / 60), r = m % 60
81
- return `约 ${h} 小时${r ? ` ${r} 分钟` : ''}`
82
- }
83
-
84
- // 平台长连接的中断观察:看门狗每次状态变化调 note(platform, snapshot),这里判「断开持续超过宽限期」才吐事件。
85
- //
86
- // 为什么要宽限期:机器断电重启后开机头两分钟 DNS 未就绪,两个平台都先报 ENOTFOUND 再自愈(10-11 真机)——
87
- // 没有宽限每次重启都是一条假告警;默认 5 分钟,比看门狗自己的退避重建周期宽得多。
88
- // 为什么要做:两台机互相探活只看控制台 HTTP 200,平台订阅一直失败(凭据被吊销 / 被另一条连接反复踢 / DNS 长期不通)
89
- // 时 daemon 活着、控制台绿着,没人知道群消息其实收不到。
90
- // 依赖全注入(now / setTimer / clearTimer)所以能用假时钟单测;本模块不碰 IO,事件交给 onAlert(= createSink 产物)。
91
- function createConnWatch({ now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout, graceMs = CONN_GRACE_MS, onAlert = () => {}, log = () => {} } = {}) {
92
- const st = new Map() // platform → { downSince, timer, reported, lastError }
93
- const get = (p) => { if (!st.has(p)) st.set(p, { downSince: null, timer: null, reported: false, lastError: '' }); return st.get(p) }
94
- const disarm = (r) => { if (r.timer) { try { clearTimer(r.timer) } catch {} r.timer = null } }
95
- const emit = (ev) => { try { onAlert(ev) } catch (e) { log('平台连接告警处理失败(忽略): ' + (e && e.message)) } }
96
-
97
- function fire(platform) {
98
- const r = get(platform)
99
- r.timer = null
100
- if (r.downSince == null || r.reported) return // connected 已撤过 / 已报过(防御:定时器照理此时已被撤)
101
- r.reported = true
102
- emit({ kind: 'platform-down', platform, downMs: now() - r.downSince, detail: r.lastError })
103
- }
104
-
105
- function note(platform, snap) {
106
- if (!platform || !snap || !snap.state) return
107
- const r = get(platform)
108
- if (snap.state === 'connected') {
109
- disarm(r)
110
- if (r.reported) emit({ kind: 'platform-up', platform, downMs: now() - (r.downSince == null ? now() : r.downSince) })
111
- r.downSince = null; r.reported = false; r.lastError = ''
112
- return
113
- }
114
- if (snap.state === 'stopped') { disarm(r); r.downSince = null; r.reported = false; return } // daemon 关停,不是故障
115
- // connecting / reconnecting / failed 都算断开态;只在**进入**断开态那一刻起计时,之后的状态抖动不重置、不重复起表
116
- if (snap.lastError) r.lastError = String(snap.lastError)
117
- if (r.downSince == null) r.downSince = now()
118
- if (!r.timer && !r.reported) {
119
- r.timer = setTimer(() => fire(platform), graceMs)
120
- if (r.timer && typeof r.timer.unref === 'function') r.timer.unref() // 观察用定时器不该拖住进程退出
121
- }
122
- }
123
-
124
- function stop() { for (const r of st.values()) disarm(r) }
125
- return { note, stop }
126
- }
127
-
128
- const windowLabel = (w) => (w === 'weekly' ? '一周' : w === 'session' ? '5 小时' : '')
129
- const causeLabel = (c) => (c === 'account' ? '账号不可用' : '额度用完')
130
-
131
- // 事件 → 告警正文 | ''。ctx:{ instance(这台机器怎么自称) · groupName(事发群名,可空) · when(距重置的人话,可空) }
132
- function alertText(ev, ctx = {}) {
133
- if (!ev || !ev.kind) return ''
134
- const where = ctx.instance ? `${ctx.instance} · ` : ''
135
- const group = ctx.groupName ? `群「${ctx.groupName}」` : (ev.chatId ? '某个群' : '')
136
- if (ev.kind === 'provider-down') {
137
- if (ev.cause === 'account') {
138
- // ⚠ 不把服务端原话(ev.detail)贴进群:原话里有「Claude Code」「/login」这类产品名 / 命令(v0.11.107 定的口径
139
- // 「英文原话只进账本」);原话由接线层写进日志,排查看日志。
140
- const lines = [`🚨 【模型账号异常】${where}${ev.provider}`]
141
- lines.push(ev.source === 'probe' ? '额度探针读到登录态不可用' : `${group || '有群'}的一轮因账号不可用失败`)
142
- lines.push('请尽快检查该模型的登录态与账号状态;配了备用模型的群会自动切换,没配的群每轮都会失败。')
143
- return lines.join('\n')
144
- }
145
- const win = windowLabel(ev.window)
146
- const head = `⏳ 【模型额度用完】${where}${ev.provider}${win ? `(${win}窗口)` : ''}${ctx.when ? ` · ${ctx.when}重置` : ''}`
147
- const src = ev.source === 'probe' ? '额度探针读到已到上限' : `${group || '有群'}的一轮因额度用完失败`
148
- return `${head}\n${src};配了备用模型的群会自动切换并到点切回。`
149
- }
150
- if (ev.kind === 'provider-up') return `✅ 【模型已恢复】${where}${ev.provider} 可以正常使用了`
151
- if (ev.kind === 'platform-issue') {
152
- const label = (ctx.platformLabels && ctx.platformLabels[ev.platform]) || ev.platform || '平台'
153
- // ⚠ 事由只给人话,不给服务端 / 工具原话(原话有命令名、链接;原话进日志)
154
- if (ev.issue === 'kicked') return `🔌 【${label}连接被挤掉】${where}另一台机器用同一个机器人登录,10 分钟内第二次被踢下线,本机已停止重连\n请检查是不是两台机器配了同一个机器人(每台各建各的);处理完后重启守护进程才会重新连接。`
155
- if (ev.issue === 'tool-gated') return `🚧 【${label}读群历史未开放】${where}官方命令行工具回「本企业不可用」(853006):读群历史这项能力官方按企业规模门控,不是配置错\n「不 @ 也回复」已暂停半小时后再试,@ 它仍正常;群里不 @ 的话它看不见,要连着聊就私聊它。`
156
- if (ev.issue === 'auth-expired') return `🔑 【${label}工具授权过期】${where}官方命令行工具的能力授权(每 7 天一续)已过期\n群里会暂时查不到成员姓名、发不了图和文件、「不 @ 也回复」停摆;请机器人创建者在企业微信里点续期链接(日志里有),续完自动恢复。`
157
- return ''
158
- }
159
- if (ev.kind === 'platform-down' || ev.kind === 'platform-up') {
160
- // 平台中文名由接线层注入(core 不认平台名);查不到就直接用字串。⚠ lastError(ENOTFOUND / 853000 这类网络 / 服务端
161
- // 原话)在 ev.detail 里,只进日志不进群 —— 与模型账号异常同一口径。
162
- const label = ctx.platformLabel || ev.platform || '平台'
163
- if (ev.kind === 'platform-up') return `✅ 【平台连接恢复】${where}${label}:中断${humanDur(ev.downMs)}后已重连`
164
- return `📡 【平台连接中断】${where}${label}:已断开${humanDur(ev.downMs)}\n这段时间该平台的群消息收不到也发不出;会一直自动重连,持续不恢复请检查网络与机器人凭据。`
165
- }
166
- if (ev.kind === 'failover') {
167
- if (ev.back) return `🔄 【已切回】${where}${group || '有群'}:${ev.from} → ${ev.to}(原模型已恢复)`
168
- const tail = ev.home && ev.to !== ev.home ? (ctx.when ? `,${ctx.when}切回 ${ev.home}` : `,${ev.home} 恢复后切回`) : ''
169
- return `🔄 【自动切换】${where}${group || '有群'}:${ev.from} → ${ev.to}(${causeLabel(ev.cause)})${tail}`
170
- }
171
- return ''
172
- }
173
-
174
- // 错误原话截断成一行(只进日志;引擎给的 detail 已脱敏,这里只管长度与换行)
175
- function clip(s) {
176
- const t = String(s || '').replace(/\s+/g, ' ').trim()
177
- if (!t) return ''
178
- return t.length > DETAIL_MAX ? t.slice(0, DETAIL_MAX - 1) + '…' : t
179
- }
180
-
181
- // 接线层(bin 用):事件 → 闸 → 文案 → 发到 alerts.chatId。依赖全注入,所以能单测;IO 只有 send 与 log。
182
- // getCfg() → 当前配置(热重载后要读新的 alerts.chatId / groups / instance,所以是函数不是值)
183
- // getPlatform() → 平台对象(可能还没建好 ⇒ null;要有 send(chatId, text))
184
- // humanReset(ms)→ 「约 N 后」(与额度告警同一份措辞,由调用方注入,core 内不互相 require 成环)
185
- // fallbackInstance → 配置没写 instance 时这台机器怎么自称(主机名)
186
- // platformLabels → { 平台名: 中文名 }(平台连接告警用;core 不认平台名,所以由 bin 注入)
187
- // 返回 onAlert(ev):同步、永不抛(抛了只记日志 —— 它挂在轮次结算路上)。
188
- // ⚠ 平台连接告警经同一个 platform.send 发出,而 send 是路由器按告警群归属分发 —— 若断的恰是告警群所在平台,
189
- // 这条告警必然发不出去(落到下面的 catch 只记日志)。这是预期:它能报的是「另一个平台断了」,本平台断了靠对端机探活。
190
- function createSink({ getCfg, getPlatform, gate = createGate(), log = () => {}, humanReset = () => '', fallbackInstance = '', platformLabels = {} }) {
191
- return function onAlert(ev) {
192
- try {
193
- const cfg = (typeof getCfg === 'function' && getCfg()) || {}
194
- const a = cfg.alerts
195
- const to = a && a.chatId != null ? String(a.chatId).trim() : ''
196
- const platform = typeof getPlatform === 'function' ? getPlatform() : null
197
- if (!to || !platform || typeof platform.send !== 'function') return false
198
- if (!gate.allow(ev)) return false
199
- const g = ev.chatId ? (cfg.groups || {})[ev.chatId] : null
200
- const when = typeof ev.until === 'number' && ev.until > Date.now() ? humanReset(ev.until - Date.now()) : ''
201
- const platformLabel = ev.platform ? ((platformLabels || {})[ev.platform] || ev.platform) : ''
202
- const text = alertText(ev, { instance: cfg.instance || fallbackInstance, groupName: g && g.name, when, platformLabel })
203
- if (!text) return false
204
- const d = clip(ev.detail)
205
- // Platform 契约:send 对「平台确认没送出去」resolve null 而不抛(企微主动推送被拒就是这形态)⇒ null 也要当失败记,否则日志写「已发」而群里没有
206
- Promise.resolve(platform.send(to, text))
207
- .then((r) => log(r === null ? `运维告警发送失败(${ev.kind}):平台确认未送达` : `运维告警已发:${ev.kind} ${ev.provider || ev.platform || ev.to || ''}${d ? ' — ' + d : ''}`))
208
- .catch((e) => log(`运维告警发送失败(${ev.kind}): ` + (e && e.message)))
209
- return true
210
- } catch (e) { log('运维告警处理失败(忽略): ' + (e && e.message)); return false }
211
- }
212
- }
213
-
214
- module.exports = { createGate, alertText, createSink, createConnWatch, humanDur, DOWN_COOLDOWN_MS, FAILOVER_COOLDOWN_MS, CONN_GRACE_MS }