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
@@ -0,0 +1,394 @@
1
+ 'use strict'
2
+
3
+ // C2 effect commit cursor. The binary adapter validates a transition before
4
+ // calling stage(); this journal stores only opaque IDs and confirmations. An
5
+ // unconfirmed external effect is ambiguous after a crash and is never replayed.
6
+ const { createHash } = require('node:crypto')
7
+
8
+ const ID = /^[0-9a-f]{32}$/
9
+ const MAX_EFFECTS = 1024
10
+ const MAX_BATCH_MEMBERS = 1024
11
+ const hash = (...parts) => createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 32)
12
+
13
+ function batchId(receiptIds) {
14
+ if (!Array.isArray(receiptIds) || receiptIds.length < 2 || receiptIds.length > MAX_BATCH_MEMBERS ||
15
+ receiptIds.some(id => !ID.test(id)) || new Set(receiptIds).size !== receiptIds.length) {
16
+ throw new TypeError('invalid C2 batch members')
17
+ }
18
+ // Preserve dispatch order: two differently ordered prompts cannot share an
19
+ // external effect identity even when they contain the same accepted inputs.
20
+ return hash('sintbot-c2-batch-v2', String(receiptIds.length), ...receiptIds)
21
+ }
22
+
23
+ function batchStepId(receiptIds, ordinal) {
24
+ if (!Number.isSafeInteger(ordinal) || ordinal < 0) throw new TypeError('invalid C2 batch step identity')
25
+ return hash('sintbot-c2-batch-step-v2', batchId(receiptIds), String(ordinal))
26
+ }
27
+
28
+ function batchEffectId(step, index) {
29
+ if (!ID.test(step) || !Number.isSafeInteger(index) || index < 0 || index >= MAX_EFFECTS) {
30
+ throw new TypeError('invalid C2 batch effect identity')
31
+ }
32
+ return hash('sintbot-c2-batch-effect-v2', step, String(index))
33
+ }
34
+
35
+ function stepId(taskId, ordinal) {
36
+ if (!ID.test(taskId) || !Number.isSafeInteger(ordinal) || ordinal < 0) throw new TypeError('invalid C2 step identity')
37
+ return hash('sintbot-c2-step-v1', taskId, String(ordinal))
38
+ }
39
+
40
+ function effectId(step, index) {
41
+ if (!ID.test(step) || !Number.isSafeInteger(index) || index < 0 || index >= MAX_EFFECTS) {
42
+ throw new TypeError('invalid C2 effect identity')
43
+ }
44
+ return hash('sintbot-c2-effect-v1', step, String(index))
45
+ }
46
+
47
+ function readCursor(state) {
48
+ const cursor = Object.hasOwn(state, 'effectCursor') ? state.effectCursor : { version: 1, steps: [] }
49
+ if (!cursor || ![1, 2].includes(cursor.version) || !Array.isArray(cursor.steps) ||
50
+ Object.keys(cursor).some((key) => !['version', 'steps'].includes(key))) throw new TypeError('invalid C2 effect cursor')
51
+ const seen = new Set(), seenBatches = new Set()
52
+ for (const step of cursor.steps) {
53
+ const isBatch = !!step && Object.hasOwn(step, 'receiptIds')
54
+ const keys = isBatch ? ['id', 'batchId', 'receiptIds', 'ordinal', 'effects', 'confirmed']
55
+ : ['id', 'taskId', 'ordinal', 'effects', 'confirmed', 'notifiedAt']
56
+ if (!step || Object.keys(step).some((key) => !keys.includes(key)) ||
57
+ !ID.test(step.id) || !Number.isSafeInteger(step.ordinal) || step.ordinal < 0 ||
58
+ (isBatch ? cursor.version !== 2 || !Array.isArray(step.receiptIds) ||
59
+ step.batchId !== batchId(step.receiptIds) || batchStepId(step.receiptIds, step.ordinal) !== step.id
60
+ : !ID.test(step.taskId) || stepId(step.taskId, step.ordinal) !== step.id) ||
61
+ seen.has(step.id) || (isBatch && seenBatches.has(step.batchId)) ||
62
+ !Array.isArray(step.effects) || step.effects.length > MAX_EFFECTS ||
63
+ !Array.isArray(step.confirmed) ||
64
+ (step.notifiedAt !== undefined && (!Number.isSafeInteger(step.notifiedAt) || step.notifiedAt <= 0))) {
65
+ throw new TypeError('invalid C2 effect cursor step')
66
+ }
67
+ seen.add(step.id)
68
+ if (isBatch) seenBatches.add(step.batchId)
69
+ const expected = step.effects.map((_, index) => isBatch ? batchEffectId(step.id, index) : effectId(step.id, index))
70
+ if (new Set(step.effects).size !== step.effects.length ||
71
+ expected.some((id, index) => id !== step.effects[index]) ||
72
+ new Set(step.confirmed).size !== step.confirmed.length ||
73
+ step.confirmed.some((id, index) => id !== step.effects[index])) {
74
+ throw new TypeError('invalid C2 effect cursor IDs')
75
+ }
76
+ }
77
+ return cursor
78
+ }
79
+
80
+ class EngineEffectCursor {
81
+ constructor(journal, { now = Date.now } = {}) {
82
+ if (!journal || typeof journal.commitState !== 'function' || typeof journal.snapshot !== 'function') {
83
+ throw new TypeError('C2 effect cursor requires the shared receipt journal')
84
+ }
85
+ if (typeof now !== 'function') throw new TypeError('C2 effect cursor clock required')
86
+ this.journal = journal
87
+ this.now = now
88
+ this.live = new Set() // only steps staged by this process may perform effects
89
+ this.blocked = new Set() // an ambiguous external failure cannot be retried here
90
+ this._read()
91
+ }
92
+
93
+ _read() {
94
+ const disk = this.journal.snapshot()
95
+ if (disk.seq !== this.journal.current.seq) throw new Error('C2 effect cursor has a stale journal owner')
96
+ return readCursor(disk.state)
97
+ }
98
+
99
+ _save(cursor, sessionResetIntent = null) {
100
+ this.journal.commitState({ ...this.journal.current.state, effectCursor: cursor },
101
+ { sessionResetIntent })
102
+ }
103
+
104
+ _sessionResetIntent(step, receipts, identity) {
105
+ if (!identity || !Number.isSafeInteger(identity.turnToken) || identity.turnToken <= 0 ||
106
+ typeof identity.generation !== 'string' || !ID.test(identity.generation) ||
107
+ identity.resetSessionAfter !== true || !['ok', 'no-reply'].includes(identity.status) ||
108
+ Object.keys(identity).some(key => !['turnToken', 'generation', 'resetSessionAfter', 'status'].includes(key)) ||
109
+ step.ordinal !== 0) {
110
+ throw new TypeError('C2 session reset needs an eligible terminal and turn identity')
111
+ }
112
+ if (this.journal.current.sessionResets.some(item =>
113
+ item.chatId === receipts[0].chatId && item.effectId !== step.id)) {
114
+ throw new Error('C2 group already has an unresolved session reset')
115
+ }
116
+ return { effectId: step.id, receiptIds: step.receiptIds || [step.taskId],
117
+ chatId: receipts[0].chatId, turnToken: identity.turnToken,
118
+ generation: identity.generation, phase: 'acked', status: identity.status }
119
+ }
120
+
121
+ _forgetSettled(steps) {
122
+ for (const step of steps) {
123
+ this.live.delete(step.id)
124
+ this.blocked.delete(step.id)
125
+ }
126
+ }
127
+
128
+ _receipt(taskId) {
129
+ const receipt = this.journal.snapshot().pending.find((item) => item.id === taskId)
130
+ if (!receipt) throw new Error('C2 effect has no durable target receipt')
131
+ return receipt
132
+ }
133
+
134
+ _batchReceipts(receiptIds) {
135
+ batchId(receiptIds)
136
+ const byId = new Map(this.journal.snapshot().pending.map(receipt => [receipt.id, receipt]))
137
+ const receipts = receiptIds.map(id => byId.get(id))
138
+ if (receipts.some(receipt => !receipt)) throw new Error('C2 batch effect has no durable target receipt')
139
+ if (new Set(receipts.map(receipt => receipt.chatId)).size !== 1) {
140
+ throw new Error('C2 batch members must belong to one group')
141
+ }
142
+ return receipts
143
+ }
144
+
145
+ // Call only after the reducer output and all effect IDs have been validated.
146
+ // The effect count is projected from that output; no payload is stored.
147
+ stage(taskId, ordinal, effectCount, { sessionReset = null } = {}) {
148
+ if (!Number.isSafeInteger(effectCount) || effectCount < 0 || effectCount > MAX_EFFECTS) {
149
+ throw new TypeError('invalid C2 effect count')
150
+ }
151
+ const id = stepId(taskId, ordinal)
152
+ const effects = Array.from({ length: effectCount }, (_, index) => effectId(id, index))
153
+ const cursor = this._read()
154
+ const receipt = this._receipt(taskId)
155
+ if (sessionReset && effectCount !== 0) throw new Error('C2 reset intent requires final effect ACK')
156
+ if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
157
+ throw new Error('C2 batch member is already bound to an effect')
158
+ }
159
+ const existing = cursor.steps.find((step) => step.id === id)
160
+ if (existing) {
161
+ if (existing.effects.length !== effectCount) throw new Error('C2 step identity changed its effects')
162
+ if (sessionReset) {
163
+ const expected = this._sessionResetIntent(existing, [receipt], sessionReset)
164
+ if (!this.journal.current.sessionResets.some(item => JSON.stringify(item) === JSON.stringify(expected))) {
165
+ throw new Error('C2 reset intent identity changed')
166
+ }
167
+ }
168
+ return { id, effects: [...existing.effects], reused: true }
169
+ }
170
+ const nextStep = { id, taskId, ordinal, effects, confirmed: [] }
171
+ this._save({ version: cursor.version, steps: [...cursor.steps, nextStep] },
172
+ sessionReset ? this._sessionResetIntent(nextStep, [receipt], sessionReset) : null)
173
+ this.live.add(id)
174
+ return { id, effects, reused: false }
175
+ }
176
+
177
+ // A zero-effect batch records a confirmed silent terminal decision. If the
178
+ // following atomic receipt settlement fails, startup can finish it quietly.
179
+ stageBatch(receiptIds, ordinal, effectCount, { sessionReset = null } = {}) {
180
+ if (!Number.isSafeInteger(effectCount) || effectCount < 0 || effectCount > MAX_EFFECTS) {
181
+ throw new TypeError('invalid C2 batch effect count')
182
+ }
183
+ const shared = batchId(receiptIds)
184
+ const members = [...receiptIds]
185
+ const id = batchStepId(members, ordinal)
186
+ const effects = Array.from({ length: effectCount }, (_, index) => batchEffectId(id, index))
187
+ const cursor = this._read()
188
+ const receipts = this._batchReceipts(members)
189
+ if (sessionReset && effectCount !== 0) throw new Error('C2 reset intent requires final effect ACK')
190
+ const existing = cursor.steps.find(step => step.id === id)
191
+ if (existing) {
192
+ if (existing.effects.length !== effectCount) throw new Error('C2 batch step identity changed its effects')
193
+ if (sessionReset) {
194
+ const expected = this._sessionResetIntent(existing, receipts, sessionReset)
195
+ if (!this.journal.current.sessionResets.some(item => JSON.stringify(item) === JSON.stringify(expected))) {
196
+ throw new Error('C2 reset intent identity changed')
197
+ }
198
+ }
199
+ return { id, batchId: shared, receiptIds: members, effects: [...existing.effects], reused: true }
200
+ }
201
+ const memberSet = new Set(members)
202
+ if (cursor.steps.some(step => step.receiptIds
203
+ ? step.receiptIds.some(member => memberSet.has(member)) : memberSet.has(step.taskId))) {
204
+ throw new Error('C2 batch member is already bound to an effect')
205
+ }
206
+ const nextStep = { id, batchId: shared, receiptIds: members, ordinal, effects, confirmed: [] }
207
+ this._save({ version: 2, steps: [...cursor.steps, nextStep] },
208
+ sessionReset ? this._sessionResetIntent(nextStep, receipts, sessionReset) : null)
209
+ this.live.add(id)
210
+ return { id, batchId: shared, receiptIds: members, effects, reused: false }
211
+ }
212
+
213
+ async apply(id, index, perform, { sessionReset = null } = {}) {
214
+ if (typeof perform !== 'function') throw new TypeError('C2 effect performer required')
215
+ const cursor = this._read()
216
+ const step = cursor.steps.find((item) => item.id === id)
217
+ if (!step || !Number.isSafeInteger(index) || index < 0 || index >= step.effects.length) {
218
+ throw new TypeError('unknown C2 effect')
219
+ }
220
+ const effect = step.effects[index]
221
+ if (sessionReset && index !== step.effects.length - 1) {
222
+ throw new Error('C2 reset intent requires final effect ACK')
223
+ }
224
+ const resetIntent = sessionReset ? this._sessionResetIntent(step,
225
+ step.receiptIds ? this._batchReceipts(step.receiptIds) : [this._receipt(step.taskId)], sessionReset) : null
226
+ if (step.confirmed.includes(effect)) {
227
+ if (resetIntent && !this.journal.current.sessionResets.some(item =>
228
+ JSON.stringify(item) === JSON.stringify(resetIntent))) {
229
+ throw new Error('C2 confirmed effect has no matching reset intent')
230
+ }
231
+ return { id: effect, status: 'already-confirmed' }
232
+ }
233
+ if (index !== step.confirmed.length) throw new Error('C2 effects must commit in order')
234
+ if (!this.live.has(id) || this.blocked.has(id) || step.notifiedAt !== undefined) {
235
+ throw new Error('C2 unconfirmed effect requires interruption notice')
236
+ }
237
+ try {
238
+ if (await perform(effect) !== true) throw new Error('C2 effect was not confirmed')
239
+ const fresh = this._read()
240
+ const next = fresh.steps.map((item) => item.id === id
241
+ ? { ...item, confirmed: [...item.confirmed, effect] } : item)
242
+ this._save({ version: fresh.version, steps: next }, resetIntent)
243
+ return { id: effect, status: 'confirmed' }
244
+ } catch (error) {
245
+ this.blocked.add(id)
246
+ throw error
247
+ }
248
+ }
249
+
250
+ unconfirmed() {
251
+ return this._read().steps.filter((step) => step.notifiedAt === undefined)
252
+ .map((step) => ({ id: step.id,
253
+ ...(step.receiptIds ? { batchId: step.batchId, receiptIds: [...step.receiptIds] } : { taskId: step.taskId }),
254
+ effects: step.effects.filter((id) => !step.confirmed.includes(id)) }))
255
+ .filter((step) => step.effects.length)
256
+ }
257
+
258
+ snapshot() { return JSON.parse(JSON.stringify(this._read())) }
259
+
260
+ // Call only AFTER the task receipt is settled. Reversing that order would
261
+ // leave a crash window where the task can restart without its effect IDs.
262
+ pruneConfirmedTask(taskId) {
263
+ if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
264
+ const cursor = this._read()
265
+ if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
266
+ throw new Error('C2 batch member requires atomic settlement')
267
+ }
268
+ const mine = cursor.steps.filter((step) => step.taskId === taskId)
269
+ if (mine.some((step) => step.confirmed.length !== step.effects.length)) {
270
+ throw new Error('C2 task still has unconfirmed effects')
271
+ }
272
+ if (mine.length && this.journal.snapshot().pending.some((item) => item.id === taskId)) {
273
+ throw new Error('C2 task receipt is still pending')
274
+ }
275
+ if (mine.length) {
276
+ this._save({ version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId) })
277
+ this._forgetSettled(mine)
278
+ }
279
+ return mine.length
280
+ }
281
+
282
+ // Normal terminal path: remove the accepted task receipt and all of its
283
+ // confirmed effect IDs in ONE checkpoint write. A failed write leaves both
284
+ // in place; neither half can disappear independently.
285
+ settleConfirmedTask(taskId) {
286
+ if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
287
+ const cursor = this._read()
288
+ if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
289
+ throw new Error('C2 batch member requires atomic settlement')
290
+ }
291
+ const mine = cursor.steps.filter((step) => step.taskId === taskId)
292
+ if (mine.some((step) => step.confirmed.length !== step.effects.length)) {
293
+ throw new Error('C2 task still has unconfirmed effects')
294
+ }
295
+ this._receipt(taskId)
296
+ const nextState = { ...this.journal.current.state, effectCursor: {
297
+ version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId),
298
+ } }
299
+ this.journal.settle(taskId, nextState)
300
+ this._forgetSettled(mine)
301
+ return mine.length
302
+ }
303
+
304
+ // Recovery-only terminal path. The caller must have a confirmed targeted
305
+ // interruption notice (or a previously durable notifiedAt receipt). This
306
+ // single checkpoint commit clears the receipt and every effect step.
307
+ settleInterruptedTask(taskId, { noticeConfirmed = false } = {}) {
308
+ if (noticeConfirmed !== true) throw new Error('C2 interruption notice is not confirmed')
309
+ if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
310
+ const cursor = this._read()
311
+ if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
312
+ throw new Error('C2 batch member requires atomic settlement')
313
+ }
314
+ this._receipt(taskId)
315
+ const mine = cursor.steps.filter((step) => step.taskId === taskId)
316
+ this.journal.settle(taskId, { ...this.journal.current.state, effectCursor: {
317
+ version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId),
318
+ } })
319
+ this._forgetSettled(mine)
320
+ return mine.length
321
+ }
322
+
323
+ settleConfirmedBatch(receiptIds) {
324
+ const shared = batchId(receiptIds)
325
+ const members = [...receiptIds]
326
+ const cursor = this._read()
327
+ const mine = cursor.steps.filter(step => step.batchId === shared)
328
+ if (!mine.length) throw new Error('unknown C2 batch effect')
329
+ if (mine.some(step => step.confirmed.length !== step.effects.length)) {
330
+ throw new Error('C2 batch still has unconfirmed effects')
331
+ }
332
+ this._batchReceipts(members)
333
+ this.journal.settleBatch(members, { ...this.journal.current.state, effectCursor: {
334
+ version: 2, steps: cursor.steps.filter(step => step.batchId !== shared),
335
+ } })
336
+ this._forgetSettled(mine)
337
+ return mine.length
338
+ }
339
+
340
+ settleInterruptedBatch(receiptIds) {
341
+ const shared = batchId(receiptIds)
342
+ const members = [...receiptIds]
343
+ const cursor = this._read()
344
+ const mine = cursor.steps.filter(step => step.batchId === shared)
345
+ if (!mine.length) throw new Error('unknown C2 batch effect')
346
+ if (this._batchReceipts(members).some(receipt => receipt.notifiedAt === undefined)) {
347
+ throw new Error('C2 batch interruption notices are not all confirmed')
348
+ }
349
+ this.journal.settleBatch(members, { ...this.journal.current.state, effectCursor: {
350
+ version: 2, steps: cursor.steps.filter(step => step.batchId !== shared),
351
+ } })
352
+ this._forgetSettled(mine)
353
+ return mine.length
354
+ }
355
+
356
+ // send() must confirm a targeted interruption notice. A failed or ambiguous
357
+ // send leaves the step pending; a confirmed one is recorded before returning.
358
+ async notifyUnconfirmed(send) {
359
+ if (typeof send !== 'function') throw new TypeError('C2 interruption sender required')
360
+ const results = []
361
+ const unconfirmed = this.unconfirmed()
362
+ if (unconfirmed.some(step => step.receiptIds)) {
363
+ throw new Error('C2 batch effects require shared startup recovery')
364
+ }
365
+ const pending = unconfirmed.map((step) => ({ ...step, receipt: this._receipt(step.taskId) }))
366
+ for (const step of pending) {
367
+ try {
368
+ const at = this.now()
369
+ if (!Number.isSafeInteger(at) || at <= 0) throw new Error('C2 notification time required')
370
+ if (await send({ id: step.id, taskId: step.taskId, effects: [...step.effects],
371
+ target: { chatId: step.receipt.chatId, source: step.receipt.source,
372
+ sourceId: step.receipt.sourceId } }) !== true) {
373
+ throw new Error('C2 interruption notice was not confirmed')
374
+ }
375
+ const cursor = this._read()
376
+ this._save({ version: cursor.version, steps: cursor.steps.map((item) => item.id === step.id
377
+ ? { ...item, notifiedAt: at } : item) })
378
+ this.live.delete(step.id)
379
+ results.push({ id: step.id, status: 'notified' })
380
+ } catch {
381
+ this.blocked.add(step.id)
382
+ results.push({ id: step.id, status: 'retry' })
383
+ }
384
+ }
385
+ if (results.some((item) => item.status === 'retry')) {
386
+ const error = new Error('C2 effect interruption notices remain pending')
387
+ error.results = results
388
+ throw error
389
+ }
390
+ return results
391
+ }
392
+ }
393
+
394
+ module.exports = { EngineEffectCursor, stepId, effectId, batchId, batchStepId, batchEffectId, MAX_BATCH_MEMBERS }
@@ -0,0 +1,183 @@
1
+ 'use strict'
2
+
3
+ const { randomUUID } = require('node:crypto')
4
+ const { engineStep, resetBinaryCore } = require('../agent/privatecore')
5
+
6
+ // Development bridge for the unified C2 entry. A prepared result reserves the
7
+ // coordinator while other host reducers validate; only commit publishes it.
8
+ // Persistence is opt-in: lifecycle state can contain turnText, so callers must
9
+ // provide an approved durable representation before enabling a writer.
10
+ class EngineStepCoordinator {
11
+ #committed
12
+ #blocked = false
13
+ #pending = null
14
+ #step
15
+ #reset
16
+ #persist
17
+ #journal
18
+
19
+ constructor({ state = { version: 1, seq: 0, lanes: {} }, step = engineStep,
20
+ reset = resetBinaryCore, persist = null, journal = null } = {}) {
21
+ const durable = journal?.snapshot?.()
22
+ if (journal) state = durable?.state
23
+ if (typeof step !== 'function' || typeof reset !== 'function' ||
24
+ (persist !== null && (typeof persist !== 'function' || persist.constructor?.name === 'AsyncFunction')) ||
25
+ (journal !== null && (persist !== null || typeof journal.snapshot !== 'function' ||
26
+ typeof journal.hasEvent !== 'function' || typeof journal.appendTransition !== 'function' ||
27
+ state?.version !== 2 || durable.cursor !== state.seq)) || !validState(state)) {
28
+ throw new TypeError('invalid C2 step coordinator')
29
+ }
30
+ this.#committed = structuredClone(state)
31
+ this.#step = step
32
+ this.#reset = reset
33
+ this.#persist = persist
34
+ this.#journal = journal
35
+ }
36
+
37
+ get blocked() { return this.#blocked }
38
+ snapshot() { return structuredClone(this.#committed) }
39
+
40
+ prepare({ domain, scope, payload, seed, id, validate = null, receipts = null } = {}) {
41
+ if (this.#blocked) throw new Error('C2 step coordinator needs recovery')
42
+ if (this.#pending) throw new Error('C2 step coordinator has an uncommitted transition')
43
+ if (this.#journal && id === undefined) throw new Error('durable C2 event ID required')
44
+ id ??= randomUUID()
45
+ const token = {}
46
+ this.#pending = token
47
+ const event = { id, domain, scope, expectedCursor: this.#committed.seq, payload }
48
+ if (seed !== undefined) event.seed = seed
49
+ try {
50
+ if (![id, domain, scope].every(value => typeof value === 'string' &&
51
+ Buffer.byteLength(value) > 0 && Buffer.byteLength(value) <= 256) ||
52
+ !payload || typeof payload !== 'object' || Array.isArray(payload) ||
53
+ (seed !== undefined && (!seed || typeof seed !== 'object' || Array.isArray(seed)))) {
54
+ throw new TypeError('invalid C2 step event')
55
+ }
56
+ if (validate !== null && typeof validate !== 'function') throw new TypeError('invalid C2 step validator')
57
+ if (validate && validate.constructor?.name === 'AsyncFunction') throw new TypeError('C2 step validator must be synchronous')
58
+ if (this.#journal && this.#journal.hasEvent(id)) throw new Error('C2 event already committed')
59
+ const durableReceipts = this.#journal
60
+ ? structuredClone(receipts ?? this.#journal.snapshot().receipts) : null
61
+ const admission = this.#journal
62
+ ? { version: 1, cursor: this.#committed.seq, eventId: id, unseen: true } : undefined
63
+ const result = this.#step(this.snapshot(), event, admission)
64
+ const copy = structuredClone(result)
65
+ if (!validResult(copy, this.#committed.seq, id, domain, scope)) {
66
+ throw new Error('invalid C2 step result')
67
+ }
68
+ if (validate) {
69
+ const verdict = validate(structuredClone(copy))
70
+ if (verdict && typeof verdict.then === 'function') throw new TypeError('C2 step validator must be synchronous')
71
+ }
72
+ return {
73
+ result: structuredClone(copy),
74
+ commit: () => {
75
+ if (this.#pending !== token || this.#blocked) throw new Error('C2 step preparation is no longer current')
76
+ try {
77
+ if (this.#journal) {
78
+ this.#journal.appendTransition({ eventId: id, before: this.#committed.seq,
79
+ after: copy.state.seq, state: copy.state, receipts: durableReceipts,
80
+ effectIds: copy.effects.map(effect => effect.effectId),
81
+ ...(copy.effectDescriptors === undefined ? {} :
82
+ { effectDescriptors: copy.effectDescriptors }) })
83
+ } else if (this.#persist) {
84
+ const saved = this.#persist(structuredClone(copy.state))
85
+ if (saved && typeof saved.then === 'function') throw new TypeError('C2 step persistence must be synchronous')
86
+ }
87
+ this.#committed = structuredClone(copy.state)
88
+ this.#pending = null
89
+ return structuredClone(copy)
90
+ } catch (error) {
91
+ this.#pending = null
92
+ this.#blocked = true
93
+ throw error
94
+ }
95
+ },
96
+ cancel: () => {
97
+ if (this.#pending !== token) return false
98
+ this.#pending = null
99
+ return true
100
+ },
101
+ }
102
+ } catch (error) {
103
+ this.#pending = null
104
+ this.#blocked = true
105
+ throw error
106
+ }
107
+ }
108
+
109
+ transition(options) { return this.prepare(options).commit() }
110
+
111
+ recover(state) {
112
+ if (this.#journal) throw new Error('durable C2 recovery needs verified journal reopen')
113
+ // A failed durable writer may have committed before reporting an error.
114
+ // Its last trusted on-disk snapshot must be supplied by the caller.
115
+ if (this.#persist && state === undefined) throw new Error('C2 recovery needs durable state import')
116
+ if (state !== undefined && !validState(state)) throw new TypeError('invalid C2 recovery state')
117
+ this.#reset()
118
+ if (state !== undefined) this.#committed = structuredClone(state)
119
+ this.#pending = null
120
+ this.#blocked = false
121
+ return this.snapshot()
122
+ }
123
+
124
+ recoverFromJournal(journal) {
125
+ if (!this.#journal || !journal || typeof journal.snapshot !== 'function' ||
126
+ typeof journal.hasEvent !== 'function' || typeof journal.appendTransition !== 'function') {
127
+ throw new TypeError('durable C2 journal required')
128
+ }
129
+ const durable = journal.snapshot()
130
+ if (!validState(durable.state) || durable.state.version !== 2 ||
131
+ durable.cursor !== durable.state.seq) throw new TypeError('invalid durable C2 state')
132
+ this.#reset()
133
+ this.#journal = journal
134
+ this.#committed = structuredClone(durable.state)
135
+ this.#pending = null
136
+ this.#blocked = false
137
+ return this.snapshot()
138
+ }
139
+ }
140
+
141
+ function validState(state) {
142
+ if (!state || typeof state !== 'object' || Array.isArray(state) ||
143
+ ![1, 2].includes(state.version) || !Number.isSafeInteger(state.seq) || state.seq < 0 ||
144
+ !state.lanes || typeof state.lanes !== 'object' || Array.isArray(state.lanes)) return false
145
+ if (state.version === 2) return Object.keys(state).every(key =>
146
+ ['version', 'seq', 'lanes', 'lastEventId'].includes(key)) &&
147
+ (state.seq === 0 ? state.lastEventId === undefined :
148
+ typeof state.lastEventId === 'string' && !!state.lastEventId &&
149
+ Buffer.byteLength(state.lastEventId) <= 256)
150
+ if (state.seq === 0 && state.committedEventIds === undefined && state.lastEventId === undefined) return true
151
+ const ids = state.committedEventIds
152
+ if (!ids || typeof ids !== 'object' || Array.isArray(ids) ||
153
+ Object.keys(ids).length !== state.seq || Object.keys(ids).length > 100_000 ||
154
+ Object.entries(ids).some(([id, value]) => value !== true || !id || Buffer.byteLength(id) > 256)) return false
155
+ if (state.seq === 0) return state.lastEventId === undefined
156
+ if (typeof state.lastEventId !== 'string' || !state.lastEventId ||
157
+ Buffer.byteLength(state.lastEventId) > 256 || !Object.hasOwn(ids, state.lastEventId)) return false
158
+ return true
159
+ }
160
+
161
+ function validResult(result, priorSeq, id, domain, scope) {
162
+ if (!result || typeof result !== 'object' || Array.isArray(result) ||
163
+ !validState(result.state) || result.state.seq !== priorSeq + 1 ||
164
+ result.cursor !== result.state.seq || result.eventId !== id ||
165
+ (result.state.lastEventId !== undefined && result.state.lastEventId !== id) ||
166
+ (result.state.committedEventIds !== undefined && !Object.hasOwn(result.state.committedEventIds, id)) ||
167
+ !Array.isArray(result.effects) ||
168
+ (result.effectDescriptors !== undefined &&
169
+ (!Array.isArray(result.effectDescriptors) ||
170
+ result.effectDescriptors.length !== result.effects.length ||
171
+ result.effectDescriptors.some((descriptor, index) =>
172
+ !descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
173
+ descriptor.effectId !== result.effects[index]?.effectId ||
174
+ descriptor.type !== result.effects[index]?.type ||
175
+ descriptor.domain !== domain || descriptor.scope !== scope))) ||
176
+ !result.decision ||
177
+ typeof result.decision !== 'object' || Array.isArray(result.decision)) return false
178
+ return result.effects.every((effect, index) => effect && typeof effect === 'object' &&
179
+ !Array.isArray(effect) && typeof effect.type === 'string' && !!effect.type &&
180
+ effect.effectId === `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:${index}`)
181
+ }
182
+
183
+ module.exports = { EngineStepCoordinator }
package/src/core/store.js CHANGED
@@ -20,15 +20,15 @@ function atomicWriteJSON(file, obj, opts = {}) {
20
20
  // the default umask — would silently widen it back to 0644 on every save.
21
21
  let mode = null
22
22
  try { mode = fs.statSync(file).mode & 0o777 } catch { mode = opts.mode == null ? null : opts.mode } // 新文件:有 opts.mode 就用它,否则随 umask
23
- const fd = fs.openSync(tmp, 'w')
24
- try {
25
- fs.writeSync(fd, data)
26
- fs.fsyncSync(fd)
27
- if (mode != null) fs.fchmodSync(fd, mode)
28
- } finally {
29
- fs.closeSync(fd)
30
- }
31
23
  try {
24
+ const fd = fs.openSync(tmp, 'w')
25
+ try {
26
+ fs.writeSync(fd, data)
27
+ fs.fsyncSync(fd)
28
+ if (mode != null) fs.fchmodSync(fd, mode)
29
+ } finally {
30
+ fs.closeSync(fd)
31
+ }
32
32
  fs.renameSync(tmp, file)
33
33
  } catch (e) {
34
34
  try { fs.unlinkSync(tmp) } catch { /* best-effort cleanup */ }
@@ -89,6 +89,35 @@ class StateStore {
89
89
 
90
90
  getSession(chatId) { return this._group(chatId).sessionId || null }
91
91
  setSession(chatId, sid) { this._group(chatId).sessionId = sid; this._save() }
92
+ // C2 writeback: persist a new snapshot before exposing the session in this
93
+ // process. Mark an aborted session in the same replacement when requested;
94
+ // omitted/false tainted leaves the existing flag as the legacy setter does.
95
+ // The legacy setter keeps its existing behavior for other callers.
96
+ setSessionAtomic(chatId, sid, { tainted, sessionStats } = {}) {
97
+ if (sessionStats !== undefined && (!sessionStats ||
98
+ typeof sessionStats !== 'object' || Array.isArray(sessionStats) ||
99
+ sessionStats.sessionId !== sid ||
100
+ ['turns', 'in', 'out'].some(key =>
101
+ !Number.isFinite(sessionStats[key]) || sessionStats[key] < 0) ||
102
+ (sessionStats.ctx !== undefined &&
103
+ (!Number.isFinite(sessionStats.ctx) || sessionStats.ctx < 0)) ||
104
+ (sessionStats.cum !== undefined && (!sessionStats.cum ||
105
+ typeof sessionStats.cum !== 'object' || Array.isArray(sessionStats.cum) ||
106
+ ['in', 'cr', 'out'].some(key =>
107
+ !Number.isFinite(sessionStats.cum[key]) || sessionStats.cum[key] < 0))))) {
108
+ throw new TypeError('session statistics must match the session being written')
109
+ }
110
+ const groups = this.data.groups || {}
111
+ const group = { ...(groups[chatId] || {}), sessionId: sid }
112
+ if (tainted === true) group.tainted = true
113
+ if (sessionStats !== undefined) group.sessionStats = {
114
+ ...sessionStats,
115
+ ...(sessionStats.cum === undefined ? {} : { cum: { ...sessionStats.cum } }),
116
+ }
117
+ const next = { ...this.data, groups: { ...groups, [chatId]: group } }
118
+ atomicWriteJSON(this.file, next, { mode: 0o600 })
119
+ this.data = next
120
+ }
92
121
  // 清会话连带清「被掐断」标记:标记属于那段会话,会话没了它就没有意义。
93
122
  clearSession(chatId) { const g = this._group(chatId); delete g.sessionId; delete g.tainted; this._save() }
94
123
 
@@ -108,6 +137,21 @@ class StateStore {
108
137
  putSessionStats(chatId, s) { this._group(chatId).sessionStats = s; this._save() }
109
138
  clearSessionStats(chatId) { delete this._group(chatId).sessionStats; this._save() }
110
139
 
140
+ // Session writeback may be followed by a requested fresh session. Publish
141
+ // all three session-owned fields in one file replacement, and only replace
142
+ // the live snapshot after that write succeeds. A failed fsync/rename cannot
143
+ // make this process observe a reset that the next process will not see.
144
+ resetSessionAndStats(chatId) {
145
+ const groups = this.data.groups || {}
146
+ const group = { ...(groups[chatId] || {}) }
147
+ delete group.sessionId
148
+ delete group.tainted
149
+ delete group.sessionStats
150
+ const next = { ...this.data, groups: { ...groups, [chatId]: group } }
151
+ atomicWriteJSON(this.file, next, { mode: 0o600 })
152
+ this.data = next
153
+ }
154
+
111
155
  getProvider(chatId) { return this._group(chatId).provider || null }
112
156
  // ⚠ 人主动切模型(/model · 控制台)连带作废「额度临时切换」记录:人说了算,引擎之后不再自作主张切回。
113
157
  setProvider(chatId, name) { const g = this._group(chatId); g.provider = name; delete g.failover; this._save() }