thinkpool-pair 0.7.364 → 0.7.366

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 (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/event-id.mjs CHANGED
@@ -1,549 +1 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { boundStructuredEvent, STRUCTURED_EVENT_CAP } from './event-bounds.mjs'
3
- import { classifyCodeEvent } from './code-event-contract.mjs'
4
-
5
- /* event-id.mjs — stamp a structured event with a stable identity + timestamp
6
- BEFORE it is logged + broadcast.
7
-
8
- WHY (the duplicate-message bug, 2026-06-19): structured transcript events
9
- (assistant / tool_result / thinking / result) carry NO id from the Claude
10
- Agent SDK. The web room's replay-union (src/pages/code/room.jsx, the
11
- `code-replay` handler) dedupes ONLY by `cid`: an event with a cid is merged
12
- once; an event WITHOUT a cid is appended unconditionally. So an id-less event
13
- that arrives both LIVE (`code-event`) and again in a reconnect REPLAY
14
- (`code-replay`, which re-sends the whole rolling log) is rendered TWICE.
15
- On mobile a background/foreground or network blip routinely triggers that
16
- replay — which is exactly when the duplicates appeared.
17
-
18
- Fix at the source: give every structured event a stable `cid` here, once, so
19
- the live copy and every replayed copy share an id and the room's existing
20
- cid-union collapses them. Idempotent — never overwrites a cid the client
21
- already set (chat / whisper / pool / propose carry their own).
22
-
23
- Kept as its own import-safe module so it can be unit-tested
24
- (bridge/test-structured-cid.mjs); bridge.mjs runs the bridge on import and
25
- cannot be imported by a test. */
26
- export function stampEvent(evt, gen = randomUUID) {
27
- if (!evt || typeof evt !== 'object') return evt
28
- if (typeof evt.ts !== 'number') evt.ts = Date.now()
29
- if (!evt.cid) evt.cid = gen()
30
- return evt
31
- }
32
-
33
- /* C1 (RT-2): a per-term monotonic, CONTIGUOUS sequence counter. Every event that
34
- enters a session's replayed log gets `seq = counter.next()`, so the web room can
35
- replay-from-cursor and dedup by "drop ≤ seqHi" instead of re-merging the whole
36
- log by cid on every reconnect (see src/pages/code/seqDedup.js).
37
-
38
- THE restart property (what makes Slice 3's between-turns restart dup-free): when a
39
- bridge restart restores a persisted log, seed the counter from maxSeq(log) so the
40
- NEXT seq is strictly above every restored seq. Reusing an old seq would make the
41
- client drop the fresh event as a duplicate. */
42
- export function makeSeqCounter(startAfter = 0) {
43
- let n = Number.isFinite(startAfter) ? startAfter : 0
44
- return {
45
- next: () => ++n,
46
- bumpTo: (m) => { if (Number.isFinite(m) && m > n) n = m },
47
- current: () => n,
48
- }
49
- }
50
-
51
- // Kinds the bridge logs for the live transcript but that the WEB treats as
52
- // replay-chrome (src/pages/code/code-constants.js REPLAY_CHROME): filtered out of
53
- // the replay + handled as transient state, never applied as a transcript line. They
54
- // must NOT consume a per-term seq — the client advances its cursor (seqHi) ONLY over
55
- // transcript events, so a seq on one of these would leave a hole in the applied
56
- // sequence → the client sees a false gap → spurious resync storms. So the counter
57
- // skips them and they ride the log seq-less (the client cid-handles/ignores them).
58
- // MUST equal REPLAY_CHROME ∩ {kinds the bridge logs} — guarded by bridge/test-seq.mjs
59
- // S6. (mode/usage/clear/compact/stalled are bridge-chrome and never logged at all, so
60
- // they never reach the counter.) 2026-07-02: models/queue-add/queue-cancel/propose-done/
61
- // update-available were MISSING here — they're bridge-logged but web-filtered, so they
62
- // consumed a seq + set the durable archive floor while the client dropped them, leaving
63
- // the client's oldest RENDERED seq above the floor → a phantom "load earlier history"
64
- // pill on a fresh lane (models fires on every session init) that fetched nothing.
65
- export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
66
- export const seqable = (evt) => {
67
- if (!evt || typeof evt !== 'object') return false
68
- return classifyCodeEvent(evt, { strict: false }).known && !NO_SEQ_KINDS.has(evt.kind)
69
- }
70
-
71
- // Highest numeric `seq` across a log of events (0 when none) — the floor a restored
72
- // session's counter resumes above.
73
- export function maxSeq(events) {
74
- if (!Array.isArray(events)) return 0
75
- let m = 0
76
- for (const e of events) if (e && typeof e.seq === 'number' && e.seq > m) m = e.seq
77
- return m
78
- }
79
-
80
- // Cap a replay event list to a byte budget so the broadcast frame fits under the
81
- // Realtime size limit. THE BUG (2026-06-26, room XE3IQ): on a fresh reload the client
82
- // sends replay-request with an EMPTY cursor (seqHi=0), so the bridge replied with the
83
- // ENTIRE per-term log — multi-thousand events / multiple MB for an established terminal.
84
- // That frame exceeds Realtime's broadcast size limit and is silently DROPPED (and breaks
85
- // the socket for the rest of the tick, taking small terminals' replays + the CHANNEL_ERROR
86
- // churn down with it). The terminal then never refills and every live event is gap-dropped
87
- // → "messages vanish, agent never responds, only brand-new terminals work".
88
- // Keep the MOST RECENT events that fit: the client's DB transcript snapshot (code_messages
89
- // tx:, last 200) already renders older history, and seqDedup.classifyReplay explicitly
90
- // accepts the seq eviction-jump, so the client's live cursor (seqHi) still reaches current.
91
- // Empirically a ~266KB frame delivered while multi-MB did not; 150KB is a safe margin.
92
- // Always keeps at least the newest event so seqHi can advance even if it alone is oversized.
93
- export const REPLAY_BYTE_BUDGET = 150000
94
- export function capReplayEvents(events, budget = REPLAY_BYTE_BUDGET) {
95
- if (!Array.isArray(events) || events.length === 0) return events
96
- let total = 0, i = events.length
97
- while (i > 0) {
98
- const sz = JSON.stringify(events[i - 1]).length + 1
99
- if (i < events.length && total + sz > budget) break
100
- total += sz
101
- i--
102
- }
103
- return i === 0 ? events : events.slice(i)
104
- }
105
-
106
- // A (2026-06-26 follow-up): chunk the replay tail into ORDERED, under-budget frames so
107
- // no event in the recent window is lost (capReplayEvents truncates; this doesn't). Each
108
- // returned chunk is broadcast as its own `code-replay` — seqDedup.classifyReplay applies
109
- // them in order (each chunk is contiguous and seq > seqHi), so the client rebuilds the
110
- // whole window across frames. We keep the MOST RECENT `maxChunks * budget` bytes.
111
- //
112
- // 6 → 14 (2026-06-29): 6 chunks = 900KB, but a real session's full log routinely
113
- // exceeds that (measured: a forge thread = 1.0MB / 7 chunks, a maxed 2000-event
114
- // lane = 1.7MB / 12 chunks), so the OLDEST turns — incl. the user's first prompt —
115
- // were dropped on replay and never reached the client ("not seeing full history of
116
- // terminal N", Max 2026-06-29). The DB transcript snapshot can't cover the gap: it's
117
- // capped at 200 tx events AND the client can only persist what it received, so the
118
- // head only ever lived in this in-memory log. The bridge already hard-caps the log at
119
- // STRUCTURED_LOG_MAX (2000) events ≈ 2.1MB worst case, so 14 chunks (2.1MB) sends the
120
- // WHOLE retained log on a fresh (cursor-0) replay — chunkReplayEvents still only emits
121
- // as many chunks as the log needs, so small sessions are unaffected.
122
- // Returns an array of event-arrays, oldest chunk first; [] when there's nothing to send.
123
- export const REPLAY_MAX_CHUNKS = 14
124
- export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks = REPLAY_MAX_CHUNKS) {
125
- if (!Array.isArray(events) || events.length === 0) return []
126
- // Walk newest→oldest accumulating into the current chunk; start a new chunk when the
127
- // next event would overflow the budget. Stop once we have maxChunks. A lone event
128
- // larger than the whole budget still gets its own chunk (so seqHi can advance).
129
- const chunksRev = [] // newest chunk first while building
130
- let cur = [], curBytes = 0
131
- for (let i = events.length - 1; i >= 0; i--) {
132
- const sz = JSON.stringify(events[i]).length + 1
133
- if (cur.length && curBytes + sz > budget) {
134
- chunksRev.push(cur)
135
- if (chunksRev.length >= maxChunks) { cur = null; break }
136
- cur = [], curBytes = 0
137
- }
138
- cur.push(events[i]) // reversed within the chunk for now
139
- curBytes += sz
140
- }
141
- if (cur && cur.length) chunksRev.push(cur)
142
- // chunksRev is newest-chunk-first, each chunk newest-event-first → reverse both axes
143
- // to oldest-first so classifyReplay applies them contiguously in arrival order.
144
- return chunksRev.reverse().map((c) => c.reverse())
145
- }
146
-
147
- // ── C-RG1/C-RG4 (2026-07-10): the bridge stamps what it trimmed. ──────────────
148
- // chunkReplayEvents keeps only the newest maxChunks — on a fat lane the OLDEST chunks
149
- // are dropped. Until now that hole was unannounced: classifyReplay accepted the seq
150
- // jump, seqHi leapt past it, and because the client's "load earlier" gate compares its
151
- // OLDEST loaded seq against the archive floor, a hole in the MIDDLE (client holds
152
- // 1..50 ∪ 900..2000) never surfaced a pill and was unrecoverable — silently, forever.
153
- //
154
- // The bridge is the only party that knows. Return the lowest seq we are ACTUALLY
155
- // sending when events below it were withheld AND are recoverable from the durable
156
- // JSONL archive; null when nothing was withheld.
157
- //
158
- // Floor-aware on purpose. The seq counter is per-SESSION, not per-terminal, so a fresh
159
- // lane's first turn legitimately lands at seq > 1 — stamping that would resurrect the
160
- // 2026-07-02 phantom "load earlier" pill (a button that fetches nothing). `cursor` is
161
- // what the client already holds (0 = nothing, and an `ahead` cursor-reset replay must
162
- // pass 0). `archiveOldestSeq` is the durable floor (bridge.mjs entry.archiveOldestSeq).
163
- // Anything at or below max(cursor, floor-1) is not missing: either the client has it,
164
- // or the archive never had it and no affordance could ever fetch it.
165
- export function trimmedBeforeSeq(sentFromSeq, cursor = 0, archiveOldestSeq = null) {
166
- if (typeof sentFromSeq !== 'number') return null
167
- const floor = typeof archiveOldestSeq === 'number' ? archiveOldestSeq : 1
168
- const known = Math.max(cursor || 0, floor - 1)
169
- return sentFromSeq > known + 1 ? sentFromSeq : null
170
- }
171
-
172
- // The lowest transcript seq in an event list (nulls sort out). Chrome events are
173
- // seq-less (NO_SEQ_KINDS) and must not anchor the stamp.
174
- export function firstSeq(events) {
175
- if (!Array.isArray(events)) return null
176
- for (const e of events) if (e && typeof e.seq === 'number') return e.seq
177
- return null
178
- }
179
-
180
- // Bound a SINGLE event so it can never overflow a broadcast frame on its own. The worst
181
- // offenders are tool_results carrying inline base64 IMAGES (a 592KB screenshot block) —
182
- // these can't ride a size-capped Realtime broadcast at all (live OR replay). Truncate
183
- // bulky string fields AND replace oversized content blocks (images, huge text) with a
184
- // compact, render-SAFE text placeholder — preserving identity (kind/seq/cid/ts/toolUseId)
185
- // so dedup, ordering, seqHi, and turn structure stay intact. Returns the event UNCHANGED
186
- // (no clone) when it's already small; clones before mutating so the persisted log is never
187
- // touched. (Proper fix for inline images = upload to storage + broadcast a URL — separate.)
188
- export const PER_EVENT_CAP = STRUCTURED_EVENT_CAP
189
- export function boundEventForBroadcast(evt, cap = PER_EVENT_CAP) {
190
- return boundStructuredEvent(evt, cap)
191
- }
192
-
193
- // Detect inline base64 IMAGE blocks on a tool_result event (a screenshot the agent
194
- // produced — content:[{type:'image', source:{type:'base64', media_type, data}}]).
195
- // Returns [{ idx, b64, mediaType }] per base64 image block, or [] when none. Pure +
196
- // import-safe so bridge/test-seq.mjs can exercise it. The bridge uses this to lift
197
- // images OUT of the event (upload to Storage, swap to a {type:'image', path} ref)
198
- // BEFORE the base64 ever enters the persisted log or a broadcast frame — so a
199
- // screenshot renders live AND in replay instead of being stripped to a placeholder
200
- // by boundEventForBroadcast (which stays the fallback for any block we don't lift).
201
- export function inlineImageBlocks(evt) {
202
- if (!evt || evt.kind !== 'tool_result' || !Array.isArray(evt.content)) return []
203
- const out = []
204
- evt.content.forEach((b, idx) => {
205
- if (b && b.type === 'image' && b.source && b.source.type === 'base64' &&
206
- typeof b.source.data === 'string' && b.source.data.length) {
207
- out.push({ idx, b64: b.source.data, mediaType: b.source.media_type || 'image/png' })
208
- }
209
- })
210
- return out
211
- }
212
-
213
- // One ordered emission boundary for structured-session events while agent screenshots
214
- // are lifted out of inline base64. This stays import-safe: bridge.mjs owns room/auth
215
- // state; these definitions own deadlines, bounded reservations, ordering, and teardown.
216
- export const SCREENSHOT_OMITTED = '[screenshot omitted — upload failed]'
217
- export const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS = 15_000
218
- export const DEFAULT_IMAGE_QUEUE_MAX_PENDING = 4
219
- export const DEFAULT_IMAGE_QUEUE_MAX_BYTES = 16_000_000
220
-
221
- const sanePositiveInt = (value, fallback) => {
222
- const n = Number(value)
223
- return Number.isSafeInteger(n) && n > 0 ? n : fallback
224
- }
225
-
226
- export function imageQueueConfig(env = process.env) {
227
- return {
228
- uploadTimeoutMs: sanePositiveInt(env.TP_CODE_IMAGE_UPLOAD_TIMEOUT_MS, DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS),
229
- maxPending: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_PENDING, DEFAULT_IMAGE_QUEUE_MAX_PENDING),
230
- maxBytes: sanePositiveInt(env.TP_CODE_IMAGE_QUEUE_MAX_BYTES, DEFAULT_IMAGE_QUEUE_MAX_BYTES),
231
- }
232
- }
233
-
234
- // Deadline covers fetch AND response parsing. Promise.race is deliberate: AbortSignal is
235
- // still passed to real fetch, but an injected/broken fetch that ignores it cannot pin the
236
- // ordered queue forever. activeUploads lets session teardown cancel the deadline now.
237
- export async function uploadCodeImage({
238
- webBase,
239
- room,
240
- authToken,
241
- term,
242
- cid,
243
- image,
244
- fetchImpl = globalThis.fetch,
245
- timeoutMs = DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS,
246
- activeUploads = new Set(),
247
- }) {
248
- const controller = new AbortController()
249
- let timer = null
250
- let rejectCancel = null
251
- let settled = false
252
-
253
- const cancelled = new Promise((_, reject) => { rejectCancel = reject })
254
- const handle = {
255
- cancel(reason = new Error('code-image upload cancelled')) {
256
- if (settled) return
257
- if (timer) { clearTimeout(timer); timer = null }
258
- try { controller.abort(reason) } catch { /* AbortController is best-effort */ }
259
- rejectCancel(reason instanceof Error ? reason : new Error(String(reason)))
260
- },
261
- }
262
- activeUploads.add(handle)
263
- timer = setTimeout(() => handle.cancel(new Error(`code-image upload timed out after ${timeoutMs}ms`)), timeoutMs)
264
- timer.unref?.()
265
-
266
- const headers = { 'Content-Type': 'application/json' }
267
- if (authToken) headers.Authorization = `Bearer ${authToken}`
268
- const request = (async () => {
269
- const res = await fetchImpl(`${webBase}/api/code-image`, {
270
- method: 'POST',
271
- headers,
272
- signal: controller.signal,
273
- body: JSON.stringify({ code: room, term, cid, idx: image.idx, mediaType: image.mediaType, b64: image.b64 }),
274
- })
275
- if (!res.ok) throw new Error(`code-image ${res.status}`)
276
- const { path } = await res.json()
277
- if (!path) throw new Error('code-image: no path')
278
- return path
279
- })()
280
-
281
- try {
282
- return await Promise.race([request, cancelled])
283
- } finally {
284
- settled = true
285
- if (timer) clearTimeout(timer)
286
- activeUploads.delete(handle)
287
- }
288
- }
289
-
290
- const encodedImageBytes = (image) => Buffer.byteLength(String(image?.b64 || ''), 'ascii')
291
-
292
- function stripRawImages(evt, images, placeholder) {
293
- for (const image of images) {
294
- const block = Array.isArray(evt?.content) ? evt.content[image.idx] : null
295
- if (block?.source?.type === 'base64' || image.b64) {
296
- evt.content[image.idx] = { type: 'text', text: placeholder }
297
- }
298
- image.b64 = ''
299
- }
300
- }
301
-
302
- export class ImageEventQueue {
303
- constructor({
304
- upload,
305
- maxPending = DEFAULT_IMAGE_QUEUE_MAX_PENDING,
306
- maxBytes = DEFAULT_IMAGE_QUEUE_MAX_BYTES,
307
- placeholder = SCREENSHOT_OMITTED,
308
- onUploadError = () => {},
309
- onOverload = () => {},
310
- }) {
311
- if (typeof upload !== 'function') throw new TypeError('ImageEventQueue requires upload')
312
- this.upload = upload
313
- this.maxPending = sanePositiveInt(maxPending, DEFAULT_IMAGE_QUEUE_MAX_PENDING)
314
- this.maxBytes = sanePositiveInt(maxBytes, DEFAULT_IMAGE_QUEUE_MAX_BYTES)
315
- this.placeholder = placeholder
316
- this.onUploadError = onUploadError
317
- this.onOverload = onOverload
318
- this.pendingCount = 0
319
- this.pendingBytes = 0
320
- this.peakPendingCount = 0
321
- this.peakPendingBytes = 0
322
- this.activeUploads = new Set()
323
- this.tasks = new Set()
324
- this.tail = Promise.resolve()
325
- this.closed = false
326
- this.overloadNotified = false
327
- }
328
-
329
- stats() {
330
- return {
331
- pendingCount: this.pendingCount,
332
- pendingBytes: this.pendingBytes,
333
- peakPendingCount: this.peakPendingCount,
334
- peakPendingBytes: this.peakPendingBytes,
335
- activeUploads: this.activeUploads.size,
336
- queuedEvents: this.tasks.size,
337
- }
338
- }
339
-
340
- enqueue(evt, images, emitTail) {
341
- if (this.closed || typeof emitTail !== 'function') return false
342
- const imgs = Array.isArray(images) ? images : []
343
- const count = imgs.length
344
- const bytes = imgs.reduce((sum, image) => sum + encodedImageBytes(image), 0)
345
- const accepted = count > 0 &&
346
- this.pendingCount + count <= this.maxPending &&
347
- this.pendingBytes + bytes <= this.maxBytes
348
-
349
- if (count && accepted) {
350
- for (const image of imgs) {
351
- image._queueBytes = encodedImageBytes(image)
352
- image._queueReserved = true
353
- }
354
- this.pendingCount += count
355
- this.pendingBytes += bytes
356
- this.peakPendingCount = Math.max(this.peakPendingCount, this.pendingCount)
357
- this.peakPendingBytes = Math.max(this.peakPendingBytes, this.pendingBytes)
358
- } else if (count) {
359
- // Critical memory boundary: discard raw base64 BEFORE this event can join a
360
- // chain blocked behind an earlier upload.
361
- stripRawImages(evt, imgs, this.placeholder)
362
- if (!this.overloadNotified) {
363
- this.overloadNotified = true
364
- this.onOverload({ count, bytes, maxPending: this.maxPending, maxBytes: this.maxBytes })
365
- }
366
- }
367
-
368
- // Preserve the old synchronous fast path when no image upload is ahead of us.
369
- if (!accepted && this.tasks.size === 0) {
370
- emitTail(evt)
371
- return true
372
- }
373
-
374
- const task = { evt, images: imgs, emitTail, accepted, emitted: false, done: false }
375
- this.tasks.add(task)
376
- this.tail = this.tail.catch(() => {}).then(() => this._run(task))
377
- return true
378
- }
379
-
380
- async _run(task) {
381
- if (task.done || this.closed) return
382
- try {
383
- if (task.accepted) {
384
- for (const image of task.images) {
385
- if (!image._queueReserved || this.closed) continue
386
- try {
387
- const path = await this.upload(task.evt, image, this.activeUploads)
388
- task.evt.content[image.idx] = { type: 'image', path, mediaType: image.mediaType }
389
- } catch (error) {
390
- task.evt.content[image.idx] = { type: 'text', text: this.placeholder }
391
- if (!this.closed) this.onUploadError(error)
392
- } finally {
393
- image.b64 = ''
394
- this._release(image)
395
- }
396
- }
397
- }
398
- if (!this.closed && !task.emitted) {
399
- task.emitTail(task.evt)
400
- task.emitted = true
401
- }
402
- } finally {
403
- task.done = true
404
- this.tasks.delete(task)
405
- }
406
- }
407
-
408
- _release(image) {
409
- if (!image?._queueReserved) return
410
- image._queueReserved = false
411
- this.pendingCount = Math.max(0, this.pendingCount - 1)
412
- this.pendingBytes = Math.max(0, this.pendingBytes - (image._queueBytes || 0))
413
- image._queueBytes = 0
414
- }
415
-
416
- async idle() {
417
- await this.tail.catch(() => {})
418
- }
419
-
420
- // Session close/provider switch/shutdown is synchronous. Cancel active deadlines,
421
- // replace any still-raw image, and flush queued tails in insertion order before the
422
- // owner persists/deletes the entry. Later promise continuations see done/closed.
423
- close({ flush = true } = {}) {
424
- if (this.closed) return
425
- this.closed = true
426
- const reason = new Error('code-image queue closed')
427
- for (const upload of this.activeUploads) upload.cancel(reason)
428
- this.activeUploads.clear()
429
- for (const task of this.tasks) {
430
- stripRawImages(task.evt, task.images, this.placeholder)
431
- for (const image of task.images) this._release(image)
432
- if (flush && !task.emitted) {
433
- try { task.emitTail(task.evt) } catch { /* teardown must continue */ }
434
- task.emitted = true
435
- }
436
- task.done = true
437
- }
438
- this.tasks.clear()
439
- this.pendingCount = 0
440
- this.pendingBytes = 0
441
- }
442
- }
443
-
444
- // Pure aggregation for the /usage report — replay a session's retained log events and
445
- // fold every completed turn (kind:'result' with usage) into per-model + total token/cost
446
- // sums. No side effects, import-safe (bridge/test-usage-summary.mjs exercises it), so the
447
- // bridge computes /usage by replaying its own durable log — no separate accumulator that a
448
- // restart would drop. `costUsd` is the SDK's total_cost_usd per turn (BYOK estimate).
449
- export function summarizeUsage(logEvents) {
450
- const results = (Array.isArray(logEvents) ? logEvents : []).filter((e) => e && e.kind === 'result' && e.usage)
451
- const per = new Map()
452
- let totalIn = 0, totalOut = 0, totalCost = 0, haveCost = false
453
- for (const e of results) {
454
- const u = e.usage || {}
455
- const inTok = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
456
- const out = u.output_tokens || 0
457
- const cached = u.cache_read_input_tokens || 0
458
- const mid = e.model || 'unknown'
459
- const r = per.get(mid) || { model: mid, in: 0, out: 0, cached: 0 }
460
- r.in += inTok; r.out += out; r.cached += cached; per.set(mid, r)
461
- totalIn += inTok; totalOut += out
462
- if (typeof e.costUsd === 'number') { totalCost += e.costUsd; haveCost = true }
463
- }
464
- return { turns: results.length, per: [...per.values()], totalIn, totalOut, totalCost, haveCost }
465
- }
466
-
467
- // Does this model id name a Claude/Anthropic model? Used by two callers with the
468
- // same question and NO room for a second opinion:
469
- // • usageReportLine — token/cost reporting is Claude-only.
470
- // • providers.mjs/effectiveLaneModel — a lane on a CUSTOM (non-anthropic) provider
471
- // must never be handed an inherited Claude id; the endpoint 400s on it.
472
- // Exported (not a local const) precisely so there is exactly one definition.
473
- export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
474
-
475
- // model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
476
- // Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
477
- // The `[1m]` long-context suffix is PRESERVED. `claude-opus-4-8` and `claude-opus-4-8[1m]`
478
- // are different models on different rates and summarizeUsage keys them apart — collapsing
479
- // both to "Opus 4.8" printed two identical, unattributable rows in one report (real log,
480
- // room 1TV7B59A: `Opus 4.8: 51.8M in … · Opus 4.8: 46.0M in …`). 2026-07-10.
481
- export function prettyModelId (m) {
482
- if (!m) return 'unknown'
483
- const raw = String(m)
484
- const long = /\[1m\]/i.test(raw) ? ' [1m]' : ''
485
- const mm = raw.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
486
- return mm ? `${mm[1][0].toUpperCase()}${mm[1].slice(1)} ${mm[2]}${mm[3] ? '.' + mm[3] : ''}${long}` : raw
487
- }
488
-
489
- // Build the ONE ◆ control line the /usage command emits, from the session's model +
490
- // retained log. Pure so the whole command — gate, empty-state, and per-model/total/cost
491
- // formatting — is exercised deterministically (bridge/test-usage-summary.mjs). The bridge
492
- // handler is a thin wrapper: ctlLine(usageReportLine(s.model, s.log)). `sessionModel` is
493
- // the resolved model id the /model state tracks; `logEvents` the session's retained log.
494
- export function usageReportLine (sessionModel, logEvents) {
495
- const sum = summarizeUsage(logEvents)
496
- // Gate (Max's ask: "make /usage work if users bridge is on claude llms"). The Code bridge
497
- // runs the Claude Agent SDK, so a normal lane IS a Claude model — but a session pointed at a
498
- // non-Claude base URL/model gets a legible line, not a bogus token report. Only assert this
499
- // when we actually know a model (else a fresh empty session would wrongly trip the gate).
500
- const anyClaude = claudeish(sessionModel) || sum.per.some((r) => claudeish(r.model))
501
- if (!anyClaude && (sessionModel || sum.turns)) return 'usage reporting is available on Claude models only'
502
- if (!sum.turns) return 'usage · no completed turns yet this session'
503
- const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n)
504
- // Drop models that burned nothing. The SDK stamps a `result` with the resolved id even for
505
- // turns that spent no tokens (an interrupt, a no-op /compact), so a real report carried dead
506
- // rows like `opus[1m]: 0 in / 0 out` (room 1TV7B59A). If EVERY row is empty, print them all
507
- // rather than an eerie modelless line. `total` keys off the printed rows, not the raw set.
508
- const rows = sum.per.filter((r) => r.in || r.out).length ? sum.per.filter((r) => r.in || r.out) : sum.per
509
- const perStr = rows.map((r) => {
510
- const cch = r.cached ? ` (${fmtTok(r.cached)} cached)` : ''
511
- return `${prettyModelId(r.model)}: ${fmtTok(r.in)} in${cch} / ${fmtTok(r.out)} out`
512
- }).join(' · ')
513
- const total = rows.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
514
- const cost = sum.haveCost ? ` · ~$${sum.totalCost.toFixed(2)} est` : ''
515
- const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
516
- // No "(no plan/rate-limit meters)" tail any more — the handler appends a second control
517
- // line from bridge/plan-meters.mjs carrying the real 5h/weekly utilization + resets, or
518
- // an explicit "plan meters unavailable". Neither claim belongs on this token/cost line.
519
- return `usage · ${turns} · ${perStr}${total}${cost}`
520
- }
521
-
522
- // Codex exposes exact current-context and lifetime-thread counters in its native
523
- // rollout telemetry. Keep this separate from usageReportLine: Claude result
524
- // events are per turn, while Codex's public turn.completed counters are lifetime
525
- // totals and cannot safely be folded from the retained log.
526
- export function codexUsageReportLine (sessionModel, snapshot) {
527
- const ctx = snapshot?.context
528
- const total = snapshot?.total
529
- if (!ctx || !total) return 'usage · no completed Codex turn yet'
530
- const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n || 0))
531
- const used = Math.max(0, Number(ctx.used) || 0)
532
- const max = Math.max(0, Number(ctx.max) || 0)
533
- const pct = max > 0 ? Math.min(100, Math.max(0, Math.round((used / max) * 100))) : 0
534
- const input = Math.max(0, Number(total.input_tokens) || 0)
535
- const cached = Math.min(input, Math.max(0, Number(total.cached_input_tokens) || 0))
536
- const output = Math.max(0, Number(total.output_tokens) || 0)
537
- return `usage · ${prettyModelId(sessionModel)} · context ${fmtTok(used)}/${fmtTok(max)} (${pct}%) · thread ${fmtTok(input)} processed (${fmtTok(cached)} cached) / ${fmtTok(output)} out`
538
- }
539
-
540
- /* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
541
- *
542
- * Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
543
- * conversation across a bridge wake, and this module imports `node:crypto` —
544
- * unresolvable in a browser bundle. The pure half lives in an import-free file
545
- * both sides can load; these re-exports keep every existing bridge-side import
546
- * (`from './event-id.mjs'`) working untouched, including bridge/recap.test.mjs.
547
- *
548
- * recap.mjs must stay in bridge/package.json `files` — event-id.mjs imports it. */
549
- export { CHECKPOINT_CAP, CURRENT_PERSON_REQUEST_MARKER, RECAP_CAP, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, recapHasCheckpoint, resolveCheckpointCarry } from './recap.mjs'
1
+ import{randomUUID as e}from"node:crypto";import{boundStructuredEvent as t,STRUCTURED_EVENT_CAP as n}from"./event-bounds.mjs";import{classifyCodeEvent as o}from"./code-event-contract.mjs";export function stampEvent(t,n=e){return t&&"object"==typeof t?("number"!=typeof t.ts&&(t.ts=Date.now()),t.cid||(t.cid=n()),t):t}export function makeSeqCounter(e=0){let t=Number.isFinite(e)?e:0;return{next:()=>++t,bumpTo:e=>{Number.isFinite(e)&&e>t&&(t=e)},current:()=>t}}export const NO_SEQ_KINDS=new Set(["system","thinking_tokens","effort","models","queue-add","queue-cancel","propose-done","update-available"]);export const seqable=e=>!(!e||"object"!=typeof e)&&o(e,{strict:!1}).known&&!NO_SEQ_KINDS.has(e.kind);export function maxSeq(e){if(!Array.isArray(e))return 0;let t=0;for(const n of e)n&&"number"==typeof n.seq&&n.seq>t&&(t=n.seq);return t}export const REPLAY_BYTE_BUDGET=15e4;export function capReplayEvents(e,t=15e4){if(!Array.isArray(e)||0===e.length)return e;let n=0,o=e.length;for(;o>0;){const s=JSON.stringify(e[o-1]).length+1;if(o<e.length&&n+s>t)break;n+=s,o--}return 0===o?e:e.slice(o)}export const REPLAY_MAX_CHUNKS=14;export function chunkReplayEvents(e,t=15e4,n=14){if(!Array.isArray(e)||0===e.length)return[];const o=[];let s=[],i=0;for(let r=e.length-1;r>=0;r--){const a=JSON.stringify(e[r]).length+1;if(s.length&&i+a>t){if(o.push(s),o.length>=n){s=null;break}s=[],i=0}s.push(e[r]),i+=a}return s&&s.length&&o.push(s),o.reverse().map(e=>e.reverse())}export function trimmedBeforeSeq(e,t=0,n=null){if("number"!=typeof e)return null;const o="number"==typeof n?n:1;return e>Math.max(t||0,o-1)+1?e:null}export function firstSeq(e){if(!Array.isArray(e))return null;for(const t of e)if(t&&"number"==typeof t.seq)return t.seq;return null}export const PER_EVENT_CAP=n;export function boundEventForBroadcast(e,n=PER_EVENT_CAP){return t(e,n)}export function inlineImageBlocks(e){if(!e||"tool_result"!==e.kind||!Array.isArray(e.content))return[];const t=[];return e.content.forEach((e,n)=>{e&&"image"===e.type&&e.source&&"base64"===e.source.type&&"string"==typeof e.source.data&&e.source.data.length&&t.push({idx:n,b64:e.source.data,mediaType:e.source.media_type||"image/png"})}),t}export const SCREENSHOT_OMITTED="[screenshot omitted — upload failed]";export const DEFAULT_IMAGE_UPLOAD_TIMEOUT_MS=15e3;export const DEFAULT_IMAGE_QUEUE_MAX_PENDING=4;export const DEFAULT_IMAGE_QUEUE_MAX_BYTES=16e6;const s=(e,t)=>{const n=Number(e);return Number.isSafeInteger(n)&&n>0?n:t};export function imageQueueConfig(e=process.env){return{uploadTimeoutMs:s(e.TP_CODE_IMAGE_UPLOAD_TIMEOUT_MS,15e3),maxPending:s(e.TP_CODE_IMAGE_QUEUE_MAX_PENDING,4),maxBytes:s(e.TP_CODE_IMAGE_QUEUE_MAX_BYTES,16e6)}}export async function uploadCodeImage({webBase:e,room:t,authToken:n,term:o,cid:s,image:i,fetchImpl:r=globalThis.fetch,timeoutMs:a=15e3,activeUploads:u=new Set}){const c=new AbortController;let d=null,l=null,p=!1;const h=new Promise((e,t)=>{l=t}),m={cancel(e=new Error("code-image upload cancelled")){if(!p){d&&(clearTimeout(d),d=null);try{c.abort(e)}catch{}l(e instanceof Error?e:new Error(String(e)))}}};u.add(m),d=setTimeout(()=>m.cancel(new Error(`code-image upload timed out after ${a}ms`)),a),d.unref?.();const f={"Content-Type":"application/json"};n&&(f.Authorization=`Bearer ${n}`);const g=(async()=>{const n=await r(`${e}/api/code-image`,{method:"POST",headers:f,signal:c.signal,body:JSON.stringify({code:t,term:o,cid:s,idx:i.idx,mediaType:i.mediaType,b64:i.b64})});if(!n.ok)throw new Error(`code-image ${n.status}`);const{path:a}=await n.json();if(!a)throw new Error("code-image: no path");return a})();try{return await Promise.race([g,h])}finally{p=!0,d&&clearTimeout(d),u.delete(m)}}const i=e=>Buffer.byteLength(String(e?.b64||""),"ascii");function r(e,t,n){for(const o of t){const t=Array.isArray(e?.content)?e.content[o.idx]:null;("base64"===t?.source?.type||o.b64)&&(e.content[o.idx]={type:"text",text:n}),o.b64=""}}export class ImageEventQueue{constructor({upload:e,maxPending:t=4,maxBytes:n=16e6,placeholder:o=SCREENSHOT_OMITTED,onUploadError:i=()=>{},onOverload:r=()=>{}}){if("function"!=typeof e)throw new TypeError("ImageEventQueue requires upload");this.upload=e,this.maxPending=s(t,4),this.maxBytes=s(n,16e6),this.placeholder=o,this.onUploadError=i,this.onOverload=r,this.pendingCount=0,this.pendingBytes=0,this.peakPendingCount=0,this.peakPendingBytes=0,this.activeUploads=new Set,this.tasks=new Set,this.tail=Promise.resolve(),this.closed=!1,this.overloadNotified=!1}stats(){return{pendingCount:this.pendingCount,pendingBytes:this.pendingBytes,peakPendingCount:this.peakPendingCount,peakPendingBytes:this.peakPendingBytes,activeUploads:this.activeUploads.size,queuedEvents:this.tasks.size}}enqueue(e,t,n){if(this.closed||"function"!=typeof n)return!1;const o=Array.isArray(t)?t:[],s=o.length,a=o.reduce((e,t)=>e+i(t),0),u=s>0&&this.pendingCount+s<=this.maxPending&&this.pendingBytes+a<=this.maxBytes;if(s&&u){for(const e of o)e._queueBytes=i(e),e._queueReserved=!0;this.pendingCount+=s,this.pendingBytes+=a,this.peakPendingCount=Math.max(this.peakPendingCount,this.pendingCount),this.peakPendingBytes=Math.max(this.peakPendingBytes,this.pendingBytes)}else s&&(r(e,o,this.placeholder),this.overloadNotified||(this.overloadNotified=!0,this.onOverload({count:s,bytes:a,maxPending:this.maxPending,maxBytes:this.maxBytes})));if(!u&&0===this.tasks.size)return n(e),!0;const c={evt:e,images:o,emitTail:n,accepted:u,emitted:!1,done:!1};return this.tasks.add(c),this.tail=this.tail.catch(()=>{}).then(()=>this._run(c)),!0}async _run(e){if(!e.done&&!this.closed)try{if(e.accepted)for(const t of e.images)if(t._queueReserved&&!this.closed)try{const n=await this.upload(e.evt,t,this.activeUploads);e.evt.content[t.idx]={type:"image",path:n,mediaType:t.mediaType}}catch(n){e.evt.content[t.idx]={type:"text",text:this.placeholder},this.closed||this.onUploadError(n)}finally{t.b64="",this._release(t)}this.closed||e.emitted||(e.emitTail(e.evt),e.emitted=!0)}finally{e.done=!0,this.tasks.delete(e)}}_release(e){e?._queueReserved&&(e._queueReserved=!1,this.pendingCount=Math.max(0,this.pendingCount-1),this.pendingBytes=Math.max(0,this.pendingBytes-(e._queueBytes||0)),e._queueBytes=0)}async idle(){await this.tail.catch(()=>{})}close({flush:e=!0}={}){if(this.closed)return;this.closed=!0;const t=new Error("code-image queue closed");for(const e of this.activeUploads)e.cancel(t);this.activeUploads.clear();for(const t of this.tasks){r(t.evt,t.images,this.placeholder);for(const e of t.images)this._release(e);if(e&&!t.emitted){try{t.emitTail(t.evt)}catch{}t.emitted=!0}t.done=!0}this.tasks.clear(),this.pendingCount=0,this.pendingBytes=0}}export function summarizeUsage(e){const t=(Array.isArray(e)?e:[]).filter(e=>e&&"result"===e.kind&&e.usage),n=new Map;let o=0,s=0,i=0,r=!1;for(const e of t){const t=e.usage||{},a=(t.input_tokens||0)+(t.cache_creation_input_tokens||0)+(t.cache_read_input_tokens||0),u=t.output_tokens||0,c=t.cache_read_input_tokens||0,d=e.model||"unknown",l=n.get(d)||{model:d,in:0,out:0,cached:0};l.in+=a,l.out+=u,l.cached+=c,n.set(d,l),o+=a,s+=u,"number"==typeof e.costUsd&&(i+=e.costUsd,r=!0)}return{turns:t.length,per:[...n.values()],totalIn:o,totalOut:s,totalCost:i,haveCost:r}}export const claudeish=e=>/claude|anthropic|opus|sonnet|haiku|fable/i.test(String(e||""));export function prettyModelId(e){if(!e)return"unknown";const t=String(e),n=/\[1m\]/i.test(t)?" [1m]":"",o=t.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i);return o?`${o[1][0].toUpperCase()}${o[1].slice(1)} ${o[2]}${o[3]?"."+o[3]:""}${n}`:t}export function usageReportLine(e,t){const n=summarizeUsage(t);if(!claudeish(e)&&!n.per.some(e=>claudeish(e.model))&&(e||n.turns))return"usage reporting is available on Claude models only";if(!n.turns)return"usage · no completed turns yet this session";const o=e=>e>=1e6?(e/1e6).toFixed(1)+"M":e>=1e3?(e/1e3).toFixed(1)+"k":String(e),s=n.per.filter(e=>e.in||e.out).length?n.per.filter(e=>e.in||e.out):n.per,i=s.map(e=>{const t=e.cached?` (${o(e.cached)} cached)`:"";return`${prettyModelId(e.model)}: ${o(e.in)} in${t} / ${o(e.out)} out`}).join(" · "),r=s.length>1?` · total ${o(n.totalIn)} in / ${o(n.totalOut)} out`:"",a=n.haveCost?` · ~$${n.totalCost.toFixed(2)} est`:"";return`usage · ${n.turns} turn${1===n.turns?"":"s"} · ${i}${r}${a}`}export function codexUsageReportLine(e,t){const n=t?.context,o=t?.total;if(!n||!o)return"usage · no completed Codex turn yet";const s=e=>e>=1e6?(e/1e6).toFixed(1)+"M":e>=1e3?(e/1e3).toFixed(1)+"k":String(Math.round(e||0)),i=Math.max(0,Number(n.used)||0),r=Math.max(0,Number(n.max)||0),a=r>0?Math.min(100,Math.max(0,Math.round(i/r*100))):0,u=Math.max(0,Number(o.input_tokens)||0),c=Math.min(u,Math.max(0,Number(o.cached_input_tokens)||0)),d=Math.max(0,Number(o.output_tokens)||0);return`usage · ${prettyModelId(e)} · context ${s(i)}/${s(r)} (${a}%) · thread ${s(u)} processed (${s(c)} cached) / ${s(d)} out`}export{CHECKPOINT_CAP,CURRENT_PERSON_REQUEST_MARKER,RECAP_CAP,appendCurrentPersonRequest,buildCheckpointFromLog,buildRecapFromLog,recapHasCheckpoint,resolveCheckpointCarry}from"./recap.mjs";
@@ -1,50 +1 @@
1
- // Strict, runtime-neutral EvidenceEnvelopeV1 parsing. Only an actual
2
- // project-context evidence tool result can create room evidence in S1.
3
- import { createHash } from 'node:crypto'
4
- import { EVIDENCE_CAP, compactEvidenceEnvelope } from './evidence-compact.mjs'
5
-
6
- export { EVIDENCE_CAP, compactEvidenceEnvelope }
7
-
8
- export const EVIDENCE_START = '<<<TP_EVIDENCE_V1>>>'
9
- export const EVIDENCE_END = '<<<END_TP_EVIDENCE_V1>>>'
10
- const LEVELS = new Set(['exact', 'section', 'source', 'unavailable'])
11
- const WARNINGS = new Set(['NO_MATCH', 'LOW_LEXICAL_MATCH', 'UNCALIBRATED_RELEVANCE', 'SECTION_ONLY', 'SOURCE_ONLY', 'SOURCE_UNAVAILABLE', 'SPAN_ANCHOR_MISMATCH', 'TRANSCRIPT_BOUNDED'])
12
- const safeText = (value, max) => typeof value === 'string' && value.length <= max && !/(?:https?:\/\/|file:|bearer\s|token=|signature=|x-amz-|\.\.[\\/]|^\/|[A-Za-z]:[\\/])/i.test(value)
13
- const isProjectEvidenceTool = (name) => ['mcp__project_context__search_context_evidence', 'mcp__project-context__search_context_evidence'].includes(String(name || ''))
14
- const textContent = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.map((part) => typeof part === 'string' ? part : part?.text || '').join('\n') : ''
15
- const excerptDigest = (value) => createHash('sha256').update(value, 'utf8').digest('hex')
16
-
17
- function validCitation(raw) {
18
- if (!raw || raw.v !== 1 || !/^ev1_[a-f0-9]{24,64}$/.test(raw.id || '')) return null
19
- const source = raw.source || {}, locator = raw.locator || {}, revision = source.revision || {}, assessment = raw.assessment || {}, provenance = raw.provenance || {}
20
- if (source.kind !== 'project_prose' || source.collection !== 'repo' || !safeText(source.label, 180) || !safeText(source.path, 500) || !/^[a-f0-9]{40,64}$/i.test(revision.value || '') || !['git', 'content_sha256'].includes(revision.kind)) return null
21
- if (!LEVELS.has(locator.level) || !safeText(locator.heading || '', 180)) return null
22
- if (!['high', 'medium', 'low', 'unscored'].includes(assessment.relevance) || !['direct', 'partial', 'source_only', 'unavailable'].includes(assessment.confidence) || !Array.isArray(assessment.warnings) || assessment.warnings.some((warning) => !WARNINGS.has(warning))) return null
23
- if (provenance.capturedBy !== 'project-context' || provenance.method !== 'fts5_heading') return null
24
- const citation = { v: 1, id: raw.id, source: { kind: source.kind, collection: source.collection, label: source.label, path: source.path, revision: { kind: revision.kind, value: revision.value, ...(safeText(revision.indexedAt || '', 80) ? { indexedAt: revision.indexedAt } : {}) } }, locator: { level: locator.level, ...(safeText(locator.heading || '', 180) ? { heading: locator.heading } : {}) }, provenance: { capturedBy: provenance.capturedBy, method: provenance.method, ...(safeText(provenance.retrievedAt || '', 80) ? { retrievedAt: provenance.retrievedAt } : {}), ...(safeText(provenance.query || '', 160) ? { query: provenance.query } : {}), ...(Number.isSafeInteger(provenance.rank) && provenance.rank > 0 ? { rank: provenance.rank } : {}) }, assessment: { relevance: assessment.relevance, confidence: assessment.confidence, warnings: [...new Set(assessment.warnings)].slice(0, 8) } }
25
- if (locator.level === 'exact') {
26
- if (![locator.charStart, locator.charEnd, locator.lineStart, locator.lineEnd].every(Number.isSafeInteger) || locator.charStart < 0 || locator.charEnd <= locator.charStart || locator.lineStart < 1 || locator.lineEnd < locator.lineStart || !safeText(locator.anchorBefore || '', 96) || !safeText(locator.anchorAfter || '', 96) || !safeText(raw.excerpt || '', 600) || !/^[a-f0-9]{64}$/i.test(raw.excerptSha256 || '') || excerptDigest(raw.excerpt) !== raw.excerptSha256) return null
27
- citation.locator = { ...citation.locator, charStart: locator.charStart, charEnd: locator.charEnd, lineStart: locator.lineStart, lineEnd: locator.lineEnd, anchorBefore: locator.anchorBefore, anchorAfter: locator.anchorAfter }
28
- citation.excerpt = raw.excerpt
29
- citation.excerptSha256 = raw.excerptSha256
30
- }
31
- return citation
32
- }
33
-
34
- export function parseEvidenceEnvelope(toolName, content) {
35
- if (!isProjectEvidenceTool(toolName)) return null
36
- const text = textContent(content)
37
- const start = text.indexOf(EVIDENCE_START), end = text.indexOf(EVIDENCE_END)
38
- if (start < 0 || end < start || text.indexOf(EVIDENCE_START, start + EVIDENCE_START.length) >= 0 || text.indexOf(EVIDENCE_END, end + EVIDENCE_END.length) >= 0) return null
39
- let raw
40
- try { raw = JSON.parse(text.slice(start + EVIDENCE_START.length, end)) } catch { return null }
41
- if (!raw || raw.v !== 1 || !Array.isArray(raw.citations) || raw.citations.length > EVIDENCE_CAP) return null
42
- const citations = raw.citations.map(validCitation)
43
- if (citations.some((citation) => !citation)) return null
44
- const codes = Array.isArray(raw.summary?.warningCodes) ? raw.summary.warningCodes.filter((code) => WARNINGS.has(code)).slice(0, 8) : []
45
- const status = ['evidence_found', 'weak_evidence', 'no_evidence'].includes(raw.summary?.status) ? raw.summary.status : null
46
- if (!status || !Array.isArray(raw.summary?.searchedCollections) || raw.summary.searchedCollections.some((collection) => collection !== 'repo')) return null
47
- return { v: 1, citations, summary: { searchedCollections: ['repo'], directCount: citations.filter((citation) => ['exact', 'section'].includes(citation.locator.level)).length, warningCodes: [...new Set(codes)], status } }
48
- }
49
-
50
- export function evidenceForToolResult(toolName, content) { return parseEvidenceEnvelope(toolName, content) || undefined }
1
+ import{createHash as e}from"node:crypto";import{EVIDENCE_CAP as r,compactEvidenceEnvelope as n}from"./evidence-compact.mjs";export{r as EVIDENCE_CAP,n as compactEvidenceEnvelope};export const EVIDENCE_START="<<<TP_EVIDENCE_V1>>>";export const EVIDENCE_END="<<<END_TP_EVIDENCE_V1>>>";const t=new Set(["exact","section","source","unavailable"]),a=new Set(["NO_MATCH","LOW_LEXICAL_MATCH","UNCALIBRATED_RELEVANCE","SECTION_ONLY","SOURCE_ONLY","SOURCE_UNAVAILABLE","SPAN_ANCHOR_MISMATCH","TRANSCRIPT_BOUNDED"]),i=(e,r)=>"string"==typeof e&&e.length<=r&&!/(?:https?:\/\/|file:|bearer\s|token=|signature=|x-amz-|\.\.[\\/]|^\/|[A-Za-z]:[\\/])/i.test(e);function c(r){if(!r||1!==r.v||!/^ev1_[a-f0-9]{24,64}$/.test(r.id||""))return null;const n=r.source||{},c=r.locator||{},o=n.revision||{},l=r.assessment||{},s=r.provenance||{};if(!("project_prose"===n.kind&&"repo"===n.collection&&i(n.label,180)&&i(n.path,500)&&/^[a-f0-9]{40,64}$/i.test(o.value||"")&&["git","content_sha256"].includes(o.kind)))return null;if(!t.has(c.level)||!i(c.heading||"",180))return null;if(!["high","medium","low","unscored"].includes(l.relevance)||!["direct","partial","source_only","unavailable"].includes(l.confidence)||!Array.isArray(l.warnings)||l.warnings.some(e=>!a.has(e)))return null;if("project-context"!==s.capturedBy||"fts5_heading"!==s.method)return null;const d={v:1,id:r.id,source:{kind:n.kind,collection:n.collection,label:n.label,path:n.path,revision:{kind:o.kind,value:o.value,...i(o.indexedAt||"",80)?{indexedAt:o.indexedAt}:{}}},locator:{level:c.level,...i(c.heading||"",180)?{heading:c.heading}:{}},provenance:{capturedBy:s.capturedBy,method:s.method,...i(s.retrievedAt||"",80)?{retrievedAt:s.retrievedAt}:{},...i(s.query||"",160)?{query:s.query}:{},...Number.isSafeInteger(s.rank)&&s.rank>0?{rank:s.rank}:{}},assessment:{relevance:l.relevance,confidence:l.confidence,warnings:[...new Set(l.warnings)].slice(0,8)}};if("exact"===c.level){if(![c.charStart,c.charEnd,c.lineStart,c.lineEnd].every(Number.isSafeInteger)||c.charStart<0||c.charEnd<=c.charStart||c.lineStart<1||c.lineEnd<c.lineStart||!i(c.anchorBefore||"",96)||!i(c.anchorAfter||"",96)||!i(r.excerpt||"",600)||!/^[a-f0-9]{64}$/i.test(r.excerptSha256||"")||(u=r.excerpt,e("sha256").update(u,"utf8").digest("hex")!==r.excerptSha256))return null;d.locator={...d.locator,charStart:c.charStart,charEnd:c.charEnd,lineStart:c.lineStart,lineEnd:c.lineEnd,anchorBefore:c.anchorBefore,anchorAfter:c.anchorAfter},d.excerpt=r.excerpt,d.excerptSha256=r.excerptSha256}var u;return d}export function parseEvidenceEnvelope(e,n){if(!["mcp__project_context__search_context_evidence","mcp__project-context__search_context_evidence"].includes(String(e||"")))return null;const t=(e=>"string"==typeof e?e:Array.isArray(e)?e.map(e=>"string"==typeof e?e:e?.text||"").join("\n"):"")(n),i=t.indexOf(EVIDENCE_START),o=t.indexOf(EVIDENCE_END);if(i<0||o<i||t.indexOf(EVIDENCE_START,i+20)>=0||t.indexOf(EVIDENCE_END,o+24)>=0)return null;let l;try{l=JSON.parse(t.slice(i+20,o))}catch{return null}if(!l||1!==l.v||!Array.isArray(l.citations)||l.citations.length>r)return null;const s=l.citations.map(c);if(s.some(e=>!e))return null;const d=Array.isArray(l.summary?.warningCodes)?l.summary.warningCodes.filter(e=>a.has(e)).slice(0,8):[],u=["evidence_found","weak_evidence","no_evidence"].includes(l.summary?.status)?l.summary.status:null;return u&&Array.isArray(l.summary?.searchedCollections)&&!l.summary.searchedCollections.some(e=>"repo"!==e)?{v:1,citations:s,summary:{searchedCollections:["repo"],directCount:s.filter(e=>["exact","section"].includes(e.locator.level)).length,warningCodes:[...new Set(d)],status:u}}:null}export function evidenceForToolResult(e,r){return parseEvidenceEnvelope(e,r)||void 0}
@@ -1,11 +1 @@
1
- // Browser-safe evidence bounding shared by the bridge and replay client.
2
- // Parsing and digest verification stay in evidence-citations.mjs (Node-only).
3
- export const EVIDENCE_CAP = 8
4
-
5
- export function compactEvidenceEnvelope(envelope, maxExcerpt = 600) {
6
- if (!envelope?.citations?.length) return envelope
7
- return { ...envelope, citations: envelope.citations.slice(0, EVIDENCE_CAP).map((citation) => {
8
- if (typeof citation.excerpt !== 'string' || citation.excerpt.length <= maxExcerpt) return citation
9
- return { ...citation, excerpt: undefined, excerptSha256: undefined, locator: { ...citation.locator, level: 'unavailable' }, assessment: { ...citation.assessment, confidence: 'unavailable', warnings: [...new Set([...citation.assessment.warnings, 'TRANSCRIPT_BOUNDED', 'SOURCE_UNAVAILABLE'])].slice(0, 8) } }
10
- }) }
11
- }
1
+ export const EVIDENCE_CAP=8;export function compactEvidenceEnvelope(e,t=600){return e?.citations?.length?{...e,citations:e.citations.slice(0,8).map(e=>"string"!=typeof e.excerpt||e.excerpt.length<=t?e:{...e,excerpt:void 0,excerptSha256:void 0,locator:{...e.locator,level:"unavailable"},assessment:{...e.assessment,confidence:"unavailable",warnings:[...new Set([...e.assessment.warnings,"TRANSCRIPT_BOUNDED","SOURCE_UNAVAILABLE"])].slice(0,8)}})}:e}