sidekick-shared 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +50 -3
  3. package/dist/accountChanges.js +22 -3
  4. package/dist/accountRegistry.d.ts +22 -1
  5. package/dist/accountRegistry.js +37 -14
  6. package/dist/accountStatus.d.ts +9 -1
  7. package/dist/accountStatus.js +3 -3
  8. package/dist/accounts.d.ts +3 -1
  9. package/dist/accounts.js +84 -64
  10. package/dist/aggregation/EventAggregator.d.ts +15 -2
  11. package/dist/aggregation/EventAggregator.js +54 -9
  12. package/dist/aggregation/costProvenance.d.ts +14 -0
  13. package/dist/aggregation/costProvenance.js +35 -0
  14. package/dist/aggregation/snapshot.d.ts +13 -2
  15. package/dist/aggregation/snapshot.js +57 -6
  16. package/dist/aggregation/types.d.ts +30 -1
  17. package/dist/analytics/compactionLedger.js +15 -5
  18. package/dist/analytics/qualityScore.js +2 -1
  19. package/dist/browser.d.ts +11 -1
  20. package/dist/browser.js +29 -1
  21. package/dist/codexProfiles.d.ts +3 -1
  22. package/dist/codexProfiles.js +4 -2
  23. package/dist/codexQuota.d.ts +2 -0
  24. package/dist/codexQuota.js +11 -66
  25. package/dist/failingTools.d.ts +37 -0
  26. package/dist/failingTools.js +70 -0
  27. package/dist/formatters/sessionDump.js +13 -7
  28. package/dist/formatting.d.ts +13 -0
  29. package/dist/formatting.js +35 -0
  30. package/dist/historicalStore.d.ts +47 -0
  31. package/dist/historicalStore.js +312 -0
  32. package/dist/index.d.ts +46 -12
  33. package/dist/index.js +112 -24
  34. package/dist/modelContext.js +8 -3
  35. package/dist/modelInfo.js +45 -27
  36. package/dist/node.d.ts +19 -0
  37. package/dist/node.js +47 -1
  38. package/dist/observedContextWindows.d.ts +7 -0
  39. package/dist/observedContextWindows.js +17 -2
  40. package/dist/observedSessionCollector.d.ts +3 -8
  41. package/dist/observedSessionCollector.js +10 -27
  42. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  43. package/dist/parsers/sessionActivityDetector.js +95 -17
  44. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  45. package/dist/parsers/sessionPathResolver.js +13 -10
  46. package/dist/peakHours.d.ts +34 -3
  47. package/dist/peakHours.js +92 -7
  48. package/dist/pricingCatalog.d.ts +7 -0
  49. package/dist/pricingCatalog.js +8 -5
  50. package/dist/providers/claudeCode.js +11 -96
  51. package/dist/providers/codex.d.ts +2 -0
  52. package/dist/providers/codex.js +94 -259
  53. package/dist/providers/detect.d.ts +2 -0
  54. package/dist/providers/detect.js +29 -24
  55. package/dist/providers/factory.d.ts +6 -0
  56. package/dist/providers/openCode.d.ts +2 -0
  57. package/dist/providers/openCode.js +105 -121
  58. package/dist/providers/rolloutWalker.d.ts +53 -0
  59. package/dist/providers/rolloutWalker.js +209 -0
  60. package/dist/providers/types.d.ts +30 -0
  61. package/dist/quota.d.ts +30 -3
  62. package/dist/quota.js +35 -3
  63. package/dist/quotaHistory.d.ts +1 -1
  64. package/dist/quotaHistory.js +36 -35
  65. package/dist/quotaPresentation.js +17 -5
  66. package/dist/quotaResolver.d.ts +83 -0
  67. package/dist/quotaResolver.js +234 -0
  68. package/dist/quotaSnapshots.d.ts +15 -2
  69. package/dist/quotaSnapshots.js +25 -3
  70. package/dist/quotaThresholds.d.ts +60 -0
  71. package/dist/quotaThresholds.js +90 -0
  72. package/dist/report/htmlReportGenerator.js +10 -4
  73. package/dist/report/sessionReportInputs.d.ts +23 -0
  74. package/dist/report/sessionReportInputs.js +34 -0
  75. package/dist/schemas/index.d.ts +2 -0
  76. package/dist/schemas/index.js +10 -1
  77. package/dist/schemas/quota.d.ts +139 -0
  78. package/dist/schemas/quota.js +17 -2
  79. package/dist/schemas/quotaHistory.d.ts +1 -0
  80. package/dist/schemas/quotaHistory.js +1 -1
  81. package/dist/schemas/sessionEvent.d.ts +6 -0
  82. package/dist/schemas/sessionEvent.js +1 -0
  83. package/dist/schemas/stateFile.d.ts +198 -0
  84. package/dist/schemas/stateFile.js +77 -0
  85. package/dist/schemas/transcript.js +2 -0
  86. package/dist/sessionFingerprint.d.ts +25 -0
  87. package/dist/sessionFingerprint.js +46 -0
  88. package/dist/sessionPreviews.d.ts +7 -1
  89. package/dist/sessionPreviews.js +15 -3
  90. package/dist/sessionStats.d.ts +51 -0
  91. package/dist/sessionStats.js +166 -0
  92. package/dist/stateFile.d.ts +113 -0
  93. package/dist/stateFile.js +160 -0
  94. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  95. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  96. package/dist/statusline/formatter.d.ts +13 -0
  97. package/dist/statusline/formatter.js +48 -6
  98. package/dist/statusline/index.d.ts +10 -2
  99. package/dist/statusline/index.js +19 -1
  100. package/dist/tokenSummary.d.ts +62 -0
  101. package/dist/tokenSummary.js +72 -0
  102. package/dist/transcript.d.ts +5 -0
  103. package/dist/transcript.js +2 -0
  104. package/dist/types/observedSessionV1.d.ts +2 -0
  105. package/dist/types/observedSessionV1.js +75 -18
  106. package/dist/types/sessionEvent.d.ts +2 -0
  107. package/dist/usage/billingBlocks.d.ts +85 -0
  108. package/dist/usage/billingBlocks.js +153 -0
  109. package/dist/usage/importSessionHistory.d.ts +53 -0
  110. package/dist/usage/importSessionHistory.js +88 -0
  111. package/dist/usage/usageEvents.d.ts +92 -0
  112. package/dist/usage/usageEvents.js +322 -0
  113. package/dist/usage/usageReports.d.ts +76 -0
  114. package/dist/usage/usageReports.js +208 -0
  115. package/dist/watchers/factory.d.ts +6 -0
  116. package/dist/watchers/factory.js +30 -25
  117. package/dist/zaiQuotaApi.d.ts +2 -0
  118. package/dist/zaiQuotaApi.js +2 -0
  119. package/package.json +1 -1
@@ -57,6 +57,7 @@ const os = __importStar(require("os"));
57
57
  const path = __importStar(require("path"));
58
58
  const child_process_1 = require("child_process");
59
59
  const sessionContext_1 = require("../context/sessionContext");
60
+ const sessionStats_1 = require("../sessionStats");
60
61
  const openCodeParser_1 = require("../parsers/openCodeParser");
61
62
  const openCodeDatabase_1 = require("./openCodeDatabase");
62
63
  const zaiQuota_1 = require("../zaiQuota");
@@ -170,16 +171,24 @@ function makeDbSessionPath(dataDir, projectId, sessionId) {
170
171
  function isDbSessionPath(sessionPath) {
171
172
  return sessionPath.includes(path.sep + DB_SESSION_PREFIX + path.sep);
172
173
  }
173
- function workspaceMatches(left, right) {
174
- const a = path
175
- .resolve(left)
174
+ /**
175
+ * Whether a session recorded in `sessionDirectory` belongs to `workspacePath`.
176
+ *
177
+ * A session started inside the workspace (the workspace itself or any
178
+ * subdirectory, e.g. a monorepo package) matches. A session started in a
179
+ * *parent* of the workspace does not: a sub-package workspace must not list
180
+ * every session of the enclosing repo as its own.
181
+ */
182
+ function workspaceMatches(sessionDirectory, workspacePath) {
183
+ const session = path
184
+ .resolve(sessionDirectory)
176
185
  .replace(/[\\/]+$/, '')
177
186
  .toLowerCase();
178
- const b = path
179
- .resolve(right)
187
+ const workspace = path
188
+ .resolve(workspacePath)
180
189
  .replace(/[\\/]+$/, '')
181
190
  .toLowerCase();
182
- return a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
191
+ return session === workspace || session.startsWith(workspace + path.sep);
183
192
  }
184
193
  /** Extract project ID from a synthetic DB session path. */
185
194
  function extractProjectIdFromDbPath(sessionPath) {
@@ -322,18 +331,15 @@ function normalizePath(input) {
322
331
  return path.resolve(input);
323
332
  }
324
333
  }
325
- // ---------------------------------------------------------------------------
326
- // File-based reader (legacy fallback)
327
- // ---------------------------------------------------------------------------
328
- /**
329
- * Incremental reader for file-based OpenCode sessions.
330
- *
331
- * Tracks which message IDs have been seen and reads new messages
332
- * plus their parts on each readNew() call.
333
- */
334
+ function messageCreatedMs(message) {
335
+ const created = message.time?.created;
336
+ const ms = typeof created === 'number' ? created : Date.parse(String(created ?? ''));
337
+ return Number.isFinite(ms) ? ms : 0;
338
+ }
334
339
  class OpenCodeFileReader {
335
340
  sessionId;
336
341
  seenMessages = new Map();
342
+ pendingSkip = 0;
337
343
  storageBase;
338
344
  constructor(sessionId) {
339
345
  this.sessionId = sessionId;
@@ -350,47 +356,47 @@ class OpenCodeFileReader {
350
356
  catch {
351
357
  return [];
352
358
  }
353
- const newEvents = [];
359
+ const entries = [];
354
360
  for (const file of messageFiles) {
355
361
  const msgId = path.basename(file, '.json');
356
362
  const messagePath = path.join(messageDir, file);
357
363
  const message = readJsonSafe(messagePath);
358
364
  if (!message)
359
365
  continue;
360
- let messageMtimeMs = 0;
366
+ let mtimeMs = 0;
361
367
  try {
362
- messageMtimeMs = fs.statSync(messagePath).mtimeMs;
368
+ mtimeMs = fs.statSync(messagePath).mtimeMs;
363
369
  }
364
370
  catch {
365
- messageMtimeMs = 0;
371
+ mtimeMs = 0;
366
372
  }
367
- const partDir = path.join(this.storageBase, 'part', msgId);
368
- let parts = [];
369
- if (fs.existsSync(partDir)) {
370
- try {
371
- parts = fs
372
- .readdirSync(partDir)
373
- .filter((f) => f.endsWith('.json'))
374
- .map((f) => readJsonSafe(path.join(partDir, f)))
375
- .filter((p) => p !== null);
376
- }
377
- catch {
378
- // Skip unreadable part directories
379
- }
373
+ const parts = this.readParts(msgId);
374
+ entries.push({ msgId, message, mtimeMs, parts, partIds: new Set(parts.map((p) => p.id)) });
375
+ }
376
+ if (this.pendingSkip > 0) {
377
+ // A position for this reader is a message count (see getPosition()), so a
378
+ // seek marks the oldest N messages as already seen without emitting them.
379
+ const ordered = [...entries].sort((a, b) => messageCreatedMs(a.message) - messageCreatedMs(b.message) ||
380
+ a.msgId.localeCompare(b.msgId));
381
+ for (const entry of ordered.slice(0, this.pendingSkip)) {
382
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
380
383
  }
381
- const partIds = new Set(parts.map((part) => part.id));
382
- const previous = this.seenMessages.get(msgId);
384
+ this.pendingSkip = 0;
385
+ }
386
+ const newEvents = [];
387
+ for (const entry of entries) {
388
+ const previous = this.seenMessages.get(entry.msgId);
383
389
  const isNewMessage = !previous;
384
390
  let hasNewParts = false;
385
391
  if (previous) {
386
- if (previous.mtimeMs !== messageMtimeMs) {
392
+ if (previous.mtimeMs !== entry.mtimeMs) {
387
393
  hasNewParts = true;
388
394
  }
389
- else if (partIds.size !== previous.partIds.size) {
395
+ else if (entry.partIds.size !== previous.partIds.size) {
390
396
  hasNewParts = true;
391
397
  }
392
398
  else {
393
- for (const partId of partIds) {
399
+ for (const partId of entry.partIds) {
394
400
  if (!previous.partIds.has(partId)) {
395
401
  hasNewParts = true;
396
402
  break;
@@ -399,19 +405,36 @@ class OpenCodeFileReader {
399
405
  }
400
406
  }
401
407
  if (isNewMessage || hasNewParts) {
402
- newEvents.push(...(0, openCodeParser_1.convertOpenCodeMessage)(message, parts));
403
- this.seenMessages.set(msgId, { partIds, mtimeMs: messageMtimeMs });
408
+ newEvents.push(...(0, openCodeParser_1.convertOpenCodeMessage)(entry.message, entry.parts));
409
+ this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
404
410
  }
405
411
  }
406
412
  // Sort by timestamp
407
413
  return newEvents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
408
414
  }
415
+ readParts(msgId) {
416
+ const partDir = path.join(this.storageBase, 'part', msgId);
417
+ if (!fs.existsSync(partDir))
418
+ return [];
419
+ try {
420
+ return fs
421
+ .readdirSync(partDir)
422
+ .filter((f) => f.endsWith('.json'))
423
+ .map((f) => readJsonSafe(path.join(partDir, f)))
424
+ .filter((p) => p !== null);
425
+ }
426
+ catch {
427
+ // Skip unreadable part directories
428
+ return [];
429
+ }
430
+ }
409
431
  readAll() {
410
432
  this.reset();
411
433
  return this.readNew();
412
434
  }
413
435
  reset() {
414
436
  this.seenMessages.clear();
437
+ this.pendingSkip = 0;
415
438
  }
416
439
  exists() {
417
440
  return fs.existsSync(path.join(this.storageBase, 'message', this.sessionId));
@@ -422,8 +445,10 @@ class OpenCodeFileReader {
422
445
  getPosition() {
423
446
  return this.seenMessages.size;
424
447
  }
425
- seekTo(_position) {
426
- // File-backed reader does not support seeking — full replay required
448
+ seekTo(position) {
449
+ // Positions are message counts (getPosition()), so the next readNew() marks
450
+ // the oldest `position` messages as seen and emits only what follows.
451
+ this.pendingSkip = Number.isFinite(position) && position > 0 ? Math.floor(position) : 0;
427
452
  }
428
453
  wasTruncated() {
429
454
  return false; // JSON files don't truncate
@@ -438,11 +463,15 @@ class OpenCodeFileReader {
438
463
  * Uses time_updated timestamps to track which data has been seen,
439
464
  * querying only for newer messages/parts on each readNew() call.
440
465
  */
466
+ /** How long a database-backed reader trusts its last session-row check. */
467
+ const DB_EXISTS_MEMO_MS = 30_000;
441
468
  class OpenCodeDbReader {
442
469
  sessionId;
443
470
  db;
444
471
  lastTimeUpdated = 0;
445
472
  hasReadOnce = false;
473
+ existsCheckedAtMs = 0;
474
+ existsResult = true;
446
475
  constructor(sessionId, db) {
447
476
  this.sessionId = sessionId;
448
477
  this.db = db;
@@ -628,10 +657,22 @@ class OpenCodeDbReader {
628
657
  this.hasReadOnce = false;
629
658
  }
630
659
  exists() {
631
- // DB-backed sessions are durable rows rather than ephemeral files.
632
- // Treat transient sqlite timeout/read failures as "still exists" so
633
- // SessionMonitor does not flap into discovery mode.
634
- return true;
660
+ // DB-backed sessions are durable rows rather than ephemeral files. The row
661
+ // check costs a sqlite3 subprocess, so it is memoised, and a transient
662
+ // query failure still reports "exists" so SessionMonitor does not flap
663
+ // into discovery mode.
664
+ const now = Date.now();
665
+ if (now - this.existsCheckedAtMs < DB_EXISTS_MEMO_MS)
666
+ return this.existsResult;
667
+ this.existsCheckedAtMs = now;
668
+ try {
669
+ const row = this.db.getSession(this.sessionId);
670
+ this.existsResult = row !== null || this.db.getRuntimeStatus().kind === 'query_failed';
671
+ }
672
+ catch {
673
+ this.existsResult = true;
674
+ }
675
+ return this.existsResult;
635
676
  }
636
677
  flush() {
637
678
  // No-op for DB reading
@@ -1271,23 +1312,30 @@ class OpenCodeProvider {
1271
1312
  const db = this.ensureDb();
1272
1313
  return resolveProjectId(workspacePath, db, this.getRuntimeStatus()) || workspacePath;
1273
1314
  }
1274
- extractSessionLabel(sessionPath) {
1275
- const db = this.ensureDb();
1276
- const sessionId = this.getSessionId(sessionPath);
1315
+ /** Session title from the metadata cache or the database; null without either. */
1316
+ extractSessionLabelFromMetadata(sessionPath) {
1277
1317
  // Check metadata cache first
1278
1318
  const cached = this.sessionMetaCache.get(sessionPath);
1279
1319
  if (cached?.title) {
1280
1320
  return truncateTitle(cached.title);
1281
1321
  }
1282
1322
  // DB lookup
1323
+ const db = this.ensureDb();
1283
1324
  if (db) {
1284
- const session = db.getSession(sessionId);
1325
+ const session = db.getSession(this.getSessionId(sessionPath));
1285
1326
  if (session?.title) {
1286
1327
  this.recordDatabaseStatus('extractSessionLabel', true);
1287
1328
  return truncateTitle(session.title);
1288
1329
  }
1289
1330
  }
1331
+ return null;
1332
+ }
1333
+ extractSessionLabel(sessionPath) {
1334
+ const fromMetadata = this.extractSessionLabelFromMetadata(sessionPath);
1335
+ if (fromMetadata)
1336
+ return fromMetadata;
1290
1337
  // File fallback
1338
+ const sessionId = this.getSessionId(sessionPath);
1291
1339
  const label = this.extractSessionLabelFromFiles(sessionPath, sessionId);
1292
1340
  this.recordDatabaseStatus('extractSessionLabel', label !== null || !isDbSessionPath(sessionPath));
1293
1341
  return label;
@@ -1649,78 +1697,13 @@ class OpenCodeProvider {
1649
1697
  }
1650
1698
  // --- Stats ---
1651
1699
  readSessionStats(sessionPath) {
1652
- const sessionId = path.basename(sessionPath, '.json');
1653
- const db = this.ensureDb();
1654
- let messageCount = 0;
1655
- let startTime = '';
1656
- let endTime = '';
1657
- const tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
1658
- const modelUsage = {};
1659
- const toolUsage = {};
1660
- let reportedCost = 0;
1661
- if (db) {
1662
- const messages = db.getMessagesForSession(sessionId);
1663
- const parts = db.getPartsForSession(sessionId);
1664
- for (const msg of messages) {
1665
- try {
1666
- const data = JSON.parse(msg.data);
1667
- const role = data.role;
1668
- if (role === 'assistant' || role === 'user')
1669
- messageCount++;
1670
- const msgTokens = data.tokens;
1671
- const cache = msgTokens?.cache;
1672
- if (msgTokens) {
1673
- tokens.input += msgTokens.input || 0;
1674
- tokens.output += msgTokens.output || 0;
1675
- tokens.cacheRead += cache?.read || 0;
1676
- tokens.cacheWrite += cache?.write || 0;
1677
- }
1678
- if (data.cost)
1679
- reportedCost += data.cost || 0;
1680
- const modelId = data.modelID || 'unknown';
1681
- if (role === 'assistant' && msgTokens) {
1682
- if (!modelUsage[modelId])
1683
- modelUsage[modelId] = { calls: 0, tokens: 0 };
1684
- modelUsage[modelId].calls++;
1685
- modelUsage[modelId].tokens +=
1686
- (msgTokens.input || 0) + (msgTokens.output || 0);
1687
- }
1688
- }
1689
- catch {
1690
- /* skip */
1691
- }
1692
- if (!startTime)
1693
- startTime = new Date(msg.time_created).toISOString();
1694
- endTime = new Date(msg.time_updated || msg.time_created).toISOString();
1695
- }
1696
- for (const part of parts) {
1697
- try {
1698
- const data = JSON.parse(part.data);
1699
- if (data.type === 'tool' || data.type === 'tool-invocation') {
1700
- const toolName = data.tool || 'unknown';
1701
- toolUsage[toolName] = (toolUsage[toolName] || 0) + 1;
1702
- }
1703
- }
1704
- catch {
1705
- /* skip */
1706
- }
1707
- }
1708
- }
1709
- return {
1710
- providerId: 'opencode',
1711
- sessionId,
1712
- filePath: sessionPath,
1713
- label: this.extractSessionLabel(sessionPath),
1714
- startTime,
1715
- endTime,
1716
- messageCount,
1717
- tokens,
1718
- modelUsage,
1719
- toolUsage,
1720
- compactionEstimate: 0,
1721
- truncationCount: 0,
1722
- reportedCost,
1723
- };
1700
+ // One reader pass (database-backed or file-backed) feeds the shared
1701
+ // aggregator, so compaction and truncation counts are real instead of the
1702
+ // hardcoded zeros the direct SQLite read returned, and a session that
1703
+ // cannot be read reports `availability: 'unavailable'` instead of zeros.
1704
+ return (0, sessionStats_1.readSessionFileStats)(this, sessionPath, {
1705
+ resolveLabel: () => this.extractSessionLabelFromMetadata(sessionPath),
1706
+ });
1724
1707
  }
1725
1708
  readSessionContextSnapshot(sessionPath, options = {}) {
1726
1709
  return (0, sessionContext_1.readSessionContextSnapshot)(this, sessionPath, options);
@@ -1934,6 +1917,7 @@ class OpenCodeProvider {
1934
1917
  this.db?.close();
1935
1918
  this.db = null;
1936
1919
  this.dbInitialized = false;
1920
+ this.dbStatus = { available: false, kind: 'db_missing' };
1937
1921
  this.sessionMetaCache.clear();
1938
1922
  this.dynamicContextWindowLimit = null;
1939
1923
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * One capped walker for Codex rollout files.
3
+ *
4
+ * Codex writes `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`.
5
+ * Six recursive, uncapped walkers used to exist across the provider and the
6
+ * quota resolver; this module replaces them with a single walk that caps
7
+ * depth and file count, visits the newest-dated directories first so a
8
+ * `limit` can stop early, stats each file exactly once, and returns results
9
+ * newest first with the size and session id already attached.
10
+ *
11
+ * With `limit`, the early exit is by directory date: a rollout that was
12
+ * created days ago but is still being written lands in its creation day's
13
+ * directory, so a very small limit can miss it in favour of newer-dated,
14
+ * older-modified files. Callers that need exact mtime order over the whole
15
+ * history omit `limit` (the `maxFiles` cap still applies).
16
+ *
17
+ * @module providers/rolloutWalker
18
+ */
19
+ export interface RolloutFileInfo {
20
+ path: string;
21
+ mtime: Date;
22
+ sizeBytes: number;
23
+ /** Session id parsed from the file name. */
24
+ sessionId: string;
25
+ }
26
+ export interface WalkRolloutFilesOptions {
27
+ /** Directory depth below each root to descend into (default 6; the dated tree is 3). */
28
+ maxDepth?: number;
29
+ /** Upper bound on files returned across all roots (default 20 000). */
30
+ maxFiles?: number;
31
+ /** Stop once this many files are collected, visiting newest-dated directories first. */
32
+ limit?: number;
33
+ /** Only files whose name carries this session id (case-insensitive). */
34
+ sessionId?: string;
35
+ /** Include zero-byte files (default false). */
36
+ includeEmpty?: boolean;
37
+ }
38
+ export declare const DEFAULT_ROLLOUT_WALK_MAX_DEPTH = 6;
39
+ export declare const DEFAULT_ROLLOUT_WALK_MAX_FILES = 20000;
40
+ /** Test if a filename is a Codex rollout file. */
41
+ export declare function isRolloutFile(filename: string): boolean;
42
+ /**
43
+ * Extract the session UUID from a rollout filename.
44
+ * Format: rollout-<timestamp>-<uuid>.jsonl -> <uuid>
45
+ */
46
+ export declare function extractRolloutSessionId(filename: string): string;
47
+ /**
48
+ * Walk one or more `sessions` roots for rollout files, newest first.
49
+ * Missing or unreadable directories are skipped; paths are deduplicated.
50
+ */
51
+ export declare function walkRolloutFiles(roots: readonly string[], options?: WalkRolloutFilesOptions): RolloutFileInfo[];
52
+ /** Async twin of {@link walkRolloutFiles} that yields between directories. */
53
+ export declare function walkRolloutFilesAsync(roots: readonly string[], options?: WalkRolloutFilesOptions): Promise<RolloutFileInfo[]>;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ /**
3
+ * One capped walker for Codex rollout files.
4
+ *
5
+ * Codex writes `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl`.
6
+ * Six recursive, uncapped walkers used to exist across the provider and the
7
+ * quota resolver; this module replaces them with a single walk that caps
8
+ * depth and file count, visits the newest-dated directories first so a
9
+ * `limit` can stop early, stats each file exactly once, and returns results
10
+ * newest first with the size and session id already attached.
11
+ *
12
+ * With `limit`, the early exit is by directory date: a rollout that was
13
+ * created days ago but is still being written lands in its creation day's
14
+ * directory, so a very small limit can miss it in favour of newer-dated,
15
+ * older-modified files. Callers that need exact mtime order over the whole
16
+ * history omit `limit` (the `maxFiles` cap still applies).
17
+ *
18
+ * @module providers/rolloutWalker
19
+ */
20
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ var desc = Object.getOwnPropertyDescriptor(m, k);
23
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
24
+ desc = { enumerable: true, get: function() { return m[k]; } };
25
+ }
26
+ Object.defineProperty(o, k2, desc);
27
+ }) : (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ o[k2] = m[k];
30
+ }));
31
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
32
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
33
+ }) : function(o, v) {
34
+ o["default"] = v;
35
+ });
36
+ var __importStar = (this && this.__importStar) || (function () {
37
+ var ownKeys = function(o) {
38
+ ownKeys = Object.getOwnPropertyNames || function (o) {
39
+ var ar = [];
40
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
41
+ return ar;
42
+ };
43
+ return ownKeys(o);
44
+ };
45
+ return function (mod) {
46
+ if (mod && mod.__esModule) return mod;
47
+ var result = {};
48
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
49
+ __setModuleDefault(result, mod);
50
+ return result;
51
+ };
52
+ })();
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.DEFAULT_ROLLOUT_WALK_MAX_FILES = exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH = void 0;
55
+ exports.isRolloutFile = isRolloutFile;
56
+ exports.extractRolloutSessionId = extractRolloutSessionId;
57
+ exports.walkRolloutFiles = walkRolloutFiles;
58
+ exports.walkRolloutFilesAsync = walkRolloutFilesAsync;
59
+ const fs = __importStar(require("fs"));
60
+ const path = __importStar(require("path"));
61
+ exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH = 6;
62
+ exports.DEFAULT_ROLLOUT_WALK_MAX_FILES = 20_000;
63
+ /** Test if a filename is a Codex rollout file. */
64
+ function isRolloutFile(filename) {
65
+ return filename.startsWith('rollout-') && filename.endsWith('.jsonl');
66
+ }
67
+ /**
68
+ * Extract the session UUID from a rollout filename.
69
+ * Format: rollout-<timestamp>-<uuid>.jsonl -> <uuid>
70
+ */
71
+ function extractRolloutSessionId(filename) {
72
+ const base = path.basename(filename, '.jsonl');
73
+ // rollout-YYYYMMDD-HHMMSS-<uuid> or rollout-<timestamp>-<uuid>
74
+ const parts = base.split('-');
75
+ // The UUID is typically the last 5 segments (8-4-4-4-12)
76
+ if (parts.length >= 6) {
77
+ const possibleUuid = parts.slice(-5).join('-');
78
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(possibleUuid)) {
79
+ return possibleUuid;
80
+ }
81
+ }
82
+ // Fallback: use everything after "rollout-"
83
+ return base.replace(/^rollout-/, '');
84
+ }
85
+ function walkState(options) {
86
+ const maxFiles = Math.max(0, options.maxFiles ?? exports.DEFAULT_ROLLOUT_WALK_MAX_FILES);
87
+ const limit = options.limit === undefined ? Infinity : Math.max(0, options.limit);
88
+ return {
89
+ results: [],
90
+ seen: new Set(),
91
+ cap: Math.min(maxFiles, limit),
92
+ maxDepth: options.maxDepth ?? exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH,
93
+ sessionId: options.sessionId ? options.sessionId.trim().toLowerCase() : null,
94
+ includeEmpty: options.includeEmpty ?? false,
95
+ };
96
+ }
97
+ /** Newest-dated first: directory and file names sort descending. */
98
+ function byNameDescending(a, b) {
99
+ return b.name.localeCompare(a.name);
100
+ }
101
+ function matchesFilter(state, name) {
102
+ if (!isRolloutFile(name))
103
+ return false;
104
+ return (state.sessionId === null || extractRolloutSessionId(name).toLowerCase() === state.sessionId);
105
+ }
106
+ function record(state, fullPath, name, stat) {
107
+ if (!state.includeEmpty && stat.size <= 0)
108
+ return;
109
+ if (state.seen.has(fullPath))
110
+ return;
111
+ state.seen.add(fullPath);
112
+ state.results.push({
113
+ path: fullPath,
114
+ mtime: stat.mtime,
115
+ sizeBytes: stat.size,
116
+ sessionId: extractRolloutSessionId(name),
117
+ });
118
+ }
119
+ function finish(state) {
120
+ return state.results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
121
+ }
122
+ function visitSync(state, dir, depth) {
123
+ if (depth > state.maxDepth || state.results.length >= state.cap)
124
+ return;
125
+ let entries;
126
+ try {
127
+ entries = fs.readdirSync(dir, { withFileTypes: true });
128
+ }
129
+ catch {
130
+ return;
131
+ }
132
+ entries.sort(byNameDescending);
133
+ for (const entry of entries) {
134
+ if (state.results.length >= state.cap)
135
+ return;
136
+ if (!entry.isFile() || !matchesFilter(state, entry.name))
137
+ continue;
138
+ const fullPath = path.join(dir, entry.name);
139
+ try {
140
+ record(state, fullPath, entry.name, fs.statSync(fullPath));
141
+ }
142
+ catch {
143
+ // Skip files that vanish between readdir and stat.
144
+ }
145
+ }
146
+ for (const entry of entries) {
147
+ if (state.results.length >= state.cap)
148
+ return;
149
+ if (entry.isDirectory())
150
+ visitSync(state, path.join(dir, entry.name), depth + 1);
151
+ }
152
+ }
153
+ /**
154
+ * Walk one or more `sessions` roots for rollout files, newest first.
155
+ * Missing or unreadable directories are skipped; paths are deduplicated.
156
+ */
157
+ function walkRolloutFiles(roots, options = {}) {
158
+ const state = walkState(options);
159
+ for (const root of roots)
160
+ visitSync(state, root, 0);
161
+ return finish(state);
162
+ }
163
+ function yieldToEventLoop() {
164
+ return new Promise((resolve) => setImmediate(resolve));
165
+ }
166
+ async function visitAsync(state, dir, depth) {
167
+ if (depth > state.maxDepth || state.results.length >= state.cap)
168
+ return;
169
+ let entries;
170
+ try {
171
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ return;
175
+ }
176
+ entries.sort(byNameDescending);
177
+ let sinceYield = 0;
178
+ for (const entry of entries) {
179
+ if (state.results.length >= state.cap)
180
+ return;
181
+ if (!entry.isFile() || !matchesFilter(state, entry.name))
182
+ continue;
183
+ const fullPath = path.join(dir, entry.name);
184
+ try {
185
+ record(state, fullPath, entry.name, await fs.promises.stat(fullPath));
186
+ }
187
+ catch {
188
+ // Skip files that vanish between readdir and stat.
189
+ }
190
+ if (++sinceYield >= 200) {
191
+ sinceYield = 0;
192
+ await yieldToEventLoop();
193
+ }
194
+ }
195
+ await yieldToEventLoop();
196
+ for (const entry of entries) {
197
+ if (state.results.length >= state.cap)
198
+ return;
199
+ if (entry.isDirectory())
200
+ await visitAsync(state, path.join(dir, entry.name), depth + 1);
201
+ }
202
+ }
203
+ /** Async twin of {@link walkRolloutFiles} that yields between directories. */
204
+ async function walkRolloutFilesAsync(roots, options = {}) {
205
+ const state = walkState(options);
206
+ for (const root of roots)
207
+ await visitAsync(state, root, 0);
208
+ return finish(state);
209
+ }
@@ -11,6 +11,7 @@
11
11
  import type { ReadSessionContextSnapshotOptions, SessionContextSnapshot } from '../context/sessionContext';
12
12
  import type { SessionEvent, SubagentStats, TokenUsage, ContextAttribution } from '../types/sessionEvent';
13
13
  import { SESSION_PROVIDER_IDS } from '../providerIds';
14
+ import type { AggregatedCostProvenance } from '../aggregation/types';
14
15
  /** Stable runtime list for consumers that must enumerate session providers. */
15
16
  export { SESSION_PROVIDER_IDS } from '../providerIds';
16
17
  export type ProviderId = (typeof SESSION_PROVIDER_IDS)[number];
@@ -58,6 +59,12 @@ export interface SessionFileInfo {
58
59
  /** Optional provider-recorded creation timestamp. */
59
60
  createdAt?: Date;
60
61
  }
62
+ /** How much of a session `readSessionStats()` could read. */
63
+ export type SessionFileStatsAvailability = 'full' | 'partial' | 'unavailable';
64
+ /**
65
+ * Aggregated stats for one session file, computed by `computeSessionFileStats()`
66
+ * over the shared `EventAggregator` for every provider.
67
+ */
61
68
  export interface SessionFileStats {
62
69
  providerId: ProviderId;
63
70
  sessionId: string;
@@ -66,19 +73,42 @@ export interface SessionFileStats {
66
73
  startTime: string;
67
74
  endTime: string;
68
75
  messageCount: number;
76
+ /** Token buckets; `summarizeTokens()` turns them into the cache-inclusive total. */
69
77
  tokens: {
70
78
  input: number;
71
79
  output: number;
72
80
  cacheWrite: number;
73
81
  cacheRead: number;
74
82
  };
83
+ /**
84
+ * Per-model calls, cache-inclusive token total (the `summarizeTokens().total`
85
+ * vocabulary), priced cost, and whether every call could be priced.
86
+ */
75
87
  modelUsage: Record<string, {
76
88
  calls: number;
77
89
  tokens: number;
90
+ costUsd: number;
91
+ priced: boolean;
78
92
  }>;
93
+ /** Calls per tool name: successful, failed, and still pending. */
79
94
  toolUsage: Record<string, number>;
95
+ /** Failed calls per tool name; a subset of `toolUsage`. */
96
+ toolFailures: Record<string, number>;
80
97
  compactionEstimate: number;
81
98
  truncationCount: number;
99
+ /** Cost of the priced portion of the session; read `costProvenance` before labelling it. */
100
+ costUsd: number;
101
+ costProvenance: AggregatedCostProvenance;
102
+ /** Usage events with neither a provider-reported cost nor a catalog price. */
103
+ unpricedCalls: number;
104
+ /**
105
+ * `full` when every event was read, `partial` when the reader reported a
106
+ * truncated source, `unavailable` when the session could not be read at all
107
+ * (`unavailableReason` says why and every number is zero).
108
+ */
109
+ availability: SessionFileStatsAvailability;
110
+ unavailableReason?: string;
111
+ /** @deprecated Same value as `costUsd`; use it together with `costProvenance`. */
82
112
  reportedCost: number;
83
113
  }
84
114
  export interface ProjectFolderInfo {