@cesarus777/opencode-cgp 0.1.0 → 0.2.2

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/dist/cli.js ADDED
@@ -0,0 +1,1411 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/rpc.ts
5
+ import { Rpc } from "@opencode/plugin/rpc";
6
+ var count = {
7
+ type: "integer",
8
+ minimum: 0,
9
+ maximum: Number.MAX_SAFE_INTEGER
10
+ };
11
+ var text = { type: "string", minLength: 1, maxLength: 512 };
12
+ var enumeration = (...values) => ({ type: "string", enum: values });
13
+ var object = (properties, required = Object.keys(properties)) => ({
14
+ type: "object",
15
+ properties,
16
+ required,
17
+ additionalProperties: false
18
+ });
19
+ var array = (items, maxItems = 128) => ({
20
+ type: "array",
21
+ items,
22
+ maxItems
23
+ });
24
+ var measurement = object({
25
+ tokens: count,
26
+ at: count,
27
+ model: text,
28
+ requestId: text,
29
+ provenance: enumeration("provider-normalized", "serialized-estimate"),
30
+ confidence: enumeration("reported", "approximate"),
31
+ scope: enumeration("completed-request", "prepared-request")
32
+ }, ["tokens", "at", "model", "provenance", "confidence", "scope"]);
33
+ var row = object({
34
+ sessionId: text,
35
+ revision: count,
36
+ observedAt: count,
37
+ parentId: text,
38
+ agent: text,
39
+ model: text,
40
+ measurement,
41
+ limits: object({ context: count, input: count, output: count }, [
42
+ "context",
43
+ "output"
44
+ ]),
45
+ capacityTokens: count,
46
+ outputReserveTokens: count,
47
+ peakTokens: count,
48
+ policy: object({
49
+ id: text,
50
+ configured: object({
51
+ type: enumeration("tokens", "ratio", "remaining"),
52
+ value: { type: "number", minimum: 0 }
53
+ }),
54
+ triggerTokens: { type: "number" },
55
+ comparison: enumeration(">=", ">"),
56
+ reason: enumeration("missing-usage", "model-changed", "invalid-usage", "stale-usage", "missing-model-budget", "incompatible-model-budget", "threshold-crossed", "below-threshold"),
57
+ crossed: { type: "boolean" }
58
+ }, ["id", "configured", "comparison", "reason", "crossed"]),
59
+ compaction: object({
60
+ phase: enumeration("idle", "requesting", "accepted", "running", "awaiting-measurement", "blocked", "uncertain"),
61
+ count,
62
+ armed: { type: "boolean" },
63
+ blockedReason: enumeration("session-removed", "compaction-failed", "reclaim-unverifiable", "insufficient-reclaim", "completion-event-timeout", "admission-outcome-unknown", "restart-needs-lifecycle-evidence"),
64
+ lastAttempt: count,
65
+ lastCompleted: count,
66
+ cooldownUntil: count
67
+ }, ["phase", "armed"]),
68
+ cumulative: object({
69
+ input: count,
70
+ output: count,
71
+ reasoning: count,
72
+ cache: object({ read: count, write: count })
73
+ }),
74
+ cumulativeScope: enumeration("session", "unknown"),
75
+ lastObservedActive: { type: "boolean" }
76
+ }, [
77
+ "sessionId",
78
+ "revision",
79
+ "observedAt",
80
+ "policy",
81
+ "compaction",
82
+ "cumulativeScope",
83
+ "lastObservedActive"
84
+ ]);
85
+ var batchFields = {
86
+ schemaVersion: { const: 1 },
87
+ epoch: text,
88
+ sequence: count,
89
+ generatedAt: count,
90
+ health: object({
91
+ enabled: { type: "boolean" },
92
+ mode: enumeration("observe", "enforce"),
93
+ enforcement: enumeration("observing", "available", "suspended", "disabled"),
94
+ reasons: array(enumeration("event-stream-gap", "persistence-unavailable", "capacity-exceeded", "model-metadata-unavailable"), 8),
95
+ staleMs: count,
96
+ maxSessions: count
97
+ }),
98
+ upserts: array(row),
99
+ removed: array(object({
100
+ sessionId: text,
101
+ revision: count,
102
+ reason: enumeration("deleted", "evicted")
103
+ }))
104
+ };
105
+ var readSchema = object({
106
+ schemaVersion: { const: 1 },
107
+ sessionIds: { ...array(text), uniqueItems: true }
108
+ });
109
+ var batchSchema = object(batchFields);
110
+ var resultSchema = object({
111
+ ...batchFields,
112
+ unavailable: array(object({ sessionId: text, reason: enumeration("not-monitored") }))
113
+ });
114
+ var monitorRpc = Rpc.define({
115
+ id: "context-guard.monitor.v1",
116
+ methods: { read: { input: readSchema, output: resultSchema } },
117
+ events: { changed: { schema: batchSchema } }
118
+ });
119
+ var nonnegative = { type: "number", minimum: 0 };
120
+ var performanceSchema = object({
121
+ observedSince: count,
122
+ at: count,
123
+ sessionCreatedAt: count,
124
+ sessionOutputTokens: count,
125
+ status: enumeration("waiting", "streaming", "idle", "unavailable"),
126
+ partial: { type: "boolean" },
127
+ liveTps: nonnegative,
128
+ averageModelTps: nonnegative,
129
+ averageSessionTps: nonnegative,
130
+ ttftMs: nonnegative,
131
+ latestRequestTps: nonnegative,
132
+ requestId: text,
133
+ requestAgent: text,
134
+ requestModel: text,
135
+ requestOutputTokens: count,
136
+ requestStartedAt: count,
137
+ firstOutputAt: count,
138
+ streamEndedAt: count,
139
+ estimatedOutputTokens: nonnegative,
140
+ modelOutputTokens: nonnegative,
141
+ modelDurationMs: nonnegative,
142
+ completedRequests: count,
143
+ tokenSource: enumeration("estimated", "reported", "unavailable")
144
+ }, [
145
+ "sessionCreatedAt",
146
+ "observedSince",
147
+ "at",
148
+ "status",
149
+ "partial",
150
+ "estimatedOutputTokens",
151
+ "modelOutputTokens",
152
+ "modelDurationMs",
153
+ "completedRequests",
154
+ "tokenSource"
155
+ ]);
156
+ var performanceRow = {
157
+ ...row,
158
+ properties: { ...row.properties, performance: performanceSchema }
159
+ };
160
+ var performanceBatchSchema = object({
161
+ ...batchFields,
162
+ upserts: array(performanceRow)
163
+ });
164
+ var performanceResultSchema = object({
165
+ ...batchFields,
166
+ upserts: array(performanceRow),
167
+ unavailable: resultSchema.properties.unavailable ?? {}
168
+ });
169
+ var performanceRpc = Rpc.define({
170
+ id: "context-guard.monitor.v2",
171
+ methods: { read: { input: readSchema, output: performanceResultSchema } },
172
+ events: { changed: { schema: performanceBatchSchema } }
173
+ });
174
+ function isPerformanceBatch(value) {
175
+ return valid(value, performanceBatchSchema) && uniqueRows(value);
176
+ }
177
+ function isPerformanceResult(value) {
178
+ return valid(value, performanceResultSchema) && uniqueRows(value);
179
+ }
180
+ function isPerformanceSnapshot(value) {
181
+ return valid(value, performanceSchema);
182
+ }
183
+ var chartPointSchema = object({
184
+ time: count,
185
+ agent: text,
186
+ model: text,
187
+ breakBefore: { type: "boolean" },
188
+ partial: { type: "boolean" },
189
+ liveTps: nonnegative,
190
+ averageModelTps: nonnegative,
191
+ averageSessionTps: nonnegative,
192
+ sessionOutputTokens: count
193
+ }, ["time", "breakBefore", "partial"]);
194
+ var chartRequestSchema = object({
195
+ time: count,
196
+ requestId: text,
197
+ started: count,
198
+ agent: text,
199
+ model: text,
200
+ ttftMs: nonnegative,
201
+ outputTokens: count,
202
+ durationMs: nonnegative,
203
+ tps: nonnegative,
204
+ partial: { type: "boolean" }
205
+ }, ["time", "requestId", "started", "partial"]);
206
+ var historyReadSchema = object({
207
+ schemaVersion: { const: 1 },
208
+ sessionId: text,
209
+ from: count,
210
+ to: count,
211
+ maxPoints: { type: "integer", minimum: 16, maximum: 500 }
212
+ });
213
+ var historyResultSchema = object({
214
+ schemaVersion: { const: 1 },
215
+ generatedAt: count,
216
+ enabled: { type: "boolean" },
217
+ truncated: { type: "boolean" },
218
+ metadata: object({
219
+ schema: { const: 1 },
220
+ retainedFrom: count,
221
+ retainedBytes: count,
222
+ skippedBytes: count,
223
+ malformedRecords: count,
224
+ completeness: enumeration("bounded-retention")
225
+ }, [
226
+ "schema",
227
+ "retainedBytes",
228
+ "skippedBytes",
229
+ "malformedRecords",
230
+ "completeness"
231
+ ]),
232
+ data: object({
233
+ schema: { const: 1 },
234
+ omittedSessions: count,
235
+ omittedGaps: count,
236
+ maxPoints: count,
237
+ gaps: array(object({ time: count, sessionId: text, reason: text }), 128),
238
+ series: array(object({
239
+ sessionId: text,
240
+ parentId: text,
241
+ agents: array(text, 1024),
242
+ models: array(text, 1024),
243
+ points: array(chartPointSchema, 500),
244
+ requests: array(chartRequestSchema, 500),
245
+ originalPoints: count
246
+ }, [
247
+ "sessionId",
248
+ "agents",
249
+ "models",
250
+ "points",
251
+ "requests",
252
+ "originalPoints"
253
+ ]), 1)
254
+ }, ["schema", "omittedSessions", "maxPoints", "gaps", "series"])
255
+ });
256
+ var historyRpc = Rpc.define({
257
+ id: "context-guard.history.v1",
258
+ methods: { read: { input: historyReadSchema, output: historyResultSchema } },
259
+ events: {}
260
+ });
261
+ function isHistoryRead(value) {
262
+ return valid(value, historyReadSchema) && record(value) && typeof value.from === "number" && typeof value.to === "number" && value.from <= value.to && value.to - value.from <= 7 * 86400000;
263
+ }
264
+ function isHistoryResult(value) {
265
+ return valid(value, historyResultSchema);
266
+ }
267
+ function record(x) {
268
+ return typeof x === "object" && x !== null && !Array.isArray(x);
269
+ }
270
+ function matches(value, schema) {
271
+ if (!record(schema))
272
+ return false;
273
+ if ("const" in schema && value !== schema.const)
274
+ return false;
275
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value))
276
+ return false;
277
+ if (schema.type === "number" || schema.type === "integer")
278
+ return typeof value === "number" && Number.isFinite(value) && (schema.type !== "integer" || Number.isSafeInteger(value)) && (typeof schema.minimum !== "number" || value >= schema.minimum) && (typeof schema.maximum !== "number" || value <= schema.maximum);
279
+ if (schema.type === "string")
280
+ return typeof value === "string" && value.length > 0 && value.length <= 512;
281
+ if (schema.type === "boolean")
282
+ return typeof value === "boolean";
283
+ if (schema.type === "array")
284
+ return Array.isArray(value) && value.length <= Number(schema.maxItems) && (!schema.uniqueItems || new Set(value).size === value.length) && value.every((v) => matches(v, schema.items));
285
+ if (schema.type === "object") {
286
+ if (!record(value) || !record(schema.properties) || !Array.isArray(schema.required))
287
+ return false;
288
+ const properties = schema.properties;
289
+ return schema.required.every((k) => typeof k === "string" && (k in value)) && Object.keys(value).every((k) => (k in properties) && matches(value[k], properties[k]));
290
+ }
291
+ return "const" in schema;
292
+ }
293
+ function valid(value, schema) {
294
+ try {
295
+ return JSON.stringify(value).length <= 524288 && matches(value, schema);
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+ function isRead(value) {
301
+ return valid(value, readSchema);
302
+ }
303
+ function isBatch(value) {
304
+ return valid(value, batchSchema) && uniqueRows(value);
305
+ }
306
+ function isResult(value) {
307
+ return valid(value, resultSchema) && uniqueRows(value);
308
+ }
309
+ function uniqueRows(value) {
310
+ if (!record(value) || !Array.isArray(value.upserts) || !Array.isArray(value.removed))
311
+ return false;
312
+ const ids = [...value.upserts, ...value.removed].map((v) => record(v) ? v.sessionId : undefined);
313
+ return ids.length <= 128 && new Set(ids).size === ids.length;
314
+ }
315
+
316
+ // src/cli.ts
317
+ import { writeFile } from "fs/promises";
318
+ import { resolve } from "path";
319
+
320
+ // src/charts.ts
321
+ var rateFields = [
322
+ "liveTps",
323
+ "averageModelTps",
324
+ "averageSessionTps",
325
+ "sessionOutputTokens"
326
+ ];
327
+ function downsamplePoints(points, limit) {
328
+ if (!Number.isSafeInteger(limit) || limit < 16 || limit > 5000)
329
+ throw new Error("maxPoints must be an integer from 16 to 5000");
330
+ if (points.length <= limit)
331
+ return [...points];
332
+ const buckets = Math.max(1, Math.floor(limit / (2 + rateFields.length * 2)));
333
+ const selected = new Set;
334
+ for (let b = 0;b < buckets; b++) {
335
+ const start = Math.floor(b * points.length / buckets), end = Math.floor((b + 1) * points.length / buckets);
336
+ selected.add(start);
337
+ selected.add(end - 1);
338
+ for (const field of rateFields) {
339
+ let low, high;
340
+ for (let i = start;i < end; i++) {
341
+ const value = points[i]?.[field];
342
+ if (value === undefined)
343
+ continue;
344
+ if (low === undefined || value < (points[low]?.[field] ?? Infinity))
345
+ low = i;
346
+ if (high === undefined || value > (points[high]?.[field] ?? -Infinity))
347
+ high = i;
348
+ }
349
+ if (low !== undefined)
350
+ selected.add(low);
351
+ if (high !== undefined)
352
+ selected.add(high);
353
+ }
354
+ }
355
+ let previous = -1;
356
+ return [...selected].sort((a, b) => a - b).flatMap((index) => {
357
+ const point = points[index];
358
+ if (!point)
359
+ return [];
360
+ const skippedGap = points.slice(previous + 1, index + 1).some((p) => p.breakBefore);
361
+ previous = index;
362
+ return [{ ...point, breakBefore: point.breakBefore || skippedGap }];
363
+ });
364
+ }
365
+ function buildChartData(records, options = {}) {
366
+ const maxPoints = options.maxPoints ?? 1000, maxSessions = options.maxSessions ?? 128;
367
+ if (!Number.isSafeInteger(maxSessions) || maxSessions < 1 || maxSessions > 1024)
368
+ throw new Error("maxSessions must be from 1 to 1024");
369
+ downsamplePoints([], maxPoints);
370
+ const sorted = [...records].sort((a, b) => a.time - b.time || a.recordId.localeCompare(b.recordId));
371
+ const lastSeen = new Map;
372
+ for (const r of sorted)
373
+ if (r.performance && (!options.sessionId || r.sessionId === options.sessionId))
374
+ lastSeen.set(r.sessionId, r.time);
375
+ const sessions = [...lastSeen].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
376
+ const selected = new Set(sessions.slice(0, maxSessions).map(([id]) => id));
377
+ const series = new Map, gaps = [];
378
+ const seen = new Set, requestSeen = new Set;
379
+ const pendingGaps = new Set;
380
+ const observedSince = new Map;
381
+ for (const r of sorted) {
382
+ if (seen.has(r.recordId))
383
+ continue;
384
+ seen.add(r.recordId);
385
+ if (r.type === "gap") {
386
+ if (r.sessionId === "__history__" || selected.has(r.sessionId)) {
387
+ gaps.push({
388
+ time: r.time,
389
+ sessionId: r.sessionId,
390
+ reason: r.event ?? "observation-gap"
391
+ });
392
+ if (r.sessionId === "__history__")
393
+ for (const id of selected)
394
+ pendingGaps.add(id);
395
+ else
396
+ pendingGaps.add(r.sessionId);
397
+ }
398
+ continue;
399
+ }
400
+ const p = r.performance;
401
+ if (!p || !selected.has(r.sessionId))
402
+ continue;
403
+ let s = series.get(r.sessionId);
404
+ if (!s) {
405
+ s = {
406
+ sessionId: r.sessionId,
407
+ ...r.parentId ? { parentId: r.parentId } : {},
408
+ agents: [],
409
+ models: [],
410
+ points: [],
411
+ requests: [],
412
+ originalPoints: 0
413
+ };
414
+ series.set(r.sessionId, s);
415
+ }
416
+ const agent = p.requestAgent ?? r.agent, model = p.requestModel ?? r.model;
417
+ if (agent && !s.agents.includes(agent))
418
+ s.agents.push(agent);
419
+ if (model && !s.models.includes(model))
420
+ s.models.push(model);
421
+ const previous = s.points.at(-1);
422
+ const restart = observedSince.has(r.sessionId) && observedSince.get(r.sessionId) !== p.observedSince;
423
+ const reset = previous?.sessionOutputTokens !== undefined && p.sessionOutputTokens !== undefined && p.sessionOutputTokens < previous.sessionOutputTokens;
424
+ const discontinuity = pendingGaps.has(r.sessionId) || restart || reset || !previous || previous.partial !== p.partial || previous.liveTps === undefined || p.liveTps === undefined || r.time - previous.time > 2500 || previous.agent !== agent || previous.model !== model;
425
+ const point = {
426
+ time: r.time,
427
+ ...agent ? { agent } : {},
428
+ ...model ? { model } : {},
429
+ breakBefore: discontinuity,
430
+ partial: p.partial,
431
+ ...p.liveTps === undefined ? {} : { liveTps: p.liveTps },
432
+ ...p.averageModelTps === undefined ? {} : { averageModelTps: p.averageModelTps },
433
+ ...p.averageSessionTps === undefined ? {} : { averageSessionTps: p.averageSessionTps },
434
+ ...p.sessionOutputTokens === undefined ? {} : { sessionOutputTokens: p.sessionOutputTokens }
435
+ };
436
+ if (previous?.time === point.time)
437
+ s.points[s.points.length - 1] = {
438
+ ...point,
439
+ breakBefore: point.breakBefore || previous.breakBefore
440
+ };
441
+ else
442
+ s.points.push(point);
443
+ observedSince.set(r.sessionId, p.observedSince);
444
+ pendingGaps.delete(r.sessionId);
445
+ if (r.type === "request" && p.requestId !== undefined && p.requestStartedAt !== undefined) {
446
+ const key = JSON.stringify([
447
+ r.sessionId,
448
+ p.requestId,
449
+ p.requestStartedAt
450
+ ]);
451
+ if (requestSeen.has(key))
452
+ continue;
453
+ requestSeen.add(key);
454
+ const duration = p.streamEndedAt === undefined ? undefined : p.streamEndedAt - p.requestStartedAt;
455
+ const validDuration = duration !== undefined && duration > 0;
456
+ const output = p.tokenSource === "reported" ? p.requestOutputTokens : undefined;
457
+ s.requests.push({
458
+ time: r.time,
459
+ requestId: p.requestId,
460
+ started: p.requestStartedAt,
461
+ ...agent ? { agent } : {},
462
+ ...model ? { model } : {},
463
+ partial: p.partial,
464
+ ...p.ttftMs === undefined ? {} : { ttftMs: p.ttftMs },
465
+ ...output === undefined ? {} : { outputTokens: output },
466
+ ...validDuration ? { durationMs: duration } : {},
467
+ ...output !== undefined && validDuration ? { tps: output * 1000 / duration } : {}
468
+ });
469
+ }
470
+ }
471
+ return {
472
+ schema: 1,
473
+ series: [...series.values()].map((s) => ({
474
+ ...s,
475
+ originalPoints: s.points.length,
476
+ points: downsamplePoints(s.points, maxPoints)
477
+ })),
478
+ gaps: gaps.slice(-1024),
479
+ omittedGaps: Math.max(0, gaps.length - 1024),
480
+ omittedSessions: Math.max(0, sessions.length - selected.size),
481
+ maxPoints
482
+ };
483
+ }
484
+
485
+ // src/chartreport.ts
486
+ function renderChartReport(report) {
487
+ const payload = JSON.stringify(report).replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
488
+ return `<!doctype html>
489
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
490
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src blob: data:; connect-src 'none'">
491
+ <title>Context Guard \xB7 Performance history</title>
492
+ <style>
493
+ :root{color-scheme:dark;font-family:system-ui,sans-serif;background:#0b1120;color:#e2e8f0}*{box-sizing:border-box}body{max-width:1600px;margin:auto;padding:28px}h1{font-size:28px;letter-spacing:-.03em;margin:8px 0}h2{font-size:16px;margin:0}p{color:#a8b5cb;line-height:1.6;margin:8px 0}.eyebrow{color:#7dd3fc;font-size:12px;letter-spacing:.13em;text-transform:uppercase}.toolbar,.stats,.charts{display:grid;gap:14px;margin-top:22px}.toolbar{grid-template-columns:2fr 1fr 1fr 1.4fr 1.4fr auto;align-items:end}label{font-size:12px;color:#a8b5cb;display:grid;gap:7px}select,input,button{background:#172338;border:1px solid #33435a;border-radius:7px;color:inherit;padding:10px;min-width:0;font:inherit}button{cursor:pointer;font-size:12px}button:hover,button:focus-visible{border-color:#7dd3fc}select:focus-visible,input:focus-visible{outline:2px solid #7dd3fc}.stats{grid-template-columns:repeat(4,1fr)}.stat,.chart{background:#111c2e;border:1px solid #26354e;border-radius:12px;padding:18px}.stat strong{font-size:24px;display:block;margin-top:7px}.stat span{font-size:12px;color:#a8b5cb}.charts{grid-template-columns:repeat(2,minmax(0,1fr))}.charthead{display:flex;justify-content:space-between;align-items:center;gap:10px}.chart p{font-size:12px;min-height:38px}.chart svg{display:block;width:100%;height:auto}.notice{padding:12px 15px;border-left:3px solid #fbbf24;background:#201f2d;font-size:13px;margin-top:15px}.legend{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:18px;font-size:12px}.legend span{overflow-wrap:anywhere}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:7px}.empty{padding:30px;text-align:center;color:#a8b5cb}.tooltip{position:fixed;z-index:5;pointer-events:none;background:#e2e8f0;color:#0b1120;border-radius:7px;padding:10px;max-width:360px;white-space:pre-wrap;font-size:12px;box-shadow:0 8px 30px #0008}footer{margin-top:25px;font-size:12px;color:#a8b5cb}svg circle:focus{outline:none;stroke:white;stroke-width:3}@media(max-width:1000px){.toolbar{grid-template-columns:repeat(2,minmax(0,1fr))}.charts{grid-template-columns:1fr}}@media(max-width:600px){body{padding:16px}.stats{grid-template-columns:repeat(2,1fr)}}@media print{button,.toolbar,.tooltip{display:none}body{padding:0}.chart{break-inside:avoid}.charts{grid-template-columns:1fr}}
494
+ </style></head><body>
495
+ <header><span class="eyebrow">Context Guard / Local analysis</span><h1>Performance history</h1><p>Compare agent instances over time. All timestamps are UTC. This report is an offline snapshot of retained metrics.</p></header>
496
+ <section class="toolbar" aria-label="Chart filters">
497
+ <label>Session instance<select id="session"></select></label><label>Agent<select id="agent"></select></label><label>Model<select id="model"></select></label>
498
+ <label>From (UTC)<input id="from" type="datetime-local" step="1"></label><label>To (UTC)<input id="to" type="datetime-local" step="1"></label><button id="reset">Reset filters</button>
499
+ </section>
500
+ <div id="notice" class="notice" role="status"></div><section id="stats" class="stats" aria-label="Request statistics"></section>
501
+ <div id="legend" class="legend"></div><main id="charts" class="charts"></main>
502
+ <footer>Live TPS uses a character estimate; TTFT includes output publication delay. Dashed curves mark estimates or partial observations. Gaps and missing samples are never filled with zero. Request statistics use retained matching requests; average curves retain the session's original cumulative totals. Agent/model filters select observations, not recomputed lifetime averages. Exported SVGs reflect the current filters. Regenerate this report to include new history.</footer>
503
+ <div id="tooltip" class="tooltip" hidden role="tooltip"></div>
504
+ <script id="chart-data" type="application/json">${payload}</script>
505
+ <script>(${chartClient.toString()})(JSON.parse(document.getElementById("chart-data").textContent));</script>
506
+ </body></html>`;
507
+ }
508
+ function chartClient(report) {
509
+ const { data, metadata } = report;
510
+ const element = (id, kind) => {
511
+ const node = document.getElementById(id);
512
+ if (!(node instanceof kind))
513
+ throw new Error(`Missing report element ${id}`);
514
+ return node;
515
+ };
516
+ const session = element("session", HTMLSelectElement), agent = element("agent", HTMLSelectElement), model = element("model", HTMLSelectElement);
517
+ const from = element("from", HTMLInputElement), to = element("to", HTMLInputElement);
518
+ const charts = element("charts", HTMLElement), stats = element("stats", HTMLElement), legend = element("legend", HTMLElement);
519
+ const notice = element("notice", HTMLElement), tooltip = element("tooltip", HTMLElement);
520
+ const color = (index) => `hsl(${(index * 137.508 + 195) % 360}, 78%, 68%)`;
521
+ const date = (value) => {
522
+ const d = new Date(value);
523
+ return Number.isFinite(d.getTime()) ? d.toISOString() : String(value);
524
+ };
525
+ const number = (n, digits = 1) => n === undefined ? "Unavailable" : n.toLocaleString("en-US", { maximumFractionDigits: digits });
526
+ function options(select, values) {
527
+ for (const v of values) {
528
+ const o = document.createElement("option");
529
+ o.value = v.value;
530
+ o.textContent = v.label;
531
+ select.append(o);
532
+ }
533
+ }
534
+ options(session, [
535
+ { value: "*", label: "Compare sessions (up to 12)" },
536
+ ...data.series.map((s) => ({
537
+ value: s.sessionId,
538
+ label: `${s.agents.join(", ") || "Agent"} \xB7 ${s.sessionId}`
539
+ }))
540
+ ]);
541
+ options(agent, [
542
+ { value: "*", label: "All agents" },
543
+ ...[...new Set(data.series.flatMap((s) => s.agents))].sort().map((v) => ({ value: v, label: v }))
544
+ ]);
545
+ options(model, [
546
+ { value: "*", label: "All models" },
547
+ ...[...new Set(data.series.flatMap((s) => s.models))].sort().map((v) => ({ value: v, label: v }))
548
+ ]);
549
+ function reset() {
550
+ session.value = data.series[0]?.sessionId ?? "*";
551
+ agent.value = report.agent ?? "*";
552
+ model.value = report.model ?? "*";
553
+ from.value = report.from === undefined ? "" : date(report.from).slice(0, 19);
554
+ to.value = report.to === undefined ? "" : date(report.to).slice(0, 19);
555
+ }
556
+ const timestamp = (value) => value ? Date.parse(`${value}Z`) : undefined;
557
+ function matches2(p, start, end) {
558
+ return (agent.value === "*" || p.agent === agent.value) && (model.value === "*" || p.model === model.value) && (start === undefined || p.time >= start) && (end === undefined || p.time <= end);
559
+ }
560
+ const ns = "http://www.w3.org/2000/svg";
561
+ function svgNode(name, attrs = {}, text2) {
562
+ const n = document.createElementNS(ns, name);
563
+ for (const [key, value] of Object.entries(attrs))
564
+ n.setAttribute(key, String(value));
565
+ if (text2 !== undefined)
566
+ n.textContent = text2;
567
+ return n;
568
+ }
569
+ function showTooltip(text2, x, y) {
570
+ tooltip.textContent = text2;
571
+ tooltip.hidden = false;
572
+ tooltip.style.left = `${Math.max(8, Math.min(x + 12, window.innerWidth - 370))}px`;
573
+ tooltip.style.top = `${Math.max(8, Math.min(y + 12, window.innerHeight - tooltip.offsetHeight - 10))}px`;
574
+ }
575
+ const definitions = [
576
+ {
577
+ key: "liveTps",
578
+ title: "Live TPS",
579
+ unit: "tokens / second",
580
+ description: "Estimated text + reasoning throughput in the rolling collection window.",
581
+ requests: false,
582
+ estimated: true
583
+ },
584
+ {
585
+ key: "averageModelTps",
586
+ title: "Average model throughput",
587
+ unit: "tokens / second",
588
+ description: "Session output / matched model-request time, including TTFT and excluding subsequent tool time.",
589
+ requests: false,
590
+ estimated: false
591
+ },
592
+ {
593
+ key: "averageSessionTps",
594
+ title: "Average session throughput",
595
+ unit: "tokens / second",
596
+ description: "Session output / elapsed session lifetime, including tools and idle time.",
597
+ requests: false,
598
+ estimated: false
599
+ },
600
+ {
601
+ key: "ttftMs",
602
+ title: "Time to first output",
603
+ unit: "seconds",
604
+ description: "One point per request with observed text/reasoning output. Includes publication delay.",
605
+ requests: true,
606
+ estimated: true
607
+ },
608
+ {
609
+ key: "tps",
610
+ title: "Completed request throughput",
611
+ unit: "tokens / second",
612
+ description: "Reported output + reasoning / request duration. Requests are never joined into a continuous curve.",
613
+ requests: true,
614
+ estimated: false
615
+ },
616
+ {
617
+ key: "sessionOutputTokens",
618
+ title: "Cumulative output",
619
+ unit: "tokens",
620
+ description: "Authoritative session output + reasoning totals. Lifecycle and sample records are snapshots, not increments.",
621
+ requests: false,
622
+ estimated: false
623
+ }
624
+ ];
625
+ function render() {
626
+ tooltip.hidden = true;
627
+ charts.replaceChildren();
628
+ stats.replaceChildren();
629
+ legend.replaceChildren();
630
+ const start = timestamp(from.value), end = timestamp(to.value);
631
+ if (start !== undefined && !Number.isFinite(start) || end !== undefined && !Number.isFinite(end) || start !== undefined && end !== undefined && start > end) {
632
+ notice.textContent = "Choose a valid time range with From before To.";
633
+ return;
634
+ }
635
+ const selected = data.series.filter((s) => (session.value === "*" || s.sessionId === session.value) && s.points.some((p) => matches2(p, start, end)) || (session.value === "*" || s.sessionId === session.value) && s.requests.some((p) => matches2(p, start, end)));
636
+ const visible = selected.slice(0, 12);
637
+ const requests = visible.flatMap((s) => s.requests.filter((r) => matches2(r, start, end)));
638
+ const matched = requests.filter((r) => r.outputTokens !== undefined && r.durationMs !== undefined && r.durationMs > 0);
639
+ const duration = matched.reduce((sum, r) => sum + (r.durationMs ?? 0), 0), tokens = matched.reduce((sum, r) => sum + (r.outputTokens ?? 0), 0);
640
+ const ttfts = requests.flatMap((r) => r.ttftMs === undefined ? [] : [r.ttftMs]).sort((a, b) => a - b);
641
+ const percentile = (q) => ttfts.length ? (ttfts[Math.max(0, Math.ceil(ttfts.length * q) - 1)] ?? 0) / 1000 : undefined;
642
+ for (const [label, value] of [
643
+ ["Matched / observed requests", `${matched.length} / ${requests.length}`],
644
+ [
645
+ "Retained request throughput",
646
+ duration ? `${number(tokens * 1000 / duration)} t/s` : "Unavailable"
647
+ ],
648
+ [
649
+ "Median observed TTFT",
650
+ ttfts.length ? `${number(percentile(0.5), 2)}s` : "Unavailable"
651
+ ],
652
+ [
653
+ "95th percentile TTFT",
654
+ ttfts.length ? `${number(percentile(0.95), 2)}s` : "Unavailable"
655
+ ]
656
+ ]) {
657
+ const box = document.createElement("div");
658
+ box.className = "stat";
659
+ const name = document.createElement("span"), valueNode = document.createElement("strong");
660
+ name.textContent = label ?? "";
661
+ valueNode.textContent = value ?? "";
662
+ box.append(name, valueNode);
663
+ stats.append(box);
664
+ }
665
+ const gaps = data.gaps.filter((g) => (g.sessionId === "__history__" || visible.some((s) => s.sessionId === g.sessionId)) && (start === undefined || g.time >= start) && (end === undefined || g.time <= end));
666
+ const notes = [
667
+ `Bounded retained history: ${metadata.retainedFrom === undefined ? "empty archive" : `segments since ${date(metadata.retainedFrom)}`}.`,
668
+ `${gaps.length} recorded gap markers.`
669
+ ];
670
+ if (metadata.skippedBytes)
671
+ notes.push(`${number(metadata.skippedBytes, 0)} bytes excluded by the read limit.`);
672
+ if (metadata.malformedRecords)
673
+ notes.push(`${metadata.malformedRecords} malformed/partial records skipped.`);
674
+ if (data.omittedGaps)
675
+ notes.push(`${data.omittedGaps} older gap markers omitted; curve discontinuities remain preserved.`);
676
+ if (data.omittedSessions)
677
+ notes.push(`${data.omittedSessions} sessions omitted from the report limit; regenerate with --session to inspect one.`);
678
+ if (selected.length > 12)
679
+ notes.push(`Showing 12 of ${selected.length} matching sessions; select a session for detail.`);
680
+ if (visible.some((s) => s.originalPoints > s.points.length))
681
+ notes.push(`Curves downsampled to at most ${data.maxPoints} observations per session, preserving extrema and gaps.`);
682
+ if (visible.some((s) => s.points.some((p) => p.partial && matches2(p, start, end))))
683
+ notes.push("Partial timing coverage is present.");
684
+ if (!visible.length)
685
+ notes.push("No observations match these filters.");
686
+ notice.textContent = notes.join(" ");
687
+ visible.forEach((s, index) => {
688
+ const item = document.createElement("span"), dot = document.createElement("i");
689
+ dot.className = "dot";
690
+ dot.style.backgroundColor = color(index);
691
+ item.append(dot, document.createTextNode(`${s.agents.join(", ") || "Agent"} \xB7 ${s.sessionId}${s.parentId ? ` (child of ${s.parentId})` : ""}`));
692
+ legend.append(item);
693
+ });
694
+ const times = visible.flatMap((s) => [...s.points, ...s.requests].filter((p) => matches2(p, start, end)).map((p) => p.time));
695
+ let minTime = start ?? times.reduce((a, t) => Math.min(a, t), Infinity), maxTime = end ?? times.reduce((a, t) => Math.max(a, t), -Infinity);
696
+ if (!Number.isFinite(minTime))
697
+ minTime = 0;
698
+ if (!Number.isFinite(maxTime))
699
+ maxTime = minTime + 1000;
700
+ if (maxTime <= minTime)
701
+ maxTime = minTime + 1000;
702
+ for (const def of definitions) {
703
+ const card = document.createElement("section");
704
+ card.className = "chart";
705
+ const heading = document.createElement("div");
706
+ heading.className = "charthead";
707
+ const title = document.createElement("h2");
708
+ title.textContent = def.title;
709
+ const download = document.createElement("button");
710
+ download.textContent = "Save SVG";
711
+ download.setAttribute("aria-label", `Save ${def.title} as SVG`);
712
+ heading.append(title, download);
713
+ const desc = document.createElement("p");
714
+ desc.textContent = def.description;
715
+ card.append(heading, desc);
716
+ charts.append(card);
717
+ const plots = visible.map((s) => {
718
+ if (def.requests)
719
+ return s.requests.filter((p) => matches2(p, start, end)).map((p) => {
720
+ const raw = def.key === "ttftMs" ? p.ttftMs : p.tps;
721
+ return {
722
+ ...p,
723
+ breakBefore: true,
724
+ ...raw === undefined ? {} : { value: def.key === "ttftMs" ? raw / 1000 : raw }
725
+ };
726
+ });
727
+ let skipped = false;
728
+ return s.points.flatMap((p) => {
729
+ if (!matches2(p, start, end)) {
730
+ skipped = true;
731
+ return [];
732
+ }
733
+ const key = def.key;
734
+ const value = key === "liveTps" ? p.liveTps : key === "averageModelTps" ? p.averageModelTps : key === "averageSessionTps" ? p.averageSessionTps : p.sessionOutputTokens;
735
+ const point = {
736
+ ...p,
737
+ breakBefore: p.breakBefore || skipped,
738
+ ...value === undefined ? {} : { value }
739
+ };
740
+ skipped = false;
741
+ return [point];
742
+ });
743
+ });
744
+ const drawn = plots.map((points) => {
745
+ if (!def.requests || points.length <= data.maxPoints)
746
+ return points;
747
+ const bins = Math.max(1, Math.floor(data.maxPoints / 4)), chosen = new Set;
748
+ for (let b = 0;b < bins; b++) {
749
+ const first = Math.floor(b * points.length / bins), last = Math.floor((b + 1) * points.length / bins) - 1;
750
+ chosen.add(first);
751
+ chosen.add(last);
752
+ let low = first, high = first;
753
+ for (let i = first;i <= last; i++) {
754
+ if ((points[i]?.value ?? Infinity) < (points[low]?.value ?? Infinity))
755
+ low = i;
756
+ if ((points[i]?.value ?? -Infinity) > (points[high]?.value ?? -Infinity))
757
+ high = i;
758
+ }
759
+ chosen.add(low);
760
+ chosen.add(high);
761
+ }
762
+ return [...chosen].sort((a, b) => a - b).flatMap((i) => points[i] ? [points[i]] : []);
763
+ });
764
+ const maxValue = drawn.reduce((maximum, points) => points.reduce((n, p) => Math.max(n, p.value ?? 0), maximum), 1) * 1.08;
765
+ const w = 720, h = 300, left = 64, right = 20, top = 24, bottom = 47;
766
+ const x = (t) => left + (t - minTime) / (maxTime - minTime) * (w - left - right);
767
+ const y = (v) => h - bottom - v / maxValue * (h - top - bottom);
768
+ const svg = svgNode("svg", {
769
+ viewBox: `0 0 ${w} ${h}`,
770
+ role: "img",
771
+ "aria-label": `${def.title} over UTC timestamps`,
772
+ xmlns: ns
773
+ });
774
+ svg.append(svgNode("rect", { width: w, height: h, fill: "#111c2e" }), svgNode("title", {}, `${def.title}. ${def.description}`));
775
+ svg.append(svgNode("text", {
776
+ x: left,
777
+ y: 14,
778
+ fill: "#a8b5cb",
779
+ "font-size": 11,
780
+ "font-family": "system-ui,sans-serif"
781
+ }, def.unit));
782
+ for (let tick = 0;tick <= 4; tick++) {
783
+ const v = maxValue * tick / 4;
784
+ svg.append(svgNode("line", {
785
+ x1: left,
786
+ x2: w - right,
787
+ y1: y(v),
788
+ y2: y(v),
789
+ stroke: "#2a3950",
790
+ "stroke-width": 1
791
+ }));
792
+ svg.append(svgNode("text", {
793
+ x: left - 8,
794
+ y: y(v) + 4,
795
+ fill: "#a8b5cb",
796
+ "text-anchor": "end",
797
+ "font-size": 11,
798
+ "font-family": "system-ui,sans-serif"
799
+ }, v >= 1e4 ? `${number(v / 1000)}k` : number(v)));
800
+ const t = minTime + (maxTime - minTime) * tick / 4;
801
+ const label = maxTime - minTime > 86400000 ? date(t).slice(5, 16).replace("T", " ") : date(t).slice(11, 19);
802
+ svg.append(svgNode("text", {
803
+ x: x(t),
804
+ y: h - 22,
805
+ fill: "#a8b5cb",
806
+ "text-anchor": tick === 0 ? "start" : tick === 4 ? "end" : "middle",
807
+ "font-size": 11,
808
+ "font-family": "system-ui,sans-serif"
809
+ }, label));
810
+ }
811
+ for (const gap of gaps)
812
+ if (gap.time >= minTime && gap.time <= maxTime) {
813
+ const line = svgNode("line", {
814
+ x1: x(gap.time),
815
+ x2: x(gap.time),
816
+ y1: top,
817
+ y2: h - bottom,
818
+ stroke: "#fb7185",
819
+ "stroke-dasharray": "3 5",
820
+ opacity: 0.65
821
+ });
822
+ line.append(svgNode("title", {}, `${date(gap.time)} \xB7 ${gap.reason}`));
823
+ svg.append(line);
824
+ }
825
+ let count2 = 0;
826
+ drawn.forEach((points, index) => {
827
+ let run = [];
828
+ const flush = () => {
829
+ if (run.length > 1)
830
+ svg.append(svgNode("polyline", {
831
+ points: run.map((p) => `${x(p.time)},${y(p.value ?? 0)}`).join(" "),
832
+ fill: "none",
833
+ stroke: color(index),
834
+ "stroke-width": 1.8,
835
+ ...def.estimated || run.some((p) => p.partial) ? { "stroke-dasharray": "5 4" } : {}
836
+ }));
837
+ run = [];
838
+ };
839
+ for (const point of points) {
840
+ if (point.breakBefore || point.value === undefined)
841
+ flush();
842
+ if (point.value === undefined)
843
+ continue;
844
+ count2++;
845
+ if (!def.requests)
846
+ run.push(point);
847
+ const label = `${date(point.time)}\n${visible[index]?.sessionId ?? ""}${point.agent ? ` \xB7 ${point.agent}` : ""}${point.model ? `\n${point.model}` : ""}\n${def.title}: ${def.estimated ? "~" : ""}${number(point.value, 3)} ${def.unit}${point.requestId ? `\nRequest: ${point.requestId}` : ""}${point.partial ? "\nPartial observation coverage" : ""}`;
848
+ const dot = svgNode("circle", {
849
+ cx: x(point.time),
850
+ cy: y(point.value),
851
+ r: def.requests ? 3 : 2.5,
852
+ fill: color(index),
853
+ tabindex: 0,
854
+ "aria-label": label
855
+ });
856
+ dot.append(svgNode("title", {}, label));
857
+ dot.addEventListener("mouseenter", (e) => showTooltip(label, e.clientX, e.clientY));
858
+ dot.addEventListener("mouseleave", () => {
859
+ tooltip.hidden = true;
860
+ });
861
+ dot.addEventListener("focus", () => {
862
+ const box = dot.getBoundingClientRect();
863
+ showTooltip(label, box.x, box.y);
864
+ });
865
+ dot.addEventListener("blur", () => {
866
+ tooltip.hidden = true;
867
+ });
868
+ svg.append(dot);
869
+ }
870
+ flush();
871
+ });
872
+ if (!count2)
873
+ svg.append(svgNode("text", {
874
+ x: w / 2,
875
+ y: h / 2,
876
+ fill: "#a8b5cb",
877
+ "text-anchor": "middle",
878
+ "font-size": 14,
879
+ "font-family": "system-ui,sans-serif"
880
+ }, "No measurements in this selection"));
881
+ card.append(svg);
882
+ download.onclick = () => {
883
+ const blob = new Blob([new XMLSerializer().serializeToString(svg)], {
884
+ type: "image/svg+xml"
885
+ });
886
+ const url = URL.createObjectURL(blob);
887
+ const link = document.createElement("a");
888
+ link.href = url;
889
+ link.download = `context-guard-${def.key}.svg`;
890
+ link.click();
891
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
892
+ };
893
+ }
894
+ }
895
+ for (const control of [session, agent, model, from, to])
896
+ control.addEventListener("change", render);
897
+ element("reset", HTMLButtonElement).onclick = () => {
898
+ reset();
899
+ render();
900
+ };
901
+ reset();
902
+ render();
903
+ }
904
+
905
+ // src/history.ts
906
+ import { mkdir, readdir, stat, open, unlink } from "fs/promises";
907
+ import { join } from "path";
908
+ var filename = /^([0-9]+)-[a-f0-9-]+\.jsonl$/;
909
+ async function segments(directory) {
910
+ const names = await readdir(directory).catch((error) => {
911
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
912
+ return [];
913
+ throw error;
914
+ });
915
+ return (await Promise.all(names.filter((n) => filename.test(n)).map(async (name) => ({
916
+ name,
917
+ time: Number(filename.exec(name)?.[1]),
918
+ bytes: (await stat(join(directory, name))).size
919
+ })))).sort((a, b) => a.time - b.time || a.name.localeCompare(b.name));
920
+ }
921
+
922
+ class PerformanceHistory {
923
+ path;
924
+ config;
925
+ now;
926
+ report;
927
+ pending = [];
928
+ working;
929
+ active;
930
+ bytes = 0;
931
+ started = 0;
932
+ disabled = false;
933
+ dropped = 0;
934
+ droppedTotal = 0;
935
+ constructor(path, config, now = Date.now, report = () => {
936
+ }) {
937
+ this.path = path;
938
+ this.config = config;
939
+ this.now = now;
940
+ this.report = report;
941
+ }
942
+ emit(record2) {
943
+ if (!this.config.enabled || this.disabled)
944
+ return;
945
+ const line = JSON.stringify({
946
+ schema: 1,
947
+ recordId: record2.recordId,
948
+ time: record2.time,
949
+ type: record2.type,
950
+ sessionId: record2.sessionId,
951
+ ...record2.parentId ? { parentId: record2.parentId } : {},
952
+ ...record2.agent ? { agent: record2.agent } : {},
953
+ ...record2.model ? { model: record2.model } : {},
954
+ ...record2.event ? { event: record2.event } : {},
955
+ ...record2.droppedRecords === undefined ? {} : { droppedRecords: record2.droppedRecords },
956
+ ...record2.performance ? { performance: scalarPerformance(record2.performance) } : {}
957
+ }) + "\n";
958
+ if (this.pending.length >= 256 || Buffer.byteLength(line) > Math.min(4 * 1024 * 1024, this.config.maxBytes)) {
959
+ this.dropped++;
960
+ this.droppedTotal++;
961
+ if (this.dropped === 1)
962
+ this.report("history-records-dropped");
963
+ return;
964
+ }
965
+ this.pending.push(line);
966
+ this.kick();
967
+ }
968
+ kick() {
969
+ if (this.working || this.disabled)
970
+ return;
971
+ this.working = this.drain().catch(() => {
972
+ this.disabled = true;
973
+ this.pending = [];
974
+ this.report("history-unavailable");
975
+ }).finally(() => {
976
+ this.working = undefined;
977
+ if (this.pending.length)
978
+ this.kick();
979
+ });
980
+ }
981
+ async prune(incoming = 0) {
982
+ const files = await segments(this.path);
983
+ let total = files.reduce((n, f) => n + f.bytes, 0);
984
+ let truncated = false;
985
+ for (const file of files) {
986
+ if (file.time >= this.now() - this.config.retentionDays * 86400000 && total + incoming <= this.config.maxBytes)
987
+ break;
988
+ await unlink(join(this.path, file.name));
989
+ if (file.name === this.active) {
990
+ this.active = undefined;
991
+ this.bytes = 0;
992
+ }
993
+ total -= file.bytes;
994
+ truncated = true;
995
+ }
996
+ if (truncated)
997
+ this.report("history-truncated");
998
+ }
999
+ gapLine(event, droppedRecords) {
1000
+ return JSON.stringify({
1001
+ schema: 1,
1002
+ recordId: crypto.randomUUID(),
1003
+ time: this.now(),
1004
+ type: "gap",
1005
+ sessionId: "__history__",
1006
+ event,
1007
+ ...droppedRecords === undefined ? {} : { droppedRecords }
1008
+ }) + "\n";
1009
+ }
1010
+ async drain() {
1011
+ await mkdir(this.path, { recursive: true, mode: 448 });
1012
+ await this.prune();
1013
+ while (this.pending.length) {
1014
+ let line = this.pending.shift();
1015
+ if (!line)
1016
+ continue;
1017
+ if (this.dropped > 0) {
1018
+ this.pending.unshift(line);
1019
+ line = this.gapLine("history-records-dropped", this.dropped);
1020
+ this.dropped = 0;
1021
+ }
1022
+ const size = Buffer.byteLength(line);
1023
+ const now = this.now();
1024
+ if (!this.active || this.bytes + size > Math.min(4 * 1024 * 1024, this.config.maxBytes) || now - this.started >= 3600000) {
1025
+ this.active = undefined;
1026
+ this.bytes = 0;
1027
+ await this.prune(size);
1028
+ this.started = now;
1029
+ this.active = `${now}-${crypto.randomUUID()}.jsonl`;
1030
+ } else
1031
+ await this.prune(size);
1032
+ if (!this.active) {
1033
+ this.started = now;
1034
+ this.active = `${now}-${crypto.randomUUID()}.jsonl`;
1035
+ }
1036
+ const file = await open(join(this.path, this.active), "a", 384);
1037
+ try {
1038
+ await file.writeFile(line);
1039
+ } finally {
1040
+ await file.close();
1041
+ }
1042
+ this.bytes += size;
1043
+ }
1044
+ }
1045
+ health() {
1046
+ return { unavailable: this.disabled, droppedRecords: this.droppedTotal };
1047
+ }
1048
+ async flush() {
1049
+ while (this.working)
1050
+ await this.working;
1051
+ }
1052
+ async initialize() {
1053
+ if (!this.config.enabled)
1054
+ return;
1055
+ await mkdir(this.path, { recursive: true, mode: 448 }).then(() => this.prune()).catch(() => {
1056
+ this.disabled = true;
1057
+ this.report("history-unavailable");
1058
+ });
1059
+ }
1060
+ }
1061
+ function scalarPerformance(p) {
1062
+ return {
1063
+ observedSince: p.observedSince,
1064
+ sessionCreatedAt: p.sessionCreatedAt,
1065
+ at: p.at,
1066
+ status: p.status,
1067
+ partial: p.partial,
1068
+ estimatedOutputTokens: p.estimatedOutputTokens,
1069
+ modelOutputTokens: p.modelOutputTokens,
1070
+ modelDurationMs: p.modelDurationMs,
1071
+ completedRequests: p.completedRequests,
1072
+ tokenSource: p.tokenSource,
1073
+ ...Object.fromEntries([
1074
+ "sessionOutputTokens",
1075
+ "liveTps",
1076
+ "averageModelTps",
1077
+ "averageSessionTps",
1078
+ "ttftMs",
1079
+ "latestRequestTps",
1080
+ "requestId",
1081
+ "requestAgent",
1082
+ "requestModel",
1083
+ "requestOutputTokens",
1084
+ "requestStartedAt",
1085
+ "firstOutputAt",
1086
+ "streamEndedAt"
1087
+ ].flatMap((key) => {
1088
+ const value = p[key];
1089
+ return value === undefined ? [] : [[key, value]];
1090
+ }))
1091
+ };
1092
+ }
1093
+ async function readPerformanceHistory(directory, filter = {}) {
1094
+ const records = [];
1095
+ const files = await segments(directory);
1096
+ const maxBytes = filter.maxBytes ?? 64 * 1024 * 1024;
1097
+ let bytes = 0, skippedBytes = 0, malformedRecords = 0;
1098
+ const selected = [];
1099
+ for (const file of files.slice().reverse()) {
1100
+ if (bytes + file.bytes > maxBytes) {
1101
+ skippedBytes += file.bytes;
1102
+ continue;
1103
+ }
1104
+ bytes += file.bytes;
1105
+ selected.unshift(file);
1106
+ }
1107
+ for (const file of selected) {
1108
+ const handle = await open(join(directory, file.name), "r");
1109
+ let source;
1110
+ try {
1111
+ const buffer = Buffer.alloc(file.bytes);
1112
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
1113
+ source = buffer.subarray(0, bytesRead).toString("utf8");
1114
+ } finally {
1115
+ await handle.close();
1116
+ }
1117
+ for (const line of source.split("\n")) {
1118
+ if (!line)
1119
+ continue;
1120
+ try {
1121
+ const value = JSON.parse(line);
1122
+ if (!isHistoryRecord(value)) {
1123
+ malformedRecords++;
1124
+ continue;
1125
+ }
1126
+ if (filter.sessionId !== undefined && value.sessionId !== filter.sessionId && value.sessionId !== "__history__" || value.type !== "gap" && filter.agent !== undefined && (value.performance?.requestAgent ?? value.agent) !== filter.agent || value.type !== "gap" && filter.model !== undefined && (value.performance?.requestModel ?? value.model) !== filter.model || filter.from !== undefined && value.time < filter.from || filter.to !== undefined && value.time > filter.to)
1127
+ continue;
1128
+ records.push(value);
1129
+ } catch {
1130
+ malformedRecords++;
1131
+ }
1132
+ }
1133
+ }
1134
+ const metadata = {
1135
+ schema: 1,
1136
+ ...files[0] ? { retainedFrom: files[0].time } : {},
1137
+ retainedBytes: files.reduce((n, f) => n + f.bytes, 0),
1138
+ skippedBytes,
1139
+ malformedRecords,
1140
+ completeness: "bounded-retention"
1141
+ };
1142
+ return { metadata, records };
1143
+ }
1144
+ function isRecord(value) {
1145
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1146
+ }
1147
+ function isHistoryRecord(value) {
1148
+ if (!isRecord(value))
1149
+ return false;
1150
+ const r = value;
1151
+ const allowed = new Set([
1152
+ "schema",
1153
+ "recordId",
1154
+ "time",
1155
+ "type",
1156
+ "sessionId",
1157
+ "parentId",
1158
+ "agent",
1159
+ "model",
1160
+ "event",
1161
+ "performance",
1162
+ "droppedRecords"
1163
+ ]);
1164
+ return Object.keys(r).every((k) => allowed.has(k)) && r.schema === 1 && typeof r.recordId === "string" && typeof r.time === "number" && Number.isSafeInteger(r.time) && r.time >= 0 && typeof r.sessionId === "string" && typeof r.type === "string" && ["sample", "request", "lifecycle", "gap", "terminal"].includes(r.type) && ["parentId", "agent", "model", "event"].every((k) => r[k] === undefined || typeof r[k] === "string") && (r.performance === undefined || isPerformanceSnapshot(r.performance)) && (r.droppedRecords === undefined || typeof r.droppedRecords === "number" && Number.isSafeInteger(r.droppedRecords) && r.droppedRecords >= 0);
1165
+ }
1166
+
1167
+ // src/telemetry.ts
1168
+ import { mkdir as mkdir2, open as open2, rename, stat as stat2 } from "fs/promises";
1169
+ import { dirname, join as join2 } from "path";
1170
+ import { homedir } from "os";
1171
+ import { createHash } from "crypto";
1172
+ function defaultPath(directory) {
1173
+ const key = createHash("sha256").update(directory).digest("hex").slice(0, 16);
1174
+ return join2(process.env.XDG_STATE_HOME || join2(homedir(), ".local/state"), "opencode-context-guard", `${key}.jsonl`);
1175
+ }
1176
+
1177
+ class JsonlTelemetrySink {
1178
+ path;
1179
+ config;
1180
+ pending = [];
1181
+ working;
1182
+ disabled = false;
1183
+ bytes = 0;
1184
+ ready = false;
1185
+ constructor(path, config) {
1186
+ this.path = path;
1187
+ this.config = config;
1188
+ }
1189
+ emit(event) {
1190
+ if (!this.config.enabled || this.disabled)
1191
+ return;
1192
+ const line = JSON.stringify({ schema: 1, ...event }) + "\n";
1193
+ if (!this.config.jsonl) {
1194
+ console.error(line.trimEnd());
1195
+ return;
1196
+ }
1197
+ if (this.pending.length >= 256) {
1198
+ console.error("context-guard: telemetry queue full; record dropped");
1199
+ return;
1200
+ }
1201
+ this.pending.push(line);
1202
+ this.kick();
1203
+ }
1204
+ kick() {
1205
+ if (!this.working && !this.disabled)
1206
+ this.working = this.drain().finally(() => {
1207
+ this.working = undefined;
1208
+ if (this.pending.length)
1209
+ this.kick();
1210
+ });
1211
+ }
1212
+ async drain() {
1213
+ try {
1214
+ if (!this.ready) {
1215
+ await mkdir2(dirname(this.path), { recursive: true, mode: 448 });
1216
+ this.bytes = await stat2(this.path).then((s) => s.size).catch(() => 0);
1217
+ this.ready = true;
1218
+ }
1219
+ while (this.pending.length) {
1220
+ const line = this.pending.shift();
1221
+ if (!line)
1222
+ continue;
1223
+ const size = Buffer.byteLength(line);
1224
+ if (size > this.config.maxBytes)
1225
+ continue;
1226
+ if (this.bytes + size > this.config.maxBytes) {
1227
+ await rename(this.path, `${this.path}.1`).catch((e) => {
1228
+ if (e.code !== "ENOENT")
1229
+ throw e;
1230
+ });
1231
+ this.bytes = 0;
1232
+ }
1233
+ const file = await open2(this.path, "a", 384);
1234
+ try {
1235
+ await file.writeFile(line);
1236
+ } finally {
1237
+ await file.close();
1238
+ }
1239
+ this.bytes += size;
1240
+ }
1241
+ } catch {
1242
+ this.disabled = true;
1243
+ this.pending = [];
1244
+ console.error("context-guard: telemetry unavailable (check path and permissions)");
1245
+ }
1246
+ }
1247
+ async flush() {
1248
+ while (this.working)
1249
+ await this.working;
1250
+ }
1251
+ }
1252
+ async function readJournal(path, maxBytes = 16 * 1024 * 1024) {
1253
+ const records = [];
1254
+ for (const name of [`${path}.1`, path]) {
1255
+ const size = await stat2(name).then((s) => s.size).catch(() => 0);
1256
+ if (!size)
1257
+ continue;
1258
+ const file = await open2(name, "r");
1259
+ const buffer = Buffer.alloc(Math.min(size, maxBytes));
1260
+ try {
1261
+ await file.read(buffer, 0, buffer.length, Math.max(0, size - maxBytes));
1262
+ } finally {
1263
+ await file.close();
1264
+ }
1265
+ const lines = buffer.toString("utf8").split("\n");
1266
+ if (size > maxBytes)
1267
+ lines.shift();
1268
+ for (const line of lines) {
1269
+ if (!line)
1270
+ continue;
1271
+ try {
1272
+ records.push(JSON.parse(line));
1273
+ } catch {
1274
+ }
1275
+ }
1276
+ }
1277
+ return records;
1278
+ }
1279
+
1280
+ // src/cli.ts
1281
+ var args = process.argv.slice(2);
1282
+ if (!args[0] || args.includes("--help")) {
1283
+ console.log("Usage: bun run inspect /absolute/path/to/journal.jsonl [--history] [--json]\nThe ready log reports the location-specific journal path.\nMetrics: inspect metrics /absolute/history-directory [--session ID] [--from ISO-or-ms] [--to ISO-or-ms] [--csv]\nCharts: inspect charts /absolute/history-directory --output report.html [--session ID] [--agent NAME] [--model provider/model] [--from ISO-or-ms] [--to ISO-or-ms] [--max-points 1000] [--force]");
1284
+ } else if (args[0] === "metrics" || args[0] === "charts") {
1285
+ const directory = args[1];
1286
+ if (!directory || directory.startsWith("--"))
1287
+ throw new Error("Usage: inspect metrics /absolute/history-directory [--session ID] [--from ISO-or-ms] [--to ISO-or-ms] [--csv]");
1288
+ const option = (name) => {
1289
+ const index = args.indexOf(name);
1290
+ if (index < 0)
1291
+ return;
1292
+ const value = args[index + 1];
1293
+ if (!value || value.startsWith("--"))
1294
+ throw new Error(`Missing value for ${name}`);
1295
+ return value;
1296
+ };
1297
+ const timestamp = (value) => {
1298
+ if (value === undefined)
1299
+ return;
1300
+ const n = /^\d+$/.test(value) ? Number(value) : Date.parse(value);
1301
+ if (!Number.isSafeInteger(n) || n < 0)
1302
+ throw new Error("Invalid timestamp");
1303
+ return n;
1304
+ };
1305
+ const sessionId = option("--session"), agent = option("--agent"), model = option("--model"), from = timestamp(option("--from")), to = timestamp(option("--to"));
1306
+ if (from !== undefined && to !== undefined && from > to)
1307
+ throw new Error("--from must precede --to");
1308
+ const chart = args[0] === "charts" || args.includes("--chart") || args.includes("--output");
1309
+ if (chart && args.includes("--csv"))
1310
+ throw new Error("Choose charts or CSV export");
1311
+ const positiveInteger = (name, fallback, min, max) => {
1312
+ const raw = option(name), n = raw === undefined ? fallback : Number(raw);
1313
+ if (!Number.isSafeInteger(n) || n < min || n > max)
1314
+ throw new Error(`${name} must be an integer from ${min} to ${max}`);
1315
+ return n;
1316
+ };
1317
+ const maxPoints = positiveInteger("--max-points", 1000, 16, 5000);
1318
+ const maxSessions = positiveInteger("--max-sessions", 128, 1, 1024);
1319
+ const result = await readPerformanceHistory(directory, {
1320
+ ...sessionId === undefined ? {} : { sessionId },
1321
+ ...agent === undefined ? {} : { agent },
1322
+ ...model === undefined ? {} : { model },
1323
+ ...from === undefined ? {} : { from },
1324
+ ...to === undefined ? {} : { to }
1325
+ });
1326
+ if (chart) {
1327
+ const output = resolve(option("--chart") ?? option("--output") ?? "context-guard-charts.html");
1328
+ if (!output.toLowerCase().endsWith(".html"))
1329
+ throw new Error("Chart output must use an .html extension");
1330
+ const data = buildChartData(result.records, { maxPoints, maxSessions });
1331
+ const report = renderChartReport({
1332
+ data,
1333
+ metadata: result.metadata,
1334
+ ...agent === undefined ? {} : { agent },
1335
+ ...model === undefined ? {} : { model },
1336
+ ...from === undefined ? {} : { from },
1337
+ ...to === undefined ? {} : { to }
1338
+ });
1339
+ await writeFile(output, report, {
1340
+ flag: args.includes("--force") ? "w" : "wx",
1341
+ mode: 384
1342
+ });
1343
+ console.log(`Charts written to ${output} (${data.series.length} session instances). Open this file in a browser. No server or external assets are needed.`);
1344
+ } else if (args.includes("--csv")) {
1345
+ console.error(JSON.stringify(result.metadata));
1346
+ const keys = [
1347
+ "recordId",
1348
+ "time",
1349
+ "type",
1350
+ "sessionId",
1351
+ "parentId",
1352
+ "agent",
1353
+ "model",
1354
+ "event",
1355
+ "at",
1356
+ "sessionCreatedAt",
1357
+ "sessionOutputTokens",
1358
+ "requestId",
1359
+ "requestAgent",
1360
+ "requestModel",
1361
+ "requestOutputTokens",
1362
+ "requestStartedAt",
1363
+ "firstOutputAt",
1364
+ "streamEndedAt",
1365
+ "liveTps",
1366
+ "averageModelTps",
1367
+ "averageSessionTps",
1368
+ "ttftMs",
1369
+ "latestRequestTps",
1370
+ "estimatedOutputTokens",
1371
+ "modelOutputTokens",
1372
+ "modelDurationMs",
1373
+ "completedRequests",
1374
+ "observedSince",
1375
+ "status",
1376
+ "partial",
1377
+ "tokenSource"
1378
+ ];
1379
+ console.log(keys.join(","));
1380
+ for (const record2 of result.records) {
1381
+ if (!record2 || typeof record2 !== "object")
1382
+ continue;
1383
+ const row2 = {
1384
+ ...record2,
1385
+ ..."performance" in record2 && record2.performance && typeof record2.performance === "object" ? record2.performance : {}
1386
+ };
1387
+ console.log(keys.map((key) => '"' + String(row2[key] ?? "").replaceAll('"', '""') + '"').join(","));
1388
+ }
1389
+ } else
1390
+ console.log(JSON.stringify(result, null, 2));
1391
+ } else {
1392
+ const records = await readJournal(args[0]);
1393
+ const sessions = new Map;
1394
+ const history = [];
1395
+ for (const record2 of records) {
1396
+ history.push(record2);
1397
+ if (!record2 || typeof record2 !== "object" || !("session" in record2))
1398
+ continue;
1399
+ const s = record2.session;
1400
+ if (!s || typeof s !== "object" || !("sessionId" in s) || typeof s.sessionId !== "string")
1401
+ continue;
1402
+ sessions.set(s.sessionId, { ...s });
1403
+ }
1404
+ if (args.includes("--history"))
1405
+ console.log(JSON.stringify(history, null, 2));
1406
+ else if (args.includes("--json"))
1407
+ console.log(JSON.stringify([...sessions.values()], null, 2));
1408
+ else
1409
+ for (const s of sessions.values())
1410
+ console.log(JSON.stringify(s));
1411
+ }