sidekick-shared 0.25.0 → 0.26.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +49 -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/node.d.ts +19 -0
  35. package/dist/node.js +47 -1
  36. package/dist/observedContextWindows.d.ts +7 -0
  37. package/dist/observedContextWindows.js +17 -2
  38. package/dist/observedSessionCollector.d.ts +3 -8
  39. package/dist/observedSessionCollector.js +10 -27
  40. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  41. package/dist/parsers/sessionActivityDetector.js +95 -17
  42. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  43. package/dist/parsers/sessionPathResolver.js +13 -10
  44. package/dist/peakHours.d.ts +34 -3
  45. package/dist/peakHours.js +92 -7
  46. package/dist/pricingCatalog.d.ts +7 -0
  47. package/dist/pricingCatalog.js +8 -5
  48. package/dist/providers/claudeCode.js +11 -96
  49. package/dist/providers/codex.d.ts +2 -0
  50. package/dist/providers/codex.js +94 -259
  51. package/dist/providers/detect.d.ts +2 -0
  52. package/dist/providers/detect.js +29 -24
  53. package/dist/providers/factory.d.ts +6 -0
  54. package/dist/providers/openCode.d.ts +2 -0
  55. package/dist/providers/openCode.js +105 -121
  56. package/dist/providers/rolloutWalker.d.ts +53 -0
  57. package/dist/providers/rolloutWalker.js +209 -0
  58. package/dist/providers/types.d.ts +30 -0
  59. package/dist/quota.d.ts +30 -3
  60. package/dist/quota.js +35 -3
  61. package/dist/quotaHistory.d.ts +1 -1
  62. package/dist/quotaHistory.js +36 -35
  63. package/dist/quotaResolver.d.ts +81 -0
  64. package/dist/quotaResolver.js +213 -0
  65. package/dist/quotaSnapshots.d.ts +15 -2
  66. package/dist/quotaSnapshots.js +25 -3
  67. package/dist/quotaThresholds.d.ts +60 -0
  68. package/dist/quotaThresholds.js +90 -0
  69. package/dist/report/htmlReportGenerator.js +10 -4
  70. package/dist/report/sessionReportInputs.d.ts +23 -0
  71. package/dist/report/sessionReportInputs.js +34 -0
  72. package/dist/schemas/index.d.ts +2 -0
  73. package/dist/schemas/index.js +10 -1
  74. package/dist/schemas/quota.d.ts +139 -0
  75. package/dist/schemas/quota.js +17 -2
  76. package/dist/schemas/quotaHistory.d.ts +1 -0
  77. package/dist/schemas/quotaHistory.js +1 -1
  78. package/dist/schemas/sessionEvent.d.ts +6 -0
  79. package/dist/schemas/sessionEvent.js +1 -0
  80. package/dist/schemas/stateFile.d.ts +198 -0
  81. package/dist/schemas/stateFile.js +77 -0
  82. package/dist/schemas/transcript.js +2 -0
  83. package/dist/sessionFingerprint.d.ts +25 -0
  84. package/dist/sessionFingerprint.js +46 -0
  85. package/dist/sessionPreviews.d.ts +7 -1
  86. package/dist/sessionPreviews.js +15 -3
  87. package/dist/sessionStats.d.ts +51 -0
  88. package/dist/sessionStats.js +166 -0
  89. package/dist/stateFile.d.ts +113 -0
  90. package/dist/stateFile.js +160 -0
  91. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  92. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  93. package/dist/statusline/formatter.d.ts +13 -0
  94. package/dist/statusline/formatter.js +48 -6
  95. package/dist/statusline/index.d.ts +10 -2
  96. package/dist/statusline/index.js +19 -1
  97. package/dist/tokenSummary.d.ts +62 -0
  98. package/dist/tokenSummary.js +72 -0
  99. package/dist/transcript.d.ts +5 -0
  100. package/dist/transcript.js +2 -0
  101. package/dist/types/observedSessionV1.d.ts +2 -0
  102. package/dist/types/observedSessionV1.js +75 -18
  103. package/dist/types/sessionEvent.d.ts +2 -0
  104. package/dist/usage/billingBlocks.d.ts +85 -0
  105. package/dist/usage/billingBlocks.js +153 -0
  106. package/dist/usage/importSessionHistory.d.ts +53 -0
  107. package/dist/usage/importSessionHistory.js +88 -0
  108. package/dist/usage/usageEvents.d.ts +92 -0
  109. package/dist/usage/usageEvents.js +322 -0
  110. package/dist/usage/usageReports.d.ts +76 -0
  111. package/dist/usage/usageReports.js +208 -0
  112. package/dist/watchers/factory.d.ts +6 -0
  113. package/dist/watchers/factory.js +30 -25
  114. package/dist/zaiQuotaApi.d.ts +2 -0
  115. package/dist/zaiQuotaApi.js +2 -0
  116. package/package.json +1 -1
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Size/mtime fingerprints for session sources.
3
+ *
4
+ * Shared by the observed-session collector, the V1 adapter's watch loop, and
5
+ * the usage cache, so "has this session changed?" is answered the same way
6
+ * everywhere without re-reading content.
7
+ *
8
+ * @module sessionFingerprint
9
+ */
10
+ import type { SessionProviderBase } from './providers/types';
11
+ export interface ObservedSessionFingerprintParts {
12
+ sizeBytes: number;
13
+ mtimeMs: number;
14
+ }
15
+ /** Structured size/mtime fingerprint; returns null for inaccessible or synthetic paths. */
16
+ export declare function fileFingerprintParts(sourcePath: string): ObservedSessionFingerprintParts | null;
17
+ /** Size/mtime fingerprint suitable for retry bypass; returns null for synthetic DB locators. */
18
+ export declare function fileFingerprint(sourcePath: string): string | null;
19
+ /** Canonical `${sizeBytes}:${mtimeMs}` form of a fingerprint. */
20
+ export declare function fingerprintString(parts: ObservedSessionFingerprintParts): string;
21
+ /**
22
+ * Fingerprint for a session path, falling back to provider metadata (mtime
23
+ * only) for database-backed sessions whose path is synthetic.
24
+ */
25
+ export declare function sessionFingerprintParts(provider: SessionProviderBase, sessionPath: string): ObservedSessionFingerprintParts | null;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * Size/mtime fingerprints for session sources.
4
+ *
5
+ * Shared by the observed-session collector, the V1 adapter's watch loop, and
6
+ * the usage cache, so "has this session changed?" is answered the same way
7
+ * everywhere without re-reading content.
8
+ *
9
+ * @module sessionFingerprint
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.fileFingerprintParts = fileFingerprintParts;
13
+ exports.fileFingerprint = fileFingerprint;
14
+ exports.fingerprintString = fingerprintString;
15
+ exports.sessionFingerprintParts = sessionFingerprintParts;
16
+ const node_fs_1 = require("node:fs");
17
+ /** Structured size/mtime fingerprint; returns null for inaccessible or synthetic paths. */
18
+ function fileFingerprintParts(sourcePath) {
19
+ try {
20
+ const stat = (0, node_fs_1.statSync)(sourcePath);
21
+ return { sizeBytes: stat.size, mtimeMs: stat.mtimeMs };
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Size/mtime fingerprint suitable for retry bypass; returns null for synthetic DB locators. */
28
+ function fileFingerprint(sourcePath) {
29
+ const parts = fileFingerprintParts(sourcePath);
30
+ return parts ? fingerprintString(parts) : null;
31
+ }
32
+ /** Canonical `${sizeBytes}:${mtimeMs}` form of a fingerprint. */
33
+ function fingerprintString(parts) {
34
+ return `${parts.sizeBytes}:${parts.mtimeMs}`;
35
+ }
36
+ /**
37
+ * Fingerprint for a session path, falling back to provider metadata (mtime
38
+ * only) for database-backed sessions whose path is synthetic.
39
+ */
40
+ function sessionFingerprintParts(provider, sessionPath) {
41
+ const parts = fileFingerprintParts(sessionPath);
42
+ if (parts)
43
+ return parts;
44
+ const metadata = provider.getSessionMetadata?.(sessionPath);
45
+ return metadata ? { sizeBytes: 0, mtimeMs: metadata.mtime.getTime() } : null;
46
+ }
@@ -36,7 +36,11 @@ export interface SessionPreview {
36
36
  workspacePath: string | null;
37
37
  }
38
38
  export interface ReadSessionPreviewOptions {
39
- /** Byte budget for the generic prefix scan (default 16 KiB). */
39
+ /**
40
+ * Byte budget for the generic prefix scan (default 16 KiB). `0` skips the
41
+ * scan entirely for callers that ignore `firstTimestamp` and
42
+ * `workspacePath`; both come back `null` and the file is not opened.
43
+ */
40
44
  maxPrefixBytes?: number;
41
45
  }
42
46
  export interface ListSessionPreviewsOptions extends ReadSessionPreviewOptions {
@@ -64,6 +68,8 @@ export interface SessionPreviewReadResult {
64
68
  export interface SessionPreviewListResult {
65
69
  previews: SessionPreview[];
66
70
  diagnostics: SessionProviderDiagnostic[];
71
+ /** True when more candidates existed than `limit` allowed; the caller may offer a larger limit. */
72
+ hasMore: boolean;
67
73
  }
68
74
  /**
69
75
  * Reads a bounded preview of one session file. Returns `null` when the file is
@@ -57,6 +57,9 @@ exports.readSessionPreviewAsync = readSessionPreviewAsync;
57
57
  exports.listSessionPreviews = listSessionPreviews;
58
58
  exports.listSessionPreviewsAsync = listSessionPreviewsAsync;
59
59
  const fs = __importStar(require("fs"));
60
+ function emptyPrefixScan() {
61
+ return { firstTimestamp: null, workspacePath: null };
62
+ }
60
63
  const DEFAULT_PREFIX_BYTES = 16 * 1024;
61
64
  const DEFAULT_LIMIT = 50;
62
65
  const DEFAULT_CONCURRENCY = 4;
@@ -108,7 +111,8 @@ function readSessionPreview(provider, sessionPath, options = {}) {
108
111
  catch {
109
112
  // A provider that cannot identify the file still yields a usable preview.
110
113
  }
111
- const prefix = scanPrefix(sessionPath, options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES);
114
+ const prefixBudget = options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES;
115
+ const prefix = prefixBudget > 0 ? scanPrefix(sessionPath, prefixBudget) : emptyPrefixScan();
112
116
  return {
113
117
  provider: provider.id,
114
118
  sessionId,
@@ -238,6 +242,7 @@ async function listSessionPreviewsAsync(providers, options = {}) {
238
242
  }, concurrency(options));
239
243
  return {
240
244
  previews: previews.filter((preview) => preview !== null),
245
+ hasMore: candidates.length > limit,
241
246
  diagnostics: dedupeDiagnostics([
242
247
  ...operationDiagnostics,
243
248
  ...providers.flatMap(providerDiagnostics),
@@ -248,7 +253,13 @@ function resolveSinceMs(since) {
248
253
  if (since === undefined)
249
254
  return null;
250
255
  const ms = since instanceof Date ? since.getTime() : Date.parse(since);
251
- return Number.isFinite(ms) ? ms : null;
256
+ if (!Number.isFinite(ms)) {
257
+ // An unparseable cutoff used to mean "no cutoff", silently returning
258
+ // every session. Fail loudly instead so a typo cannot masquerade as a
259
+ // full listing.
260
+ throw new RangeError(`Invalid \`since\` value: ${String(since)}`);
261
+ }
262
+ return ms;
252
263
  }
253
264
  function enumerateSessionFiles(provider, workspacePath) {
254
265
  try {
@@ -359,7 +370,8 @@ async function readSessionPreviewFromInfoAsync(provider, info, firstUserPrompt,
359
370
  catch {
360
371
  return null;
361
372
  }
362
- const prefix = await scanPrefixAsync(info.path, options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES);
373
+ const prefixBudget = options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES;
374
+ const prefix = prefixBudget > 0 ? await scanPrefixAsync(info.path, prefixBudget) : emptyPrefixScan();
363
375
  return {
364
376
  provider: provider.id,
365
377
  sessionId: info.sessionId ?? safeSessionId(provider, info.path),
@@ -0,0 +1,51 @@
1
+ /**
2
+ * One `readSessionStats()` implementation for every session provider.
3
+ *
4
+ * Claude Code, Codex, and OpenCode used to compute session stats three
5
+ * different ways (a hand-rolled JSONL scan, an aggregator drain, and a direct
6
+ * SQLite read with hardcoded zeros). Every provider now drains its reader once
7
+ * into the shared `EventAggregator`, so per-model totals use the
8
+ * cache-inclusive vocabulary, cost carries provenance, tool failures are
9
+ * split out, and compaction and truncation counts are real for all three.
10
+ */
11
+ import type { EventAggregatorOptions } from './aggregation/types';
12
+ import type { ProviderId, SessionFileStats, SessionFileStatsAvailability, SessionProviderBase } from './providers/types';
13
+ import type { SessionEvent } from './types/sessionEvent';
14
+ export interface ComputeSessionFileStatsOptions {
15
+ providerId: ProviderId;
16
+ sessionId: string;
17
+ filePath: string;
18
+ /** Label to use; derived from the first user prompt when omitted or null. */
19
+ label?: string | null;
20
+ /** Provider-specific context formula, forwarded to the aggregator. */
21
+ computeContextSize?: EventAggregatorOptions['computeContextSize'];
22
+ /** Defaults to `full`. */
23
+ availability?: SessionFileStatsAvailability;
24
+ unavailableReason?: string;
25
+ }
26
+ export interface ReadSessionFileStatsOptions {
27
+ /**
28
+ * Cheap label source consulted before the events (for example a SQLite
29
+ * thread title). Return null to fall back to the first user prompt.
30
+ */
31
+ resolveLabel?: () => string | null;
32
+ }
33
+ /**
34
+ * First user prompt in a session, compacted to one line and truncated the
35
+ * way every provider's `extractSessionLabel()` truncates (60 characters).
36
+ */
37
+ export declare function firstUserPrompt(events: readonly SessionEvent[], maxLength?: number): string | null;
38
+ /** Compute stats for an already-read event array through the shared aggregator. */
39
+ export declare function computeSessionFileStats(events: readonly SessionEvent[], options: ComputeSessionFileStatsOptions): SessionFileStats;
40
+ /** The aggregator's context-size hook for a provider, or undefined for the default formula. */
41
+ export declare function providerContextSizeFn(provider: SessionProviderBase): EventAggregatorOptions['computeContextSize'];
42
+ /**
43
+ * Read a session once through the provider's reader and compute its stats.
44
+ *
45
+ * The reader is drained with `readAll()` and then `flush()`ed so a final line
46
+ * without a trailing newline is included, and it is never opened a second
47
+ * time for the label. A source that cannot be read yields
48
+ * `availability: 'unavailable'` with a reason instead of silent zeros; a
49
+ * reader that reports truncation yields `partial`.
50
+ */
51
+ export declare function readSessionFileStats(provider: SessionProviderBase, sessionPath: string, options?: ReadSessionFileStatsOptions): SessionFileStats;
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ /**
3
+ * One `readSessionStats()` implementation for every session provider.
4
+ *
5
+ * Claude Code, Codex, and OpenCode used to compute session stats three
6
+ * different ways (a hand-rolled JSONL scan, an aggregator drain, and a direct
7
+ * SQLite read with hardcoded zeros). Every provider now drains its reader once
8
+ * into the shared `EventAggregator`, so per-model totals use the
9
+ * cache-inclusive vocabulary, cost carries provenance, tool failures are
10
+ * split out, and compaction and truncation counts are real for all three.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.firstUserPrompt = firstUserPrompt;
14
+ exports.computeSessionFileStats = computeSessionFileStats;
15
+ exports.providerContextSizeFn = providerContextSizeFn;
16
+ exports.readSessionFileStats = readSessionFileStats;
17
+ const EventAggregator_1 = require("./aggregation/EventAggregator");
18
+ const LABEL_MAX_LENGTH = 60;
19
+ function isTextBlock(block) {
20
+ return (typeof block === 'object' &&
21
+ block !== null &&
22
+ typeof block.text === 'string' &&
23
+ typeof block.type === 'string' &&
24
+ ['text', 'input_text', 'output_text'].includes(block.type));
25
+ }
26
+ function messageText(content) {
27
+ if (typeof content === 'string')
28
+ return content;
29
+ if (!Array.isArray(content))
30
+ return null;
31
+ const block = content.find((item) => isTextBlock(item) && item.text.trim().length > 0);
32
+ return block && isTextBlock(block) ? block.text : null;
33
+ }
34
+ /**
35
+ * First user prompt in a session, compacted to one line and truncated the
36
+ * way every provider's `extractSessionLabel()` truncates (60 characters).
37
+ */
38
+ function firstUserPrompt(events, maxLength = LABEL_MAX_LENGTH) {
39
+ for (const event of events) {
40
+ if (event.type !== 'user')
41
+ continue;
42
+ const text = messageText(event.message?.content);
43
+ if (!text)
44
+ continue;
45
+ const compact = text.replace(/\s+/g, ' ').trim();
46
+ if (!compact)
47
+ continue;
48
+ return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
49
+ }
50
+ return null;
51
+ }
52
+ /** Compute stats for an already-read event array through the shared aggregator. */
53
+ function computeSessionFileStats(events, options) {
54
+ const aggregator = new EventAggregator_1.EventAggregator({
55
+ providerId: options.providerId,
56
+ computeContextSize: options.computeContextSize,
57
+ });
58
+ for (const event of events)
59
+ aggregator.processEvent(event);
60
+ const metrics = aggregator.getMetrics();
61
+ const modelUsage = {};
62
+ for (const model of metrics.modelStats) {
63
+ // `tokens` is the aggregator's cache-inclusive total for the model, the
64
+ // same figure `summarizeTokens().total` reports.
65
+ modelUsage[model.model] = {
66
+ calls: model.calls,
67
+ tokens: model.tokens,
68
+ costUsd: model.cost,
69
+ priced: model.priced !== false,
70
+ };
71
+ }
72
+ const toolUsage = {};
73
+ const toolFailures = {};
74
+ for (const tool of metrics.toolStats) {
75
+ toolUsage[tool.name] = tool.successCount + tool.failureCount + tool.pendingCount;
76
+ if (tool.failureCount > 0)
77
+ toolFailures[tool.name] = tool.failureCount;
78
+ }
79
+ return {
80
+ providerId: options.providerId,
81
+ sessionId: options.sessionId,
82
+ filePath: options.filePath,
83
+ label: options.label ?? firstUserPrompt(events),
84
+ startTime: metrics.sessionStartTime ?? '',
85
+ endTime: metrics.lastEventTime ?? '',
86
+ messageCount: metrics.messageCount,
87
+ tokens: {
88
+ input: metrics.tokens.inputTokens,
89
+ output: metrics.tokens.outputTokens,
90
+ cacheWrite: metrics.tokens.cacheWriteTokens,
91
+ cacheRead: metrics.tokens.cacheReadTokens,
92
+ },
93
+ modelUsage,
94
+ toolUsage,
95
+ toolFailures,
96
+ compactionEstimate: metrics.compactionCount,
97
+ truncationCount: metrics.truncationCount,
98
+ costUsd: metrics.tokens.costUsd,
99
+ costProvenance: metrics.tokens.costProvenance,
100
+ unpricedCalls: metrics.tokens.unpricedCalls,
101
+ availability: options.availability ?? 'full',
102
+ ...(options.unavailableReason ? { unavailableReason: options.unavailableReason } : {}),
103
+ reportedCost: metrics.tokens.costUsd,
104
+ };
105
+ }
106
+ /** The aggregator's context-size hook for a provider, or undefined for the default formula. */
107
+ function providerContextSizeFn(provider) {
108
+ if (!provider.computeContextSize)
109
+ return undefined;
110
+ return (usage) => provider.computeContextSize({ ...usage, model: '', timestamp: new Date() });
111
+ }
112
+ function errorMessage(error) {
113
+ return error instanceof Error ? error.message : String(error);
114
+ }
115
+ function describeMissingSource(provider) {
116
+ const status = provider.getRuntimeStatus?.();
117
+ if (status && !status.available) {
118
+ return status.message ?? `Provider runtime unavailable (${status.kind}).`;
119
+ }
120
+ return 'Session source not found.';
121
+ }
122
+ /**
123
+ * Read a session once through the provider's reader and compute its stats.
124
+ *
125
+ * The reader is drained with `readAll()` and then `flush()`ed so a final line
126
+ * without a trailing newline is included, and it is never opened a second
127
+ * time for the label. A source that cannot be read yields
128
+ * `availability: 'unavailable'` with a reason instead of silent zeros; a
129
+ * reader that reports truncation yields `partial`.
130
+ */
131
+ function readSessionFileStats(provider, sessionPath, options = {}) {
132
+ const base = {
133
+ providerId: provider.id,
134
+ sessionId: provider.getSessionId(sessionPath),
135
+ filePath: sessionPath,
136
+ };
137
+ let reader;
138
+ let events;
139
+ try {
140
+ reader = provider.createReader(sessionPath);
141
+ events = reader.readAll();
142
+ // Readers buffer line-oriented input; flushing emits a trailing partial
143
+ // line into the same array `readAll()` returned.
144
+ reader.flush();
145
+ }
146
+ catch (error) {
147
+ return computeSessionFileStats([], {
148
+ ...base,
149
+ availability: 'unavailable',
150
+ unavailableReason: errorMessage(error),
151
+ });
152
+ }
153
+ if (events.length === 0 && !reader.exists()) {
154
+ return computeSessionFileStats([], {
155
+ ...base,
156
+ availability: 'unavailable',
157
+ unavailableReason: describeMissingSource(provider),
158
+ });
159
+ }
160
+ return computeSessionFileStats(events, {
161
+ ...base,
162
+ label: options.resolveLabel?.() ?? undefined,
163
+ computeContextSize: providerContextSizeFn(provider),
164
+ availability: reader.wasTruncated() ? 'partial' : 'full',
165
+ });
166
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `state.json`: a small, versioned file for external consumers (tmux
3
+ * status bars, menu-bar apps, scripts) that want Sidekick's current account,
4
+ * quota windows with freshness, context usage, session cost, and the active
5
+ * billing block without running a Sidekick command.
6
+ *
7
+ * Written by the status line on every prompt and by both dashboards on their
8
+ * refresh ticks, but only when the content changed, so the hot path costs one
9
+ * small read in the common case. The schema is public: `schemaVersion` is 1,
10
+ * fields are only ever added, and the matching zod schema lives in
11
+ * `sidekick-shared/schemas` (`sidekickStateFileSchema`). This module does not
12
+ * import zod, so the status-line fast path stays small.
13
+ *
14
+ * @module stateFile
15
+ */
16
+ import type { QuotaState } from './quota';
17
+ import type { BillingBlock } from './usage/billingBlocks';
18
+ export declare const STATE_FILE_SCHEMA_VERSION = 1;
19
+ export declare const STATE_FILE_NAME = "state.json";
20
+ export type StateFileWriter = 'statusline' | 'cli-dashboard' | 'vscode-dashboard';
21
+ export interface StateFileQuotaWindow {
22
+ /** Utilization percentage (0–100). */
23
+ utilization: number;
24
+ /** ISO timestamp when the window resets; empty when unknown. */
25
+ resetsAt: string;
26
+ }
27
+ export interface StateFileQuota {
28
+ fiveHour: StateFileQuotaWindow;
29
+ sevenDay: StateFileQuotaWindow;
30
+ /** How the sample was obtained (`cache` when read back from the snapshot store). */
31
+ source: 'api' | 'session' | 'cache' | 'statusline' | null;
32
+ /** Original origin of a cached sample. */
33
+ capturedSource: 'api' | 'session' | 'statusline' | null;
34
+ capturedAt: string | null;
35
+ ageMs: number | null;
36
+ freshness: 'fresh' | 'aging' | 'stale' | null;
37
+ }
38
+ export interface StateFileAccount {
39
+ providerId: 'claude-code' | 'codex';
40
+ /** Sidekick registry id used to key quota snapshots and history. */
41
+ id: string | null;
42
+ label: string | null;
43
+ }
44
+ export interface StateFileContext {
45
+ usedPercentage: number | null;
46
+ contextWindowSize: number | null;
47
+ totalInputTokens: number | null;
48
+ totalOutputTokens: number | null;
49
+ }
50
+ export interface StateFileSession {
51
+ sessionId: string | null;
52
+ cwd: string | null;
53
+ model: string | null;
54
+ costUsd: number | null;
55
+ durationMs: number | null;
56
+ linesAdded: number | null;
57
+ linesRemoved: number | null;
58
+ /** Prompt-cache hit ratio (0–1) when the provider reports it. */
59
+ promptCacheHitRatio: number | null;
60
+ }
61
+ export interface StateFileBillingBlock {
62
+ start: string;
63
+ end: string;
64
+ isActive: boolean;
65
+ /** Cache-inclusive tokens so far. */
66
+ tokens: number;
67
+ costUsd: number;
68
+ costProvenance: 'reported' | 'estimated' | 'mixed' | 'unpriced' | 'none';
69
+ burnRatePerMinute: number;
70
+ projectedTokens: number;
71
+ projectedCostUsd: number;
72
+ remainingMs: number;
73
+ }
74
+ export interface SidekickStateFile {
75
+ schemaVersion: typeof STATE_FILE_SCHEMA_VERSION;
76
+ /** ISO timestamp of the write. Excluded from the unchanged comparison. */
77
+ writtenAt: string;
78
+ writer: StateFileWriter;
79
+ account: StateFileAccount | null;
80
+ quota: {
81
+ claude: StateFileQuota | null;
82
+ codex: StateFileQuota | null;
83
+ };
84
+ context: StateFileContext | null;
85
+ session: StateFileSession | null;
86
+ /** Active five-hour block (local estimate); null when unknown or not computed by the writer. */
87
+ billingBlock: StateFileBillingBlock | null;
88
+ }
89
+ /** Everything but the fields the writer stamps itself. */
90
+ export type SidekickStateInput = Omit<SidekickStateFile, 'schemaVersion' | 'writtenAt'>;
91
+ export interface WriteStateFileOptions {
92
+ /** Defaults to `<configDir>/state.json`. */
93
+ filePath?: string;
94
+ /** Clock for `writtenAt` (default `new Date()`). */
95
+ now?: Date;
96
+ }
97
+ export declare function getStateFilePath(): string;
98
+ /** Read the current state file; null when absent or unreadable. */
99
+ export declare function readStateFile(filePath?: string): SidekickStateFile | null;
100
+ /**
101
+ * Write the state file atomically, but only when something other than
102
+ * `writtenAt` changed since the last write. Returns true when a write
103
+ * happened. Never throws: the state file is a convenience for other tools.
104
+ */
105
+ export declare function writeStateFile(state: SidekickStateInput, options?: WriteStateFileOptions): boolean;
106
+ /**
107
+ * Project a quota sample onto the state-file shape. A live sample (for
108
+ * example from the status-line payload) carries `capturedAt` but no age, so
109
+ * the age and freshness are derived from `now` when the sample omits them.
110
+ */
111
+ export declare function quotaToStateFile(quota: QuotaState | null | undefined, now?: number): StateFileQuota | null;
112
+ /** Project a billing block onto the state-file shape (totals only). */
113
+ export declare function billingBlockToStateFile(block: BillingBlock | null | undefined): StateFileBillingBlock | null;
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ /**
3
+ * `state.json`: a small, versioned file for external consumers (tmux
4
+ * status bars, menu-bar apps, scripts) that want Sidekick's current account,
5
+ * quota windows with freshness, context usage, session cost, and the active
6
+ * billing block without running a Sidekick command.
7
+ *
8
+ * Written by the status line on every prompt and by both dashboards on their
9
+ * refresh ticks, but only when the content changed, so the hot path costs one
10
+ * small read in the common case. The schema is public: `schemaVersion` is 1,
11
+ * fields are only ever added, and the matching zod schema lives in
12
+ * `sidekick-shared/schemas` (`sidekickStateFileSchema`). This module does not
13
+ * import zod, so the status-line fast path stays small.
14
+ *
15
+ * @module stateFile
16
+ */
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.STATE_FILE_NAME = exports.STATE_FILE_SCHEMA_VERSION = void 0;
52
+ exports.getStateFilePath = getStateFilePath;
53
+ exports.readStateFile = readStateFile;
54
+ exports.writeStateFile = writeStateFile;
55
+ exports.quotaToStateFile = quotaToStateFile;
56
+ exports.billingBlockToStateFile = billingBlockToStateFile;
57
+ const fs = __importStar(require("fs"));
58
+ const paths_1 = require("./paths");
59
+ const quota_1 = require("./quota");
60
+ const atomic_1 = require("./writers/atomic");
61
+ exports.STATE_FILE_SCHEMA_VERSION = 1;
62
+ exports.STATE_FILE_NAME = 'state.json';
63
+ function getStateFilePath() {
64
+ return (0, paths_1.getGlobalDataPath)(exports.STATE_FILE_NAME);
65
+ }
66
+ /** Read the current state file; null when absent or unreadable. */
67
+ function readStateFile(filePath = getStateFilePath()) {
68
+ try {
69
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
70
+ return parsed && parsed.schemaVersion === exports.STATE_FILE_SCHEMA_VERSION ? parsed : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** Key-order-independent serialisation, dropping undefined values. */
77
+ function stableStringify(value) {
78
+ if (Array.isArray(value))
79
+ return `[${value.map(stableStringify).join(',')}]`;
80
+ if (value && typeof value === 'object') {
81
+ const record = value;
82
+ const entries = Object.keys(record)
83
+ .sort()
84
+ .filter((key) => record[key] !== undefined)
85
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
86
+ return `{${entries.join(',')}}`;
87
+ }
88
+ return JSON.stringify(value) ?? 'null';
89
+ }
90
+ /** The content that decides whether a rewrite is needed: everything the writer does not stamp. */
91
+ function comparable(state) {
92
+ return stableStringify({ ...state, schemaVersion: undefined, writtenAt: undefined });
93
+ }
94
+ /**
95
+ * Write the state file atomically, but only when something other than
96
+ * `writtenAt` changed since the last write. Returns true when a write
97
+ * happened. Never throws: the state file is a convenience for other tools.
98
+ */
99
+ function writeStateFile(state, options = {}) {
100
+ const filePath = options.filePath ?? getStateFilePath();
101
+ try {
102
+ const existing = readStateFile(filePath);
103
+ if (existing && comparable(existing) === comparable(state))
104
+ return false;
105
+ const next = {
106
+ schemaVersion: exports.STATE_FILE_SCHEMA_VERSION,
107
+ writtenAt: (options.now ?? new Date()).toISOString(),
108
+ ...state,
109
+ };
110
+ (0, atomic_1.atomicWriteJsonSync)(filePath, next);
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * Project a quota sample onto the state-file shape. A live sample (for
119
+ * example from the status-line payload) carries `capturedAt` but no age, so
120
+ * the age and freshness are derived from `now` when the sample omits them.
121
+ */
122
+ function quotaToStateFile(quota, now = Date.now()) {
123
+ if (!quota || !quota.available)
124
+ return null;
125
+ let ageMs = typeof quota.ageMs === 'number' ? quota.ageMs : null;
126
+ let freshness = quota.freshness ?? null;
127
+ if (ageMs === null && quota.capturedAt) {
128
+ const capturedMs = Date.parse(quota.capturedAt);
129
+ if (Number.isFinite(capturedMs)) {
130
+ ageMs = Math.max(0, now - capturedMs);
131
+ freshness ??= (0, quota_1.classifyQuotaFreshness)(ageMs);
132
+ }
133
+ }
134
+ return {
135
+ fiveHour: { utilization: quota.fiveHour.utilization, resetsAt: quota.fiveHour.resetsAt },
136
+ sevenDay: { utilization: quota.sevenDay.utilization, resetsAt: quota.sevenDay.resetsAt },
137
+ source: quota.source ?? null,
138
+ capturedSource: quota.capturedSource ?? null,
139
+ capturedAt: quota.capturedAt ?? null,
140
+ ageMs,
141
+ freshness,
142
+ };
143
+ }
144
+ /** Project a billing block onto the state-file shape (totals only). */
145
+ function billingBlockToStateFile(block) {
146
+ if (!block)
147
+ return null;
148
+ return {
149
+ start: block.start,
150
+ end: block.end,
151
+ isActive: block.isActive,
152
+ tokens: block.tokens.total,
153
+ costUsd: block.costUsd,
154
+ costProvenance: block.costProvenance,
155
+ burnRatePerMinute: block.burnRatePerMinute,
156
+ projectedTokens: block.projectedTokens,
157
+ projectedCostUsd: block.projectedCostUsd,
158
+ remainingMs: block.remainingMs,
159
+ };
160
+ }