@cesarus777/opencode-cgp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -44,6 +44,12 @@ function isHistoryRead(value) {
44
44
  function isHistoryResult(value) {
45
45
  return valid(value, historyResultSchema);
46
46
  }
47
+ function isAnalyticsRead(value) {
48
+ return valid(value, analyticsReadSchema) && record(value) && typeof value.from === "number" && typeof value.to === "number" && value.from < value.to && value.to - value.from <= 604800000 && !(value.model !== undefined && value.unknownModel === true) && (!(value.modelCursor || value.requestCursor) || typeof value.snapshotId === "string");
49
+ }
50
+ function isAnalyticsResult(value) {
51
+ return valid(value, analyticsResultSchema);
52
+ }
47
53
  function record(x) {
48
54
  return typeof x === "object" && x !== null && !Array.isArray(x);
49
55
  }
@@ -101,7 +107,7 @@ var count, text, enumeration = (...values) => ({ type: "string", enum: values })
101
107
  type: "array",
102
108
  items,
103
109
  maxItems
104
- }), measurement, row, batchFields, readSchema, batchSchema, resultSchema, monitorRpc, nonnegative, performanceSchema, performanceRow, performanceBatchSchema, performanceResultSchema, performanceRpc, chartPointSchema, chartRequestSchema, historyReadSchema, historyResultSchema, historyRpc;
110
+ }), measurement, row, batchFields, readSchema, batchSchema, resultSchema, monitorRpc, nonnegative, performanceSchema, performanceRow, performanceBatchSchema, performanceResultSchema, performanceRpc, chartPointSchema, chartRequestSchema, historyReadSchema, historyResultSchema, historyRpc, modelSummarySchema, analyticsReadSchema, analyticsResultSchema, analyticsRpc;
105
111
  var init_rpc = __esm(() => {
106
112
  count = {
107
113
  type: "integer",
@@ -337,6 +343,123 @@ var init_rpc = __esm(() => {
337
343
  methods: { read: { input: historyReadSchema, output: historyResultSchema } },
338
344
  events: {}
339
345
  });
346
+ modelSummarySchema = object({
347
+ model: text,
348
+ sessions: count,
349
+ completedRequests: count,
350
+ partialRequests: count,
351
+ legacyAttributionRequests: count,
352
+ tpsRequests: count,
353
+ ttftRequests: count,
354
+ excludedTpsRequests: count,
355
+ excludedTtftRequests: count,
356
+ outputTokens: nonnegative,
357
+ durationMs: nonnegative,
358
+ averageTps: nonnegative,
359
+ meanTtftMs: nonnegative,
360
+ medianTtftMs: nonnegative,
361
+ p95TtftMs: nonnegative
362
+ }, [
363
+ "sessions",
364
+ "completedRequests",
365
+ "partialRequests",
366
+ "legacyAttributionRequests",
367
+ "tpsRequests",
368
+ "ttftRequests",
369
+ "excludedTpsRequests",
370
+ "excludedTtftRequests",
371
+ "outputTokens",
372
+ "durationMs"
373
+ ]);
374
+ analyticsReadSchema = object({
375
+ schemaVersion: { const: 1 },
376
+ from: count,
377
+ to: count,
378
+ buckets: { type: "integer", minimum: 1, maximum: 240 },
379
+ agent: text,
380
+ model: text,
381
+ unknownModel: { type: "boolean" },
382
+ sort: enumeration("requests", "tps", "ttft", "identity"),
383
+ snapshotId: text,
384
+ modelCursor: text,
385
+ requestCursor: text
386
+ }, ["schemaVersion", "from", "to", "buckets"]);
387
+ analyticsResultSchema = object({
388
+ schemaVersion: { const: 1 },
389
+ generatedAt: count,
390
+ expiresAt: count,
391
+ snapshotId: text,
392
+ enabled: { type: "boolean" },
393
+ from: count,
394
+ to: count,
395
+ agent: text,
396
+ models: array(modelSummarySchema, 50),
397
+ totalModels: count,
398
+ nextModelsCursor: text,
399
+ selected: modelSummarySchema,
400
+ buckets: array(object({ from: nonnegative, to: nonnegative, summary: modelSummarySchema }, ["from", "to"]), 240),
401
+ requests: array(object({
402
+ location: text,
403
+ sessionId: text,
404
+ requestId: text,
405
+ startedAt: count,
406
+ completedAt: count,
407
+ model: text,
408
+ agent: text,
409
+ attribution: enumeration("request", "legacy-record", "unknown"),
410
+ partial: { type: "boolean" },
411
+ outputTokens: nonnegative,
412
+ ttftMs: nonnegative
413
+ }, [
414
+ "location",
415
+ "sessionId",
416
+ "requestId",
417
+ "startedAt",
418
+ "completedAt",
419
+ "attribution",
420
+ "partial"
421
+ ]), 50),
422
+ nextRequestsCursor: text,
423
+ coverage: object({
424
+ source: enumeration("retained-request-records"),
425
+ scannedRecords: count,
426
+ unscannedRecords: count,
427
+ duplicateRecords: count,
428
+ conflictingRequests: count,
429
+ missingIdentityRecords: count,
430
+ incompleteRequests: count,
431
+ invalidTimingRequests: count,
432
+ gapRecords: count,
433
+ maxRequests: count,
434
+ partial: { type: "boolean" }
435
+ }),
436
+ metadata: historyResultSchema.properties.metadata ?? {},
437
+ gaps: array(count, 128),
438
+ omittedGaps: count
439
+ }, [
440
+ "schemaVersion",
441
+ "generatedAt",
442
+ "expiresAt",
443
+ "snapshotId",
444
+ "enabled",
445
+ "from",
446
+ "to",
447
+ "models",
448
+ "totalModels",
449
+ "buckets",
450
+ "requests",
451
+ "coverage",
452
+ "metadata",
453
+ "gaps",
454
+ "omittedGaps"
455
+ ]);
456
+ analyticsRpc = Rpc.define({
457
+ id: "context-guard.analytics.v1",
458
+ methods: {
459
+ read: { input: analyticsReadSchema, output: analyticsResultSchema }
460
+ },
461
+ events: {}
462
+ });
340
463
  });
341
464
 
342
465
  // src/history.ts
@@ -529,6 +652,7 @@ function scalarPerformance(p) {
529
652
  async function readPerformanceHistory(directory, filter = {}) {
530
653
  const records = [];
531
654
  const files = await segments(directory);
655
+ filter.signal?.throwIfAborted();
532
656
  const maxBytes = filter.maxBytes ?? 64 * 1024 * 1024;
533
657
  let bytes = 0, skippedBytes = 0, malformedRecords = 0;
534
658
  const selected = [];
@@ -541,6 +665,7 @@ async function readPerformanceHistory(directory, filter = {}) {
541
665
  selected.unshift(file);
542
666
  }
543
667
  for (const file of selected) {
668
+ filter.signal?.throwIfAborted();
544
669
  const handle = await open(join(directory, file.name), "r");
545
670
  let source;
546
671
  try {
@@ -550,7 +675,12 @@ async function readPerformanceHistory(directory, filter = {}) {
550
675
  } finally {
551
676
  await handle.close();
552
677
  }
678
+ let scannedLines = 0;
553
679
  for (const line of source.split("\n")) {
680
+ if (filter.signal && ++scannedLines % 256 === 0) {
681
+ await new Promise((done) => setTimeout(done, 0));
682
+ filter.signal.throwIfAborted();
683
+ }
554
684
  if (!line)
555
685
  continue;
556
686
  try {
@@ -663,19 +793,43 @@ async function loadTerminalRenderer() {
663
793
  try {
664
794
  return await import("@opentui/core");
665
795
  } catch {
666
- throw new Error("Terminal renderer unavailable; install @opentui/core@0.5.17 beside @cesarus777/opencode-cgp using Bun >=1.3.0");
667
- }
668
- }
669
- var dashboardHelp = `Usage: context-guard dashboard tui [--server URL] [--directory PATH]
670
- Read-only standalone terminal dashboard; never starts an OpenCode server.
671
- Requires an interactive terminal, Bun >=1.3.0 and @opentui/core@0.5.17.
672
- Install the renderer beside this package: npm install @opentui/core@0.5.17
673
- Local discovery defaults to the current directory.
674
- An explicit server requires --directory with an owning absolute server path.
675
- Remote authentication: CONTEXT_GUARD_PASSWORD and optional CONTEXT_GUARD_USERNAME
676
- (default opencode). Credentials must never be supplied in the URL.
677
- Keys: 1 overview, 2 detail, 3 history, arrows/j/k move, / search, f filters,
678
- l location, w history window, r refresh, ? help, Escape back, q/Ctrl+C quit.`;
796
+ throw new Error("Terminal renderer unavailable; reinstall @cesarus777/opencode-cgp with optional dependencies enabled for its platform renderer, using Bun >=1.3.0");
797
+ }
798
+ }
799
+ var dashboardHelp = `Usage: context-guard dashboard tui [options]
800
+ Open a read-only live terminal dashboard for an existing OpenCode server.
801
+
802
+ Start here
803
+ 1. Keep an OpenCode server running with Context Guard registered.
804
+ 2. Open a separate terminal tab, window or pane for the dashboard.
805
+ 3. In the same project directory, run: context-guard dashboard tui
806
+ Keep working in OpenCode; the dashboard reads live updates from its server.
807
+
808
+ Options
809
+ --directory PATH Owning project directory (local default: current directory)
810
+ --server URL Connect to an explicit server; requires --directory with
811
+ an absolute server-side project path. Use HTTPS except
812
+ on loopback. Credentials in URLs are rejected.
813
+ -h, --help Show this help
814
+
815
+ Examples
816
+ context-guard dashboard tui
817
+ context-guard dashboard tui --directory /absolute/project
818
+ context-guard dashboard tui --server https://server.example --directory /server/project
819
+
820
+ Requirements
821
+ Interactive terminal and Bun >=1.3.0. The OpenTUI renderer is installed
822
+ automatically with CGP. This command never starts a server.
823
+
824
+ Authentication (explicit servers)
825
+ CONTEXT_GUARD_PASSWORD Server password
826
+ CONTEXT_GUARD_USERNAME Server username (default: opencode)
827
+
828
+ Keys
829
+ 1/2/3/4 overview/detail/history/models; arrows or j/k move; Enter detail/chart;
830
+ Models: Left/Right metric, [/] bucket, d requests, n/p pages, s sort;
831
+ / session search; f filters (Models: agent); l location; w time window;
832
+ r refresh; ? help; q or Ctrl+C quit.`;
679
833
  var init_command = () => {
680
834
  };
681
835
 
@@ -1143,6 +1297,249 @@ var init_inventory = __esm(() => {
1143
1297
  });
1144
1298
  });
1145
1299
 
1300
+ // src/dashboard/port.ts
1301
+ var AnalyticsUnavailable;
1302
+ var init_port = __esm(() => {
1303
+ AnalyticsUnavailable = class AnalyticsUnavailable extends Error {
1304
+ reason;
1305
+ constructor(reason) {
1306
+ super(`Model analytics ${reason}`);
1307
+ this.reason = reason;
1308
+ }
1309
+ };
1310
+ });
1311
+
1312
+ // src/dashboard/models.ts
1313
+ class ModelsController {
1314
+ changed;
1315
+ port;
1316
+ active = false;
1317
+ key = "";
1318
+ agent = "";
1319
+ seedModel = "";
1320
+ selection;
1321
+ resolution = 120;
1322
+ from = 0;
1323
+ to = 1;
1324
+ generation = 0;
1325
+ abort;
1326
+ pending;
1327
+ modelPages = [undefined];
1328
+ requestPages = [undefined];
1329
+ state = {
1330
+ status: "idle",
1331
+ stale: false,
1332
+ sort: "requests",
1333
+ windowMs: 300000,
1334
+ modelPage: 0,
1335
+ requestPage: 0
1336
+ };
1337
+ constructor(changed) {
1338
+ this.changed = changed;
1339
+ }
1340
+ getView() {
1341
+ return this.state;
1342
+ }
1343
+ configure(port, active, windowMs, agent, seedModel, now) {
1344
+ const key = JSON.stringify([
1345
+ port.location.directory,
1346
+ windowMs,
1347
+ agent,
1348
+ seedModel
1349
+ ]);
1350
+ const different = key !== this.key;
1351
+ const preserveSelection = this.port?.location.directory === port.location.directory && this.agent === agent && this.seedModel === seedModel;
1352
+ const entering = active && !this.active;
1353
+ if (different || !active)
1354
+ this.cancel();
1355
+ this.port = port;
1356
+ this.active = active;
1357
+ if (different) {
1358
+ this.key = key;
1359
+ this.agent = agent;
1360
+ this.seedModel = seedModel;
1361
+ if (!preserveSelection)
1362
+ this.selection = seedModel || undefined;
1363
+ this.modelPages = [undefined];
1364
+ this.requestPages = [undefined];
1365
+ this.state = {
1366
+ status: "idle",
1367
+ stale: false,
1368
+ sort: this.state.sort,
1369
+ windowMs,
1370
+ modelPage: 0,
1371
+ requestPage: 0
1372
+ };
1373
+ }
1374
+ if (active && (different || entering))
1375
+ this.refresh(now);
1376
+ }
1377
+ cancel() {
1378
+ this.generation++;
1379
+ this.abort?.abort();
1380
+ this.abort = undefined;
1381
+ this.pending = undefined;
1382
+ }
1383
+ stop() {
1384
+ this.cancel();
1385
+ this.active = false;
1386
+ this.state = { ...this.state, stale: Boolean(this.state.result) };
1387
+ }
1388
+ async refresh(now) {
1389
+ if (!this.active || !this.port)
1390
+ return;
1391
+ if (this.pending)
1392
+ return this.pending;
1393
+ this.to = Math.max(1, Math.floor(now));
1394
+ this.from = Math.max(0, this.to - this.state.windowMs);
1395
+ this.modelPages = [undefined];
1396
+ this.requestPages = [undefined];
1397
+ this.state = { ...this.state, modelPage: 0, requestPage: 0 };
1398
+ return this.query(false);
1399
+ }
1400
+ setWidth(width) {
1401
+ const next = Math.max(1, Math.min(120, Math.floor(width) - 10));
1402
+ if (next === this.resolution)
1403
+ return;
1404
+ this.resolution = next;
1405
+ if (this.active && this.state.result)
1406
+ this.query(true);
1407
+ }
1408
+ select(model) {
1409
+ if (!this.active || model === this.selection)
1410
+ return;
1411
+ this.selection = model;
1412
+ this.requestPages = [undefined];
1413
+ this.state = { ...this.state, requestPage: 0 };
1414
+ this.query(true);
1415
+ }
1416
+ sort() {
1417
+ const sorts = ["requests", "tps", "ttft", "identity"];
1418
+ this.modelPages = [undefined];
1419
+ this.state = {
1420
+ ...this.state,
1421
+ sort: sorts[(sorts.indexOf(this.state.sort) + 1) % sorts.length] ?? "requests",
1422
+ modelPage: 0
1423
+ };
1424
+ this.query(true);
1425
+ }
1426
+ page(kind, direction) {
1427
+ if (this.state.status !== "ready")
1428
+ return;
1429
+ const pages = kind === "models" ? this.modelPages : this.requestPages;
1430
+ const index = kind === "models" ? this.state.modelPage : this.state.requestPage;
1431
+ const next = kind === "models" ? this.state.result?.nextModelsCursor : this.state.result?.nextRequestsCursor;
1432
+ if (direction < 0 && index === 0 || direction > 0 && !next)
1433
+ return;
1434
+ if (direction > 0)
1435
+ pages[index + 1] = next;
1436
+ this.state = {
1437
+ ...this.state,
1438
+ ...kind === "models" ? { modelPage: index + direction, requestPage: 0 } : { requestPage: index + direction }
1439
+ };
1440
+ if (kind === "models") {
1441
+ this.selection = undefined;
1442
+ this.requestPages = [undefined];
1443
+ }
1444
+ this.query(true);
1445
+ }
1446
+ query(reuse) {
1447
+ this.cancel();
1448
+ const port = this.port;
1449
+ if (!this.active || !port)
1450
+ return Promise.resolve();
1451
+ if (!port.analytics) {
1452
+ this.state = { ...this.state, status: "unsupported", stale: false };
1453
+ this.changed();
1454
+ return Promise.resolve();
1455
+ }
1456
+ const read = port.analytics.bind(port);
1457
+ const controller = new AbortController;
1458
+ this.abort = controller;
1459
+ const generation = this.generation;
1460
+ const signal = AbortSignal.any([
1461
+ controller.signal,
1462
+ AbortSignal.timeout(15000)
1463
+ ]);
1464
+ const modelCursor = this.modelPages[this.state.modelPage];
1465
+ const requestCursor = this.requestPages[this.state.requestPage];
1466
+ const input = {
1467
+ schemaVersion: 1,
1468
+ from: this.from,
1469
+ to: this.to,
1470
+ buckets: this.resolution,
1471
+ sort: this.state.sort,
1472
+ ...this.agent ? { agent: this.agent } : {},
1473
+ ...this.selection === "" ? { unknownModel: true } : this.selection ? { model: this.selection } : {},
1474
+ ...reuse && this.state.result ? { snapshotId: this.state.result.snapshotId } : {},
1475
+ ...reuse && modelCursor ? { modelCursor } : {},
1476
+ ...reuse && requestCursor ? { requestCursor } : {}
1477
+ };
1478
+ this.state = {
1479
+ ...this.state,
1480
+ status: "loading",
1481
+ stale: Boolean(this.state.result)
1482
+ };
1483
+ this.changed();
1484
+ const task = Promise.resolve().then(async () => {
1485
+ try {
1486
+ let result;
1487
+ try {
1488
+ result = await read(input, signal);
1489
+ } catch (error) {
1490
+ if (!input.snapshotId || signal.aborted || error instanceof AnalyticsUnavailable && error.reason === "unsupported")
1491
+ throw error;
1492
+ const {
1493
+ snapshotId: _id,
1494
+ modelCursor: _m,
1495
+ requestCursor: _r,
1496
+ ...fresh
1497
+ } = input;
1498
+ result = await read(fresh, signal);
1499
+ if (generation !== this.generation)
1500
+ return;
1501
+ signal.throwIfAborted();
1502
+ this.modelPages = [undefined];
1503
+ this.requestPages = [undefined];
1504
+ this.state = { ...this.state, modelPage: 0, requestPage: 0 };
1505
+ }
1506
+ if (generation !== this.generation)
1507
+ return;
1508
+ signal.throwIfAborted();
1509
+ if (!isAnalyticsResult(result) || result.from !== input.from || result.to !== input.to || result.agent !== input.agent || result.requests.some((r) => r.location !== port.location.directory))
1510
+ throw new Error("Invalid analytics response");
1511
+ if (result.selected)
1512
+ this.selection = result.selected.model ?? "";
1513
+ this.state = {
1514
+ ...this.state,
1515
+ result: structuredClone(result),
1516
+ status: result.enabled ? "ready" : "disabled",
1517
+ stale: false
1518
+ };
1519
+ } catch (error) {
1520
+ if (generation !== this.generation || controller.signal.aborted)
1521
+ return;
1522
+ this.state = {
1523
+ ...this.state,
1524
+ status: error instanceof AnalyticsUnavailable && error.reason === "unsupported" && !this.state.result ? "unsupported" : "unavailable",
1525
+ stale: Boolean(this.state.result)
1526
+ };
1527
+ } finally {
1528
+ if (generation === this.generation) {
1529
+ this.pending = undefined;
1530
+ this.changed();
1531
+ }
1532
+ }
1533
+ });
1534
+ this.pending = task;
1535
+ return task;
1536
+ }
1537
+ }
1538
+ var init_models = __esm(() => {
1539
+ init_rpc();
1540
+ init_port();
1541
+ });
1542
+
1146
1543
  // src/dashboard/view.ts
1147
1544
  function immutableView(value) {
1148
1545
  const copy = structuredClone(value);
@@ -1235,6 +1632,7 @@ class DashboardController {
1235
1632
  refreshTimer;
1236
1633
  ageTimer;
1237
1634
  view;
1635
+ models;
1238
1636
  constructor(port, options = {}) {
1239
1637
  this.limits = { ...options.limits ?? inventoryLimits };
1240
1638
  validateInventoryLimits(this.limits);
@@ -1245,6 +1643,7 @@ class DashboardController {
1245
1643
  throw new Error("Invalid dashboard refresh interval");
1246
1644
  this.state = this.createLocation(port);
1247
1645
  this.locations.set(port.location.directory, this.state);
1646
+ this.models = new ModelsController(() => this.publish());
1248
1647
  this.view = this.buildView();
1249
1648
  }
1250
1649
  createLocation(port) {
@@ -1294,6 +1693,7 @@ class DashboardController {
1294
1693
  await this.refresh();
1295
1694
  }
1296
1695
  stop() {
1696
+ this.models.stop();
1297
1697
  this.running = false;
1298
1698
  this.generation++;
1299
1699
  this.scope?.abort();
@@ -1328,6 +1728,7 @@ class DashboardController {
1328
1728
  this.locations.delete(directory);
1329
1729
  this.locations.set(directory, next);
1330
1730
  this.state = next;
1731
+ this.configureModels();
1331
1732
  if (this.locations.size > 4) {
1332
1733
  const oldest = this.locations.keys().next().value;
1333
1734
  if (oldest !== undefined)
@@ -1349,11 +1750,12 @@ class DashboardController {
1349
1750
  this.publish();
1350
1751
  }
1351
1752
  setScreen(screen) {
1352
- if (!["overview", "detail", "history"].includes(screen))
1753
+ if (!["overview", "detail", "history", "models"].includes(screen))
1353
1754
  throw new Error("Invalid dashboard screen");
1354
1755
  if (screen === this.screen)
1355
1756
  return;
1356
1757
  this.screen = screen;
1758
+ this.configureModels();
1357
1759
  this.queueHistory();
1358
1760
  this.publish();
1359
1761
  }
@@ -1363,6 +1765,7 @@ class DashboardController {
1363
1765
  if (windowMs === this.windowMs)
1364
1766
  return;
1365
1767
  this.windowMs = windowMs;
1768
+ this.configureModels();
1366
1769
  this.queueHistory();
1367
1770
  this.publish();
1368
1771
  }
@@ -1375,11 +1778,18 @@ class DashboardController {
1375
1778
  agent: filters.agent ?? this.filters.agent,
1376
1779
  model: filters.model ?? this.filters.model
1377
1780
  };
1781
+ this.configureModels();
1378
1782
  this.publish();
1379
1783
  }
1784
+ configureModels() {
1785
+ this.models.configure(this.state.port, this.running && this.screen === "models", this.windowMs, this.filters.agent, this.filters.model, this.serverNow());
1786
+ }
1380
1787
  async refresh() {
1381
1788
  if (!this.running || !this.scope)
1382
1789
  return;
1790
+ this.configureModels();
1791
+ if (this.screen === "models")
1792
+ this.models.refresh(this.serverNow());
1383
1793
  for (const cached of this.cache.values())
1384
1794
  if (cached.directory === this.state.port.location.directory)
1385
1795
  cached.valid = false;
@@ -1680,6 +2090,7 @@ class DashboardController {
1680
2090
  "maximum-500-points-and-requests"
1681
2091
  ]
1682
2092
  },
2093
+ models: this.models.getView(),
1683
2094
  notices: [
1684
2095
  ...!state.complete ? ["partial-inventory"] : [],
1685
2096
  ...state.connection !== "live" ? ["live-data-stale"] : [],
@@ -1692,6 +2103,7 @@ var init_controller = __esm(() => {
1692
2103
  init_rpc();
1693
2104
  init_transport();
1694
2105
  init_inventory();
2106
+ init_models();
1695
2107
  init_view();
1696
2108
  });
1697
2109
 
@@ -1844,6 +2256,19 @@ async function connectDashboard(options, discovery = Service) {
1844
2256
  }
1845
2257
  };
1846
2258
  },
2259
+ async analytics(input, signal) {
2260
+ if (!isAnalyticsRead(input))
2261
+ throw new Error("Invalid analytics request");
2262
+ try {
2263
+ const result = await client.rpc(analyticsRpc).read(input, { location, signal: deadline(signal) });
2264
+ if (!isAnalyticsResult(result))
2265
+ throw new Error("Invalid analytics response");
2266
+ return result;
2267
+ } catch (error) {
2268
+ const missing = typeof error === "object" && error !== null && "type" in error && (error.type === "rpc.unavailable" || error.type === "rpc.method_not_found");
2269
+ throw new AnalyticsUnavailable(missing ? "unsupported" : "unavailable");
2270
+ }
2271
+ },
1847
2272
  async history(input, signal) {
1848
2273
  if (!isHistoryRead(input))
1849
2274
  throw new Error("History requires one session, at most seven days and 16\u2013500 points");
@@ -1871,6 +2296,7 @@ async function connectDashboard(options, discovery = Service) {
1871
2296
  }
1872
2297
  var init_connection = __esm(() => {
1873
2298
  init_rpc();
2299
+ init_port();
1874
2300
  });
1875
2301
 
1876
2302
  // src/tui/format.ts
@@ -1991,9 +2417,9 @@ var init_format = __esm(() => {
1991
2417
  });
1992
2418
 
1993
2419
  // src/tui/chartformat.ts
1994
- function columns(points, from, to, width, gaps = []) {
2420
+ function columns(points, from, to, width, gaps = [], halfOpen = false) {
1995
2421
  const size = Math.max(1, Math.min(120, Math.floor(width))), values = Array.from({ length: size });
1996
- const column = (time) => Math.max(0, Math.min(size - 1, Math.floor((time - from) / Math.max(1, to - from) * (size - 1))));
2422
+ const column = (time) => Math.max(0, Math.min(size - 1, Math.floor((time - from) / Math.max(1, to - from) * (halfOpen ? size : size - 1))));
1997
2423
  let min = Infinity, max = -Infinity;
1998
2424
  for (const point of points) {
1999
2425
  if (point.time < from || point.time > to || point.value === undefined || !Number.isFinite(point.value) || point.value < 0)
@@ -2017,12 +2443,12 @@ function sparkline(points, from, to, width, gaps = []) {
2017
2443
  chars[column(time)] = "\u2506";
2018
2444
  return { line: chars.join(""), ...min === Infinity ? {} : { min, max } };
2019
2445
  }
2020
- function metricPlot(points, from, to, width, gaps = []) {
2446
+ function metricPlot(points, from, to, width, gaps = [], halfOpen = false) {
2021
2447
  const label = (value) => value >= 1e6 ? `${(value / 1e6).toFixed(1)}M` : value >= 1000 ? `${(value / 1000).toFixed(1)}k` : value.toFixed(1);
2022
2448
  const peak = Math.max(0, ...points.filter((p) => p.time >= from && p.time <= to && Number.isFinite(p.value)).map((p) => p.value ?? 0));
2023
2449
  const scale = Math.max(1, peak);
2024
2450
  const gutter = width >= 20 ? Math.max(label(scale).length, 3) : 0;
2025
- const { values, column } = columns(points, from, to, Math.max(1, width - gutter - 2), gaps);
2451
+ const { values, column } = columns(points, from, to, Math.max(1, halfOpen ? Math.min(points.length, width - gutter - 2) : width - gutter - 2), gaps, halfOpen);
2026
2452
  const gapColumns = new Set(gaps.filter((time) => time >= from && time <= to).map(column));
2027
2453
  const height = 6;
2028
2454
  const blocks = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
@@ -2099,6 +2525,108 @@ var init_chartformat = __esm(() => {
2099
2525
  ];
2100
2526
  });
2101
2527
 
2528
+ // src/dashboard/modelview.ts
2529
+ function metricValue(summary, metric2) {
2530
+ if (metric2.key === "outputTokens" && !summary?.tpsRequests)
2531
+ return;
2532
+ const value = summary?.[metric2.key];
2533
+ return value === undefined ? undefined : value / metric2.divisor;
2534
+ }
2535
+ function terminalModelRow(model, focused) {
2536
+ return [
2537
+ `${focused ? "> " : " "}${model.model ?? "unknown model attribution"}`,
2538
+ ` ${stats(model)}`,
2539
+ ` TPS eligible ${model.tpsRequests}; TTFT eligible ${model.ttftRequests}; partial ${model.partialRequests}; legacy attribution ${model.legacyAttributionRequests}`
2540
+ ];
2541
+ }
2542
+ function terminalModels(view, width, detail, metricIndex, sample, focus, requests) {
2543
+ const state = view.models;
2544
+ const result = state.result;
2545
+ const lines = [
2546
+ `Models \xB7 ${state.status}${state.stale ? " \xB7 STALE cached results" : ""} \xB7 ${state.windowMs / 60000} minutes \xB7 retained history`,
2547
+ `Agent: ${(result?.agent ?? view.filters.agent) || "all"} \xB7 sort ${state.sort} \xB7 location ${view.location}`,
2548
+ "4 Models \xB7 Enter chart \xB7 Esc model list/overview \xB7 w window \xB7 f agent \xB7 r refresh"
2549
+ ];
2550
+ if (state.status === "unsupported")
2551
+ return [
2552
+ ...lines,
2553
+ "Models requires a server with context-guard.analytics.v1; update the server plugin. Overview and session history remain available."
2554
+ ];
2555
+ if (state.status === "disabled")
2556
+ return [
2557
+ ...lines,
2558
+ "Retained performance recording is disabled on this server."
2559
+ ];
2560
+ if (!result)
2561
+ return [
2562
+ ...lines,
2563
+ state.status === "loading" ? "Loading model aggregates\u2026" : "Model analytics unavailable; press r to retry."
2564
+ ];
2565
+ lines.push(`${new Date(result.from).toISOString()} \u2192 ${new Date(result.to).toISOString()} (UTC; completion time)`);
2566
+ const partial = result.coverage.partial || result.metadata.skippedBytes > 0 || result.metadata.malformedRecords > 0 || result.gaps.length > 0 || result.omittedGaps > 0;
2567
+ lines.push(`Coverage: ${partial ? "PARTIAL" : "bounded observations"}; 64 MiB scan limit; ${result.coverage.maxRequests} request identities maximum; no pre-observation reconstruction.`);
2568
+ lines.push(`Archive retained since ${result.metadata.retainedFrom === undefined ? "unknown" : new Date(result.metadata.retainedFrom).toISOString()}; snapshot observed at ${new Date(result.generatedAt).toISOString()}.`);
2569
+ lines.push(`Scan: ${result.coverage.scannedRecords} records; ${result.metadata.skippedBytes} bytes skipped; ${result.metadata.malformedRecords} malformed; ${result.coverage.conflictingRequests} conflicting; ${result.coverage.incompleteRequests} incomplete; ${result.coverage.invalidTimingRequests} invalid timing; gaps ${result.gaps.length}${result.omittedGaps ? ` (+${result.omittedGaps} omitted)` : ""}.`);
2570
+ if (!detail) {
2571
+ lines.push(`Model page ${state.modelPage + 1} \xB7 ${result.totalModels} models \xB7 Up/Down scroll/select \xB7 n/p pages \xB7 s sort`);
2572
+ if (!result.models.length)
2573
+ lines.push("No completed requests match this range and agent filter.");
2574
+ result.models.forEach((model, index) => {
2575
+ lines.push(...terminalModelRow(model, index === focus));
2576
+ });
2577
+ return lines;
2578
+ }
2579
+ const selected = result.selected;
2580
+ if (!selected)
2581
+ return [
2582
+ ...lines,
2583
+ "No completed requests for this model in the selected range."
2584
+ ];
2585
+ lines.push(`Model: ${selected.model ?? "unknown model attribution"}`, stats(selected));
2586
+ lines.push(`Reported output ${number(selected.tpsRequests ? selected.outputTokens : undefined)} tokens; matched duration ${number(selected.tpsRequests ? selected.durationMs / 1000 : undefined)}s; TPS eligible/excluded ${selected.tpsRequests}/${selected.excludedTpsRequests}; TTFT eligible/excluded ${selected.ttftRequests}/${selected.excludedTtftRequests}; partial ${selected.partialRequests}; legacy attribution ${selected.legacyAttributionRequests}`);
2587
+ if (requests) {
2588
+ lines.push(`Request page ${state.requestPage + 1} \xB7 n/p pages \xB7 d chart \xB7 completed stream \u2260 task success`);
2589
+ result.requests.forEach((r) => lines.push(`Request ${r.requestId} \xB7 session ${r.sessionId}`, `Completed ${new Date(r.completedAt).toISOString()} \xB7 ${r.agent ?? "unknown agent"} \xB7 output ${number(r.outputTokens)} \xB7 duration ${number((r.completedAt - r.startedAt) / 1000)}s \xB7 TTFT ${number(r.ttftMs === undefined ? undefined : r.ttftMs / 1000)}s ~ \xB7 ${r.attribution}${r.partial ? " \xB7 partial" : ""}`));
2590
+ return lines;
2591
+ }
2592
+ const metric2 = modelMetrics[metricIndex] ?? modelMetrics[0];
2593
+ lines.push(`\u2039 ${metric2.label} (${metric2.unit}) \u203A \xB7 Left/Right metric \xB7 [/] bucket \xB7 d requests`);
2594
+ lines.push(...metricPlot(result.buckets.map((bucket2) => {
2595
+ const value = metricValue(bucket2.summary, metric2);
2596
+ return {
2597
+ time: (bucket2.from + bucket2.to) / 2,
2598
+ ...value === undefined ? {} : { value }
2599
+ };
2600
+ }), result.from, result.to, width, result.gaps, true));
2601
+ lines.push("UTC buckets \xB7 blank = no eligible observations \xB7 \u2506 = retained gap; partial coverage may hide other gaps.");
2602
+ lines.push("TPS = reported output / summed matched request durations (includes TTFT); concurrent durations add. In-flight requests enter after stream completion.");
2603
+ const bucket = result.buckets[Math.min(sample, Math.max(0, result.buckets.length - 1))];
2604
+ if (bucket) {
2605
+ lines.push(`Bucket ${Math.min(sample + 1, result.buckets.length)}/${result.buckets.length}: ${new Date(bucket.from).toISOString()} \u2192 ${new Date(bucket.to).toISOString()}`);
2606
+ lines.push(bucket.summary ? stats(bucket.summary) : "No eligible observations; measurements unknown.");
2607
+ lines.push(`Selected ${metric2.label}: ${number(metricValue(bucket.summary, metric2))} ${metric2.unit}`);
2608
+ if (bucket.summary)
2609
+ lines.push(`Bucket eligibility: TPS ${bucket.summary.tpsRequests}/${bucket.summary.completedRequests}; TTFT ${bucket.summary.ttftRequests}/${bucket.summary.completedRequests}; partial ${bucket.summary.partialRequests}`);
2610
+ }
2611
+ return lines;
2612
+ }
2613
+ var modelMetrics, number = (n) => n === undefined ? "?" : Number.isInteger(n) ? String(n) : n.toFixed(2), stats = (m) => `${m.sessions} sessions \xB7 ${m.completedRequests} completed \xB7 weighted TPS ${number(m.averageTps)} \xB7 TTFT median/p95 ${number(m.medianTtftMs === undefined ? undefined : m.medianTtftMs / 1000)}/${number(m.p95TtftMs === undefined ? undefined : m.p95TtftMs / 1000)}s ~`;
2614
+ var init_modelview = __esm(() => {
2615
+ init_chartformat();
2616
+ modelMetrics = [
2617
+ { key: "averageTps", label: "Weighted request TPS", unit: "t/s", divisor: 1 },
2618
+ { key: "medianTtftMs", label: "TTFT median ~", unit: "s", divisor: 1000 },
2619
+ { key: "p95TtftMs", label: "TTFT p95 ~", unit: "s", divisor: 1000 },
2620
+ {
2621
+ key: "completedRequests",
2622
+ label: "Completed requests",
2623
+ unit: "requests",
2624
+ divisor: 1
2625
+ },
2626
+ { key: "outputTokens", label: "Reported output", unit: "tokens", divisor: 1 }
2627
+ ];
2628
+ });
2629
+
2102
2630
  // src/dashboard/terminalview.ts
2103
2631
  function overviewRow(row2) {
2104
2632
  const s = row2.snapshot;
@@ -2181,7 +2709,11 @@ function terminalHistory(view, width, metricIndex, sample) {
2181
2709
  ];
2182
2710
  }
2183
2711
  function wrapped(lines, width) {
2184
- return lines.flatMap((line) => line ? wrap(line, Math.max(1, width)) : [""]);
2712
+ const available = Math.max(1, width);
2713
+ return lines.flatMap((line) => {
2714
+ const clean = clip(line, Math.max(available, cells(line)));
2715
+ return cells(clean) <= available ? [clean] : wrap(clean, available);
2716
+ });
2185
2717
  }
2186
2718
  function headerLines(view, width) {
2187
2719
  return [
@@ -2261,7 +2793,10 @@ var init_terminalview = __esm(() => {
2261
2793
  ];
2262
2794
  terminalHelp = [
2263
2795
  "Context Guard standalone dashboard \u2014 keyboard help",
2264
- "1 overview \xB7 2 detail \xB7 3 history \xB7 Enter detail",
2796
+ "1 overview \xB7 2 detail \xB7 3 history \xB7 4 models \xB7 Enter detail/chart",
2797
+ "Models: Up/Down scroll tall rows, then select \xB7 Enter aggregate chart \xB7 Esc model list \xB7 s sort",
2798
+ "Models charts: Left/Right metric \xB7 w window \xB7 [/] bucket \xB7 d request details",
2799
+ "Models list/request details: n/p pages \xB7 f exact agent filter \xB7 r refresh",
2265
2800
  "Up/Down or j/k: select session in overview; scroll other screens",
2266
2801
  "PageUp/PageDown: move by a page \xB7 Home/End: first/last",
2267
2802
  "Left/Right: history metric (six metrics) \xB7 w: cycle 5m/1h/24h/7d",
@@ -2291,6 +2826,11 @@ class TerminalShell {
2291
2826
  scroll = 0;
2292
2827
  metric = 0;
2293
2828
  sample = 0;
2829
+ modelDetail = false;
2830
+ modelRequests = false;
2831
+ modelFocus = 0;
2832
+ modelFocusId;
2833
+ modelViewport;
2294
2834
  help = false;
2295
2835
  editor;
2296
2836
  input = "";
@@ -2336,7 +2876,7 @@ class TerminalShell {
2336
2876
  this.task(this.controller.selectLocation(this.input));
2337
2877
  } else
2338
2878
  this.controller.setFilters({ [editor]: this.input });
2339
- this.editor = editor === "agent" ? "model" : undefined;
2879
+ this.editor = editor === "agent" && view.screen !== "models" ? "model" : undefined;
2340
2880
  this.input = this.editor === "model" ? view.filters.model : "";
2341
2881
  this.scroll = 0;
2342
2882
  this.notice = "";
@@ -2356,6 +2896,11 @@ class TerminalShell {
2356
2896
  if (name === "escape") {
2357
2897
  this.help = false;
2358
2898
  this.scroll = 0;
2899
+ if (view.screen === "models" && this.modelDetail) {
2900
+ this.modelDetail = false;
2901
+ this.modelRequests = false;
2902
+ return;
2903
+ }
2359
2904
  this.controller.setScreen("overview");
2360
2905
  return;
2361
2906
  }
@@ -2378,6 +2923,81 @@ class TerminalShell {
2378
2923
  this.task(this.controller.refresh());
2379
2924
  return;
2380
2925
  }
2926
+ if (name === "4") {
2927
+ this.help = false;
2928
+ this.scroll = 0;
2929
+ this.modelDetail = false;
2930
+ this.modelRequests = false;
2931
+ this.controller.setScreen("models");
2932
+ return;
2933
+ }
2934
+ if (view.screen === "models" && !this.help) {
2935
+ if (name === "enter" || name === "return") {
2936
+ const model = view.models.result?.models[this.modelFocus];
2937
+ if (model) {
2938
+ this.controller.models.select(model.model ?? "");
2939
+ this.modelDetail = true;
2940
+ this.modelRequests = false;
2941
+ this.sample = 0;
2942
+ this.scroll = 0;
2943
+ }
2944
+ return;
2945
+ }
2946
+ if (name === "w") {
2947
+ this.controller.setWindow(historyWindows[(historyWindows.indexOf(view.models.windowMs) + 1) % historyWindows.length] ?? historyWindows[0]);
2948
+ this.sample = 0;
2949
+ this.scroll = 0;
2950
+ return;
2951
+ }
2952
+ if (name === "s" && !this.modelDetail) {
2953
+ this.controller.models.sort();
2954
+ this.scroll = 0;
2955
+ return;
2956
+ }
2957
+ if (name === "n" || name === "p") {
2958
+ if (!this.modelDetail || this.modelRequests) {
2959
+ this.controller.models.page(this.modelDetail ? "requests" : "models", name === "n" ? 1 : -1);
2960
+ if (!this.modelDetail) {
2961
+ this.modelFocus = 0;
2962
+ this.modelFocusId = undefined;
2963
+ }
2964
+ this.scroll = 0;
2965
+ }
2966
+ return;
2967
+ }
2968
+ if (this.modelDetail) {
2969
+ if (name === "d") {
2970
+ this.modelRequests = !this.modelRequests;
2971
+ this.scroll = 0;
2972
+ return;
2973
+ }
2974
+ if (name === "left" || name === "right") {
2975
+ this.metric = (this.metric + modelMetrics.length + (name === "left" ? -1 : 1)) % modelMetrics.length;
2976
+ this.sample = 0;
2977
+ this.scroll = 0;
2978
+ return;
2979
+ }
2980
+ if (name === "[" || name === "]") {
2981
+ this.sample = Math.max(0, Math.min((view.models.result?.buckets.length ?? 1) - 1, this.sample + (name === "[" ? -1 : 1)));
2982
+ return;
2983
+ }
2984
+ } else if (["up", "down", "j", "k", "home", "end", "pageup", "pagedown"].includes(name)) {
2985
+ const models = view.models.result?.models ?? [];
2986
+ const delta2 = name === "down" || name === "j" ? 1 : name === "up" || name === "k" ? -1 : name === "pagedown" ? page : -page;
2987
+ const viewport = this.modelViewport;
2988
+ if (viewport?.focus === this.modelFocus && viewport.end - viewport.start > viewport.height && name !== "home" && name !== "end" && (delta2 > 0 ? this.scroll + viewport.height < viewport.end : this.scroll > viewport.start)) {
2989
+ this.scroll = Math.max(viewport.start, Math.min(viewport.end - viewport.height, this.scroll + delta2));
2990
+ return;
2991
+ }
2992
+ if (name === "home")
2993
+ this.scroll = 0;
2994
+ if (name === "end")
2995
+ this.scroll = Number.MAX_SAFE_INTEGER;
2996
+ this.modelFocus = Math.max(0, Math.min(models.length - 1, name === "home" ? 0 : name === "end" ? models.length - 1 : this.modelFocus + delta2));
2997
+ this.modelFocusId = models[this.modelFocus]?.model ?? "";
2998
+ return;
2999
+ }
3000
+ }
2381
3001
  if (["1", "2", "3", "return", "enter"].includes(name)) {
2382
3002
  this.help = false;
2383
3003
  this.scroll = 0;
@@ -2414,14 +3034,28 @@ class TerminalShell {
2414
3034
  frame(width, height) {
2415
3035
  width = Math.max(1, width);
2416
3036
  height = Math.max(1, height);
3037
+ this.controller.models.setWidth(width);
2417
3038
  const view = this.controller.getView();
2418
- const identity = `${view.location}\0${view.selectedSessionId}\0${view.history.windowMs}`;
3039
+ if (view.screen === "models") {
3040
+ const models = view.models.result?.models ?? [];
3041
+ const identity2 = this.modelFocusId ?? view.models.result?.selected?.model ?? "";
3042
+ const focused = models.findIndex((m) => (m.model ?? "") === identity2);
3043
+ this.modelFocus = Math.max(0, focused < 0 ? Math.min(this.modelFocus, models.length - 1) : focused);
3044
+ if (view.models.status === "ready" && models.length)
3045
+ this.modelFocusId = models[this.modelFocus]?.model ?? "";
3046
+ }
3047
+ const identity = view.screen === "models" ? JSON.stringify([
3048
+ view.location,
3049
+ view.models.windowMs,
3050
+ view.filters.agent,
3051
+ view.models.result?.selected?.model
3052
+ ]) : `${view.location}\0${view.selectedSessionId}\0${view.history.windowMs}`;
2419
3053
  if (identity !== this.historyIdentity) {
2420
3054
  this.sample = 0;
2421
3055
  this.scroll = 0;
2422
3056
  this.historyIdentity = identity;
2423
3057
  }
2424
- const footer = this.editor ? `${this.editor}> ${this.input} [Enter apply; Esc cancel${this.editor === "location" ? "; Tab remembered" : ""}]` : "? help q quit \xB7 1 overview 2 detail 3 history \xB7 / search f filters l location r refresh";
3058
+ const footer = this.editor ? `${this.editor}> ${this.input} [Enter apply; Esc cancel${this.editor === "location" ? "; Tab remembered" : ""}]` : "? help q quit \xB7 1 overview 2 detail 3 history 4 models \xB7 / search f filters l location r refresh";
2425
3059
  if (height < 5)
2426
3060
  return [
2427
3061
  clip("Context Guard \xB7 ? help \xB7 q quit", width),
@@ -2434,7 +3068,7 @@ class TerminalShell {
2434
3068
  ...view.inventory.notices,
2435
3069
  ...view.health?.reasons ?? [],
2436
3070
  ...this.notice ? [this.notice] : [],
2437
- ...Object.values(view.filters).some(Boolean) ? [
3071
+ ...view.screen !== "models" && Object.values(view.filters).some(Boolean) ? [
2438
3072
  `Filters: search=${view.filters.search} agent=${view.filters.agent} model=${view.filters.model}`
2439
3073
  ] : []
2440
3074
  ];
@@ -2469,8 +3103,28 @@ class TerminalShell {
2469
3103
  } else
2470
3104
  body = wrapped([
2471
3105
  ...notices,
2472
- ...view.screen === "detail" ? terminalDetail(view.selected) : terminalHistory(view, width, this.metric, this.sample)
3106
+ ...view.screen === "detail" ? terminalDetail(view.selected) : view.screen === "models" ? terminalModels(view, width, this.modelDetail, this.metric, this.sample, this.modelFocus, this.modelRequests) : terminalHistory(view, width, this.metric, this.sample)
2473
3107
  ], width);
3108
+ this.modelViewport = undefined;
3109
+ if (view.screen === "models" && !this.modelDetail && !this.help && view.models.result?.models.length) {
3110
+ const heights = (view.models.result?.models ?? []).map((model, index) => wrapped(terminalModelRow(model, index === this.modelFocus), width).length);
3111
+ const start = body.length - heights.slice(this.modelFocus).reduce((a, b) => a + b, 0);
3112
+ const end = start + (heights[this.modelFocus] ?? 0);
3113
+ this.modelViewport = {
3114
+ focus: this.modelFocus,
3115
+ start,
3116
+ end,
3117
+ height: available
3118
+ };
3119
+ if (end - start > available) {
3120
+ this.scroll = Math.max(start, Math.min(this.scroll, end - available));
3121
+ } else {
3122
+ if (start < this.scroll)
3123
+ this.scroll = start;
3124
+ if (end > this.scroll + available)
3125
+ this.scroll = end - available;
3126
+ }
3127
+ }
2474
3128
  this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, body.length - available)));
2475
3129
  return [
2476
3130
  ...header,
@@ -2600,6 +3254,7 @@ var init_terminal = __esm(() => {
2600
3254
  init_connection();
2601
3255
  init_command();
2602
3256
  init_view();
3257
+ init_modelview();
2603
3258
  init_format();
2604
3259
  init_terminalview();
2605
3260
  });
@@ -2647,7 +3302,7 @@ function reconcile(a, b) {
2647
3302
  partial: a.partial || b.partial
2648
3303
  };
2649
3304
  }
2650
- function aggregateModels(records, options) {
3305
+ function analyzeModelRequests(records, options) {
2651
3306
  const maxRequests = options.maxRequests ?? 1e5;
2652
3307
  if (!Number.isSafeInteger(maxRequests) || maxRequests < 1 || maxRequests > 1e6)
2653
3308
  throw new Error("maxRequests must be an integer from 1 to 1000000");
@@ -2708,7 +3363,7 @@ function aggregateModels(records, options) {
2708
3363
  requests.set(key, { fact, conflict: false });
2709
3364
  }
2710
3365
  }
2711
- const groups = new Map;
3366
+ const accepted = [];
2712
3367
  for (const { fact: r, conflict } of requests.values()) {
2713
3368
  if (conflict) {
2714
3369
  coverage.conflictingRequests++;
@@ -2725,6 +3380,22 @@ function aggregateModels(records, options) {
2725
3380
  }
2726
3381
  if (options.from !== undefined && r.completedAt < options.from || options.to !== undefined && r.completedAt >= options.to || options.model !== undefined && r.model !== options.model || options.agent !== undefined && r.agent !== options.agent)
2727
3382
  continue;
3383
+ accepted.push({ ...r, completedAt: r.completedAt });
3384
+ }
3385
+ const models = summarizeModelRequests(accepted);
3386
+ coverage.partial = coverage.unscannedRecords > 0 || coverage.conflictingRequests > 0 || coverage.missingIdentityRecords > 0 || coverage.incompleteRequests > 0 || coverage.invalidTimingRequests > 0 || coverage.gapRecords > 0 || models.some((s) => s.partialRequests > 0 || s.legacyAttributionRequests > 0 || s.excludedTpsRequests > 0 || s.excludedTtftRequests > 0 || !s.model);
3387
+ return {
3388
+ schemaVersion: 1,
3389
+ location: options.location,
3390
+ models,
3391
+ coverage,
3392
+ requests: accepted
3393
+ };
3394
+ }
3395
+ function summarizeModelRequests(requests) {
3396
+ const groups = new Map;
3397
+ for (const r of requests) {
3398
+ const duration = r.completedAt - r.startedAt;
2728
3399
  let group = groups.get(r.model);
2729
3400
  if (!group) {
2730
3401
  group = {
@@ -2779,8 +3450,11 @@ function aggregateModels(records, options) {
2779
3450
  }
2780
3451
  return s;
2781
3452
  }).sort((a, b) => (a.model ?? "").localeCompare(b.model ?? ""));
2782
- coverage.partial = coverage.unscannedRecords > 0 || coverage.conflictingRequests > 0 || coverage.missingIdentityRecords > 0 || coverage.incompleteRequests > 0 || coverage.invalidTimingRequests > 0 || coverage.gapRecords > 0 || models.some((s) => s.partialRequests > 0 || s.legacyAttributionRequests > 0 || s.excludedTpsRequests > 0 || s.excludedTtftRequests > 0 || !s.model);
2783
- return { schemaVersion: 1, location: options.location, models, coverage };
3453
+ return models;
3454
+ }
3455
+ function aggregateModels(records, options) {
3456
+ const { requests: _requests, ...result } = analyzeModelRequests(records, options);
3457
+ return result;
2784
3458
  }
2785
3459
  var nonnegative2 = (n) => n !== undefined && Number.isFinite(n) && n >= 0;
2786
3460
 
@@ -2793,7 +3467,7 @@ import { isAbsolute as isAbsolute4, resolve as resolve2 } from "path";
2793
3467
  async function inspectModels(args) {
2794
3468
  const directory = args[0];
2795
3469
  if (!directory || !isAbsolute4(directory))
2796
- throw new Error("Usage: inspect models /absolute/history-directory [--model ID] [--agent NAME] [--from ISO-or-ms] [--to ISO-or-ms] [--csv | --json]");
3470
+ throw new Error("Use context-guard models --help for usage and examples; the history directory must be an absolute path");
2797
3471
  const values = new Map;
2798
3472
  let csv = false, json = false;
2799
3473
  for (let i = 1;i < args.length; i++) {
@@ -3592,9 +4266,124 @@ async function readJournal(path, maxBytes = 16 * 1024 * 1024) {
3592
4266
  return records;
3593
4267
  }
3594
4268
 
4269
+ // src/clihelp.ts
4270
+ var mainHelp = `Context Guard \u2014 context monitoring for OpenCode V2
4271
+
4272
+ Start here
4273
+ In your project, run: opencode
4274
+ Inside OpenCode, enter: /context-guard
4275
+ The plugin loads with OpenCode; observe mode is the default.
4276
+
4277
+ Usage: context-guard <command> [options]
4278
+ context-guard <journal.jsonl> [--history | --json]
4279
+
4280
+ Commands
4281
+ dashboard tui Open the standalone live terminal dashboard
4282
+ models Compare models across retained history
4283
+ metrics Export retained performance records as JSON or CSV
4284
+ charts Generate an interactive HTML report
4285
+ <journal.jsonl> Inspect diagnostic session records
4286
+
4287
+ Examples
4288
+ context-guard dashboard tui
4289
+ context-guard models /absolute/history-directory
4290
+ context-guard charts /absolute/history-directory --output report.html
4291
+ context-guard /absolute/events.jsonl --history
4292
+
4293
+ Help
4294
+ context-guard <command> --help Show options and examples
4295
+ context-guard help journal Explain diagnostic inspection
4296
+ -h, --help Show help
4297
+
4298
+ Requires Bun >=1.1.34; the standalone dashboard requires Bun >=1.3.0.
4299
+ From a source checkout, replace context-guard with bun run inspect.`;
4300
+ var filterOptions = ` --agent NAME Filter by exact agent name
4301
+ --model PROVIDER/MODEL
4302
+ Filter by exact model ID
4303
+ --from ISO-or-ms Start time: ISO timestamp or Unix milliseconds
4304
+ --to ISO-or-ms End time: ISO timestamp or Unix milliseconds`;
4305
+ var historyOptions = ` --session ID Filter by session
4306
+ ${filterOptions}`;
4307
+ var historyPath = `Use the owning location's history.path directory, not its diagnostic JSONL
4308
+ file. By default it is a location-hashed directory under
4309
+ \$XDG_STATE_HOME/opencode-context-guard (or ~/.local/state/opencode-context-guard).
4310
+ A custom telemetry.path does not change the history directory.`;
4311
+ var commandHelp = {
4312
+ models: `Usage: context-guard models <history-directory> [options]
4313
+ Compare model throughput and time to first token across retained sessions.
4314
+ The history directory must be an absolute path. Output defaults to JSON.
4315
+
4316
+ Options
4317
+ ${filterOptions}
4318
+ --csv Export CSV; coverage metadata goes to stderr
4319
+ --json Export JSON (default); cannot combine with --csv
4320
+ -h, --help Show this help
4321
+
4322
+ Example
4323
+ context-guard models /absolute/history-directory --agent build --csv
4324
+
4325
+ ${historyPath}`,
4326
+ metrics: `Usage: context-guard metrics <history-directory> [options]
4327
+ Read retained performance records. Output defaults to JSON.
4328
+
4329
+ Options
4330
+ ${historyOptions}
4331
+ --csv Export CSV; coverage metadata goes to stderr
4332
+ --chart FILE.html Generate an HTML report instead (see charts --help)
4333
+ -h, --help Show this help
4334
+
4335
+ Example
4336
+ context-guard metrics /absolute/history-directory --csv > metrics.csv
4337
+
4338
+ ${historyPath}`,
4339
+ charts: `Usage: context-guard charts <history-directory> [options]
4340
+ Generate a self-contained interactive HTML report, then open it in a browser.
4341
+
4342
+ Options
4343
+ ${historyOptions}
4344
+ --output FILE.html Write the report (default: context-guard-charts.html)
4345
+ --max-points N Points per series: 16\u20135000 (default: 1000)
4346
+ --max-sessions N Session instances: 1\u20131024 (default: 128)
4347
+ --force Overwrite an existing report
4348
+ -h, --help Show this help
4349
+
4350
+ Example
4351
+ context-guard charts /absolute/history-directory --output report.html
4352
+
4353
+ ${historyPath}`,
4354
+ journal: `Usage: context-guard <journal.jsonl> [options]
4355
+ Inspect the diagnostic journal reported in OpenCode's Context Guard startup log.
4356
+ By default, print the latest retained record for each session, one JSON per line.
4357
+
4358
+ Options
4359
+ --json Print latest session records as a JSON array
4360
+ --history Print all retained journal records as a JSON array
4361
+ -h, --help Show this help
4362
+
4363
+ Example
4364
+ context-guard /absolute/path/from/startup.jsonl --history
4365
+
4366
+ This reads a diagnostic file, not a performance history directory.`
4367
+ };
4368
+
3595
4369
  // src/cli.ts
3596
4370
  var args = process.argv.slice(2);
3597
- if (args[0] === "dashboard") {
4371
+ var helpRequested = args.some((arg) => arg === "--help" || arg === "-h");
4372
+ var helpTopic = args[0] === "help" ? args[1] : args[0];
4373
+ if (!args[0] || args[0] === "help" || helpRequested || args.length === 1 && (args[0] === "dashboard" || Object.hasOwn(commandHelp, args[0]))) {
4374
+ if (helpTopic === "dashboard") {
4375
+ const { dashboardHelp: dashboardHelp2 } = await Promise.resolve().then(() => (init_command(), exports_command));
4376
+ console.log(dashboardHelp2);
4377
+ } else if (helpTopic && Object.hasOwn(commandHelp, helpTopic)) {
4378
+ console.log(commandHelp[helpTopic]);
4379
+ } else if (args[0] === "help" && helpTopic) {
4380
+ console.error(`Unknown help topic. Use context-guard --help for commands.`);
4381
+ process.exitCode = 1;
4382
+ } else if (helpRequested && helpTopic && !helpTopic.startsWith("-")) {
4383
+ console.log(commandHelp.journal);
4384
+ } else
4385
+ console.log(mainHelp);
4386
+ } else if (args[0] === "dashboard") {
3598
4387
  const { parseDashboardArgs: parseDashboardArgs2, dashboardHelp: dashboardHelp2, checkTerminalRuntime: checkTerminalRuntime2 } = await Promise.resolve().then(() => (init_command(), exports_command));
3599
4388
  try {
3600
4389
  const options = parseDashboardArgs2(args.slice(1), process.cwd(), process.env);
@@ -3611,15 +4400,13 @@ if (args[0] === "dashboard") {
3611
4400
  console.error(safe ? message : "Dashboard startup failed; check server, owning directory, authentication and renderer installation");
3612
4401
  process.exitCode = 1;
3613
4402
  }
3614
- } else if (!args[0] || args.includes("--help")) {
3615
- 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]\nModels: inspect models /absolute/history-directory [--model provider/model] [--agent NAME] [--from ISO-or-ms] [--to ISO-or-ms] [--csv | --json]\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]");
3616
4403
  } else if (args[0] === "models") {
3617
4404
  const { inspectModels: inspectModels2 } = await Promise.resolve().then(() => (init_modelcli(), exports_modelcli));
3618
4405
  await inspectModels2(args.slice(1));
3619
4406
  } else if (args[0] === "metrics" || args[0] === "charts") {
3620
4407
  const directory = args[1];
3621
4408
  if (!directory || directory.startsWith("--"))
3622
- throw new Error("Usage: inspect metrics /absolute/history-directory [--session ID] [--from ISO-or-ms] [--to ISO-or-ms] [--csv]");
4409
+ throw new Error(`Use context-guard ${args[0]} --help for usage and examples`);
3623
4410
  const option = (name) => {
3624
4411
  const index = args.indexOf(name);
3625
4412
  if (index < 0)