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
package/dist/accounts.js CHANGED
@@ -201,7 +201,14 @@ function readAccountRegistry() {
201
201
  };
202
202
  }
203
203
  function writeAccountRegistry(registry) {
204
- const mappedClaudeProfiles = registry.accounts.map((account) => ({
204
+ (0, accountRegistry_1.replaceSavedAccountProfiles)('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
205
+ }
206
+ /** For callers already holding the registry lock (see withSavedAccountRegistryLock). */
207
+ function writeAccountRegistryUnlocked(registry) {
208
+ (0, accountRegistry_1.replaceSavedAccountProfilesUnlocked)('claude-code', mapClaudeProfiles(registry), registry.activeAccountUuid);
209
+ }
210
+ function mapClaudeProfiles(registry) {
211
+ return registry.accounts.map((account) => ({
205
212
  id: account.uuid,
206
213
  providerId: 'claude-code',
207
214
  providerAccountId: account.uuid,
@@ -212,7 +219,6 @@ function writeAccountRegistry(registry) {
212
219
  email: account.email,
213
220
  },
214
221
  }));
215
- (0, accountRegistry_1.replaceSavedAccountProfiles)('claude-code', mappedClaudeProfiles, registry.activeAccountUuid);
216
222
  }
217
223
  // ── Read active Claude account ───────────────────────────────────────────
218
224
  function readActiveClaudeAccount() {
@@ -254,17 +260,34 @@ function addCurrentAccount(label) {
254
260
  return { success: false, error: 'Could not read Claude config file.' };
255
261
  }
256
262
  ensureDirs();
257
- // 4. Load or create registry
258
- const registry = readAccountRegistry() ?? {
259
- version: 1,
260
- activeAccountUuid: null,
261
- accounts: [],
262
- };
263
- // 5. Check if account already exists — update credentials + label
264
- const existing = registry.accounts.find((a) => a.uuid === active.uuid);
265
- if (existing) {
266
- if (label !== undefined)
267
- existing.label = label;
263
+ // 4–7 are one read-modify-write of the registry: hold the registry lock
264
+ // across them so a concurrent add from another process is not dropped
265
+ // when this write replaces the whole Claude profile set.
266
+ return (0, accountRegistry_1.withSavedAccountRegistryLock)(() => {
267
+ // 4. Load or create registry
268
+ const registry = readAccountRegistry() ?? {
269
+ version: 1,
270
+ activeAccountUuid: null,
271
+ accounts: [],
272
+ };
273
+ // 5. Check if account already exists — update credentials + label
274
+ const existing = registry.accounts.find((a) => a.uuid === active.uuid);
275
+ if (existing) {
276
+ if (label !== undefined)
277
+ existing.label = label;
278
+ (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
279
+ (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
280
+ try {
281
+ writeClaudeProfileMirror(active.uuid, credBlob, configBlob);
282
+ }
283
+ catch {
284
+ /* flat backups remain available */
285
+ }
286
+ registry.activeAccountUuid = active.uuid;
287
+ writeAccountRegistryUnlocked(registry);
288
+ return { success: true };
289
+ }
290
+ // 6. New account — back up credentials
268
291
  (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
269
292
  (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
270
293
  try {
@@ -273,29 +296,17 @@ function addCurrentAccount(label) {
273
296
  catch {
274
297
  /* flat backups remain available */
275
298
  }
299
+ // 7. Add entry
300
+ registry.accounts.push({
301
+ uuid: active.uuid,
302
+ email: active.email,
303
+ label,
304
+ addedAt: new Date().toISOString(),
305
+ });
276
306
  registry.activeAccountUuid = active.uuid;
277
- writeAccountRegistry(registry);
307
+ writeAccountRegistryUnlocked(registry);
278
308
  return { success: true };
279
- }
280
- // 6. New account — back up credentials
281
- (0, atomic_1.atomicWriteJsonSync)(path.join(getCredentialsDir(), `${active.uuid}.credentials.json`), credBlob);
282
- (0, atomic_1.atomicWriteJsonSync)(path.join(getConfigsDir(), `${active.uuid}.config.json`), configBlob);
283
- try {
284
- writeClaudeProfileMirror(active.uuid, credBlob, configBlob);
285
- }
286
- catch {
287
- /* flat backups remain available */
288
- }
289
- // 7. Add entry
290
- registry.accounts.push({
291
- uuid: active.uuid,
292
- email: active.email,
293
- label,
294
- addedAt: new Date().toISOString(),
295
309
  });
296
- registry.activeAccountUuid = active.uuid;
297
- writeAccountRegistry(registry);
298
- return { success: true };
299
310
  }
300
311
  // ── Switch to account ────────────────────────────────────────────────────
301
312
  /**
@@ -486,34 +497,37 @@ function reconcileClaudeAuthState() {
486
497
  }
487
498
  // ── Remove account ───────────────────────────────────────────────────────
488
499
  function removeAccount(uuid) {
489
- const registry = readAccountRegistry();
490
- if (!registry) {
491
- return { success: false, error: 'No account registry found.' };
492
- }
493
- const idx = registry.accounts.findIndex((a) => a.uuid === uuid);
494
- if (idx === -1) {
495
- return { success: false, error: `Account ${uuid} not found.` };
496
- }
497
- // Remove backed-up files
498
- try {
499
- fs.unlinkSync(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
500
- }
501
- catch {
502
- /* ok */
503
- }
504
- try {
505
- fs.unlinkSync(path.join(getConfigsDir(), `${uuid}.config.json`));
506
- }
507
- catch {
508
- /* ok */
509
- }
510
- // Remove from registry
511
- registry.accounts.splice(idx, 1);
512
- if (registry.activeAccountUuid === uuid) {
513
- registry.activeAccountUuid = registry.accounts[0]?.uuid ?? null;
514
- }
515
- writeAccountRegistry(registry);
516
- return { success: true };
500
+ // One read-modify-write under the registry lock (see addCurrentAccount).
501
+ return (0, accountRegistry_1.withSavedAccountRegistryLock)(() => {
502
+ const registry = readAccountRegistry();
503
+ if (!registry) {
504
+ return { success: false, error: 'No account registry found.' };
505
+ }
506
+ const idx = registry.accounts.findIndex((a) => a.uuid === uuid);
507
+ if (idx === -1) {
508
+ return { success: false, error: `Account ${uuid} not found.` };
509
+ }
510
+ // Remove backed-up files
511
+ try {
512
+ fs.unlinkSync(path.join(getCredentialsDir(), `${uuid}.credentials.json`));
513
+ }
514
+ catch {
515
+ /* ok */
516
+ }
517
+ try {
518
+ fs.unlinkSync(path.join(getConfigsDir(), `${uuid}.config.json`));
519
+ }
520
+ catch {
521
+ /* ok */
522
+ }
523
+ // Remove from registry
524
+ registry.accounts.splice(idx, 1);
525
+ if (registry.activeAccountUuid === uuid) {
526
+ registry.activeAccountUuid = registry.accounts[0]?.uuid ?? null;
527
+ }
528
+ writeAccountRegistryUnlocked(registry);
529
+ return { success: true };
530
+ });
517
531
  }
518
532
  // ── Query helpers ────────────────────────────────────────────────────────
519
533
  function listAccounts() {
@@ -546,7 +560,8 @@ function getActiveAccount() {
546
560
  * re-pointed so registry-keyed data tracks reality too. Never creates or deletes
547
561
  * profiles; an unknown live account is shown as-is with no label and no write.
548
562
  */
549
- function resolveActiveClaudeAccount() {
563
+ function resolveActiveClaudeAccount(options = {}) {
564
+ const selfHeal = options.selfHeal ?? true;
550
565
  const live = readActiveClaudeAccount();
551
566
  if (live) {
552
567
  const profiles = (0, accountRegistry_1.listSavedAccountProfiles)('claude-code');
@@ -554,12 +569,17 @@ function resolveActiveClaudeAccount() {
554
569
  profiles.find((p) => (p.email ?? p.metadata?.email) === live.email);
555
570
  if (match) {
556
571
  const active = (0, accountRegistry_1.getActiveSavedAccount)('claude-code');
557
- if (!active || active.id !== match.id) {
572
+ // Hot paths (the status line runs on every prompt) pass selfHeal: false
573
+ // so a persistently failing registry write never becomes a write per
574
+ // prompt; the next ordinary command repairs the pointer instead.
575
+ if (selfHeal && (!active || active.id !== match.id)) {
558
576
  // Self-heal is best-effort: a registry write failure (read-only/full
559
577
  // disk) must never break display or extension activation. We still
560
578
  // return the correct live identity below.
579
+ // Silent: this is a read path repairing a pointer to the profile that
580
+ // already matches the live login, so subscribers have nothing new to see.
561
581
  try {
562
- (0, accountRegistry_1.setActiveSavedAccount)('claude-code', match.id);
582
+ (0, accountRegistry_1.setActiveSavedAccount)('claude-code', match.id, { silent: true });
563
583
  }
564
584
  catch {
565
585
  /* keep going with the live identity */
@@ -14,7 +14,7 @@ import type { SerializedFrequencyState } from './FrequencyTracker';
14
14
  import type { SerializedHeatmapState } from './HeatmapTracker';
15
15
  import type { SerializedPatternState } from './PatternExtractor';
16
16
  /** Schema version for serialized snapshots. */
17
- export declare const SNAPSHOT_SCHEMA_VERSION = 4;
17
+ export declare const SNAPSHOT_SCHEMA_VERSION = 5;
18
18
  /**
19
19
  * JSON-serializable snapshot of EventAggregator state.
20
20
  * Used by session snapshot sidecar files for fast resume.
@@ -28,7 +28,14 @@ export interface SerializedAggregatorState {
28
28
  cacheRead: number;
29
29
  reasoning: number;
30
30
  total: number;
31
+ /** @deprecated Mirrors `costUsd`; kept so older readers of the field keep working. */
31
32
  reportedCost: number;
33
+ costUsd: number;
34
+ reportedCostUsd: number;
35
+ estimatedCostUsd: number;
36
+ reportedCalls: number;
37
+ estimatedCalls: number;
38
+ unpricedCalls: number;
32
39
  };
33
40
  modelUsage: Array<[
34
41
  string,
@@ -100,7 +107,13 @@ export declare class EventAggregator {
100
107
  private cacheReadTokens;
101
108
  private reasoningTokens;
102
109
  private totalTokens;
103
- private reportedCost;
110
+ /** Running cost in USD (reported + estimated). */
111
+ private costUsd;
112
+ private reportedCostUsd;
113
+ private estimatedCostUsd;
114
+ private reportedCalls;
115
+ private estimatedCalls;
116
+ private unpricedCalls;
104
117
  private modelUsage;
105
118
  private currentContextSize;
106
119
  private previousContextSize;
@@ -16,6 +16,7 @@ const planExtractor_1 = require("../parsers/planExtractor");
16
16
  const eventBridge_1 = require("../watchers/eventBridge");
17
17
  const toolSummary_1 = require("../formatters/toolSummary");
18
18
  const noiseClassifier_1 = require("../formatters/noiseClassifier");
19
+ const costProvenance_1 = require("./costProvenance");
19
20
  const FrequencyTracker_1 = require("./FrequencyTracker");
20
21
  const HeatmapTracker_1 = require("./HeatmapTracker");
21
22
  const PatternExtractor_1 = require("./PatternExtractor");
@@ -30,7 +31,7 @@ const DEFAULT_BURN_WINDOW_MS = 5 * 60_000;
30
31
  const DEFAULT_BURN_SAMPLE_MS = 10_000;
31
32
  const COMPACTION_DROP_THRESHOLD = 0.8; // >20% drop
32
33
  /** Schema version for serialized snapshots. */
33
- exports.SNAPSHOT_SCHEMA_VERSION = 4;
34
+ exports.SNAPSHOT_SCHEMA_VERSION = 5;
34
35
  // ── Goal gate detection regex ──
35
36
  const GOAL_GATE_REGEX = /\b(CRITICAL|MUST|blocker|required|must.?complete|goal.?gate|essential|do.?not.?skip|blocking)\b/i;
36
37
  // ── Todo dependency parsing ──
@@ -78,7 +79,13 @@ class EventAggregator {
78
79
  cacheReadTokens = 0;
79
80
  reasoningTokens = 0;
80
81
  totalTokens = 0;
81
- reportedCost = 0;
82
+ /** Running cost in USD (reported + estimated). */
83
+ costUsd = 0;
84
+ reportedCostUsd = 0;
85
+ estimatedCostUsd = 0;
86
+ reportedCalls = 0;
87
+ estimatedCalls = 0;
88
+ unpricedCalls = 0;
82
89
  // Per-model usage
83
90
  modelUsage = new Map();
84
91
  // Context size tracking
@@ -262,8 +269,10 @@ class EventAggregator {
262
269
  this.attributePlanUsage(normalized.totalTokens, cost);
263
270
  }
264
271
  else if (typeof event.cost === 'number' && Number.isFinite(event.cost) && event.cost >= 0) {
265
- // Compatibility for cost-only follow events.
266
- this.reportedCost += event.cost;
272
+ // Compatibility for cost-only follow events (provider-reported by definition).
273
+ this.costUsd += event.cost;
274
+ this.reportedCostUsd += event.cost;
275
+ this.reportedCalls += 1;
267
276
  }
268
277
  // 4. Tool tracking from FollowEvent
269
278
  if (event.type === 'tool_use' && event.toolName) {
@@ -309,7 +318,16 @@ class EventAggregator {
309
318
  cacheReadTokens: this.cacheReadTokens,
310
319
  reasoningTokens: this.reasoningTokens,
311
320
  totalTokens: this.totalTokens,
312
- reportedCost: this.reportedCost,
321
+ costUsd: this.costUsd,
322
+ reportedCostUsd: this.reportedCostUsd,
323
+ estimatedCostUsd: this.estimatedCostUsd,
324
+ unpricedCalls: this.unpricedCalls,
325
+ costProvenance: (0, costProvenance_1.classifyCostProvenance)({
326
+ reportedCalls: this.reportedCalls,
327
+ estimatedCalls: this.estimatedCalls,
328
+ unpricedCalls: this.unpricedCalls,
329
+ }),
330
+ reportedCost: this.costUsd,
313
331
  };
314
332
  }
315
333
  getModelStats() {
@@ -427,7 +445,12 @@ class EventAggregator {
427
445
  this.cacheReadTokens = 0;
428
446
  this.reasoningTokens = 0;
429
447
  this.totalTokens = 0;
430
- this.reportedCost = 0;
448
+ this.costUsd = 0;
449
+ this.reportedCostUsd = 0;
450
+ this.estimatedCostUsd = 0;
451
+ this.reportedCalls = 0;
452
+ this.estimatedCalls = 0;
453
+ this.unpricedCalls = 0;
431
454
  this.modelUsage.clear();
432
455
  this.currentContextSize = 0;
433
456
  this.previousContextSize = 0;
@@ -503,7 +526,13 @@ class EventAggregator {
503
526
  cacheRead: this.cacheReadTokens,
504
527
  reasoning: this.reasoningTokens,
505
528
  total: this.totalTokens,
506
- reportedCost: this.reportedCost,
529
+ reportedCost: this.costUsd,
530
+ costUsd: this.costUsd,
531
+ reportedCostUsd: this.reportedCostUsd,
532
+ estimatedCostUsd: this.estimatedCostUsd,
533
+ reportedCalls: this.reportedCalls,
534
+ estimatedCalls: this.estimatedCalls,
535
+ unpricedCalls: this.unpricedCalls,
507
536
  },
508
537
  modelUsage: Array.from(this.modelUsage.entries()),
509
538
  contextSize: this.currentContextSize,
@@ -550,7 +579,12 @@ class EventAggregator {
550
579
  this.cacheReadTokens = state.tokens.cacheRead;
551
580
  this.reasoningTokens = state.tokens.reasoning;
552
581
  this.totalTokens = state.tokens.total;
553
- this.reportedCost = state.tokens.reportedCost;
582
+ this.costUsd = state.tokens.costUsd;
583
+ this.reportedCostUsd = state.tokens.reportedCostUsd;
584
+ this.estimatedCostUsd = state.tokens.estimatedCostUsd;
585
+ this.reportedCalls = state.tokens.reportedCalls;
586
+ this.estimatedCalls = state.tokens.estimatedCalls;
587
+ this.unpricedCalls = state.tokens.unpricedCalls;
554
588
  this.modelUsage = new Map(state.modelUsage);
555
589
  this.currentContextSize = state.contextSize;
556
590
  this.previousContextSize = state.previousContextSize;
@@ -619,7 +653,18 @@ class EventAggregator {
619
653
  this.cacheReadTokens += cacheRead;
620
654
  this.reasoningTokens += reasoningTok;
621
655
  this.totalTokens += usage.totalTokens;
622
- this.reportedCost += cost;
656
+ this.costUsd += cost;
657
+ if (pricedUsage.source === 'provider-reported') {
658
+ this.reportedCostUsd += cost;
659
+ this.reportedCalls += 1;
660
+ }
661
+ else if (pricedUsage.source === 'unpriced') {
662
+ this.unpricedCalls += 1;
663
+ }
664
+ else {
665
+ this.estimatedCostUsd += cost;
666
+ this.estimatedCalls += 1;
667
+ }
623
668
  // Burn rate sample accumulation
624
669
  this.tokensSinceLastSample += usage.totalTokens;
625
670
  // Context size computation
@@ -0,0 +1,14 @@
1
+ import type { AggregatedCostProvenance, AggregatedTokens } from './types';
2
+ /** The fields `describeCostProvenance` reads; any `AggregatedTokens` qualifies. */
3
+ export type AggregatedCostProvenanceInput = Pick<AggregatedTokens, 'costProvenance' | 'unpricedCalls'>;
4
+ /**
5
+ * Human-readable qualifier for an aggregate cost, for report footers and
6
+ * table cells. Empty when there is nothing to qualify.
7
+ */
8
+ export declare function describeCostProvenance(tokens: AggregatedCostProvenanceInput): string;
9
+ /** Classify a cost breakdown by how many events fell into each bucket. */
10
+ export declare function classifyCostProvenance(counts: {
11
+ reportedCalls: number;
12
+ estimatedCalls: number;
13
+ unpricedCalls: number;
14
+ }): AggregatedCostProvenance;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeCostProvenance = describeCostProvenance;
4
+ exports.classifyCostProvenance = classifyCostProvenance;
5
+ const PROVENANCE_LABELS = {
6
+ reported: 'provider-reported',
7
+ estimated: 'estimated from catalog pricing',
8
+ mixed: 'partly provider-reported, partly estimated',
9
+ unpriced: 'unpriced',
10
+ none: '',
11
+ };
12
+ /**
13
+ * Human-readable qualifier for an aggregate cost, for report footers and
14
+ * table cells. Empty when there is nothing to qualify.
15
+ */
16
+ function describeCostProvenance(tokens) {
17
+ const base = PROVENANCE_LABELS[tokens.costProvenance] ?? '';
18
+ const unpriced = tokens.unpricedCalls > 0
19
+ ? `${tokens.unpricedCalls} unpriced call${tokens.unpricedCalls === 1 ? '' : 's'}`
20
+ : '';
21
+ if (base && unpriced)
22
+ return `${base} · ${unpriced}`;
23
+ return base || unpriced;
24
+ }
25
+ /** Classify a cost breakdown by how many events fell into each bucket. */
26
+ function classifyCostProvenance(counts) {
27
+ const { reportedCalls, estimatedCalls, unpricedCalls } = counts;
28
+ if (reportedCalls > 0 && estimatedCalls > 0)
29
+ return 'mixed';
30
+ if (reportedCalls > 0)
31
+ return 'reported';
32
+ if (estimatedCalls > 0)
33
+ return 'estimated';
34
+ return unpricedCalls > 0 ? 'unpriced' : 'none';
35
+ }
@@ -8,6 +8,8 @@
8
8
  * @module aggregation/snapshot
9
9
  */
10
10
  import { type SerializedAggregatorState } from './EventAggregator';
11
+ /** Newest snapshots kept on disk; older ones are pruned opportunistically. */
12
+ export declare const MAX_SNAPSHOT_FILES = 200;
11
13
  /** Snapshot sidecar file content. */
12
14
  export interface SessionSnapshot {
13
15
  /** Schema version for forward-compatibility checks. */
@@ -32,10 +34,19 @@ export declare function getSnapshotPath(sessionId: string): string;
32
34
  /**
33
35
  * Saves a session snapshot to disk.
34
36
  *
35
- * Creates the snapshots directory if it doesn't exist.
36
- * Writes atomically via rename to avoid partial reads.
37
+ * Creates the snapshots directory if it doesn't exist. Uses the shared
38
+ * fsynced atomic writer with a per-process temp name, so the CLI dashboard and
39
+ * the extension host snapshotting the same session cannot collide on a shared
40
+ * `.tmp` file. Every {@link PRUNE_EVERY_N_SAVES} saves the directory is
41
+ * trimmed to the newest {@link MAX_SNAPSHOT_FILES}; snapshots are caches, so
42
+ * dropping an old one only costs a replay.
37
43
  */
38
44
  export declare function saveSnapshot(snapshot: SessionSnapshot): void;
45
+ /**
46
+ * Deletes all but the newest `keep` snapshot files (by modification time).
47
+ * Returns how many files were removed. Best-effort; never throws.
48
+ */
49
+ export declare function pruneSnapshots(keep?: number): number;
39
50
  /**
40
51
  * Loads a session snapshot from disk if it exists and is valid.
41
52
  *
@@ -42,17 +42,25 @@ var __importStar = (this && this.__importStar) || (function () {
42
42
  };
43
43
  })();
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.MAX_SNAPSHOT_FILES = void 0;
45
46
  exports.getSnapshotPath = getSnapshotPath;
46
47
  exports.saveSnapshot = saveSnapshot;
48
+ exports.pruneSnapshots = pruneSnapshots;
47
49
  exports.loadSnapshot = loadSnapshot;
48
50
  exports.deleteSnapshot = deleteSnapshot;
49
51
  exports.isSnapshotValid = isSnapshotValid;
50
52
  const fs = __importStar(require("fs"));
51
53
  const path = __importStar(require("path"));
52
54
  const paths_1 = require("../paths");
55
+ const atomic_1 = require("../writers/atomic");
53
56
  const EventAggregator_1 = require("./EventAggregator");
54
57
  /** Current schema version — bump when format changes. */
55
58
  const SNAPSHOT_VERSION = EventAggregator_1.SNAPSHOT_SCHEMA_VERSION;
59
+ /** Newest snapshots kept on disk; older ones are pruned opportunistically. */
60
+ exports.MAX_SNAPSHOT_FILES = 200;
61
+ /** Prune runs once per this many saves, so steady-state writes stay one file each. */
62
+ const PRUNE_EVERY_N_SAVES = 25;
63
+ let savesSincePrune = 0;
56
64
  /** Returns the directory for snapshot files. */
57
65
  function getSnapshotsDir() {
58
66
  return path.join((0, paths_1.getConfigDir)(), 'snapshots');
@@ -66,8 +74,12 @@ function getSnapshotPath(sessionId) {
66
74
  /**
67
75
  * Saves a session snapshot to disk.
68
76
  *
69
- * Creates the snapshots directory if it doesn't exist.
70
- * Writes atomically via rename to avoid partial reads.
77
+ * Creates the snapshots directory if it doesn't exist. Uses the shared
78
+ * fsynced atomic writer with a per-process temp name, so the CLI dashboard and
79
+ * the extension host snapshotting the same session cannot collide on a shared
80
+ * `.tmp` file. Every {@link PRUNE_EVERY_N_SAVES} saves the directory is
81
+ * trimmed to the newest {@link MAX_SNAPSHOT_FILES}; snapshots are caches, so
82
+ * dropping an old one only costs a replay.
71
83
  */
72
84
  function saveSnapshot(snapshot) {
73
85
  try {
@@ -75,15 +87,54 @@ function saveSnapshot(snapshot) {
75
87
  if (!fs.existsSync(dir)) {
76
88
  fs.mkdirSync(dir, { recursive: true });
77
89
  }
78
- const filePath = getSnapshotPath(snapshot.sessionId);
79
- const tmpPath = filePath + '.tmp';
80
- fs.writeFileSync(tmpPath, JSON.stringify(snapshot), 'utf-8');
81
- fs.renameSync(tmpPath, filePath);
90
+ (0, atomic_1.atomicWriteJsonSync)(getSnapshotPath(snapshot.sessionId), snapshot);
91
+ savesSincePrune += 1;
92
+ if (savesSincePrune >= PRUNE_EVERY_N_SAVES) {
93
+ savesSincePrune = 0;
94
+ pruneSnapshots(exports.MAX_SNAPSHOT_FILES);
95
+ }
82
96
  }
83
97
  catch {
84
98
  // Non-critical — snapshot failure should never break session monitoring
85
99
  }
86
100
  }
101
+ /**
102
+ * Deletes all but the newest `keep` snapshot files (by modification time).
103
+ * Returns how many files were removed. Best-effort; never throws.
104
+ */
105
+ function pruneSnapshots(keep = exports.MAX_SNAPSHOT_FILES) {
106
+ try {
107
+ const dir = getSnapshotsDir();
108
+ const entries = fs
109
+ .readdirSync(dir)
110
+ .filter((name) => name.endsWith('.json'))
111
+ .map((name) => {
112
+ const filePath = path.join(dir, name);
113
+ try {
114
+ return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ })
120
+ .filter((entry) => entry !== null)
121
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
122
+ let removed = 0;
123
+ for (const entry of entries.slice(Math.max(0, keep))) {
124
+ try {
125
+ fs.unlinkSync(entry.filePath);
126
+ removed += 1;
127
+ }
128
+ catch {
129
+ // Already gone or locked; skip.
130
+ }
131
+ }
132
+ return removed;
133
+ }
134
+ catch {
135
+ return 0;
136
+ }
137
+ }
87
138
  /**
88
139
  * Loads a session snapshot from disk if it exists and is valid.
89
140
  *
@@ -50,7 +50,17 @@ export interface EventAggregatorOptions {
50
50
  /** Read a plan file from disk (fallback when Edit tool is used instead of Write). */
51
51
  readPlanFile?: (path: string) => string | null;
52
52
  }
53
- /** Token accumulation totals (aggregation-specific, includes reportedCost). */
53
+ /**
54
+ * How an aggregate cost figure was produced.
55
+ *
56
+ * - `reported` — every priced event carried a provider-reported cost.
57
+ * - `estimated` — every priced event was priced from the model catalog.
58
+ * - `mixed` — some events reported, some estimated.
59
+ * - `unpriced` — no event could be priced at all.
60
+ * - `none` — no usage events yet.
61
+ */
62
+ export type AggregatedCostProvenance = 'reported' | 'estimated' | 'mixed' | 'unpriced' | 'none';
63
+ /** Token accumulation totals (aggregation-specific, includes cost). */
54
64
  export interface AggregatedTokens {
55
65
  inputTokens: number;
56
66
  outputTokens: number;
@@ -59,6 +69,25 @@ export interface AggregatedTokens {
59
69
  reasoningTokens: number;
60
70
  /** Overall total after applying provider reasoning-in-output semantics. */
61
71
  totalTokens: number;
72
+ /**
73
+ * Cost in USD for the priced portion of the session. Read `costProvenance`
74
+ * before labelling it: only `'reported'` came from the provider; anything
75
+ * else includes Sidekick's own catalog estimate.
76
+ */
77
+ costUsd: number;
78
+ /** Portion of `costUsd` the provider reported itself. */
79
+ reportedCostUsd: number;
80
+ /** Portion of `costUsd` Sidekick estimated from catalog pricing. */
81
+ estimatedCostUsd: number;
82
+ /** Usage events that had neither a reported cost nor a catalog price. */
83
+ unpricedCalls: number;
84
+ /** Classification of `costUsd`. */
85
+ costProvenance: AggregatedCostProvenance;
86
+ /**
87
+ * @deprecated Same value as `costUsd`. Despite the name it has always mixed
88
+ * catalog estimates with provider-reported figures; use `costUsd` together
89
+ * with `costProvenance`.
90
+ */
62
91
  reportedCost: number;
63
92
  }
64
93
  /** Per-model usage breakdown. */
@@ -2,18 +2,28 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.calculateCompactionLedger = calculateCompactionLedger;
4
4
  exports.formatCompactionLedger = formatCompactionLedger;
5
- const modelInfo_1 = require("../modelInfo");
5
+ const usageNormalization_1 = require("../usageNormalization");
6
6
  function calculateCompactionLedger(events, model) {
7
7
  const tokensEvicted = events.reduce((sum, event) => sum + Math.max(0, event.tokensReclaimed), 0);
8
8
  const reportedCount = events.filter((event) => event.source === 'reported').length;
9
9
  const heuristicCount = events.length - reportedCount;
10
- const pricing = model ? (0, modelInfo_1.getModelPricing)(model) : null;
10
+ // Re-establishing evicted context means sending it again as uncached input.
11
+ // Price it through the same normalized path every other cost figure uses so
12
+ // the ledger can never disagree with the session total on the same tokens.
13
+ const reestablishment = model
14
+ ? (0, usageNormalization_1.calculateNormalizedUsageCost)({
15
+ usage: (0, usageNormalization_1.normalizeProviderUsage)({
16
+ semantics: 'sidekick',
17
+ inputTokens: tokensEvicted,
18
+ reasoningIncludedInOutput: true,
19
+ }),
20
+ modelId: model,
21
+ })
22
+ : null;
11
23
  return {
12
24
  count: events.length,
13
25
  tokensEvicted,
14
- reestablishmentCostUsd: pricing
15
- ? (0, modelInfo_1.calculateCostWithPricing)({ inputTokens: tokensEvicted, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 }, pricing)
16
- : null,
26
+ reestablishmentCostUsd: reestablishment?.costUsd ?? null,
17
27
  source: reportedCount > 0 && heuristicCount > 0
18
28
  ? 'mixed'
19
29
  : reportedCount > 0
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scoreSessionQuality = scoreSessionQuality;
4
4
  exports.calculateQualityTrend = calculateQualityTrend;
5
+ const tokenSummary_1 = require("../tokenSummary");
5
6
  function awarded(maximum, ratio) {
6
7
  return Math.round(maximum * Math.max(0, Math.min(1, ratio)) * 10) / 10;
7
8
  }
@@ -10,7 +11,7 @@ function scoreSessionQuality(metrics) {
10
11
  const latencyRatio = latencyMs > 0 ? 1 - Math.min(1, latencyMs / 15_000) : 1;
11
12
  const completedCalls = metrics.toolStats.reduce((sum, tool) => sum + tool.completedCount, 0);
12
13
  const errorRate = completedCalls > 0 ? metrics.errorRollup.totalFailures / completedCalls : 0;
13
- const contextTokens = Math.max(1, metrics.tokens.inputTokens + metrics.tokens.cacheReadTokens + metrics.tokens.cacheWriteTokens);
14
+ const contextTokens = Math.max(1, (0, tokenSummary_1.summarizeTokens)(metrics.tokens).context);
14
15
  const evicted = metrics.compactionEvents.reduce((sum, event) => sum + event.tokensReclaimed, 0);
15
16
  const contextRatio = 1 - Math.min(1, evicted / contextTokens);
16
17
  const start = metrics.sessionStartTime ? Date.parse(metrics.sessionStartTime) : NaN;