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.
- package/LICENSE +18 -0
- package/README.md +20 -0
- package/abort-turn-barrier.mjs +1 -23
- package/account.mjs +1 -1437
- package/acp-client.mjs +1 -140
- package/agent-detect.mjs +1 -28
- package/agent-notify.mjs +1 -142
- package/agent-visibility.mjs +1 -67
- package/auth-store.mjs +1 -120
- package/bridge.mjs +1 -6266
- package/byok-detect.mjs +1 -126
- package/claude-command-catalog.mjs +1 -91
- package/claude-session.mjs +1 -1519
- package/code-event-contract.mjs +1 -118
- package/codex-app-server.mjs +1 -340
- package/codex-commands.mjs +1 -83
- package/codex-event-mapper.mjs +1 -232
- package/codex-images.mjs +1 -69
- package/codex-mcp-http.mjs +1 -131
- package/codex-session.mjs +1 -1295
- package/command-catalog.mjs +1 -118
- package/command-guidance.mjs +1 -8
- package/context-contract.mjs +1 -95
- package/context-windows.mjs +1 -107
- package/cross-terminal.mjs +1 -789
- package/cumulative-event-relay.mjs +1 -53
- package/design-edit.mjs +1 -424
- package/design-source-contract.mjs +1 -4
- package/direct-pair-room.mjs +1 -57
- package/dispatch-lease.mjs +1 -37
- package/dispatch-permission-cleanup.mjs +1 -86
- package/edit-diff.mjs +1 -136
- package/error-recovery.mjs +1 -50
- package/event-bounds.mjs +1 -121
- package/event-delivery-queue.mjs +1 -60
- package/event-id.mjs +1 -549
- package/evidence-citations.mjs +1 -50
- package/evidence-compact.mjs +1 -11
- package/flow-assembly.mjs +1 -196
- package/flow-budget.mjs +1 -84
- package/flow-conductor.mjs +1 -259
- package/flow-context-store.mjs +1 -387
- package/flow-host-revert.mjs +1 -42
- package/flow-models.mjs +1 -139
- package/flow-preview.mjs +1 -148
- package/flow-receipt.mjs +1 -122
- package/flow-redispatch.mjs +1 -72
- package/flow-review-gate.mjs +1 -402
- package/flow-review-reflect.mjs +1 -115
- package/flow-review.mjs +1 -152
- package/flow-scope-evidence.mjs +1 -117
- package/flow-skill-registry.mjs +1 -140
- package/flow-task-graph.mjs +1 -562
- package/flow-worktree.mjs +1 -71
- package/git-diff-report.mjs +1 -121
- package/hermes-delegation-guard.mjs +1 -14
- package/hermes-event-mapper.mjs +1 -194
- package/hermes-isolation.mjs +1 -61
- package/hermes-model-cache.mjs +1 -54
- package/hermes-policy.mjs +1 -92
- package/hermes-probe.mjs +1 -57
- package/hermes-session.mjs +1 -683
- package/hermes-setup.mjs +1 -167
- package/host-memory.mjs +1 -116
- package/interrupted-resume.mjs +1 -95
- package/keep-awake.mjs +1 -148
- package/key-shape.mjs +1 -49
- package/lane-continuation.mjs +1 -83
- package/lane-lifecycle.mjs +1 -189
- package/lane-worktree.mjs +1 -77
- package/launcher.mjs +1 -354
- package/mcp-flight-recorder.mjs +1 -79
- package/mockup-delivery.mjs +1 -57
- package/model-prices.mjs +1 -113
- package/package.json +13 -4
- package/pair-bus.mjs +1 -98
- package/pair-control-authority.mjs +1 -89
- package/past-work-search.mjs +1 -105
- package/plan-meters.mjs +1 -144
- package/presence.mjs +1 -191
- package/privacy-report.mjs +1 -108
- package/provider-resilience.mjs +1 -356
- package/provider.mjs +1 -133
- package/providers.mjs +1 -491
- package/publish-guard.mjs +2 -0
- package/publish-manifest.json +129 -0
- package/question-response.mjs +1 -58
- package/reap-terminal.mjs +1 -68
- package/recap.mjs +1 -297
- package/replay-transport.mjs +1 -64
- package/repo-search.mjs +1 -190
- package/review-check.mjs +1 -182
- package/runtime-contract.mjs +1 -93
- package/runtime-registry.mjs +1 -64
- package/runtime-session.mjs +1 -20
- package/scheduled-run-admission.mjs +1 -364
- package/scheduled-runs.mjs +1 -268
- package/sdk-admission.mjs +1 -9
- package/sdk-smoke.mjs +1 -61
- package/serve-consent.mjs +1 -118
- package/serve-dir.mjs +1 -40
- package/service.mjs +1 -877
- package/session-store.mjs +1 -484
- package/side-lane.mjs +1 -63
- package/supabase-key.mjs +1 -176
- package/supervisor-ready.mjs +1 -57
- package/switch-provider.mjs +1 -84
- package/terminal-name.mjs +1 -359
- package/terminal-row-reconcile.mjs +1 -70
- package/thinkpool-prompt-contracts.mjs +1 -85
- package/thinkpool-room-prompt.mjs +1 -187
- package/transcript-sanitize.mjs +1 -332
- package/turn-stall.mjs +1 -61
- package/update-gate.mjs +1 -53
- package/viewport.mjs +1 -810
- package/worker-completion.mjs +1 -57
package/event-id.mjs
CHANGED
|
@@ -1,549 +1 @@
|
|
|
1
|
-
import {
|
|
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";
|
package/evidence-citations.mjs
CHANGED
|
@@ -1,50 +1 @@
|
|
|
1
|
-
|
|
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}
|
package/evidence-compact.mjs
CHANGED
|
@@ -1,11 +1 @@
|
|
|
1
|
-
|
|
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}
|