sidekick-shared 0.18.2 → 0.18.4

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.
@@ -46,6 +46,36 @@ const codex_1 = require("./providers/codex");
46
46
  const DEFAULT_TAIL_BYTES = 2 * 1024 * 1024;
47
47
  const DEFAULT_MAX_SESSION_FILES = 50;
48
48
  const CHATGPT_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
49
+ function timestampMs(value, fallbackMs = 0) {
50
+ if (!value)
51
+ return fallbackMs;
52
+ const ms = Date.parse(value);
53
+ return Number.isFinite(ms) ? ms : fallbackMs;
54
+ }
55
+ // Codex can report slightly different percentages for the same reset window
56
+ // across recent sessions. Prefer newer reset windows, then the highest observed
57
+ // same-window utilization so local fallbacks do not under-report quota usage.
58
+ function isPreferredQuotaHit(candidate, current) {
59
+ if (!current)
60
+ return true;
61
+ const candidatePrimaryReset = timestampMs(candidate.quota.fiveHour.resetsAt);
62
+ const currentPrimaryReset = timestampMs(current.quota.fiveHour.resetsAt);
63
+ if (candidatePrimaryReset !== currentPrimaryReset)
64
+ return candidatePrimaryReset > currentPrimaryReset;
65
+ const candidateSecondaryReset = timestampMs(candidate.quota.sevenDay.resetsAt);
66
+ const currentSecondaryReset = timestampMs(current.quota.sevenDay.resetsAt);
67
+ if (candidateSecondaryReset !== currentSecondaryReset)
68
+ return candidateSecondaryReset > currentSecondaryReset;
69
+ const candidateUtilization = candidate.quota.fiveHour.utilization + candidate.quota.sevenDay.utilization;
70
+ const currentUtilization = current.quota.fiveHour.utilization + current.quota.sevenDay.utilization;
71
+ if (candidateUtilization !== currentUtilization)
72
+ return candidateUtilization > currentUtilization;
73
+ const candidateMs = timestampMs(candidate.quota.capturedAt, candidate.mtimeMs);
74
+ const currentMs = timestampMs(current.quota.capturedAt, current.mtimeMs);
75
+ if (candidateMs !== currentMs)
76
+ return candidateMs > currentMs;
77
+ return candidate.mtimeMs > current.mtimeMs;
78
+ }
49
79
  function normalizePercent(value) {
50
80
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
51
81
  }
@@ -175,14 +205,53 @@ function quotaFromCodexRateLimits(rateLimits, source = 'session', capturedAt = n
175
205
  };
176
206
  }
177
207
  function readLatestCodexQuotaFromRollouts(sessionPaths, options = {}) {
208
+ return readLatestCodexQuotaHitFromRollouts(sessionPaths, options)?.quota ?? null;
209
+ }
210
+ function readLatestCodexQuotaHitFromRollouts(sessionPaths, options = {}) {
178
211
  const maxSessionFiles = options.maxSessionFiles ?? DEFAULT_MAX_SESSION_FILES;
179
212
  const maxTailBytes = options.maxTailBytes ?? DEFAULT_TAIL_BYTES;
213
+ let latest = null;
180
214
  for (const sessionPath of sessionPaths.slice(0, maxSessionFiles)) {
181
215
  const hit = readLatestQuotaFromRollout(sessionPath, maxTailBytes, options.source ?? 'session');
182
- if (hit)
183
- return hit.quota;
216
+ if (hit && isPreferredQuotaHit(hit, latest)) {
217
+ latest = hit;
218
+ }
184
219
  }
185
- return null;
220
+ return latest;
221
+ }
222
+ function dedupePaths(paths) {
223
+ const seen = new Set();
224
+ const unique = [];
225
+ for (const filePath of paths) {
226
+ if (seen.has(filePath))
227
+ continue;
228
+ seen.add(filePath);
229
+ unique.push(filePath);
230
+ }
231
+ return unique;
232
+ }
233
+ function sortPathsByMtimeDesc(paths) {
234
+ return [...paths].sort((a, b) => {
235
+ const aMtime = safeMtimeMs(a);
236
+ const bMtime = safeMtimeMs(b);
237
+ return bMtime - aMtime;
238
+ });
239
+ }
240
+ function safeMtimeMs(filePath) {
241
+ try {
242
+ return fs.statSync(filePath).mtime.getTime();
243
+ }
244
+ catch {
245
+ return 0;
246
+ }
247
+ }
248
+ function findAccountRolloutFiles(codexHome) {
249
+ const homes = codexHome ? [codexHome] : (0, codexProfiles_1.getCodexMonitoringHomes)();
250
+ const files = [];
251
+ for (const home of homes) {
252
+ files.push(...findRolloutFiles(path.join(home, 'sessions')));
253
+ }
254
+ return sortPathsByMtimeDesc(dedupePaths(files));
186
255
  }
187
256
  function resolveCodexQuotaFromLocalSources(options = {}) {
188
257
  const account = options.activeAccount !== undefined ? options.activeAccount : (0, codexProfiles_1.getActiveCodexAccount)();
@@ -196,20 +265,22 @@ function resolveCodexQuotaFromLocalSources(options = {}) {
196
265
  return new codex_1.CodexProvider();
197
266
  })();
198
267
  try {
199
- const workspaceSessions = options.workspacePath ? provider.findAllSessions(options.workspacePath) : [];
200
- const workspaceQuota = readLatestCodexQuotaFromRollouts(workspaceSessions, { maxTailBytes, maxSessionFiles });
201
- if (workspaceQuota) {
202
- if (account)
203
- writeSnapshot('codex', account.id, workspaceQuota);
204
- return enrichCodexQuota(workspaceQuota, account);
268
+ const candidates = [];
269
+ if (options.workspacePath) {
270
+ const workspaceSessions = provider.findAllSessions(options.workspacePath);
271
+ const workspaceHit = readLatestCodexQuotaHitFromRollouts(workspaceSessions, { maxTailBytes, maxSessionFiles });
272
+ if (workspaceHit)
273
+ candidates.push(workspaceHit);
205
274
  }
206
- const codexHome = options.codexHome ?? (0, codexProfiles_1.resolveSidekickCodexHome)();
207
- const accountSessions = findRolloutFiles(path.join(codexHome, 'sessions'));
208
- const accountQuota = readLatestCodexQuotaFromRollouts(accountSessions, { maxTailBytes, maxSessionFiles });
209
- if (accountQuota) {
275
+ const accountSessions = findAccountRolloutFiles(options.codexHome);
276
+ const accountHit = readLatestCodexQuotaHitFromRollouts(accountSessions, { maxTailBytes, maxSessionFiles });
277
+ if (accountHit)
278
+ candidates.push(accountHit);
279
+ const latestHit = candidates.reduce((latest, candidate) => isPreferredQuotaHit(candidate, latest) ? candidate : latest, null);
280
+ if (latestHit) {
210
281
  if (account)
211
- writeSnapshot('codex', account.id, accountQuota);
212
- return enrichCodexQuota(accountQuota, account);
282
+ writeSnapshot('codex', account.id, latestHit.quota);
283
+ return enrichCodexQuota(latestHit.quota, account);
213
284
  }
214
285
  const cached = account ? readSnapshot('codex', account.id) : null;
215
286
  if (cached) {
@@ -378,7 +449,7 @@ function readLatestQuotaFromRollout(sessionPath, maxTailBytes, source) {
378
449
  continue;
379
450
  const quota = quotaFromCodexRateLimits(parsed.payload.rate_limits, source, parsed.timestamp ?? new Date(stat.mtime).toISOString());
380
451
  if (quota)
381
- return { quota, filePath: sessionPath };
452
+ return { quota, filePath: sessionPath, mtimeMs: stat.mtime.getTime() };
382
453
  }
383
454
  catch {
384
455
  // Ignore malformed or partial lines.
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import type { FSWatcher } from 'fs';
3
+ import type { QuotaHistorySample } from './quotaHistory';
3
4
  import { CodexProvider } from './providers/codex';
4
5
  import type { SavedAccountProfile } from './accountRegistry';
5
6
  import type { Disposable } from './quotaPoller';
@@ -8,6 +9,7 @@ import type { ProviderQuotaState } from './providerQuota';
8
9
  type CodexAccountReader = () => SavedAccountProfile | null;
9
10
  type SnapshotReader = (providerId: 'codex', accountId: string) => QuotaState | null;
10
11
  type SnapshotWriter = (providerId: 'codex', accountId: string, quota: QuotaState) => void;
12
+ type HistoryAppender = (sample: QuotaHistorySample) => void | Promise<void>;
11
13
  type WatchFile = (filename: fs.PathLike, listener: fs.WatchListener<string>) => FSWatcher;
12
14
  export interface CodexQuotaWatcherOptions {
13
15
  discoveryPollIntervalMs?: number;
@@ -18,6 +20,10 @@ export interface CodexQuotaWatcherOptions {
18
20
  readSnapshot?: SnapshotReader;
19
21
  writeSnapshot?: SnapshotWriter;
20
22
  watchFile?: WatchFile;
23
+ /** Stable workspace identifier. When provided, live quotas are appended to the per-workspace history JSONL. */
24
+ workspaceId?: string;
25
+ /** Override the history append function (used by tests). Default: `appendQuotaHistorySample`. */
26
+ appendHistorySample?: HistoryAppender;
21
27
  }
22
28
  /**
23
29
  * Watches the active Codex rollout for quota snapshots and falls back to the
@@ -33,6 +39,8 @@ export declare class CodexQuotaWatcher implements Disposable {
33
39
  private readonly watchFile;
34
40
  private readonly maxTailBytes;
35
41
  private readonly maxSessionFiles;
42
+ private readonly workspaceId;
43
+ private readonly appendHistorySample;
36
44
  private readonly listeners;
37
45
  private discoveryTimer;
38
46
  private provider;
@@ -38,6 +38,7 @@ const fs = __importStar(require("fs"));
38
38
  const codexProfiles_1 = require("./codexProfiles");
39
39
  const codexQuota_1 = require("./codexQuota");
40
40
  const quotaSnapshots_1 = require("./quotaSnapshots");
41
+ const quotaHistory_1 = require("./quotaHistory");
41
42
  const codex_1 = require("./providers/codex");
42
43
  const DEFAULT_DISCOVERY_POLL_INTERVAL_MS = 30_000;
43
44
  function accountEmail(account) {
@@ -80,6 +81,8 @@ class CodexQuotaWatcher {
80
81
  watchFile;
81
82
  maxTailBytes;
82
83
  maxSessionFiles;
84
+ workspaceId;
85
+ appendHistorySample;
83
86
  listeners = [];
84
87
  discoveryTimer;
85
88
  provider = null;
@@ -98,6 +101,8 @@ class CodexQuotaWatcher {
98
101
  this.watchFile = options.watchFile ?? fs.watch;
99
102
  this.maxTailBytes = options.maxTailBytes;
100
103
  this.maxSessionFiles = options.maxSessionFiles;
104
+ this.workspaceId = options.workspaceId;
105
+ this.appendHistorySample = options.appendHistorySample ?? quotaHistory_1.appendQuotaHistorySample;
101
106
  }
102
107
  start() {
103
108
  if (this.running)
@@ -196,6 +201,31 @@ class CodexQuotaWatcher {
196
201
  const account = this.getActiveAccount();
197
202
  if (account) {
198
203
  this.writeSnapshot('codex', account.id, liveQuota);
204
+ if (this.workspaceId) {
205
+ const sample = {
206
+ timestamp: liveQuota.capturedAt ?? new Date().toISOString(),
207
+ runtimeProvider: 'codex',
208
+ providerId: account.id,
209
+ workspaceId: this.workspaceId,
210
+ fiveHour: { utilization: liveQuota.fiveHour.utilization, resetsAt: liveQuota.fiveHour.resetsAt },
211
+ sevenDay: { utilization: liveQuota.sevenDay.utilization, resetsAt: liveQuota.sevenDay.resetsAt },
212
+ available: liveQuota.available,
213
+ error: liveQuota.error,
214
+ source: liveQuota.source,
215
+ stale: liveQuota.stale,
216
+ };
217
+ try {
218
+ const result = this.appendHistorySample(sample);
219
+ if (result && typeof result.catch === 'function') {
220
+ result.catch(() => {
221
+ // History append must never break the live emission path.
222
+ });
223
+ }
224
+ }
225
+ catch {
226
+ // Synchronous errors swallowed for the same reason.
227
+ }
228
+ }
199
229
  }
200
230
  this.emitState(enrichQuotaState({
201
231
  ...liveQuota,
package/dist/index.d.ts CHANGED
@@ -104,6 +104,8 @@ export type { QuotaFailureDescriptor } from './quotaPresentation';
104
104
  export { QuotaPoller } from './quotaPoller';
105
105
  export type { QuotaPollerOptions } from './quotaPoller';
106
106
  export { readQuotaSnapshot, writeQuotaSnapshot } from './quotaSnapshots';
107
+ export { appendQuotaHistorySample, readQuotaHistoryRange, readQuotaHistoryDailyBuckets, pruneQuotaHistory, getWorkspaceIdFromPath, } from './quotaHistory';
108
+ export type { QuotaHistorySample, QuotaHistoryAppendOptions, QuotaHistoryRangeOptions, QuotaHistoryDailyBucket, QuotaHistoryRuntimeProvider, } from './quotaHistory';
107
109
  export { fetchCodexQuotaFromApi, quotaFromCodexRateLimits, readLatestCodexQuotaFromRollouts, resolveCodexQuota, resolveCodexQuotaFromLocalSources, } from './codexQuota';
108
110
  export type { CodexQuotaApiOptions, CodexQuotaCreditsSnapshot, CodexQuotaResolveOptions, CodexQuotaResolveSource, } from './codexQuota';
109
111
  export { CodexQuotaWatcher } from './codexQuotaWatcher';
@@ -121,5 +123,5 @@ export { extractToolCall, extractToolCalls } from './extractors/toolCall';
121
123
  export { messageUsageSchema, sessionMessageSchema, sessionEventSchema, permissionModeSchema, } from './schemas/sessionEvent';
122
124
  export { fetchProviderStatus, fetchOpenAIStatus } from './providerStatus';
123
125
  export type { ProviderStatusState } from './providerStatus';
124
- export { fetchPeakHoursStatus } from './peakHours';
126
+ export { createPeakHoursNotApplicableState, fetchPeakHoursStatus, isClaudeCodeSessionProvider, scopePeakHoursToSessionProvider, } from './peakHours';
125
127
  export type { PeakHoursState } from './peakHours';
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.findActiveClaudeSession = exports.discoverSessionDirectory = exports.getClaudeSessionDirectory = exports.encodeClaudeWorkspacePath = exports.detectSessionActivity = exports.extractTaskInfo = exports.scanSubagentDir = exports.normalizeCodexToolInput = exports.normalizeCodexToolName = exports.extractPatchFilePaths = exports.CodexRolloutParser = exports.parseDbPartData = exports.parseDbMessageData = exports.convertOpenCodeMessage = exports.detectPlanModeFromText = exports.normalizeToolInput = exports.normalizeToolName = exports.TRUNCATION_PATTERNS = exports.JsonlParser = exports.CodexProvider = exports.OpenCodeProvider = exports.ClaudeCodeProvider = exports.getAllDetectedProviders = exports.detectProvider = exports.readClaudeCodePlanFiles = exports.getPlanAnalytics = exports.writePlans = exports.getLatestPlan = exports.readPlans = exports.readLatestHandoff = exports.readHistory = exports.readNotes = exports.readDecisions = exports.readTasks = exports.getProjectSlugRaw = exports.getProjectSlug = exports.encodeWorkspacePath = exports.getGlobalDataPath = exports.getProjectDataPath = exports.getConfigDir = exports.MAX_PLANS_PER_PROJECT = exports.PLAN_SCHEMA_VERSION = exports.createEmptyTokenTotals = exports.HISTORICAL_DATA_SCHEMA_VERSION = exports.STALENESS_THRESHOLDS = exports.IMPORTANCE_DECAY_FACTORS = exports.KNOWLEDGE_NOTE_SCHEMA_VERSION = exports.DECISION_LOG_SCHEMA_VERSION = exports.normalizeTaskStatus = exports.TASK_PERSISTENCE_SCHEMA_VERSION = void 0;
7
7
  exports.deleteSnapshot = exports.loadSnapshot = exports.saveSnapshot = exports.parseTodoDependencies = exports.EventAggregator = exports.getRandomPhrase = exports.PHRASE_CATEGORIES = exports.ALL_PHRASES = exports.HIGHLIGHT_CSS = exports.clearHighlightCache = exports.highlightEvent = exports.formatSessionJson = exports.formatSessionMarkdown = exports.formatSessionText = exports.classifyNoise = exports.shouldMergeWithPrevious = exports.classifyFollowEvent = exports.classifyMessage = exports.getSoftNoiseReason = exports.isHardNoiseFollowEvent = exports.isHardNoise = exports.formatToolSummary = exports.formatTokenCount = exports.formatDurationMs = exports.createJsonlTail = exports.toFollowEvents = exports.createWatcher = exports.parseChangelog = exports.extractProposedPlanShared = exports.parsePlanMarkdownShared = exports.PlanExtractor = exports.composeContext = exports.FilterEngine = exports.searchSessions = exports.CodexDatabase = exports.OpenCodeDatabase = exports.discoverDebugLogs = exports.collapseDuplicates = exports.filterByLevel = exports.parseDebugLog = exports.scanSubagentTraces = exports.findAllSessionsWithWorktrees = exports.discoverWorktreeSiblings = exports.resolveWorktreeMainRepo = exports.getAllClaudeProjectFolders = exports.decodeEncodedPath = exports.getMostRecentlyActiveSessionDir = exports.findSubdirectorySessionDirs = exports.findSessionsInDirectory = exports.findAllClaudeSessions = void 0;
8
- exports.fetchCodexQuotaFromApi = exports.writeQuotaSnapshot = exports.readQuotaSnapshot = exports.QuotaPoller = exports.describeQuotaFailure = exports.fetchQuota = exports.removeCodexAccount = exports.switchToCodexAccount = exports.finalizeCodexAccount = exports.prepareCodexAccount = exports.getCodexExecutionEnv = exports.resolveSidekickCodexHome = exports.getActiveCodexAccount = exports.listCodexAccounts = exports.getSystemCodexHome = exports.getCodexMonitoringHomes = exports.getCodexProfileHome = exports.getCodexProfilesDir = exports.getActiveAccountStatus = exports.removeSavedAccountProfile = exports.replaceSavedAccountProfiles = exports.setActiveSavedAccount = exports.upsertSavedAccountProfile = exports.getActiveSavedAccount = exports.listSavedAccountProfiles = exports.writeSavedAccountRegistry = exports.readSavedAccountRegistry = exports.getAccountsDir = exports.isMultiAccountEnabled = exports.getActiveAccount = exports.listAccounts = exports.removeAccount = exports.switchToAccount = exports.addCurrentAccount = exports.readActiveClaudeAccount = exports.writeAccountRegistry = exports.readAccountRegistry = exports.ensureDefaultAccounts = exports.readClaudeMaxAccessTokenSync = exports.readClaudeMaxCredentials = exports.writeActiveCredentials = exports.readActiveCredentials = exports.openInBrowser = exports.parseTranscript = exports.generateHtmlReport = exports.PatternExtractor = exports.HeatmapTracker = exports.FrequencyTracker = exports.getSnapshotPath = exports.isSnapshotValid = void 0;
9
- exports.fetchPeakHoursStatus = exports.fetchOpenAIStatus = exports.fetchProviderStatus = exports.permissionModeSchema = exports.sessionEventSchema = exports.sessionMessageSchema = exports.messageUsageSchema = exports.extractToolCalls = exports.extractToolCall = exports.extractTokenUsage = exports.LITELLM_CATALOG_URL = exports.normalizeLiteLlmCatalog = exports.hydratePricingCatalog = exports.formatCost = exports.sortModelIds = exports.compareModelIds = exports.getModelDisplayInfo = exports.shortModelName = exports.mergeCostSources = exports.calculateCostWithProvenance = exports.calculateCostWithPricing = exports.calculateCost = exports.getModelInfo = exports.getModelPricing = exports.parseModelId = exports.DEFAULT_CONTEXT_WINDOW = exports.getModelContextWindowSize = exports.MultiProviderQuotaService = exports.CodexQuotaWatcher = exports.resolveCodexQuotaFromLocalSources = exports.resolveCodexQuota = exports.readLatestCodexQuotaFromRollouts = exports.quotaFromCodexRateLimits = void 0;
8
+ exports.appendQuotaHistorySample = exports.writeQuotaSnapshot = exports.readQuotaSnapshot = exports.QuotaPoller = exports.describeQuotaFailure = exports.fetchQuota = exports.removeCodexAccount = exports.switchToCodexAccount = exports.finalizeCodexAccount = exports.prepareCodexAccount = exports.getCodexExecutionEnv = exports.resolveSidekickCodexHome = exports.getActiveCodexAccount = exports.listCodexAccounts = exports.getSystemCodexHome = exports.getCodexMonitoringHomes = exports.getCodexProfileHome = exports.getCodexProfilesDir = exports.getActiveAccountStatus = exports.removeSavedAccountProfile = exports.replaceSavedAccountProfiles = exports.setActiveSavedAccount = exports.upsertSavedAccountProfile = exports.getActiveSavedAccount = exports.listSavedAccountProfiles = exports.writeSavedAccountRegistry = exports.readSavedAccountRegistry = exports.getAccountsDir = exports.isMultiAccountEnabled = exports.getActiveAccount = exports.listAccounts = exports.removeAccount = exports.switchToAccount = exports.addCurrentAccount = exports.readActiveClaudeAccount = exports.writeAccountRegistry = exports.readAccountRegistry = exports.ensureDefaultAccounts = exports.readClaudeMaxAccessTokenSync = exports.readClaudeMaxCredentials = exports.writeActiveCredentials = exports.readActiveCredentials = exports.openInBrowser = exports.parseTranscript = exports.generateHtmlReport = exports.PatternExtractor = exports.HeatmapTracker = exports.FrequencyTracker = exports.getSnapshotPath = exports.isSnapshotValid = void 0;
9
+ exports.scopePeakHoursToSessionProvider = exports.isClaudeCodeSessionProvider = exports.fetchPeakHoursStatus = exports.createPeakHoursNotApplicableState = exports.fetchOpenAIStatus = exports.fetchProviderStatus = exports.permissionModeSchema = exports.sessionEventSchema = exports.sessionMessageSchema = exports.messageUsageSchema = exports.extractToolCalls = exports.extractToolCall = exports.extractTokenUsage = exports.LITELLM_CATALOG_URL = exports.normalizeLiteLlmCatalog = exports.hydratePricingCatalog = exports.formatCost = exports.sortModelIds = exports.compareModelIds = exports.getModelDisplayInfo = exports.shortModelName = exports.mergeCostSources = exports.calculateCostWithProvenance = exports.calculateCostWithPricing = exports.calculateCost = exports.getModelInfo = exports.getModelPricing = exports.parseModelId = exports.DEFAULT_CONTEXT_WINDOW = exports.getModelContextWindowSize = exports.MultiProviderQuotaService = exports.CodexQuotaWatcher = exports.resolveCodexQuotaFromLocalSources = exports.resolveCodexQuota = exports.readLatestCodexQuotaFromRollouts = exports.quotaFromCodexRateLimits = exports.fetchCodexQuotaFromApi = exports.getWorkspaceIdFromPath = exports.pruneQuotaHistory = exports.readQuotaHistoryDailyBuckets = exports.readQuotaHistoryRange = void 0;
10
10
  var taskPersistence_1 = require("./types/taskPersistence");
11
11
  Object.defineProperty(exports, "TASK_PERSISTENCE_SCHEMA_VERSION", { enumerable: true, get: function () { return taskPersistence_1.TASK_PERSISTENCE_SCHEMA_VERSION; } });
12
12
  Object.defineProperty(exports, "normalizeTaskStatus", { enumerable: true, get: function () { return taskPersistence_1.normalizeTaskStatus; } });
@@ -237,6 +237,12 @@ Object.defineProperty(exports, "QuotaPoller", { enumerable: true, get: function
237
237
  var quotaSnapshots_1 = require("./quotaSnapshots");
238
238
  Object.defineProperty(exports, "readQuotaSnapshot", { enumerable: true, get: function () { return quotaSnapshots_1.readQuotaSnapshot; } });
239
239
  Object.defineProperty(exports, "writeQuotaSnapshot", { enumerable: true, get: function () { return quotaSnapshots_1.writeQuotaSnapshot; } });
240
+ var quotaHistory_1 = require("./quotaHistory");
241
+ Object.defineProperty(exports, "appendQuotaHistorySample", { enumerable: true, get: function () { return quotaHistory_1.appendQuotaHistorySample; } });
242
+ Object.defineProperty(exports, "readQuotaHistoryRange", { enumerable: true, get: function () { return quotaHistory_1.readQuotaHistoryRange; } });
243
+ Object.defineProperty(exports, "readQuotaHistoryDailyBuckets", { enumerable: true, get: function () { return quotaHistory_1.readQuotaHistoryDailyBuckets; } });
244
+ Object.defineProperty(exports, "pruneQuotaHistory", { enumerable: true, get: function () { return quotaHistory_1.pruneQuotaHistory; } });
245
+ Object.defineProperty(exports, "getWorkspaceIdFromPath", { enumerable: true, get: function () { return quotaHistory_1.getWorkspaceIdFromPath; } });
240
246
  var codexQuota_1 = require("./codexQuota");
241
247
  Object.defineProperty(exports, "fetchCodexQuotaFromApi", { enumerable: true, get: function () { return codexQuota_1.fetchCodexQuotaFromApi; } });
242
248
  Object.defineProperty(exports, "quotaFromCodexRateLimits", { enumerable: true, get: function () { return codexQuota_1.quotaFromCodexRateLimits; } });
@@ -289,4 +295,7 @@ Object.defineProperty(exports, "fetchProviderStatus", { enumerable: true, get: f
289
295
  Object.defineProperty(exports, "fetchOpenAIStatus", { enumerable: true, get: function () { return providerStatus_1.fetchOpenAIStatus; } });
290
296
  // Peak Hours (PromoClock — third-party)
291
297
  var peakHours_1 = require("./peakHours");
298
+ Object.defineProperty(exports, "createPeakHoursNotApplicableState", { enumerable: true, get: function () { return peakHours_1.createPeakHoursNotApplicableState; } });
292
299
  Object.defineProperty(exports, "fetchPeakHoursStatus", { enumerable: true, get: function () { return peakHours_1.fetchPeakHoursStatus; } });
300
+ Object.defineProperty(exports, "isClaudeCodeSessionProvider", { enumerable: true, get: function () { return peakHours_1.isClaudeCodeSessionProvider; } });
301
+ Object.defineProperty(exports, "scopePeakHoursToSessionProvider", { enumerable: true, get: function () { return peakHours_1.scopePeakHoursToSessionProvider; } });
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Polling / eventing is the caller's responsibility.
11
11
  */
12
+ import type { ProviderId } from './providers/types';
12
13
  export interface PeakHoursState {
13
14
  status: 'peak' | 'off_peak' | 'unknown';
14
15
  isPeak: boolean;
@@ -20,7 +21,11 @@ export interface PeakHoursState {
20
21
  note: string;
21
22
  updatedAt: string;
22
23
  unavailable: boolean;
24
+ notApplicable?: boolean;
23
25
  }
26
+ export declare function isClaudeCodeSessionProvider(providerId: ProviderId): boolean;
27
+ export declare function createPeakHoursNotApplicableState(providerId: ProviderId): PeakHoursState;
28
+ export declare function scopePeakHoursToSessionProvider(providerId: ProviderId, status: PeakHoursState | null | undefined): PeakHoursState | null;
24
29
  /**
25
30
  * Fetch current Claude peak-hours state from promoclock.co.
26
31
  *
package/dist/peakHours.js CHANGED
@@ -11,6 +11,9 @@
11
11
  * Polling / eventing is the caller's responsibility.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.isClaudeCodeSessionProvider = isClaudeCodeSessionProvider;
15
+ exports.createPeakHoursNotApplicableState = createPeakHoursNotApplicableState;
16
+ exports.scopePeakHoursToSessionProvider = scopePeakHoursToSessionProvider;
14
17
  exports.fetchPeakHoursStatus = fetchPeakHoursStatus;
15
18
  const PROMOCLOCK_ENDPOINT = 'https://promoclock.co/api/status';
16
19
  function unavailableState() {
@@ -27,6 +30,35 @@ function unavailableState() {
27
30
  unavailable: true,
28
31
  };
29
32
  }
33
+ const PROVIDER_DISPLAY_NAMES = {
34
+ 'claude-code': 'Claude Code',
35
+ opencode: 'OpenCode',
36
+ codex: 'Codex CLI',
37
+ };
38
+ function isClaudeCodeSessionProvider(providerId) {
39
+ return providerId === 'claude-code';
40
+ }
41
+ function createPeakHoursNotApplicableState(providerId) {
42
+ const providerName = PROVIDER_DISPLAY_NAMES[providerId] ?? providerId;
43
+ return {
44
+ status: 'unknown',
45
+ isPeak: false,
46
+ sessionLimitSpeed: 'unknown',
47
+ label: 'Claude peak hours not applicable',
48
+ peakHoursDescription: '',
49
+ nextChange: null,
50
+ minutesUntilChange: null,
51
+ note: `Claude peak hours apply only to Claude Code sessions, not ${providerName}.`,
52
+ updatedAt: new Date().toISOString(),
53
+ unavailable: true,
54
+ notApplicable: true,
55
+ };
56
+ }
57
+ function scopePeakHoursToSessionProvider(providerId, status) {
58
+ if (!isClaudeCodeSessionProvider(providerId))
59
+ return null;
60
+ return status ?? null;
61
+ }
30
62
  function normalizeStatus(raw) {
31
63
  if (raw === 'peak' || raw === 'off_peak')
32
64
  return raw;
@@ -0,0 +1,64 @@
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, contextful_desktop) can render heatmaps and
7
+ * trend visualisations over a 13-week window.
8
+ */
9
+ export type QuotaHistoryRuntimeProvider = 'claude' | 'codex';
10
+ export interface QuotaHistorySample {
11
+ timestamp: string;
12
+ runtimeProvider: QuotaHistoryRuntimeProvider;
13
+ providerId: string;
14
+ workspaceId: string;
15
+ fiveHour: {
16
+ utilization: number;
17
+ resetsAt: string;
18
+ };
19
+ sevenDay: {
20
+ utilization: number;
21
+ resetsAt: string;
22
+ };
23
+ available: boolean;
24
+ error?: string;
25
+ source?: 'session' | 'cache' | 'api';
26
+ stale?: boolean;
27
+ }
28
+ export interface QuotaHistoryAppendOptions {
29
+ /** Drop the sample if the most recent in-store sample is younger than this many ms. Default: 60_000. */
30
+ minIntervalMs?: number;
31
+ /** Retention window in days. Default: 91 (13 weeks). */
32
+ retentionDays?: number;
33
+ }
34
+ export interface QuotaHistoryRangeOptions {
35
+ workspaceId: string;
36
+ provider: QuotaHistoryRuntimeProvider;
37
+ /** Inclusive ISO start. Default: 13 weeks ago. */
38
+ from?: string;
39
+ /** Inclusive ISO end. Default: now. */
40
+ to?: string;
41
+ }
42
+ export interface QuotaHistoryDailyBucket {
43
+ date: string;
44
+ samples: number;
45
+ maxUtilizationFiveHour: number;
46
+ maxUtilizationSevenDay: number;
47
+ avgUtilizationFiveHour: number;
48
+ avgUtilizationSevenDay: number;
49
+ anyUnavailable: boolean;
50
+ }
51
+ /**
52
+ * Stable, opaque workspace identifier — first 16 hex chars of sha256(realpath).
53
+ * Shared between the VS Code extension (workspace folder fsPath) and the CLI (process.cwd()).
54
+ */
55
+ export declare function getWorkspaceIdFromPath(workspacePath: string): string;
56
+ export declare function appendQuotaHistorySample(sample: QuotaHistorySample, options?: QuotaHistoryAppendOptions): Promise<void>;
57
+ export declare function readQuotaHistoryRange(options: QuotaHistoryRangeOptions): Promise<QuotaHistorySample[]>;
58
+ export declare function readQuotaHistoryDailyBuckets(options: QuotaHistoryRangeOptions): Promise<QuotaHistoryDailyBucket[]>;
59
+ export declare function pruneQuotaHistory(workspaceId: string, provider: QuotaHistoryRuntimeProvider, retentionDays?: number): Promise<{
60
+ kept: number;
61
+ pruned: number;
62
+ }>;
63
+ /** Test-only: wipe the in-memory mutex/debounce state. Not exported from index.ts. */
64
+ export declare function _resetQuotaHistoryInMemoryStateForTests(): void;
@@ -0,0 +1,385 @@
1
+ "use strict";
2
+ /**
3
+ * Append-only JSONL history of quota samples, scoped per workspace and per runtime provider.
4
+ *
5
+ * Sibling to `quotaSnapshots.ts`. Where snapshots persist a single most-recent sample per
6
+ * (provider, account), this module accumulates time-series samples so consumers (the VS Code
7
+ * dashboard, the `sidekick quota history` CLI, contextful_desktop) can render heatmaps and
8
+ * trend visualisations over a 13-week window.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.getWorkspaceIdFromPath = getWorkspaceIdFromPath;
45
+ exports.appendQuotaHistorySample = appendQuotaHistorySample;
46
+ exports.readQuotaHistoryRange = readQuotaHistoryRange;
47
+ exports.readQuotaHistoryDailyBuckets = readQuotaHistoryDailyBuckets;
48
+ exports.pruneQuotaHistory = pruneQuotaHistory;
49
+ exports._resetQuotaHistoryInMemoryStateForTests = _resetQuotaHistoryInMemoryStateForTests;
50
+ const crypto = __importStar(require("crypto"));
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const paths_1 = require("./paths");
54
+ const quotaSnapshots_1 = require("./quotaSnapshots");
55
+ const DEFAULT_MIN_INTERVAL_MS = 60_000;
56
+ const DEFAULT_RETENTION_DAYS = 91;
57
+ const PRUNE_FILESIZE_THRESHOLD = 16 * 1024;
58
+ const MS_PER_DAY = 86_400_000;
59
+ /** Per-file append serialization across in-process callers. */
60
+ const appendChains = new Map();
61
+ /** Per-file last-write timestamp cache, used for the debounce check. */
62
+ const lastWriteCache = new Map();
63
+ /**
64
+ * Stable, opaque workspace identifier — first 16 hex chars of sha256(realpath).
65
+ * Shared between the VS Code extension (workspace folder fsPath) and the CLI (process.cwd()).
66
+ */
67
+ function getWorkspaceIdFromPath(workspacePath) {
68
+ let resolved;
69
+ try {
70
+ resolved = fs.realpathSync(workspacePath);
71
+ }
72
+ catch {
73
+ resolved = path.resolve(workspacePath);
74
+ }
75
+ return crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 16);
76
+ }
77
+ function getHistoryFilePath(workspaceId, provider) {
78
+ return path.join((0, paths_1.getConfigDir)(), 'quota-history', workspaceId, `${provider}.jsonl`);
79
+ }
80
+ function ensureHistoryDir(workspaceId) {
81
+ fs.mkdirSync(path.join((0, paths_1.getConfigDir)(), 'quota-history', workspaceId), { recursive: true, mode: 0o700 });
82
+ }
83
+ function parseSampleLine(line) {
84
+ const trimmed = line.trim();
85
+ if (!trimmed)
86
+ return null;
87
+ try {
88
+ const parsed = JSON.parse(trimmed);
89
+ if (parsed &&
90
+ typeof parsed === 'object' &&
91
+ typeof parsed.timestamp === 'string' &&
92
+ parsed.fiveHour &&
93
+ parsed.sevenDay) {
94
+ return parsed;
95
+ }
96
+ return null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ function readLastSampleTimestampMs(filePath) {
103
+ let fd;
104
+ try {
105
+ fd = fs.openSync(filePath, 'r');
106
+ const stat = fs.fstatSync(fd);
107
+ if (stat.size === 0)
108
+ return null;
109
+ const chunkSize = 4096;
110
+ let remaining = stat.size;
111
+ let buffer = Buffer.alloc(0);
112
+ while (remaining > 0) {
113
+ const readSize = Math.min(chunkSize, remaining);
114
+ const chunk = Buffer.alloc(readSize);
115
+ fs.readSync(fd, chunk, 0, readSize, remaining - readSize);
116
+ buffer = Buffer.concat([chunk, buffer]);
117
+ remaining -= readSize;
118
+ const text = buffer.toString('utf8');
119
+ // Look for the last newline that's not at end-of-text — that's the boundary of the final record.
120
+ const trimmedRight = text.replace(/\n+$/, '');
121
+ const lastNewline = trimmedRight.lastIndexOf('\n');
122
+ if (lastNewline >= 0) {
123
+ const lastLine = trimmedRight.slice(lastNewline + 1);
124
+ const sample = parseSampleLine(lastLine);
125
+ return sample ? Date.parse(sample.timestamp) || null : null;
126
+ }
127
+ if (remaining === 0) {
128
+ const sample = parseSampleLine(trimmedRight);
129
+ return sample ? Date.parse(sample.timestamp) || null : null;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ catch (err) {
135
+ if (err?.code === 'ENOENT')
136
+ return null;
137
+ return null;
138
+ }
139
+ finally {
140
+ if (fd !== undefined) {
141
+ try {
142
+ fs.closeSync(fd);
143
+ }
144
+ catch {
145
+ // best effort
146
+ }
147
+ }
148
+ }
149
+ }
150
+ function atomicRewriteFile(filePath, contents) {
151
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(8).toString('hex')}.tmp`;
152
+ try {
153
+ fs.writeFileSync(tmp, contents, { encoding: 'utf8', mode: 0o600 });
154
+ fs.renameSync(tmp, filePath);
155
+ }
156
+ catch (error) {
157
+ try {
158
+ fs.rmSync(tmp, { force: true });
159
+ }
160
+ catch {
161
+ // Best effort cleanup only.
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+ function pruneFileSync(filePath, retentionDays) {
167
+ let stat;
168
+ try {
169
+ stat = fs.statSync(filePath);
170
+ }
171
+ catch {
172
+ return { kept: 0, pruned: 0 };
173
+ }
174
+ if (stat.size === 0)
175
+ return { kept: 0, pruned: 0 };
176
+ const cutoffMs = Date.now() - retentionDays * MS_PER_DAY;
177
+ const raw = fs.readFileSync(filePath, 'utf8');
178
+ const lines = raw.split('\n');
179
+ const keptLines = [];
180
+ let pruned = 0;
181
+ for (const line of lines) {
182
+ if (!line.trim())
183
+ continue;
184
+ const sample = parseSampleLine(line);
185
+ if (!sample) {
186
+ // Drop malformed lines during prune so the file self-heals.
187
+ pruned += 1;
188
+ continue;
189
+ }
190
+ const ts = Date.parse(sample.timestamp);
191
+ if (Number.isFinite(ts) && ts >= cutoffMs) {
192
+ keptLines.push(line);
193
+ }
194
+ else {
195
+ pruned += 1;
196
+ }
197
+ }
198
+ if (pruned > 0) {
199
+ atomicRewriteFile(filePath, keptLines.length > 0 ? keptLines.join('\n') + '\n' : '');
200
+ }
201
+ return { kept: keptLines.length, pruned };
202
+ }
203
+ function sampleToQuotaState(sample) {
204
+ const providerId = sample.runtimeProvider === 'claude' ? 'claude-code' : 'codex';
205
+ return {
206
+ fiveHour: { utilization: sample.fiveHour.utilization, resetsAt: sample.fiveHour.resetsAt },
207
+ sevenDay: { utilization: sample.sevenDay.utilization, resetsAt: sample.sevenDay.resetsAt },
208
+ available: sample.available,
209
+ error: sample.error,
210
+ providerId,
211
+ source: sample.source ?? 'session',
212
+ capturedAt: sample.timestamp,
213
+ stale: sample.stale,
214
+ };
215
+ }
216
+ async function runAppend(sample, filePath, options) {
217
+ let lastTs = lastWriteCache.get(filePath);
218
+ if (lastTs === undefined) {
219
+ const fromDisk = readLastSampleTimestampMs(filePath);
220
+ if (fromDisk !== null) {
221
+ lastTs = fromDisk;
222
+ lastWriteCache.set(filePath, fromDisk);
223
+ }
224
+ }
225
+ const sampleTs = Date.parse(sample.timestamp);
226
+ if (lastTs !== undefined && Number.isFinite(sampleTs) && sampleTs - lastTs < options.minIntervalMs) {
227
+ return;
228
+ }
229
+ ensureHistoryDir(sample.workspaceId);
230
+ const line = JSON.stringify(sample) + '\n';
231
+ await fs.promises.appendFile(filePath, line, { encoding: 'utf8', mode: 0o600 });
232
+ lastWriteCache.set(filePath, Number.isFinite(sampleTs) ? sampleTs : Date.now());
233
+ // Opportunistic prune. Skip when the file is small to avoid pointless rewrite churn.
234
+ try {
235
+ const stat = await fs.promises.stat(filePath);
236
+ if (stat.size >= PRUNE_FILESIZE_THRESHOLD) {
237
+ pruneFileSync(filePath, options.retentionDays);
238
+ }
239
+ }
240
+ catch {
241
+ // ignore
242
+ }
243
+ // Backwards-compat: keep the latest-snapshot store hot so existing callers (DashboardViewProvider,
244
+ // codex session provider, contextful_desktop) don't have to query history for "latest".
245
+ try {
246
+ const providerId = sample.runtimeProvider === 'claude' ? 'claude-code' : 'codex';
247
+ (0, quotaSnapshots_1.writeQuotaSnapshot)(providerId, sample.providerId, sampleToQuotaState(sample));
248
+ }
249
+ catch {
250
+ // Snapshot write failures must not poison the history append path.
251
+ }
252
+ }
253
+ async function appendQuotaHistorySample(sample, options = {}) {
254
+ const resolved = {
255
+ minIntervalMs: options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS,
256
+ retentionDays: options.retentionDays ?? DEFAULT_RETENTION_DAYS,
257
+ };
258
+ const filePath = getHistoryFilePath(sample.workspaceId, sample.runtimeProvider);
259
+ const previous = appendChains.get(filePath) ?? Promise.resolve();
260
+ const next = previous.then(() => runAppend(sample, filePath, resolved)).catch(() => {
261
+ // Swallow chain-level errors so a single failure doesn't break subsequent appends.
262
+ });
263
+ appendChains.set(filePath, next);
264
+ try {
265
+ await next;
266
+ }
267
+ finally {
268
+ if (appendChains.get(filePath) === next) {
269
+ appendChains.delete(filePath);
270
+ }
271
+ }
272
+ }
273
+ function defaultRangeMs() {
274
+ const toMs = Date.now();
275
+ const fromMs = toMs - DEFAULT_RETENTION_DAYS * MS_PER_DAY;
276
+ return { fromMs, toMs };
277
+ }
278
+ async function readQuotaHistoryRange(options) {
279
+ const filePath = getHistoryFilePath(options.workspaceId, options.provider);
280
+ let raw;
281
+ try {
282
+ raw = await fs.promises.readFile(filePath, 'utf8');
283
+ }
284
+ catch (err) {
285
+ if (err?.code === 'ENOENT')
286
+ return [];
287
+ throw err;
288
+ }
289
+ const { fromMs: defaultFromMs, toMs: defaultToMs } = defaultRangeMs();
290
+ const fromMs = options.from ? Date.parse(options.from) : defaultFromMs;
291
+ const toMs = options.to ? Date.parse(options.to) : defaultToMs;
292
+ const samples = [];
293
+ for (const line of raw.split('\n')) {
294
+ const sample = parseSampleLine(line);
295
+ if (!sample)
296
+ continue;
297
+ const ts = Date.parse(sample.timestamp);
298
+ if (!Number.isFinite(ts))
299
+ continue;
300
+ if (ts < fromMs || ts > toMs)
301
+ continue;
302
+ samples.push(sample);
303
+ }
304
+ samples.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
305
+ return samples;
306
+ }
307
+ function utcDateString(ms) {
308
+ return new Date(ms).toISOString().slice(0, 10);
309
+ }
310
+ function addDaysUtc(dateString, days) {
311
+ const ms = Date.parse(`${dateString}T00:00:00Z`) + days * MS_PER_DAY;
312
+ return utcDateString(ms);
313
+ }
314
+ async function readQuotaHistoryDailyBuckets(options) {
315
+ const samples = await readQuotaHistoryRange(options);
316
+ const { fromMs: defaultFromMs, toMs: defaultToMs } = defaultRangeMs();
317
+ const fromMs = options.from ? Date.parse(options.from) : defaultFromMs;
318
+ const toMs = options.to ? Date.parse(options.to) : defaultToMs;
319
+ const startDate = utcDateString(fromMs);
320
+ const endDate = utcDateString(toMs);
321
+ const grouped = new Map();
322
+ for (const sample of samples) {
323
+ const day = sample.timestamp.slice(0, 10);
324
+ const bucket = grouped.get(day);
325
+ if (bucket) {
326
+ bucket.push(sample);
327
+ }
328
+ else {
329
+ grouped.set(day, [sample]);
330
+ }
331
+ }
332
+ const buckets = [];
333
+ let cursor = startDate;
334
+ // Safety bound — 26 weeks worst case = 182 days; we cap iteration well above that.
335
+ for (let i = 0; i <= 366 && cursor <= endDate; i += 1) {
336
+ const daySamples = grouped.get(cursor);
337
+ if (!daySamples || daySamples.length === 0) {
338
+ buckets.push({
339
+ date: cursor,
340
+ samples: 0,
341
+ maxUtilizationFiveHour: 0,
342
+ maxUtilizationSevenDay: 0,
343
+ avgUtilizationFiveHour: 0,
344
+ avgUtilizationSevenDay: 0,
345
+ anyUnavailable: false,
346
+ });
347
+ }
348
+ else {
349
+ let maxFive = 0;
350
+ let maxSeven = 0;
351
+ let sumFive = 0;
352
+ let sumSeven = 0;
353
+ let anyUnavailable = false;
354
+ for (const s of daySamples) {
355
+ maxFive = Math.max(maxFive, s.fiveHour.utilization);
356
+ maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
357
+ sumFive += s.fiveHour.utilization;
358
+ sumSeven += s.sevenDay.utilization;
359
+ if (!s.available)
360
+ anyUnavailable = true;
361
+ }
362
+ const n = daySamples.length;
363
+ buckets.push({
364
+ date: cursor,
365
+ samples: n,
366
+ maxUtilizationFiveHour: maxFive,
367
+ maxUtilizationSevenDay: maxSeven,
368
+ avgUtilizationFiveHour: Math.round((sumFive / n) * 100) / 100,
369
+ avgUtilizationSevenDay: Math.round((sumSeven / n) * 100) / 100,
370
+ anyUnavailable,
371
+ });
372
+ }
373
+ cursor = addDaysUtc(cursor, 1);
374
+ }
375
+ return buckets;
376
+ }
377
+ async function pruneQuotaHistory(workspaceId, provider, retentionDays = DEFAULT_RETENTION_DAYS) {
378
+ const filePath = getHistoryFilePath(workspaceId, provider);
379
+ return pruneFileSync(filePath, retentionDays);
380
+ }
381
+ /** Test-only: wipe the in-memory mutex/debounce state. Not exported from index.ts. */
382
+ function _resetQuotaHistoryInMemoryStateForTests() {
383
+ appendChains.clear();
384
+ lastWriteCache.clear();
385
+ }
@@ -83,6 +83,31 @@ function writeStore(store) {
83
83
  ensureConfigDir();
84
84
  atomicWriteJson(getQuotaSnapshotPath(), store);
85
85
  }
86
+ function snapshotTimeMs(quota) {
87
+ const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : NaN;
88
+ return Number.isFinite(capturedAt) ? capturedAt : 0;
89
+ }
90
+ function windowResetMs(value) {
91
+ const ms = Date.parse(value);
92
+ return Number.isFinite(ms) ? ms : 0;
93
+ }
94
+ // Preserve the best-known same-window snapshot while still allowing lower
95
+ // utilization after Codex advances to a newer reset window.
96
+ function shouldKeepExistingSnapshot(existing, next) {
97
+ const existingPrimaryReset = windowResetMs(existing.fiveHour.resetsAt);
98
+ const nextPrimaryReset = windowResetMs(next.fiveHour.resetsAt);
99
+ if (existingPrimaryReset !== nextPrimaryReset)
100
+ return existingPrimaryReset > nextPrimaryReset;
101
+ const existingSecondaryReset = windowResetMs(existing.sevenDay.resetsAt);
102
+ const nextSecondaryReset = windowResetMs(next.sevenDay.resetsAt);
103
+ if (existingSecondaryReset !== nextSecondaryReset)
104
+ return existingSecondaryReset > nextSecondaryReset;
105
+ const existingUtilization = existing.fiveHour.utilization + existing.sevenDay.utilization;
106
+ const nextUtilization = next.fiveHour.utilization + next.sevenDay.utilization;
107
+ if (existingUtilization !== nextUtilization)
108
+ return existingUtilization > nextUtilization;
109
+ return snapshotTimeMs(existing) > snapshotTimeMs(next);
110
+ }
86
111
  function writeQuotaSnapshot(providerId, accountId, quota) {
87
112
  const store = readStore();
88
113
  const snapshot = {
@@ -93,6 +118,9 @@ function writeQuotaSnapshot(providerId, accountId, quota) {
93
118
  stale: false,
94
119
  };
95
120
  const index = store.snapshots.findIndex(item => item.providerId === providerId && item.accountId === accountId);
121
+ if (index >= 0 && shouldKeepExistingSnapshot(store.snapshots[index].quota, snapshot)) {
122
+ return;
123
+ }
96
124
  const record = {
97
125
  providerId,
98
126
  accountId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",