sintbot 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
|
@@ -145,13 +145,14 @@ function turnLane(raw) {
|
|
|
145
145
|
function terminalLane(raw) {
|
|
146
146
|
fields(raw, ['finalized', 'outcome'], 'terminal lane',
|
|
147
147
|
{ optional: ['finalEventId', 'pendingEffectIds', 'batchReceiptIds',
|
|
148
|
-
'replyTargetReceiptId'] })
|
|
148
|
+
'replyTargetReceiptId', 'steerReceiptIds'] })
|
|
149
149
|
const finalized = bool(raw.finalized, 'terminal finalized')
|
|
150
150
|
if (!finalized) {
|
|
151
151
|
if (raw.outcome !== null || Object.hasOwn(raw, 'finalEventId') ||
|
|
152
152
|
Object.hasOwn(raw, 'pendingEffectIds') ||
|
|
153
153
|
Object.hasOwn(raw, 'batchReceiptIds') ||
|
|
154
|
-
Object.hasOwn(raw, 'replyTargetReceiptId')
|
|
154
|
+
Object.hasOwn(raw, 'replyTargetReceiptId') ||
|
|
155
|
+
Object.hasOwn(raw, 'steerReceiptIds')) throw new TypeError('unfinalized terminal outcome invalid')
|
|
155
156
|
return { finalized, outcome: null }
|
|
156
157
|
}
|
|
157
158
|
if (!Object.hasOwn(raw, 'finalEventId') || !Object.hasOwn(raw, 'pendingEffectIds')) {
|
|
@@ -188,9 +189,33 @@ function terminalLane(raw) {
|
|
|
188
189
|
} else if (Object.hasOwn(raw, 'replyTargetReceiptId')) {
|
|
189
190
|
throw new TypeError('single receipt terminal has batch reply target')
|
|
190
191
|
}
|
|
192
|
+
if (Object.hasOwn(raw, 'steerReceiptIds')) {
|
|
193
|
+
out.steerReceiptIds = unique(boundedArray(raw.steerReceiptIds, 32,
|
|
194
|
+
'steer terminal receipts').map(id => token(id, HEX_RECEIPT, 'steer receipt')), 'steer receipts')
|
|
195
|
+
if (!out.steerReceiptIds.length || out.steerReceiptIds.some(id => out.batchReceiptIds?.includes(id))) {
|
|
196
|
+
throw new TypeError('steer terminal receipts invalid')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
191
199
|
return out
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
function steerLane(raw) {
|
|
203
|
+
fields(raw, ['parentReceiptId', 'turnToken', 'generation', 'entries'], 'steer lane')
|
|
204
|
+
const parentReceiptId = token(raw.parentReceiptId, HEX_RECEIPT, 'steer parent')
|
|
205
|
+
const entries = boundedArray(raw.entries, 32, 'steer entries').map(entry => {
|
|
206
|
+
fields(entry, ['receiptId', 'effectId', 'status'], 'steer entry')
|
|
207
|
+
if (!['pending', 'accepted', 'landed'].includes(entry.status)) throw new TypeError('steer status invalid')
|
|
208
|
+
if (entry.receiptId === parentReceiptId) throw new TypeError('steer repeats parent')
|
|
209
|
+
return { receiptId: token(entry.receiptId, HEX_RECEIPT, 'steer receipt'),
|
|
210
|
+
effectId: token(entry.effectId, /^sbot-c2-effect-v1:[0-9]+:[A-Za-z0-9:_-]+:0$/, 'steer effect'), status: entry.status }
|
|
211
|
+
})
|
|
212
|
+
if (!entries.length) throw new TypeError('empty steer lane')
|
|
213
|
+
unique(entries.map(entry => entry.receiptId), 'steer receipts')
|
|
214
|
+
unique(entries.map(entry => entry.effectId), 'steer effects')
|
|
215
|
+
return { parentReceiptId, turnToken: integer(raw.turnToken, 'steer turn', { positive: true }),
|
|
216
|
+
generation: token(raw.generation, HEX_RECEIPT, 'steer generation'), entries }
|
|
217
|
+
}
|
|
218
|
+
|
|
194
219
|
function handoffLane(raw) {
|
|
195
220
|
fields(raw, ['phase', 'turnToken', 'attempt', 'acceptedReceiptIds', 'nextJobId'], 'handoff lane')
|
|
196
221
|
if (!['idle', 'running', 'releasing', 'handoff', 'sealed'].includes(raw.phase)) {
|
|
@@ -296,6 +321,7 @@ function cardCleanupLane(raw) {
|
|
|
296
321
|
|
|
297
322
|
const LANE_VALIDATORS = Object.freeze({
|
|
298
323
|
queue: queueLane,
|
|
324
|
+
steer: steerLane,
|
|
299
325
|
lifecycle: lifecycleLane,
|
|
300
326
|
turn: turnLane,
|
|
301
327
|
terminal: terminalLane,
|
|
@@ -5,7 +5,7 @@ const { chatPlatform } = require('../config')
|
|
|
5
5
|
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
6
6
|
const FEISHU_CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
7
7
|
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
8
|
-
const NOTICE_TEXT = '
|
|
8
|
+
const NOTICE_TEXT = '上一轮没有完成(模型出错或服务中断),请重新发送原请求。'
|
|
9
9
|
|
|
10
10
|
// A deliberately narrow recovery sender for a previously accepted Feishu
|
|
11
11
|
// message. The ordinary platform.reply() can fall back to a second send after
|
|
@@ -27,4 +27,59 @@ function createC2V2SourceQuarantine(value, { required = false } = {}) {
|
|
|
27
27
|
rejects: message => sourceIds.includes(message?.msgId) })
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Every sealed incident under <data>/incidents/*/incident.json keeps its
|
|
31
|
+
// unsettled source messages out of admission for that group, on every start.
|
|
32
|
+
// A seal written by `sintbot incident seal` also lists the receipts its
|
|
33
|
+
// journal had already settled, so the group's new journal never runs one of
|
|
34
|
+
// those messages again. A malformed seal stops startup: an unreadable fence
|
|
35
|
+
// cannot be skipped.
|
|
36
|
+
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
37
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
38
|
+
function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = require('node:path') } = {}) {
|
|
39
|
+
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir)) {
|
|
40
|
+
throw new TypeError('C2 incident quarantine needs the installation directory')
|
|
41
|
+
}
|
|
42
|
+
const root = path.join(stateDir, 'incidents')
|
|
43
|
+
// Loaded here so the source quarantine above stays a standalone module.
|
|
44
|
+
const { taskId } = require('./enginecheckpoint')
|
|
45
|
+
const byChat = new Map()
|
|
46
|
+
const settled = new Map()
|
|
47
|
+
const fail = name => { throw new Error(`Sealed incident record is invalid: ${name}; preserve it and review`) }
|
|
48
|
+
let entries = []
|
|
49
|
+
try { entries = fs.readdirSync(root, { withFileTypes: true }) }
|
|
50
|
+
catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.isDirectory()) continue
|
|
53
|
+
const file = path.join(root, entry.name, 'incident.json')
|
|
54
|
+
let stat
|
|
55
|
+
try { stat = fs.lstatSync(file) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
|
|
56
|
+
if (!stat.isFile() || stat.isSymbolicLink() ||
|
|
57
|
+
typeof process.getuid === 'function' && stat.uid !== process.getuid() ||
|
|
58
|
+
process.platform !== 'win32' && (stat.mode & 0o077)) fail(entry.name)
|
|
59
|
+
let record
|
|
60
|
+
try { record = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { fail(entry.name) }
|
|
61
|
+
if (record?.schema !== 'sbot-c2-incident-seal-v1' || !CHAT_ID.test(record.chatId || '') ||
|
|
62
|
+
!Array.isArray(record.pending) ||
|
|
63
|
+
record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || '')) ||
|
|
64
|
+
record.completedReceiptIds !== undefined && (!Array.isArray(record.completedReceiptIds) ||
|
|
65
|
+
record.completedReceiptIds.some(id => typeof id !== 'string' || !RECEIPT_ID.test(id)))) fail(entry.name)
|
|
66
|
+
const ids = byChat.get(record.chatId) || new Set()
|
|
67
|
+
for (const item of record.pending) ids.add(item.sourceId)
|
|
68
|
+
byChat.set(record.chatId, ids)
|
|
69
|
+
if (record.completedReceiptIds?.length) {
|
|
70
|
+
const done = settled.get(record.chatId) || new Set()
|
|
71
|
+
for (const id of record.completedReceiptIds) done.add(id)
|
|
72
|
+
settled.set(record.chatId, done)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const sourceIds = Object.freeze([...byChat.values()].flatMap(ids => [...ids]))
|
|
76
|
+
const settledBefore = message => {
|
|
77
|
+
const done = settled.get(message?.chatId)
|
|
78
|
+
if (!done || typeof message.msgId !== 'string' || !message.msgId) return false
|
|
79
|
+
return done.has(taskId({ source: 'message', chatId: message.chatId, sourceId: message.msgId }))
|
|
80
|
+
}
|
|
81
|
+
return Object.freeze({ sourceIds,
|
|
82
|
+
rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) || settledBefore(message) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { createC2V2SourceQuarantine, loadC2IncidentQuarantine }
|
|
@@ -66,7 +66,8 @@ function pruneConfirmedTerminal({ journal, coordinator, scope } = {}) {
|
|
|
66
66
|
settlement?.turnToken !== descriptor.turnToken ||
|
|
67
67
|
completed?.settledAt !== settlement?.settledAt ||
|
|
68
68
|
!isDeepStrictEqual(settlement?.batchReceiptIds, lane.batchReceiptIds) ||
|
|
69
|
-
(lane.
|
|
69
|
+
!isDeepStrictEqual(settlement?.steerReceiptIds, lane.steerReceiptIds) ||
|
|
70
|
+
[...(lane.batchReceiptIds || [descriptor.receiptId]), ...(lane.steerReceiptIds || [])].some(id =>
|
|
70
71
|
!durable.receipts.completed.some(item =>
|
|
71
72
|
item.id === id && item.settledAt === settlement?.settledAt)) ||
|
|
72
73
|
!proof || !/^[0-9a-f]{32}$/.test(proof.generation || '') ||
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { engineSlotsStep, resetBinaryCore } = require('../agent/privatecore')
|
|
4
|
+
|
|
5
|
+
// Promise, AbortSignal and waiter handles belong to Node. The binary reducer
|
|
6
|
+
// owns capacity, priority and grant order; no callback fires until its complete
|
|
7
|
+
// transition has been validated and committed.
|
|
8
|
+
class BinarySemaphore {
|
|
9
|
+
constructor(max, step = engineSlotsStep, resetCore = resetBinaryCore) {
|
|
10
|
+
this.state = { max: max > 0 ? max : 0, cur: 0, waiters: [] }
|
|
11
|
+
this.step = step
|
|
12
|
+
this.resetCore = resetCore
|
|
13
|
+
this.handles = new Map()
|
|
14
|
+
this.serial = 0
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get max() { return this.state.max }
|
|
18
|
+
set max(value) { this._transition({ type: 'reload', max: value }) }
|
|
19
|
+
get cur() { return this.state.cur }
|
|
20
|
+
get waiters() { return this.state.waiters.map(waiter => this.handles.get(waiter.id)?.handle).filter(Boolean) }
|
|
21
|
+
|
|
22
|
+
_transition(event) {
|
|
23
|
+
let result
|
|
24
|
+
try {
|
|
25
|
+
result = this.step(this.state, event)
|
|
26
|
+
const next = result && result.state
|
|
27
|
+
if (!next || typeof next !== 'object' || Array.isArray(next) ||
|
|
28
|
+
!Number.isSafeInteger(next.cur) || next.cur < 0 || !Array.isArray(next.waiters) ||
|
|
29
|
+
!Array.isArray(result.effects) ||
|
|
30
|
+
(event.type === 'wouldBlock' && typeof result.wouldBlock !== 'boolean')) {
|
|
31
|
+
throw new Error('invalid binary slot transition')
|
|
32
|
+
}
|
|
33
|
+
const ids = new Set()
|
|
34
|
+
for (const waiter of next.waiters) {
|
|
35
|
+
if (!waiter || typeof waiter.id !== 'string' || !waiter.id ||
|
|
36
|
+
!Number.isSafeInteger(waiter.reserve) || waiter.reserve < 0 || ids.has(waiter.id) ||
|
|
37
|
+
(waiter.id !== event.id && !this.handles.has(waiter.id))) {
|
|
38
|
+
throw new Error('invalid binary slot waiter')
|
|
39
|
+
}
|
|
40
|
+
ids.add(waiter.id)
|
|
41
|
+
}
|
|
42
|
+
if (result.effects.length > 1 || result.effects.some(effect =>
|
|
43
|
+
!effect || !['grant', 'reject'].includes(effect.type) || typeof effect.id !== 'string' ||
|
|
44
|
+
(effect.id !== event.id && !this.handles.has(effect.id)))) {
|
|
45
|
+
throw new Error('invalid binary slot effect')
|
|
46
|
+
}
|
|
47
|
+
this.state = next
|
|
48
|
+
return result
|
|
49
|
+
} catch (error) {
|
|
50
|
+
try { this.resetCore(error) } catch { /* preserve the first failure */ }
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
wouldBlock(reserve = 0) { return this._transition({ type: 'wouldBlock', reserve }).wouldBlock }
|
|
56
|
+
|
|
57
|
+
async acquire(signal, reserve = 0, onWait = null) {
|
|
58
|
+
const id = `s${++this.serial}`
|
|
59
|
+
const result = this._transition({ type: 'acquire', id, reserve })
|
|
60
|
+
if (result.effects.some(effect => effect.type === 'grant' && effect.id === id)) return
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const record = { resolve, reject, signal, onAbort: null, handle: null }
|
|
63
|
+
const handle = { id,
|
|
64
|
+
get reserve() { return record.reserve },
|
|
65
|
+
set reserve(value) {
|
|
66
|
+
if (value !== 0) throw new TypeError('slot waiter promotion must set reserve to zero')
|
|
67
|
+
if (record.done) { record.reserve = 0; return }
|
|
68
|
+
const changed = record.owner._transition({ type: 'promote', id })
|
|
69
|
+
if (changed.effects.length) throw new Error('slot promotion emitted an effect')
|
|
70
|
+
record.reserve = 0
|
|
71
|
+
} }
|
|
72
|
+
record.owner = this
|
|
73
|
+
record.reserve = reserve
|
|
74
|
+
record.handle = handle
|
|
75
|
+
this.handles.set(id, record)
|
|
76
|
+
if (onWait) onWait(handle)
|
|
77
|
+
const onAbort = () => {
|
|
78
|
+
try {
|
|
79
|
+
const changed = this._transition({ type: 'abort', id })
|
|
80
|
+
if (!changed.effects.some(effect => effect.type === 'reject' && effect.id === id)) return
|
|
81
|
+
record.done = true
|
|
82
|
+
this.handles.delete(id)
|
|
83
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
84
|
+
reject(new Error('aborted while waiting for a concurrency slot'))
|
|
85
|
+
} catch (error) { reject(error) }
|
|
86
|
+
}
|
|
87
|
+
record.onAbort = onAbort
|
|
88
|
+
if (signal) {
|
|
89
|
+
if (signal.aborted) onAbort()
|
|
90
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
release() {
|
|
96
|
+
const result = this._transition({ type: 'release' })
|
|
97
|
+
const granted = result.effects.find(effect => effect.type === 'grant')
|
|
98
|
+
if (!granted) return
|
|
99
|
+
const record = this.handles.get(granted.id)
|
|
100
|
+
if (!record) throw new Error('binary slot grant has no host waiter')
|
|
101
|
+
record.done = true
|
|
102
|
+
this.handles.delete(granted.id)
|
|
103
|
+
if (record.signal) record.signal.removeEventListener('abort', record.onAbort)
|
|
104
|
+
record.resolve()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { BinarySemaphore }
|
|
@@ -0,0 +1,185 @@
|
|
|
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 }
|
|
@@ -0,0 +1,30 @@
|
|
|
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 }
|