sidekick-shared 0.27.6 → 0.28.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 (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,182 @@
1
+ export { QUOTA_PROVIDER_IDS } from './providerIds';
2
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
3
+ const BETA_HEADER = 'oauth-2025-04-20';
4
+ const FETCH_TIMEOUT_MS = 10_000;
5
+ export const FIVE_HOUR_WINDOW_MS = 5 * 3_600_000;
6
+ export const SEVEN_DAY_WINDOW_MS = 7 * 86_400_000;
7
+ /** A snapshot younger than this is as good as live. */
8
+ export const QUOTA_FRESH_MAX_AGE_MS = 5 * 60_000;
9
+ /** A snapshot older than this should be labelled as stale wherever it is shown. */
10
+ export const QUOTA_AGING_MAX_AGE_MS = 60 * 60_000;
11
+ /** Classify a cached sample's age. Non-finite or negative ages count as stale. */
12
+ export function classifyQuotaFreshness(ageMs) {
13
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
14
+ return 'stale';
15
+ if (ageMs < QUOTA_FRESH_MAX_AGE_MS)
16
+ return 'fresh';
17
+ if (ageMs < QUOTA_AGING_MAX_AGE_MS)
18
+ return 'aging';
19
+ return 'stale';
20
+ }
21
+ /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
22
+ export function formatQuotaAge(ageMs) {
23
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
24
+ return 'age unknown';
25
+ const minutes = Math.floor(ageMs / 60_000);
26
+ if (minutes < 1)
27
+ return 'just now';
28
+ if (minutes < 60)
29
+ return `${minutes}m ago`;
30
+ const hours = Math.floor(minutes / 60);
31
+ if (hours < 48)
32
+ return `${hours}h ago`;
33
+ return `${Math.floor(hours / 24)}d ago`;
34
+ }
35
+ /**
36
+ * Projects utilization at end of window based on elapsed time.
37
+ *
38
+ * Formula: `current * (windowDuration / elapsed)`, capped at 200%.
39
+ */
40
+ export function projectQuotaWindow(input) {
41
+ const { utilization, resetsAt, windowMs, capturedAt } = input;
42
+ if (!resetsAt || utilization <= 0 || !Number.isFinite(utilization) || windowMs <= 0)
43
+ return undefined;
44
+ const resetTime = new Date(resetsAt).getTime();
45
+ if (!Number.isFinite(resetTime))
46
+ return undefined;
47
+ const capturedTime = capturedAt ? Date.parse(capturedAt) : NaN;
48
+ const now = Number.isFinite(capturedTime) ? capturedTime : Date.now();
49
+ const elapsed = windowMs - (resetTime - now);
50
+ if (elapsed <= 0)
51
+ return undefined;
52
+ return Math.min(Math.round(utilization * (windowMs / elapsed)), 200);
53
+ }
54
+ export function withQuotaProjections(state, options = {}) {
55
+ if (!state.available)
56
+ return state;
57
+ const next = { ...state };
58
+ const capturedAt = options.capturedAt ?? state.capturedAt;
59
+ if (next.projectedFiveHour == null) {
60
+ const projected = projectQuotaWindow({
61
+ utilization: next.fiveHour.utilization,
62
+ resetsAt: next.fiveHour.resetsAt,
63
+ windowMs: options.fiveHourWindowMs ?? FIVE_HOUR_WINDOW_MS,
64
+ capturedAt,
65
+ });
66
+ if (projected !== undefined)
67
+ next.projectedFiveHour = projected;
68
+ }
69
+ if (next.projectedSevenDay == null) {
70
+ const projected = projectQuotaWindow({
71
+ utilization: next.sevenDay.utilization,
72
+ resetsAt: next.sevenDay.resetsAt,
73
+ windowMs: options.sevenDayWindowMs ?? SEVEN_DAY_WINDOW_MS,
74
+ capturedAt,
75
+ });
76
+ if (projected !== undefined)
77
+ next.projectedSevenDay = projected;
78
+ }
79
+ return next;
80
+ }
81
+ /**
82
+ * Helper to build an unavailable QuotaState.
83
+ */
84
+ function unavailableState(error, meta = {}) {
85
+ return {
86
+ fiveHour: { utilization: 0, resetsAt: '' },
87
+ sevenDay: { utilization: 0, resetsAt: '' },
88
+ available: false,
89
+ error,
90
+ ...meta,
91
+ };
92
+ }
93
+ function parseRetryAfterMs(retryAfter) {
94
+ if (!retryAfter)
95
+ return undefined;
96
+ const seconds = Number(retryAfter);
97
+ if (Number.isFinite(seconds) && seconds >= 0) {
98
+ return Math.round(seconds * 1000);
99
+ }
100
+ const retryAt = Date.parse(retryAfter);
101
+ if (Number.isNaN(retryAt))
102
+ return undefined;
103
+ return Math.max(retryAt - Date.now(), 0);
104
+ }
105
+ /**
106
+ * Fetch current quota utilization from the Anthropic OAuth usage API.
107
+ *
108
+ * This is a **single-shot** function — it does not poll. The caller wraps
109
+ * it in a polling loop, VS Code EventEmitter, or CLI interval as needed.
110
+ *
111
+ * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
112
+ * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
113
+ * @returns QuotaState with utilization data and elapsed-time projections
114
+ */
115
+ export async function fetchQuota(accessToken, options = {}) {
116
+ const controller = new AbortController();
117
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
118
+ timeout.unref();
119
+ try {
120
+ const fetchImpl = options.fetchImpl ?? fetch;
121
+ const res = await fetchImpl(USAGE_URL, {
122
+ method: 'GET',
123
+ signal: controller.signal,
124
+ headers: {
125
+ Authorization: `Bearer ${accessToken}`,
126
+ 'anthropic-beta': BETA_HEADER,
127
+ 'Content-Type': 'application/json',
128
+ },
129
+ });
130
+ if (!res.ok) {
131
+ if (res.status === 401) {
132
+ return unavailableState('Sign in to Claude Code to view quota', {
133
+ failureKind: 'auth',
134
+ httpStatus: res.status,
135
+ });
136
+ }
137
+ if (res.status === 429) {
138
+ return unavailableState(`API error: ${res.status}`, {
139
+ failureKind: 'rate_limit',
140
+ httpStatus: res.status,
141
+ retryAfterMs: parseRetryAfterMs(res.headers.get('retry-after')),
142
+ });
143
+ }
144
+ if (res.status >= 500 && res.status <= 599) {
145
+ return unavailableState(`API error: ${res.status}`, {
146
+ failureKind: 'server',
147
+ httpStatus: res.status,
148
+ });
149
+ }
150
+ return unavailableState(`API error: ${res.status}`, {
151
+ failureKind: 'unknown',
152
+ httpStatus: res.status,
153
+ });
154
+ }
155
+ const data = await res.json();
156
+ const fiveUtil = data.five_hour?.utilization ?? 0;
157
+ const sevenUtil = data.seven_day?.utilization ?? 0;
158
+ const fiveResetsAt = data.five_hour?.resets_at ?? '';
159
+ const sevenResetsAt = data.seven_day?.resets_at ?? '';
160
+ return {
161
+ fiveHour: { utilization: fiveUtil, resetsAt: fiveResetsAt },
162
+ sevenDay: { utilization: sevenUtil, resetsAt: sevenResetsAt },
163
+ available: true,
164
+ projectedFiveHour: projectQuotaWindow({
165
+ utilization: fiveUtil,
166
+ resetsAt: fiveResetsAt,
167
+ windowMs: FIVE_HOUR_WINDOW_MS,
168
+ }),
169
+ projectedSevenDay: projectQuotaWindow({
170
+ utilization: sevenUtil,
171
+ resetsAt: sevenResetsAt,
172
+ windowMs: SEVEN_DAY_WINDOW_MS,
173
+ }),
174
+ };
175
+ }
176
+ catch {
177
+ return unavailableState('Network error', { failureKind: 'network' });
178
+ }
179
+ finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Append-only JSONL history of quota samples, scoped per workspace and per runtime provider.
3
+ *
4
+ * Sibling to `quotaSnapshots.ts`. Where snapshots persist a single most-recent sample per
5
+ * (provider, account), this module accumulates time-series samples so consumers (the VS Code
6
+ * dashboard, the `sidekick quota history` CLI, external consumers) can render heatmaps and
7
+ * trend visualisations over a 13-week window.
8
+ */
9
+ import * as crypto from 'crypto';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { addLocalDays, formatLocalDateKey } from './formatting';
13
+ import { getConfigDir } from './paths';
14
+ import { writeQuotaSnapshot } from './quotaSnapshots';
15
+ import { atomicWriteFileSync, withFileLockSync } from './writers/atomic';
16
+ const DEFAULT_MIN_INTERVAL_MS = 60_000;
17
+ const DEFAULT_RETENTION_DAYS = 91;
18
+ const PRUNE_FILESIZE_THRESHOLD = 16 * 1024;
19
+ const MS_PER_DAY = 86_400_000;
20
+ /** Per-file append serialization across in-process callers. */
21
+ const appendChains = new Map();
22
+ /** Per-file last-write timestamp cache, used for the debounce check. */
23
+ const lastWriteCache = new Map();
24
+ /**
25
+ * Stable, opaque workspace identifier — first 16 hex chars of sha256(realpath).
26
+ * Shared between the VS Code extension (workspace folder fsPath) and the CLI (process.cwd()).
27
+ */
28
+ export function getWorkspaceIdFromPath(workspacePath) {
29
+ let resolved;
30
+ try {
31
+ resolved = fs.realpathSync(workspacePath);
32
+ }
33
+ catch {
34
+ resolved = path.resolve(workspacePath);
35
+ }
36
+ return crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 16);
37
+ }
38
+ function getHistoryFilePath(workspaceId, provider) {
39
+ return path.join(getConfigDir(), 'quota-history', workspaceId, `${provider}.jsonl`);
40
+ }
41
+ function ensureHistoryDir(workspaceId) {
42
+ fs.mkdirSync(path.join(getConfigDir(), 'quota-history', workspaceId), {
43
+ recursive: true,
44
+ mode: 0o700,
45
+ });
46
+ }
47
+ function parseSampleLine(line) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed)
50
+ return null;
51
+ try {
52
+ const parsed = JSON.parse(trimmed);
53
+ if (parsed &&
54
+ typeof parsed === 'object' &&
55
+ typeof parsed.timestamp === 'string' &&
56
+ parsed.fiveHour &&
57
+ parsed.sevenDay) {
58
+ return parsed;
59
+ }
60
+ return null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function readLastSampleTimestampMs(filePath) {
67
+ let fd;
68
+ try {
69
+ fd = fs.openSync(filePath, 'r');
70
+ const stat = fs.fstatSync(fd);
71
+ if (stat.size === 0)
72
+ return null;
73
+ const chunkSize = 4096;
74
+ let remaining = stat.size;
75
+ let buffer = Buffer.alloc(0);
76
+ while (remaining > 0) {
77
+ const readSize = Math.min(chunkSize, remaining);
78
+ const chunk = Buffer.alloc(readSize);
79
+ fs.readSync(fd, chunk, 0, readSize, remaining - readSize);
80
+ buffer = Buffer.concat([chunk, buffer]);
81
+ remaining -= readSize;
82
+ const text = buffer.toString('utf8');
83
+ // Look for the last newline that's not at end-of-text — that's the boundary of the final record.
84
+ const trimmedRight = text.replace(/\n+$/, '');
85
+ const lastNewline = trimmedRight.lastIndexOf('\n');
86
+ if (lastNewline >= 0) {
87
+ const lastLine = trimmedRight.slice(lastNewline + 1);
88
+ const sample = parseSampleLine(lastLine);
89
+ return sample ? Date.parse(sample.timestamp) || null : null;
90
+ }
91
+ if (remaining === 0) {
92
+ const sample = parseSampleLine(trimmedRight);
93
+ return sample ? Date.parse(sample.timestamp) || null : null;
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ catch (err) {
99
+ if (err?.code === 'ENOENT')
100
+ return null;
101
+ return null;
102
+ }
103
+ finally {
104
+ if (fd !== undefined) {
105
+ try {
106
+ fs.closeSync(fd);
107
+ }
108
+ catch {
109
+ // best effort
110
+ }
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Append-and-prune under the shared cross-process lock. Without it a prune
116
+ * rewrite (rename over the file) could race another process's append, and
117
+ * the appended line would land on the old, unlinked inode.
118
+ */
119
+ function withHistoryFileLock(filePath, operation) {
120
+ return withFileLockSync(`${filePath}.lock`, operation);
121
+ }
122
+ function atomicRewriteFile(filePath, contents) {
123
+ atomicWriteFileSync(filePath, contents, 0o600);
124
+ }
125
+ function pruneFileSync(filePath, retentionDays) {
126
+ let stat;
127
+ try {
128
+ stat = fs.statSync(filePath);
129
+ }
130
+ catch {
131
+ return { kept: 0, pruned: 0 };
132
+ }
133
+ if (stat.size === 0)
134
+ return { kept: 0, pruned: 0 };
135
+ const cutoffMs = Date.now() - retentionDays * MS_PER_DAY;
136
+ const raw = fs.readFileSync(filePath, 'utf8');
137
+ const lines = raw.split('\n');
138
+ const keptLines = [];
139
+ let pruned = 0;
140
+ for (const line of lines) {
141
+ if (!line.trim())
142
+ continue;
143
+ const sample = parseSampleLine(line);
144
+ if (!sample) {
145
+ // Drop malformed lines during prune so the file self-heals.
146
+ pruned += 1;
147
+ continue;
148
+ }
149
+ const ts = Date.parse(sample.timestamp);
150
+ if (Number.isFinite(ts) && ts >= cutoffMs) {
151
+ keptLines.push(line);
152
+ }
153
+ else {
154
+ pruned += 1;
155
+ }
156
+ }
157
+ if (pruned > 0) {
158
+ atomicRewriteFile(filePath, keptLines.length > 0 ? keptLines.join('\n') + '\n' : '');
159
+ }
160
+ return { kept: keptLines.length, pruned };
161
+ }
162
+ function sampleToQuotaState(sample) {
163
+ const providerId = runtimeProviderToSnapshotProvider(sample.runtimeProvider);
164
+ return {
165
+ fiveHour: { utilization: sample.fiveHour.utilization, resetsAt: sample.fiveHour.resetsAt },
166
+ sevenDay: { utilization: sample.sevenDay.utilization, resetsAt: sample.sevenDay.resetsAt },
167
+ available: sample.available,
168
+ error: sample.error,
169
+ providerId,
170
+ source: sample.source ?? 'session',
171
+ capturedAt: sample.timestamp,
172
+ stale: sample.stale,
173
+ };
174
+ }
175
+ /**
176
+ * Maps a runtime provider to the snapshot storage key.
177
+ * `'zai'` is allowed at the storage layer even though it is not part of
178
+ * `AccountProviderId` (z.ai has no full account management in v1).
179
+ */
180
+ function runtimeProviderToSnapshotProvider(runtime) {
181
+ if (runtime === 'claude')
182
+ return 'claude-code';
183
+ if (runtime === 'zai')
184
+ return 'zai';
185
+ return 'codex';
186
+ }
187
+ async function runAppend(sample, filePath, options) {
188
+ let lastTs = lastWriteCache.get(filePath);
189
+ if (lastTs === undefined) {
190
+ const fromDisk = readLastSampleTimestampMs(filePath);
191
+ if (fromDisk !== null) {
192
+ lastTs = fromDisk;
193
+ lastWriteCache.set(filePath, fromDisk);
194
+ }
195
+ }
196
+ const sampleTs = Date.parse(sample.timestamp);
197
+ if (lastTs !== undefined &&
198
+ Number.isFinite(sampleTs) &&
199
+ sampleTs - lastTs < options.minIntervalMs) {
200
+ return;
201
+ }
202
+ ensureHistoryDir(sample.workspaceId);
203
+ const line = JSON.stringify(sample) + '\n';
204
+ withHistoryFileLock(filePath, () => {
205
+ fs.appendFileSync(filePath, line, { encoding: 'utf8', mode: 0o600 });
206
+ // Opportunistic prune. Skip when the file is small to avoid pointless rewrite churn.
207
+ try {
208
+ if (fs.statSync(filePath).size >= PRUNE_FILESIZE_THRESHOLD) {
209
+ pruneFileSync(filePath, options.retentionDays);
210
+ }
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ });
216
+ lastWriteCache.set(filePath, Number.isFinite(sampleTs) ? sampleTs : Date.now());
217
+ // Backwards-compat: keep the latest-snapshot store hot so existing callers (DashboardViewProvider,
218
+ // codex session provider, external consumers) don't have to query history for "latest".
219
+ try {
220
+ const providerId = runtimeProviderToSnapshotProvider(sample.runtimeProvider);
221
+ writeQuotaSnapshot(providerId, sample.providerId, sampleToQuotaState(sample));
222
+ }
223
+ catch {
224
+ // Snapshot write failures must not poison the history append path.
225
+ }
226
+ }
227
+ export async function appendQuotaHistorySample(sample, options = {}) {
228
+ const resolved = {
229
+ minIntervalMs: options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS,
230
+ retentionDays: options.retentionDays ?? DEFAULT_RETENTION_DAYS,
231
+ };
232
+ const filePath = getHistoryFilePath(sample.workspaceId, sample.runtimeProvider);
233
+ const previous = appendChains.get(filePath) ?? Promise.resolve();
234
+ const next = previous
235
+ .then(() => runAppend(sample, filePath, resolved))
236
+ .catch(() => {
237
+ // Swallow chain-level errors so a single failure doesn't break subsequent appends.
238
+ });
239
+ appendChains.set(filePath, next);
240
+ try {
241
+ await next;
242
+ }
243
+ finally {
244
+ if (appendChains.get(filePath) === next) {
245
+ appendChains.delete(filePath);
246
+ }
247
+ }
248
+ }
249
+ function defaultRangeMs() {
250
+ const toMs = Date.now();
251
+ const fromMs = toMs - DEFAULT_RETENTION_DAYS * MS_PER_DAY;
252
+ return { fromMs, toMs };
253
+ }
254
+ export async function readQuotaHistoryRange(options) {
255
+ const filePath = getHistoryFilePath(options.workspaceId, options.provider);
256
+ let raw;
257
+ try {
258
+ raw = await fs.promises.readFile(filePath, 'utf8');
259
+ }
260
+ catch (err) {
261
+ if (err?.code === 'ENOENT')
262
+ return [];
263
+ throw err;
264
+ }
265
+ const { fromMs: defaultFromMs, toMs: defaultToMs } = defaultRangeMs();
266
+ const fromMs = options.from ? Date.parse(options.from) : defaultFromMs;
267
+ const toMs = options.to ? Date.parse(options.to) : defaultToMs;
268
+ const samples = [];
269
+ for (const line of raw.split('\n')) {
270
+ const sample = parseSampleLine(line);
271
+ if (!sample)
272
+ continue;
273
+ const ts = Date.parse(sample.timestamp);
274
+ if (!Number.isFinite(ts))
275
+ continue;
276
+ if (ts < fromMs || ts > toMs)
277
+ continue;
278
+ samples.push(sample);
279
+ }
280
+ samples.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
281
+ return samples;
282
+ }
283
+ /**
284
+ * Daily buckets are keyed by the machine's local calendar day, matching the
285
+ * `today` brief, the historical-data store, and the dashboard date filters.
286
+ */
287
+ function localDateString(ms) {
288
+ return formatLocalDateKey(ms);
289
+ }
290
+ export async function readQuotaHistoryDailyBuckets(options) {
291
+ const samples = await readQuotaHistoryRange(options);
292
+ const { fromMs: defaultFromMs, toMs: defaultToMs } = defaultRangeMs();
293
+ const fromMs = options.from ? Date.parse(options.from) : defaultFromMs;
294
+ const toMs = options.to ? Date.parse(options.to) : defaultToMs;
295
+ const startDate = localDateString(fromMs);
296
+ const endDate = localDateString(toMs);
297
+ const grouped = new Map();
298
+ for (const sample of samples) {
299
+ const sampleMs = Date.parse(sample.timestamp);
300
+ if (!Number.isFinite(sampleMs))
301
+ continue;
302
+ const day = localDateString(sampleMs);
303
+ const bucket = grouped.get(day);
304
+ if (bucket) {
305
+ bucket.push(sample);
306
+ }
307
+ else {
308
+ grouped.set(day, [sample]);
309
+ }
310
+ }
311
+ const buckets = [];
312
+ let cursor = startDate;
313
+ // Safety bound — 26 weeks worst case = 182 days; we cap iteration well above that.
314
+ for (let i = 0; i <= 366 && cursor <= endDate; i += 1) {
315
+ const daySamples = grouped.get(cursor);
316
+ if (!daySamples || daySamples.length === 0) {
317
+ buckets.push({
318
+ date: cursor,
319
+ samples: 0,
320
+ maxUtilizationFiveHour: 0,
321
+ maxUtilizationSevenDay: 0,
322
+ avgUtilizationFiveHour: 0,
323
+ avgUtilizationSevenDay: 0,
324
+ anyUnavailable: false,
325
+ });
326
+ }
327
+ else {
328
+ let maxFive = 0;
329
+ let maxSeven = 0;
330
+ let sumFive = 0;
331
+ let sumSeven = 0;
332
+ let anyUnavailable = false;
333
+ for (const s of daySamples) {
334
+ maxFive = Math.max(maxFive, s.fiveHour.utilization);
335
+ maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
336
+ sumFive += s.fiveHour.utilization;
337
+ sumSeven += s.sevenDay.utilization;
338
+ if (!s.available)
339
+ anyUnavailable = true;
340
+ }
341
+ const n = daySamples.length;
342
+ buckets.push({
343
+ date: cursor,
344
+ samples: n,
345
+ maxUtilizationFiveHour: maxFive,
346
+ maxUtilizationSevenDay: maxSeven,
347
+ avgUtilizationFiveHour: Math.round((sumFive / n) * 100) / 100,
348
+ avgUtilizationSevenDay: Math.round((sumSeven / n) * 100) / 100,
349
+ anyUnavailable,
350
+ });
351
+ }
352
+ cursor = addLocalDays(cursor, 1);
353
+ }
354
+ return buckets;
355
+ }
356
+ export async function pruneQuotaHistory(workspaceId, provider, retentionDays = DEFAULT_RETENTION_DAYS) {
357
+ const filePath = getHistoryFilePath(workspaceId, provider);
358
+ return pruneFileSync(filePath, retentionDays);
359
+ }
360
+ /** Test-only: wipe the in-memory mutex/debounce state. Not exported from index.ts. */
361
+ export function _resetQuotaHistoryInMemoryStateForTests() {
362
+ appendChains.clear();
363
+ lastWriteCache.clear();
364
+ }