threadwire 0.1.5 → 0.1.6

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/src/cli.js CHANGED
@@ -1,18 +1,20 @@
1
1
  // @ts-check
2
2
 
3
3
  import {readFile} from "node:fs/promises"
4
- import {resolve} from "node:path"
4
+ import {randomUUID} from "node:crypto"
5
+ import {isAbsolute, normalize, resolve} from "node:path"
5
6
  import {stdin, stderr, stdout} from "node:process"
6
7
  import {createFetchTransport} from "./notifiers/fetch-transport.js"
7
8
  import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
8
- import {NoticeQueue} from "./notice-queue.js"
9
9
  import {createProvider, PROVIDERS} from "./providers/index.js"
10
- import {Relay} from "./relay.js"
11
10
  import {runWorker} from "./run-worker.js"
12
11
  import {ActivityLog} from "./activity-log.js"
13
- import {HermesProtocol} from "./hermes-protocol.js"
14
- import {buildProviderEnvironment, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
12
+ import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
13
+ import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
15
14
  import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
+ import {WorkerControl} from "./worker-control.js"
16
+ import {EvidenceStore} from "./evidence-store.js"
17
+ import {ContextBudgetMetrics} from "./context-budget-metrics.js"
16
18
 
17
19
  const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
18
20
  [--process-number <positive-integer>] [--cwd <directory>]
@@ -20,9 +22,12 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
20
22
  [--tool-messages] [--max-output-length <positive-integer>]
21
23
  [--resume-session <provider-session-id>] [--activity-log <local-jsonl-path>]
22
24
  [--prompt <text> | --prompt-file <path> | stdin]
23
- [-- <provider arguments...>]`
25
+ [-- <provider arguments...>]
26
+ threadwire evidence read --handle <opaque-handle>
27
+ (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
24
28
 
25
29
  /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
30
+ /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
26
31
  /**
27
32
  * @typedef {{
28
33
  * env?: NodeJS.ProcessEnv,
@@ -31,6 +36,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
31
36
  * errorOutput?: NodeJS.WritableStream,
32
37
  * transportFactory?: typeof createFetchTransport,
33
38
  * workerRunner?: typeof runWorker,
39
+ * evidenceStore?: EvidenceStore,
40
+ * evidenceOwnerScope?: {destinationId: string, runId: string},
41
+ * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
34
42
  * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
35
43
  * }} MainDependencies
36
44
  */
@@ -82,6 +90,46 @@ export function parseArguments(arguments_) {
82
90
  return /** @type {ParsedArguments} */ (parsed)
83
91
  }
84
92
 
93
+ /** @param {string[]} arguments_ @returns {EvidenceParsedArguments} */
94
+ function parseEvidenceArguments(arguments_) {
95
+ if (arguments_[1] !== "read") throw new Error(HELP)
96
+ /** @type {Record<string, string>} */
97
+ const options = {}
98
+ for (let index = 2; index < arguments_.length; index += 2) {
99
+ const name = arguments_[index]
100
+ const value = arguments_[index + 1]
101
+ if (!name || !value || !["--handle", "--bytes", "--lines", "--query", "--context-bytes"].includes(name)) throw new Error(HELP)
102
+ if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
103
+ options[name] = value
104
+ }
105
+ const handle = options["--handle"]
106
+ if (!handle) throw new Error("--handle is required")
107
+ const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
108
+ if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
109
+ let request
110
+ if (options["--bytes"]) request = {handle, bytes: rangePair(options["--bytes"], true)}
111
+ else if (options["--lines"]) request = {handle, lines: rangePair(options["--lines"], false)}
112
+ else {
113
+ const context = options["--context-bytes"]
114
+ if (!context) throw new Error("--context-bytes is required with --query")
115
+ request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(context, "--context-bytes")}}
116
+ }
117
+ if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
118
+ return {evidenceRead: true, request}
119
+ }
120
+
121
+ /** @param {string} value @param {boolean} zeroStart */
122
+ function rangePair(value, zeroStart) {
123
+ const match = /^(\d+):(\d+)$/u.exec(value)
124
+ if (!match) throw new Error("Evidence range must be <start>:<limit>")
125
+ const start = Number(match[1])
126
+ const limit = Number(match[2])
127
+ if (!Number.isSafeInteger(start) || (!zeroStart && start < 1) || (zeroStart && start < 0) || !Number.isSafeInteger(limit) || limit < 1) {
128
+ throw new Error("Evidence range must contain bounded safe integers")
129
+ }
130
+ return zeroStart ? {offset: start, limit} : {start, limit}
131
+ }
132
+
85
133
  /**
86
134
  * @param {string[]} arguments_
87
135
  * @param {MainDependencies} [dependencies]
@@ -93,11 +141,24 @@ export async function main(arguments_, dependencies = {}) {
93
141
  let environment = sourceEnvironment
94
142
  const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
95
143
  try {
96
- const parsed = parseArguments(arguments_)
144
+ const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
97
145
  if ("help" in parsed) {
98
146
  if (!validateOnly) output.write(`${HELP}\n`)
99
147
  return 0
100
148
  }
149
+ if ("evidenceRead" in parsed) {
150
+ const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
151
+ if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
152
+ if (validateOnly) return 0
153
+ const store = await EvidenceStore.open({root})
154
+ try {
155
+ const result = await store.readBearer(parsed.request)
156
+ output.write(`${JSON.stringify(result)}\n`)
157
+ return 0
158
+ } finally {
159
+ await store.close()
160
+ }
161
+ }
101
162
  const target = parseTelegramTarget(parsed.target)
102
163
  const resolvedWorkspace = parsed.workspaceProfile === undefined
103
164
  ? undefined
@@ -106,35 +167,57 @@ export async function main(arguments_, dependencies = {}) {
106
167
  dependencies.workspaceProfileOperations
107
168
  )
108
169
  if (validateOnly) return 0
109
- environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
110
- const hermes = new HermesProtocol({output})
170
+ const metrics = new ContextBudgetMetrics()
171
+ const admission = new DelegatedResultAdmission({output, metrics})
111
172
  let terminalExitCode = 2
173
+ /** @type {unknown} */
174
+ let evidenceError
112
175
  /** @type {ActivityLog | undefined} */
113
176
  let activity
177
+ /** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
178
+ let evidence
179
+ /** @type {EvidenceStore | undefined} */
180
+ let ownedEvidenceStore
181
+ let evidencePayloadBytes = 0
114
182
  try {
183
+ environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
115
184
  const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
185
+ const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
186
+ if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
187
+ ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
188
+ }
189
+ const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
190
+ if (evidenceStore !== undefined) {
191
+ const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
192
+ destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
193
+ runId: randomUUID()
194
+ })
195
+ evidence = await evidenceStore.createArtifact(evidenceOwner, {
196
+ contentType: "text/plain; charset=utf-8",
197
+ redactions: await collectEvidenceRedactions(environment)
198
+ })
199
+ }
200
+ if (evidence !== undefined) {
201
+ const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
202
+ await evidence.append("prompt", promptEvidence)
203
+ evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
204
+ }
116
205
  const providerEnvironment = buildProviderEnvironment(environment)
117
206
  const provider = createProvider(parsed.provider, parsed.providerArguments, prompt, parsed.resumeSession, providerEnvironment)
207
+ if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
118
208
  if (parsed.workspaceProfile !== undefined) {
119
209
  if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
120
- hermes.write({
121
- type: "execution_environment",
122
- profile: resolvedWorkspace.profile,
123
- environment: "container",
124
- repositoryPath: resolvedWorkspace.cwd,
125
- revision: resolvedWorkspace.revision,
126
- sourceIdentity: resolvedWorkspace.sourceIdentity
127
- })
128
210
  }
129
211
  const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
130
212
  if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
131
213
  const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
132
- const queue = new NoticeQueue({sender: createTelegramSender(target, transport)})
133
- const relay = new Relay({
134
- queue,
214
+ const control = new WorkerControl({
215
+ sender: createTelegramSender(target, transport),
135
216
  processNumber: parsed.processNumber ?? process.pid,
136
217
  toolMessages: parsed.toolMessages,
137
- ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength})
218
+ ...(dependencies.workerControlOptions ?? {}),
219
+ ...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
220
+ metrics
138
221
  })
139
222
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
140
223
  if (activity && resolvedWorkspace) {
@@ -152,30 +235,121 @@ export async function main(arguments_, dependencies = {}) {
152
235
  environment: providerEnvironment,
153
236
  parse: provider.parse,
154
237
  onEvent: (event) => {
155
- relay.accept(event)
156
- hermes.accept(event)
238
+ validateNormalizedWorkerEvent(event)
239
+ if (event.type !== "text-delta") {
240
+ metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
241
+ }
242
+ acceptAdmissionEvent(admission, event)
243
+ return control.accept(event)
157
244
  },
158
245
  onSpawn: (pid) => {
159
246
  if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
160
247
  },
161
248
  onRecord: (record) => {
249
+ metrics.recordParsedProviderRecord("provider_stdout")
162
250
  const id = provider.sessionId(record)
163
- if (id !== undefined) activity?.recordSession(provider.name, id)
251
+ if (id !== undefined) {
252
+ admission.setContinuationHandle(id)
253
+ activity?.recordSession(provider.name, id)
254
+ }
255
+ },
256
+ onStdoutChunk: (chunk) => {
257
+ metrics.recordRawChildChunk("provider_stdout", chunk.length)
258
+ metrics.recordRejected("provider_stream", chunk.length)
259
+ if (evidence === undefined) return undefined
260
+ return evidence.append("provider-stdout", chunk).then(() => { evidencePayloadBytes += chunk.length })
261
+ },
262
+ onStderrChunk: (chunk) => {
263
+ metrics.recordRawChildChunk("provider_stderr", chunk.length)
264
+ metrics.recordRejected("stderr", chunk.length)
265
+ if (evidence === undefined) return undefined
266
+ return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
164
267
  }
165
268
  })
166
- await relay.close()
269
+ await control.close()
167
270
  terminalExitCode = exitCode
168
- return exitCode
169
271
  } finally {
170
- hermes.complete(terminalExitCode)
272
+ if (evidence !== undefined) {
273
+ try {
274
+ admission.addArtifactHandle(evidence.handle)
275
+ const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
276
+ metrics.recordAdmission(admission.preview(terminal))
277
+ const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
278
+ await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
279
+ const artifact = await evidence.finalize()
280
+ assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
281
+ } catch (error) {
282
+ await evidence.abort()
283
+ admission.removeArtifactHandle(evidence.handle)
284
+ metrics.clearArtifact()
285
+ terminalExitCode = 2
286
+ evidenceError = error
287
+ }
288
+ }
289
+ admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
290
+ errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
171
291
  activity?.close()
292
+ await ownedEvidenceStore?.close()
172
293
  }
294
+ if (evidenceError !== undefined) throw evidenceError
295
+ return terminalExitCode
173
296
  } catch (error) {
174
297
  errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
175
298
  return 2
176
299
  }
177
300
  }
178
301
 
302
+ /** @param {string | undefined} value */
303
+ function evidenceRoot(value) {
304
+ if (value === undefined) return undefined
305
+ if (!isAbsolute(value) || normalize(value) !== value) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
306
+ return value
307
+ }
308
+
309
+ /** @param {number} actual @param {number} projected */
310
+ function assertArtifactProjection(actual, projected) {
311
+ if (actual !== projected) throw new Error("Context metrics artifact projection mismatch")
312
+ }
313
+
314
+ /** @param {DelegatedResultAdmission} admission @param {import("./types.js").WorkerEvent} event */
315
+ function acceptAdmissionEvent(admission, event) {
316
+ if (event.type === "text-delta") admission.acceptConclusionEvent(event)
317
+ }
318
+
319
+ /** @param {unknown} event */
320
+ function validateNormalizedWorkerEvent(event) {
321
+ if (!isPlainObject(event) || typeof event.type !== "string") {
322
+ throw new Error("invalid normalized worker event")
323
+ }
324
+ if (event.type === "text-delta") {
325
+ if (!hasExactEnumerableFields(event, ["type", "text", "streamId"]) || typeof event.text !== "string" || typeof event.streamId !== "string") {
326
+ throw new Error("invalid normalized worker event")
327
+ }
328
+ return
329
+ }
330
+ if (event.type === "lifecycle") {
331
+ if (
332
+ !hasExactEnumerableFields(event, ["type", "phase", "summary"])
333
+ || (event.phase !== "started" && event.phase !== "completed" && event.phase !== "failed")
334
+ || typeof event.summary !== "string"
335
+ ) throw new Error("invalid normalized worker event")
336
+ return
337
+ }
338
+ if (event.type === "diagnostic") {
339
+ if (
340
+ !hasExactEnumerableFields(event, ["type", "level", "summary"])
341
+ || (event.level !== "warning" && event.level !== "error")
342
+ || typeof event.summary !== "string"
343
+ ) throw new Error("invalid normalized worker event")
344
+ return
345
+ }
346
+ if (event.type === "tool") {
347
+ if (!isToolEventShape(event)) throw new Error("invalid normalized worker event")
348
+ return
349
+ }
350
+ throw new Error("Worker emitted an unknown normalized event kind")
351
+ }
352
+
179
353
  /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
180
354
  async function readPrompt(parsed, input) {
181
355
  let prompt
@@ -207,6 +381,53 @@ function positiveInteger(value, option) {
207
381
 
208
382
  /** @param {string} value */
209
383
  function sessionId(value) {
210
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u.test(value)) throw new Error("--resume-session must be a valid provider session ID")
211
- return value
384
+ try {
385
+ return validateContinuationHandle(value)
386
+ } catch {
387
+ throw new Error("--resume-session must be a valid provider session ID")
388
+ }
389
+ }
390
+
391
+ /** @param {Record<string, unknown>} event */
392
+ function isToolEventShape(event) {
393
+ const allowed = event.phase === "finished"
394
+ ? ["type", "phase", "name", "key", "detail", "output"]
395
+ : ["type", "phase", "name", "key", "detail"]
396
+ if (
397
+ !hasOnlyAllowedEnumerableFields(event, allowed, ["type", "phase", "name", "key"])
398
+ || (event.phase !== "started" && event.phase !== "finished")
399
+ || typeof event.name !== "string"
400
+ || typeof event.key !== "string"
401
+ || (event.detail !== undefined && typeof event.detail !== "string")
402
+ || (event.output !== undefined && typeof event.output !== "string")
403
+ ) return false
404
+ if (event.phase === "started" && event.output !== undefined) return false
405
+ return true
406
+ }
407
+
408
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
409
+ function isPlainObject(value) {
410
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
411
+ const prototype = Object.getPrototypeOf(value)
412
+ return prototype === Object.prototype || prototype === null
413
+ }
414
+
415
+ /** @param {Record<string, unknown>} value @param {string[]} expected */
416
+ function hasExactEnumerableFields(value, expected) {
417
+ const fields = Reflect.ownKeys(value)
418
+ return fields.length === expected.length && fields.every((field) => (
419
+ typeof field === "string"
420
+ && expected.includes(field)
421
+ && Object.prototype.propertyIsEnumerable.call(value, field)
422
+ ))
423
+ }
424
+
425
+ /** @param {Record<string, unknown>} value @param {string[]} allowed @param {string[]} required */
426
+ function hasOnlyAllowedEnumerableFields(value, allowed, required) {
427
+ const fields = Reflect.ownKeys(value)
428
+ return required.every((field) => Object.prototype.propertyIsEnumerable.call(value, field)) && fields.every((field) => (
429
+ typeof field === "string"
430
+ && allowed.includes(field)
431
+ && Object.prototype.propertyIsEnumerable.call(value, field)
432
+ ))
212
433
  }
@@ -0,0 +1,180 @@
1
+ // @ts-check
2
+
3
+ export const METRIC_COUNTER_LIMIT = 2_147_483_647
4
+ export const CONTEXT_GROWTH_METADATA_ALLOWANCE_BYTES = 256
5
+
6
+ const RAW_CLASSES = ["provider_stdout", "provider_stderr"]
7
+ const REJECTED_CLASSES = ["provider_stream", "stderr", "lifecycle_progress", "diagnostic_progress", "tool_progress"]
8
+ const PROGRESS_CLASSES = ["assistant", "lifecycle", "diagnostic", "tool"]
9
+ const ENVELOPE_FIELDS = [
10
+ "version", "type", "state", "exitCode", "conclusion", "blocker", "decisionRequest",
11
+ "continuationHandle", "artifactHandles", "references", "validationSummary"
12
+ ]
13
+
14
+ export class ContextBudgetMetrics {
15
+ constructor() {
16
+ this.streamBytes = 0
17
+ this.streamChunks = 0
18
+ this.streamBytesByClass = counters(RAW_CLASSES)
19
+ this.streamChunksByClass = counters(RAW_CLASSES)
20
+ this.parsedRecords = 0
21
+ this.parsedRecordsByClass = counters(RAW_CLASSES)
22
+ this.rejectedBytes = counters(REJECTED_CLASSES)
23
+ this.rejectedEvents = counters(REJECTED_CLASSES)
24
+ this.progress = progressCounters()
25
+ this.artifactBytes = 0
26
+ this.artifactHandles = 0
27
+ this.admittedBytes = 0
28
+ this.admittedTokens = 0
29
+ this.admittedEnvelopeFields = counters(ENVELOPE_FIELDS)
30
+ }
31
+
32
+ /** @param {string} metricClass @param {number} bytes */
33
+ recordRawChildChunk(metricClass, bytes) {
34
+ addClass(this.streamBytesByClass, metricClass, bytes)
35
+ addClass(this.streamChunksByClass, metricClass, 1)
36
+ this.streamBytes = saturatingAdd(this.streamBytes, bytes)
37
+ this.streamChunks = saturatingAdd(this.streamChunks, 1)
38
+ }
39
+
40
+ /** Backward-compatible name for callers outside the package. @param {string} metricClass @param {number} bytes */
41
+ recordRawChildEvent(metricClass, bytes) {
42
+ this.recordRawChildChunk(metricClass, bytes)
43
+ }
44
+
45
+ /** @param {string} metricClass @param {number} [records] */
46
+ recordParsedProviderRecord(metricClass, records = 1) {
47
+ addClass(this.parsedRecordsByClass, metricClass, records)
48
+ this.parsedRecords = saturatingAdd(this.parsedRecords, records)
49
+ }
50
+
51
+ /** @param {string} metricClass @param {number} bytes */
52
+ recordRejected(metricClass, bytes) {
53
+ addClass(this.rejectedBytes, metricClass, bytes)
54
+ addClass(this.rejectedEvents, metricClass, 1)
55
+ }
56
+
57
+ /** @param {string} metricClass @param {number} bytes */
58
+ recordProgress(metricClass, bytes, outcome = "delivered") {
59
+ if (!Object.prototype.hasOwnProperty.call(this.progress, outcome)) throw new Error("Invalid context progress outcome")
60
+ const target = this.progress[outcome]
61
+ if (target === undefined) throw new Error("Invalid context progress outcome")
62
+ addClass(target.bytesByClass, metricClass, bytes)
63
+ addClass(target.eventsByClass, metricClass, 1)
64
+ target.bytes = saturatingAdd(target.bytes, bytes)
65
+ target.events = saturatingAdd(target.events, 1)
66
+ }
67
+
68
+ /** @param {number} bytes @param {number} handles */
69
+ recordArtifact(bytes, handles) {
70
+ this.artifactBytes = saturatingAdd(this.artifactBytes, bytes)
71
+ this.artifactHandles = saturatingAdd(this.artifactHandles, handles)
72
+ }
73
+
74
+ /**
75
+ * Set the final logical payload size for an artifact containing this snapshot.
76
+ * The returned fixed point includes the snapshot's own newline-terminated bytes.
77
+ * @param {number} bytesBeforeMetrics @param {number} handles
78
+ */
79
+ projectSelfInclusiveArtifact(bytesBeforeMetrics, handles) {
80
+ const base = bounded(bytesBeforeMetrics)
81
+ this.artifactHandles = bounded(handles)
82
+ this.artifactBytes = base
83
+ for (let iteration = 0; iteration < 16; iteration += 1) {
84
+ const projected = saturatingAdd(base, Buffer.byteLength(`${JSON.stringify(this.snapshot())}\n`, "utf8"))
85
+ if (projected === this.artifactBytes) return projected
86
+ this.artifactBytes = projected
87
+ }
88
+ throw new Error("Context metrics artifact projection did not converge")
89
+ }
90
+
91
+ clearArtifact() {
92
+ this.artifactBytes = 0
93
+ this.artifactHandles = 0
94
+ }
95
+
96
+ /** @param {Record<string, unknown>} envelope */
97
+ recordAdmission(envelope) {
98
+ const serialized = `${JSON.stringify(envelope)}\n`
99
+ this.admittedBytes = bounded(Buffer.byteLength(serialized, "utf8"))
100
+ this.admittedTokens = bounded(Math.ceil(this.admittedBytes / 4))
101
+ for (const field of ENVELOPE_FIELDS) {
102
+ if (!Object.prototype.hasOwnProperty.call(envelope, field)) continue
103
+ this.admittedEnvelopeFields[field] = bounded(Buffer.byteLength(JSON.stringify(envelope[field]), "utf8"))
104
+ }
105
+ }
106
+
107
+ snapshot() {
108
+ return {
109
+ version: 1,
110
+ units: {bytes: "utf8", tokens: "estimated_ceiling_bytes_div_4", events: "count"},
111
+ rawChild: {
112
+ streamBytes: this.streamBytes,
113
+ streamChunks: this.streamChunks,
114
+ streamBytesByClass: {...this.streamBytesByClass},
115
+ streamChunksByClass: {...this.streamChunksByClass},
116
+ parsedRecords: this.parsedRecords,
117
+ parsedRecordsByClass: {...this.parsedRecordsByClass}
118
+ },
119
+ admitted: {bytes: this.admittedBytes, estimatedTokens: this.admittedTokens},
120
+ rejected: {bytesByClass: {...this.rejectedBytes}, eventsByClass: {...this.rejectedEvents}},
121
+ artifacts: {bytes: this.artifactBytes, handles: this.artifactHandles},
122
+ progress: Object.fromEntries(Object.entries(this.progress).map(([outcome, values]) => [
123
+ outcome,
124
+ {bytes: values.bytes, events: values.events, bytesByClass: {...values.bytesByClass}, eventsByClass: {...values.eventsByClass}}
125
+ ])),
126
+ admittedEnvelopeFields: {...this.admittedEnvelopeFields},
127
+ parentContextDeltaBytes: this.admittedBytes
128
+ }
129
+ }
130
+
131
+ conciseDiagnostic() {
132
+ return {
133
+ version: 1,
134
+ rawBytes: this.streamBytes,
135
+ rawChunks: this.streamChunks,
136
+ parsedRecords: this.parsedRecords,
137
+ admittedBytes: this.admittedBytes,
138
+ rejectedBytes: sumCounters(this.rejectedBytes),
139
+ artifactBytes: this.artifactBytes,
140
+ artifactHandles: this.artifactHandles,
141
+ progressEvents: this.progress.delivered?.events ?? 0,
142
+ parentContextDeltaBytes: this.admittedBytes
143
+ }
144
+ }
145
+ }
146
+
147
+ /** @returns {Record<string, {bytes: number, events: number, bytesByClass: Record<string, number>, eventsByClass: Record<string, number>}>} */
148
+ function progressCounters() {
149
+ return Object.fromEntries(["attempted", "suppressed", "coalesced", "delivered"].map((outcome) => [
150
+ outcome,
151
+ {bytes: 0, events: 0, bytesByClass: counters(PROGRESS_CLASSES), eventsByClass: counters(PROGRESS_CLASSES)}
152
+ ]))
153
+ }
154
+
155
+ /** @param {string[]} classes */
156
+ function counters(classes) {
157
+ return Object.fromEntries(classes.map((metricClass) => [metricClass, 0]))
158
+ }
159
+
160
+ /** @param {Record<string, number>} target @param {string} metricClass @param {number} increment */
161
+ function addClass(target, metricClass, increment) {
162
+ if (!Object.prototype.hasOwnProperty.call(target, metricClass)) throw new Error("Invalid context metric class")
163
+ target[metricClass] = saturatingAdd(target[metricClass] ?? 0, increment)
164
+ }
165
+
166
+ /** @param {number} current @param {number} increment */
167
+ function saturatingAdd(current, increment) {
168
+ return Math.min(METRIC_COUNTER_LIMIT, current + bounded(increment))
169
+ }
170
+
171
+ /** @param {number} value */
172
+ function bounded(value) {
173
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid context metric counter")
174
+ return Math.min(value, METRIC_COUNTER_LIMIT)
175
+ }
176
+
177
+ /** @param {Record<string, number>} values */
178
+ function sumCounters(values) {
179
+ return Object.values(values).reduce((total, value) => saturatingAdd(total, value), 0)
180
+ }