sidekick-shared 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +75 -63
  2. package/dist/accountManager.js +10 -0
  3. package/dist/accountRegistry.d.ts +2 -0
  4. package/dist/accountStatus.d.ts +1 -0
  5. package/dist/accountStatus.js +6 -0
  6. package/dist/accounts.js +2 -0
  7. package/dist/aggregation/EventAggregator.d.ts +36 -2
  8. package/dist/aggregation/EventAggregator.js +259 -33
  9. package/dist/aggregation/snapshot.d.ts +1 -1
  10. package/dist/aggregation/snapshot.js +2 -1
  11. package/dist/aggregation/types.d.ts +25 -1
  12. package/dist/analytics/codeImpact.d.ts +20 -0
  13. package/dist/analytics/codeImpact.js +21 -0
  14. package/dist/analytics/compactionLedger.d.ts +11 -0
  15. package/dist/analytics/compactionLedger.js +31 -0
  16. package/dist/analytics/qualityScore.d.ts +26 -0
  17. package/dist/analytics/qualityScore.js +115 -0
  18. package/dist/autoSwitch.d.ts +1 -1
  19. package/dist/autoSwitch.js +14 -3
  20. package/dist/codexProfiles.js +2 -0
  21. package/dist/codexQuota.d.ts +2 -0
  22. package/dist/codexQuota.js +27 -0
  23. package/dist/codexQuotaWatcher.d.ts +7 -0
  24. package/dist/codexQuotaWatcher.js +37 -19
  25. package/dist/context/composer.d.ts +3 -2
  26. package/dist/context/composer.js +5 -5
  27. package/dist/context/sessionContext.js +35 -9
  28. package/dist/credentialIO.js +19 -5
  29. package/dist/doctor.d.ts +55 -0
  30. package/dist/doctor.js +244 -0
  31. package/dist/errorHistory.d.ts +33 -0
  32. package/dist/errorHistory.js +95 -0
  33. package/dist/extractors/errorTaxonomy.d.ts +6 -0
  34. package/dist/extractors/errorTaxonomy.js +47 -0
  35. package/dist/extractors/toolCall.d.ts +9 -0
  36. package/dist/extractors/toolCall.js +79 -0
  37. package/dist/handoffUrl.d.ts +7 -0
  38. package/dist/handoffUrl.js +26 -0
  39. package/dist/index.d.ts +32 -9
  40. package/dist/index.js +64 -24
  41. package/dist/modelInfo.js +6 -0
  42. package/dist/multiProviderQuotaService.js +13 -2
  43. package/dist/parsers/codexParser.js +17 -1
  44. package/dist/parsers/openCodeParser.js +13 -0
  45. package/dist/parsers/planExtractor.js +17 -6
  46. package/dist/parsers/sessionActivityDetector.js +2 -0
  47. package/dist/parsers/sessionPathResolver.js +19 -16
  48. package/dist/parsers/subagentScanner.d.ts +1 -1
  49. package/dist/parsers/subagentScanner.js +4 -5
  50. package/dist/parsers/subagentTraceParser.js +4 -7
  51. package/dist/paths.d.ts +10 -0
  52. package/dist/paths.js +18 -5
  53. package/dist/projectMigration.d.ts +23 -0
  54. package/dist/projectMigration.js +95 -0
  55. package/dist/providerStatus.js +13 -2
  56. package/dist/providers/claudeCode.js +18 -4
  57. package/dist/providers/codex.js +42 -13
  58. package/dist/providers/codexDatabase.d.ts +1 -11
  59. package/dist/providers/detect.js +32 -3
  60. package/dist/providers/openCodeDatabase.d.ts +1 -31
  61. package/dist/providers/openCodeDatabase.js +21 -11
  62. package/dist/providers/types.d.ts +1 -0
  63. package/dist/quota.js +8 -0
  64. package/dist/quotaPoller.js +17 -6
  65. package/dist/quotaSnapshots.js +80 -26
  66. package/dist/readers/decisions.d.ts +2 -1
  67. package/dist/readers/decisions.js +5 -5
  68. package/dist/readers/handoff.d.ts +2 -1
  69. package/dist/readers/handoff.js +13 -8
  70. package/dist/readers/helpers.d.ts +3 -0
  71. package/dist/readers/helpers.js +12 -0
  72. package/dist/readers/notes.d.ts +2 -1
  73. package/dist/readers/notes.js +11 -8
  74. package/dist/readers/plans.js +3 -5
  75. package/dist/readers/tasks.d.ts +2 -1
  76. package/dist/readers/tasks.js +5 -5
  77. package/dist/report/htmlHelpers.js +6 -1
  78. package/dist/report/openBrowser.js +3 -3
  79. package/dist/schemas/index.d.ts +2 -0
  80. package/dist/schemas/index.js +10 -1
  81. package/dist/schemas/observedSessionV1.d.ts +499 -0
  82. package/dist/schemas/observedSessionV1.js +69 -0
  83. package/dist/schemas/sessionEvent.d.ts +13 -0
  84. package/dist/schemas/sessionEvent.js +19 -0
  85. package/dist/search/sessionSearch.d.ts +2 -2
  86. package/dist/search/sessionSearch.js +2 -3
  87. package/dist/sessionMonitor.d.ts +29 -0
  88. package/dist/sessionMonitor.js +71 -0
  89. package/dist/statusline/BurnRateCalculator.d.ts +14 -0
  90. package/dist/statusline/BurnRateCalculator.js +45 -0
  91. package/dist/statusline/formatter.d.ts +16 -0
  92. package/dist/statusline/formatter.js +84 -0
  93. package/dist/statusline/index.d.ts +7 -0
  94. package/dist/statusline/index.js +13 -0
  95. package/dist/terminalSync.js +16 -7
  96. package/dist/types/codex.d.ts +2 -0
  97. package/dist/types/historicalData.d.ts +49 -1
  98. package/dist/types/historicalData.js +28 -2
  99. package/dist/types/observedSessionV1.d.ts +70 -0
  100. package/dist/types/observedSessionV1.js +186 -0
  101. package/dist/types/opencode.d.ts +1 -0
  102. package/dist/types/sessionEvent.d.ts +19 -1
  103. package/dist/watchers/eventBridge.js +1 -0
  104. package/dist/watchers/factory.js +14 -3
  105. package/dist/watchers/jsonlTail.js +34 -14
  106. package/dist/watchers/jsonlWatcher.js +5 -3
  107. package/dist/watchers/providerReaderWatcher.js +4 -2
  108. package/dist/watchers/sqliteWatcher.d.ts +2 -0
  109. package/dist/watchers/sqliteWatcher.js +27 -14
  110. package/dist/watchers/types.d.ts +5 -0
  111. package/dist/writers/atomic.d.ts +4 -0
  112. package/dist/writers/atomic.js +200 -0
  113. package/dist/writers/decisions.d.ts +12 -0
  114. package/dist/writers/decisions.js +88 -0
  115. package/dist/writers/notes.d.ts +8 -0
  116. package/dist/writers/notes.js +82 -0
  117. package/dist/writers/tasks.d.ts +6 -0
  118. package/dist/writers/tasks.js +100 -0
  119. package/dist/zaiQuotaWatcher.js +6 -0
  120. package/package.json +5 -1
@@ -0,0 +1,20 @@
1
+ export interface ModelCostInput {
2
+ model: string;
3
+ cost: number;
4
+ priced?: boolean;
5
+ }
6
+ export interface CodeImpact {
7
+ changedLines: number;
8
+ additions: number;
9
+ deletions: number;
10
+ totalCost: number;
11
+ costPerChangedLine: number | null;
12
+ byModel: Array<{
13
+ model: string;
14
+ cost: number;
15
+ priced: boolean;
16
+ changedLines: number;
17
+ costPerChangedLine: number | null;
18
+ }>;
19
+ }
20
+ export declare function calculateCodeImpact(totalCost: number, additions: number, deletions: number, models?: ModelCostInput[]): CodeImpact;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculateCodeImpact = calculateCodeImpact;
4
+ function calculateCodeImpact(totalCost, additions, deletions, models = []) {
5
+ const safeAdditions = Math.max(0, additions);
6
+ const safeDeletions = Math.max(0, deletions);
7
+ const changedLines = safeAdditions + safeDeletions;
8
+ return {
9
+ changedLines,
10
+ additions: safeAdditions,
11
+ deletions: safeDeletions,
12
+ totalCost,
13
+ costPerChangedLine: changedLines > 0 ? totalCost / changedLines : null,
14
+ byModel: models.map((model) => ({
15
+ ...model,
16
+ priced: model.priced !== false,
17
+ changedLines,
18
+ costPerChangedLine: changedLines > 0 && model.priced !== false ? model.cost / changedLines : null,
19
+ })),
20
+ };
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { CompactionEvent } from '../types/sessionEvent';
2
+ export interface CompactionLedger {
3
+ count: number;
4
+ tokensEvicted: number;
5
+ reestablishmentCostUsd: number | null;
6
+ source: 'reported' | 'heuristic' | 'mixed';
7
+ reportedCount: number;
8
+ heuristicCount: number;
9
+ }
10
+ export declare function calculateCompactionLedger(events: CompactionEvent[], model?: string | null): CompactionLedger;
11
+ export declare function formatCompactionLedger(ledger: CompactionLedger): string;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.calculateCompactionLedger = calculateCompactionLedger;
4
+ exports.formatCompactionLedger = formatCompactionLedger;
5
+ const modelInfo_1 = require("../modelInfo");
6
+ function calculateCompactionLedger(events, model) {
7
+ const tokensEvicted = events.reduce((sum, event) => sum + Math.max(0, event.tokensReclaimed), 0);
8
+ const reportedCount = events.filter((event) => event.source === 'reported').length;
9
+ const heuristicCount = events.length - reportedCount;
10
+ const pricing = model ? (0, modelInfo_1.getModelPricing)(model) : null;
11
+ return {
12
+ count: events.length,
13
+ tokensEvicted,
14
+ reestablishmentCostUsd: pricing
15
+ ? (0, modelInfo_1.calculateCostWithPricing)({ inputTokens: tokensEvicted, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 }, pricing)
16
+ : null,
17
+ source: reportedCount > 0 && heuristicCount > 0
18
+ ? 'mixed'
19
+ : reportedCount > 0
20
+ ? 'reported'
21
+ : 'heuristic',
22
+ reportedCount,
23
+ heuristicCount,
24
+ };
25
+ }
26
+ function formatCompactionLedger(ledger) {
27
+ const cost = ledger.reestablishmentCostUsd == null
28
+ ? 'cost unavailable'
29
+ : `~$${ledger.reestablishmentCostUsd.toFixed(2)} re-establishing context`;
30
+ return `${ledger.count} compaction${ledger.count === 1 ? '' : 's'} · ${ledger.tokensEvicted.toLocaleString()} tokens evicted · ${cost} · ${ledger.source}`;
31
+ }
@@ -0,0 +1,26 @@
1
+ import type { AggregatedMetrics } from '../aggregation/types';
2
+ import type { SessionHistoryRecord } from '../types/historicalData';
3
+ export type QualityFactorId = 'latency' | 'reliability' | 'context' | 'permission_safety' | 'outcomes';
4
+ export interface QualityFactorContribution {
5
+ id: QualityFactorId;
6
+ label: string;
7
+ contribution: number;
8
+ maximum: number;
9
+ detail: string;
10
+ }
11
+ export interface SessionQualityScore {
12
+ /** Composite score. Beta until weights are calibrated against durable history. */
13
+ score: number;
14
+ beta: true;
15
+ factors: QualityFactorContribution[];
16
+ }
17
+ export interface QualityTrend {
18
+ currentWeekAverage: number | null;
19
+ previousWeekAverage: number | null;
20
+ delta: number | null;
21
+ direction: 'improving' | 'declining' | 'flat' | 'insufficient';
22
+ currentWeekSessions: number;
23
+ previousWeekSessions: number;
24
+ }
25
+ export declare function scoreSessionQuality(metrics: AggregatedMetrics): SessionQualityScore;
26
+ export declare function calculateQualityTrend(records: SessionHistoryRecord[], now?: Date): QualityTrend;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scoreSessionQuality = scoreSessionQuality;
4
+ exports.calculateQualityTrend = calculateQualityTrend;
5
+ function awarded(maximum, ratio) {
6
+ return Math.round(maximum * Math.max(0, Math.min(1, ratio)) * 10) / 10;
7
+ }
8
+ function scoreSessionQuality(metrics) {
9
+ const latencyMs = metrics.latencyStats?.avgFirstTokenLatencyMs ?? 0;
10
+ const latencyRatio = latencyMs > 0 ? 1 - Math.min(1, latencyMs / 15_000) : 1;
11
+ const completedCalls = metrics.toolStats.reduce((sum, tool) => sum + tool.completedCount, 0);
12
+ const errorRate = completedCalls > 0 ? metrics.errorRollup.totalFailures / completedCalls : 0;
13
+ const contextTokens = Math.max(1, metrics.tokens.inputTokens + metrics.tokens.cacheReadTokens + metrics.tokens.cacheWriteTokens);
14
+ const evicted = metrics.compactionEvents.reduce((sum, event) => sum + event.tokensReclaimed, 0);
15
+ const contextRatio = 1 - Math.min(1, evicted / contextTokens);
16
+ const start = metrics.sessionStartTime ? Date.parse(metrics.sessionStartTime) : NaN;
17
+ const end = metrics.lastEventTime ? Date.parse(metrics.lastEventTime) : NaN;
18
+ let bypassMs = 0;
19
+ for (let index = 0; index < metrics.permissionModeHistory.length; index++) {
20
+ const change = metrics.permissionModeHistory[index];
21
+ if (change.mode !== 'bypassPermissions')
22
+ continue;
23
+ const from = Date.parse(change.timestamp);
24
+ const to = Date.parse(metrics.permissionModeHistory[index + 1]?.timestamp ?? metrics.lastEventTime ?? '');
25
+ if (Number.isFinite(from) && Number.isFinite(to))
26
+ bypassMs += Math.max(0, to - from);
27
+ }
28
+ const sessionMs = Number.isFinite(start) && Number.isFinite(end) ? Math.max(1, end - start) : 1;
29
+ const bypassRatio = Math.min(1, bypassMs / sessionMs);
30
+ const tasks = Array.from(metrics.taskState.tasks.values()).filter((task) => task.status !== 'deleted');
31
+ const completedTasks = tasks.filter((task) => task.status === 'completed').length;
32
+ const goalGates = tasks.filter((task) => task.isGoalGate);
33
+ const completedGates = goalGates.filter((task) => task.status === 'completed').length;
34
+ const taskRatio = tasks.length > 0 ? completedTasks / tasks.length : 1;
35
+ const goalRatio = goalGates.length > 0 ? completedGates / goalGates.length : taskRatio;
36
+ const outcomeRatio = taskRatio * 0.6 + goalRatio * 0.4;
37
+ const factors = [
38
+ {
39
+ id: 'latency',
40
+ label: 'Response latency',
41
+ contribution: awarded(15, latencyRatio),
42
+ maximum: 15,
43
+ detail: latencyMs > 0 ? `${Math.round(latencyMs)}ms average first token` : 'No latency penalty',
44
+ },
45
+ {
46
+ id: 'reliability',
47
+ label: 'Tool reliability',
48
+ contribution: awarded(30, 1 - Math.min(1, errorRate * 2)),
49
+ maximum: 30,
50
+ detail: `${metrics.errorRollup.totalFailures}/${completedCalls} completed tool calls failed`,
51
+ },
52
+ {
53
+ id: 'context',
54
+ label: 'Context stability',
55
+ contribution: awarded(15, contextRatio),
56
+ maximum: 15,
57
+ detail: `${metrics.compactionCount} compactions, ${evicted} tokens evicted`,
58
+ },
59
+ {
60
+ id: 'permission_safety',
61
+ label: 'Permission safety',
62
+ contribution: awarded(10, 1 - bypassRatio),
63
+ maximum: 10,
64
+ detail: `${Math.round(bypassRatio * 100)}% of observed time bypassing permissions`,
65
+ },
66
+ {
67
+ id: 'outcomes',
68
+ label: 'Task outcomes',
69
+ contribution: awarded(30, outcomeRatio),
70
+ maximum: 30,
71
+ detail: `${completedTasks}/${tasks.length} tasks and ${completedGates}/${goalGates.length} goal gates completed`,
72
+ },
73
+ ];
74
+ return {
75
+ score: Math.round(factors.reduce((sum, factor) => sum + factor.contribution, 0)),
76
+ beta: true,
77
+ factors,
78
+ };
79
+ }
80
+ function calculateQualityTrend(records, now = new Date()) {
81
+ const end = now.getTime();
82
+ const week = 7 * 24 * 60 * 60 * 1000;
83
+ const eligible = records.filter((record) => record.qualityScore > 0 && Number.isFinite(Date.parse(record.endTime)));
84
+ const current = eligible.filter((record) => {
85
+ const at = Date.parse(record.endTime);
86
+ return at >= end - week && at <= end;
87
+ });
88
+ const previous = eligible.filter((record) => {
89
+ const at = Date.parse(record.endTime);
90
+ return at >= end - 2 * week && at < end - week;
91
+ });
92
+ const average = (items) => items.length > 0
93
+ ? Math.round((items.reduce((sum, item) => sum + item.qualityScore, 0) / items.length) * 10) /
94
+ 10
95
+ : null;
96
+ const currentWeekAverage = average(current);
97
+ const previousWeekAverage = average(previous);
98
+ const delta = currentWeekAverage != null && previousWeekAverage != null
99
+ ? Math.round((currentWeekAverage - previousWeekAverage) * 10) / 10
100
+ : null;
101
+ return {
102
+ currentWeekAverage,
103
+ previousWeekAverage,
104
+ delta,
105
+ direction: delta == null
106
+ ? 'insufficient'
107
+ : delta > 0.5
108
+ ? 'improving'
109
+ : delta < -0.5
110
+ ? 'declining'
111
+ : 'flat',
112
+ currentWeekSessions: current.length,
113
+ previousWeekSessions: previous.length,
114
+ };
115
+ }
@@ -42,7 +42,7 @@ export interface AutoSwitchControllerOptions {
42
42
  cooldownMs?: number;
43
43
  now?: () => number;
44
44
  }
45
- export declare function decideAutoSwitch(_provider: AccountProviderId, active: AutoSwitchActiveAccount, candidates: AutoSwitchCandidate[], config?: AutoSwitchConfig): AutoSwitchDecision | null;
45
+ export declare function decideAutoSwitch(_provider: AccountProviderId, active: AutoSwitchActiveAccount, candidates: AutoSwitchCandidate[], config?: AutoSwitchConfig, nowMs?: number): AutoSwitchDecision | null;
46
46
  export declare class AutoSwitchController implements Disposable {
47
47
  private readonly quotaService;
48
48
  private readonly getClaudeAccounts;
@@ -11,6 +11,7 @@ exports.DEFAULT_AUTO_SWITCH_CONFIG = {
11
11
  thresholdPct: 90,
12
12
  };
13
13
  const MATERIAL_REMAINING_IMPROVEMENT_PCT = 5;
14
+ const MAX_STALE_SNAPSHOT_AGE_MS = 5 * 60 * 60 * 1000;
14
15
  function mergedConfig(config) {
15
16
  return {
16
17
  ...exports.DEFAULT_AUTO_SWITCH_CONFIG,
@@ -27,7 +28,17 @@ function quotaRemaining(quota) {
27
28
  return 0;
28
29
  return Math.max(0, 100 - quotaUtilization(quota));
29
30
  }
30
- function decideAutoSwitch(_provider, active, candidates, config) {
31
+ function isUsableCandidateQuota(quota, nowMs) {
32
+ if (!quota?.available)
33
+ return false;
34
+ if (quota.stale !== true)
35
+ return true;
36
+ const capturedAt = Date.parse(quota.capturedAt ?? '');
37
+ if (!Number.isFinite(capturedAt) || capturedAt > nowMs)
38
+ return false;
39
+ return nowMs - capturedAt <= MAX_STALE_SNAPSHOT_AGE_MS;
40
+ }
41
+ function decideAutoSwitch(_provider, active, candidates, config, nowMs = Date.now()) {
31
42
  const cfg = mergedConfig(config);
32
43
  if (!cfg.enabled)
33
44
  return null;
@@ -40,7 +51,7 @@ function decideAutoSwitch(_provider, active, candidates, config) {
40
51
  const best = candidates
41
52
  .filter((candidate) => candidate.accountId !== active.accountId &&
42
53
  candidate.switchable !== false &&
43
- candidate.quota?.available)
54
+ isUsableCandidateQuota(candidate.quota, nowMs))
44
55
  .sort((a, b) => quotaRemaining(b.quota) - quotaRemaining(a.quota))[0];
45
56
  if (!best)
46
57
  return null;
@@ -129,7 +140,7 @@ class AutoSwitchController {
129
140
  quota: accountId === activeAccountId ? activeQuota : this.readSnapshot(providerId, accountId),
130
141
  };
131
142
  });
132
- const decision = decideAutoSwitch(providerId, { accountId: activeAccountId, quota: activeQuota }, candidates, this.config);
143
+ const decision = decideAutoSwitch(providerId, { accountId: activeAccountId, quota: activeQuota }, candidates, this.config, this.now());
133
144
  if (!decision)
134
145
  return;
135
146
  if (this.switchedDuringCrossing.has(providerId))
@@ -382,6 +382,7 @@ function resolveActiveCodexAccount() {
382
382
  email: identity.email ?? match?.email ?? match?.metadata?.email,
383
383
  label: match?.label,
384
384
  providerAccountId: match?.providerAccountId ?? identity.workspaceId,
385
+ registryAccountId: match?.id,
385
386
  source: 'live',
386
387
  };
387
388
  }
@@ -392,6 +393,7 @@ function resolveActiveCodexAccount() {
392
393
  email: active.email ?? active.metadata?.email,
393
394
  label: active.label,
394
395
  providerAccountId: active.providerAccountId,
396
+ registryAccountId: active.id,
395
397
  source: 'registry',
396
398
  };
397
399
  }
@@ -32,6 +32,8 @@ export interface CodexQuotaApiOptions {
32
32
  usageUrl?: string;
33
33
  resetCreditsUrl?: string;
34
34
  capturedAt?: string;
35
+ signal?: AbortSignal;
36
+ timeoutMs?: number;
35
37
  }
36
38
  export declare function quotaFromCodexRateLimits(rateLimits: CodexRateLimits | null | undefined, source?: 'api' | 'session' | 'cache', capturedAt?: string): QuotaState | null;
37
39
  export declare function readLatestCodexQuotaFromRollouts(sessionPaths: string[], options?: {
@@ -414,10 +414,19 @@ async function fetchCodexQuotaFromApi(options = {}) {
414
414
  sevenDayLabel: 'Secondary',
415
415
  };
416
416
  }
417
+ const controller = new AbortController();
418
+ const onAbort = () => controller.abort();
419
+ if (options.signal?.aborted)
420
+ controller.abort();
421
+ else
422
+ options.signal?.addEventListener('abort', onAbort, { once: true });
423
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
424
+ timeout.unref();
417
425
  try {
418
426
  const fetchImpl = options.fetchImpl ?? fetch;
419
427
  const response = await fetchImpl(options.usageUrl ?? CHATGPT_USAGE_URL, {
420
428
  method: 'GET',
429
+ signal: controller.signal,
421
430
  headers: {
422
431
  Authorization: `Bearer ${accessToken}`,
423
432
  Accept: 'application/json',
@@ -468,6 +477,7 @@ async function fetchCodexQuotaFromApi(options = {}) {
468
477
  accessToken,
469
478
  accountId: auth?.accountId,
470
479
  capturedAt,
480
+ signal: controller.signal,
471
481
  });
472
482
  if (resetCredits) {
473
483
  quota.resetCredits = resetCredits;
@@ -488,6 +498,10 @@ async function fetchCodexQuotaFromApi(options = {}) {
488
498
  sevenDayLabel: 'Secondary',
489
499
  };
490
500
  }
501
+ finally {
502
+ clearTimeout(timeout);
503
+ options.signal?.removeEventListener('abort', onAbort);
504
+ }
491
505
  }
492
506
  async function fetchCodexResetCreditsFromApi(options = {}) {
493
507
  const capturedAt = options.capturedAt ?? new Date().toISOString();
@@ -497,6 +511,14 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
497
511
  const accessToken = auth?.accessToken;
498
512
  if (!accessToken)
499
513
  return undefined;
514
+ const controller = new AbortController();
515
+ const onAbort = () => controller.abort();
516
+ if (options.signal?.aborted)
517
+ controller.abort();
518
+ else
519
+ options.signal?.addEventListener('abort', onAbort, { once: true });
520
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
521
+ timeout.unref();
500
522
  try {
501
523
  const fetchImpl = options.fetchImpl ?? fetch;
502
524
  const headers = {
@@ -511,6 +533,7 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
511
533
  const response = await fetchImpl(options.resetCreditsUrl ?? CHATGPT_RESET_CREDITS_URL, {
512
534
  method: 'GET',
513
535
  headers,
536
+ signal: controller.signal,
514
537
  });
515
538
  if (!response.ok)
516
539
  return undefined;
@@ -519,6 +542,10 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
519
542
  catch {
520
543
  return undefined;
521
544
  }
545
+ finally {
546
+ clearTimeout(timeout);
547
+ options.signal?.removeEventListener('abort', onAbort);
548
+ }
522
549
  }
523
550
  function readCodexAuth(codexHome) {
524
551
  try {
@@ -15,6 +15,9 @@ export interface CodexQuotaWatcherOptions {
15
15
  discoveryPollIntervalMs?: number;
16
16
  maxTailBytes?: number;
17
17
  maxSessionFiles?: number;
18
+ /** Minimum interval between expensive rollout-tail fallback scans. */
19
+ localScanCacheMs?: number;
20
+ now?: () => number;
18
21
  providerFactory?: () => CodexProvider;
19
22
  getActiveAccount?: CodexAccountReader;
20
23
  readSnapshot?: SnapshotReader;
@@ -39,6 +42,8 @@ export declare class CodexQuotaWatcher implements Disposable {
39
42
  private readonly watchFile;
40
43
  private readonly maxTailBytes;
41
44
  private readonly maxSessionFiles;
45
+ private readonly localScanCacheMs;
46
+ private readonly now;
42
47
  private readonly workspaceId;
43
48
  private readonly appendHistorySample;
44
49
  private readonly listeners;
@@ -48,6 +53,8 @@ export declare class CodexQuotaWatcher implements Disposable {
48
53
  private fileWatcher;
49
54
  private sessionPath;
50
55
  private lastEmissionKey;
56
+ private lastLocalScanAt;
57
+ private lastLocalScanState;
51
58
  private running;
52
59
  constructor(workspacePath: string, options?: CodexQuotaWatcherOptions);
53
60
  start(): void;
@@ -41,6 +41,7 @@ const quotaSnapshots_1 = require("./quotaSnapshots");
41
41
  const quotaHistory_1 = require("./quotaHistory");
42
42
  const codex_1 = require("./providers/codex");
43
43
  const DEFAULT_DISCOVERY_POLL_INTERVAL_MS = 30_000;
44
+ const DEFAULT_LOCAL_SCAN_CACHE_MS = 5 * 60_000;
44
45
  function accountEmail(account) {
45
46
  return account?.email ?? account?.metadata?.email;
46
47
  }
@@ -81,6 +82,8 @@ class CodexQuotaWatcher {
81
82
  watchFile;
82
83
  maxTailBytes;
83
84
  maxSessionFiles;
85
+ localScanCacheMs;
86
+ now;
84
87
  workspaceId;
85
88
  appendHistorySample;
86
89
  listeners = [];
@@ -90,6 +93,8 @@ class CodexQuotaWatcher {
90
93
  fileWatcher = null;
91
94
  sessionPath = null;
92
95
  lastEmissionKey = null;
96
+ lastLocalScanAt = Number.NEGATIVE_INFINITY;
97
+ lastLocalScanState = null;
93
98
  running = false;
94
99
  constructor(workspacePath, options = {}) {
95
100
  this.workspacePath = workspacePath;
@@ -110,6 +115,8 @@ class CodexQuotaWatcher {
110
115
  this.watchFile = options.watchFile ?? fs.watch;
111
116
  this.maxTailBytes = options.maxTailBytes;
112
117
  this.maxSessionFiles = options.maxSessionFiles;
118
+ this.localScanCacheMs = options.localScanCacheMs ?? DEFAULT_LOCAL_SCAN_CACHE_MS;
119
+ this.now = options.now ?? Date.now;
113
120
  this.workspaceId = options.workspaceId;
114
121
  this.appendHistorySample = options.appendHistorySample ?? quotaHistory_1.appendQuotaHistorySample;
115
122
  }
@@ -255,28 +262,39 @@ class CodexQuotaWatcher {
255
262
  }
256
263
  emitCachedOrUnavailable() {
257
264
  const account = this.getActiveAccount();
258
- let localProvider = null;
259
- try {
260
- localProvider = this.providerFactory();
261
- const local = (0, codexQuota_1.resolveCodexQuotaFromLocalSources)({
262
- workspacePath: this.workspacePath,
263
- activeAccount: account,
264
- readSnapshot: this.readSnapshot,
265
- writeSnapshot: this.writeSnapshot,
266
- provider: localProvider,
267
- maxTailBytes: this.maxTailBytes,
268
- maxSessionFiles: this.maxSessionFiles,
269
- });
270
- if (local) {
271
- this.emitState(local);
265
+ const scanNow = this.now();
266
+ if (scanNow - this.lastLocalScanAt < this.localScanCacheMs) {
267
+ if (this.lastLocalScanState) {
268
+ this.emitState(this.lastLocalScanState);
272
269
  return;
273
270
  }
274
271
  }
275
- catch {
276
- // Fall through to account-scoped cache or unavailable state.
277
- }
278
- finally {
279
- localProvider?.dispose();
272
+ else {
273
+ let localProvider = null;
274
+ this.lastLocalScanAt = scanNow;
275
+ this.lastLocalScanState = null;
276
+ try {
277
+ localProvider = this.providerFactory();
278
+ this.lastLocalScanState = (0, codexQuota_1.resolveCodexQuotaFromLocalSources)({
279
+ workspacePath: this.workspacePath,
280
+ activeAccount: account,
281
+ readSnapshot: this.readSnapshot,
282
+ writeSnapshot: this.writeSnapshot,
283
+ provider: localProvider,
284
+ maxTailBytes: this.maxTailBytes,
285
+ maxSessionFiles: this.maxSessionFiles,
286
+ });
287
+ if (this.lastLocalScanState) {
288
+ this.emitState(this.lastLocalScanState);
289
+ return;
290
+ }
291
+ }
292
+ catch {
293
+ // Fall through to account-scoped cache or unavailable state.
294
+ }
295
+ finally {
296
+ localProvider?.dispose();
297
+ }
280
298
  }
281
299
  const cached = account ? this.readSnapshot('codex', account.id) : null;
282
300
  if (cached) {
@@ -5,7 +5,8 @@ import type { PersistedTask } from '../types/taskPersistence';
5
5
  import type { DecisionEntry } from '../types/decisionLog';
6
6
  import type { KnowledgeNote } from '../types/knowledgeNote';
7
7
  import type { TokenTotals } from '../types/historicalData';
8
- import type { SessionProvider, SessionFileStats, ProviderId } from '../providers/types';
8
+ import type { SessionProviderBase, SessionFileStats, ProviderId } from '../providers/types';
9
+ import type { ProjectIdentity } from '../paths';
9
10
  export type Fidelity = 'full' | 'compact' | 'brief';
10
11
  export interface ContextResult {
11
12
  provider: ProviderId;
@@ -28,4 +29,4 @@ export interface ContextResult {
28
29
  } | null;
29
30
  sessionSummaries: SessionFileStats[];
30
31
  }
31
- export declare function composeContext(slug: string, fidelity: Fidelity, provider: SessionProvider, workspacePath?: string): Promise<ContextResult>;
32
+ export declare function composeContext(project: string | ProjectIdentity, fidelity: Fidelity, provider: SessionProviderBase, workspacePath?: string): Promise<ContextResult>;
@@ -9,14 +9,14 @@ const decisions_1 = require("../readers/decisions");
9
9
  const notes_1 = require("../readers/notes");
10
10
  const history_1 = require("../readers/history");
11
11
  const handoff_1 = require("../readers/handoff");
12
- async function composeContext(slug, fidelity, provider, workspacePath) {
12
+ async function composeContext(project, fidelity, provider, workspacePath) {
13
13
  // Fetch all data in parallel
14
14
  const [allTasks, allDecisions, allNotes, history, handoff] = await Promise.all([
15
- (0, tasks_1.readTasks)(slug),
16
- (0, decisions_1.readDecisions)(slug),
17
- (0, notes_1.readNotes)(slug),
15
+ (0, tasks_1.readTasks)(project),
16
+ (0, decisions_1.readDecisions)(project),
17
+ (0, notes_1.readNotes)(project),
18
18
  (0, history_1.readHistory)(),
19
- (0, handoff_1.readLatestHandoff)(slug),
19
+ (0, handoff_1.readLatestHandoff)(project),
20
20
  ]);
21
21
  // Get session summaries
22
22
  const sessionFiles = workspacePath ? provider.findAllSessions(workspacePath) : [];
@@ -57,6 +57,10 @@ function buildSessionContextSnapshot(events, options = {}) {
57
57
  if (event.rateLimits)
58
58
  lastRateLimits = event.rateLimits;
59
59
  }
60
+ return assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options);
61
+ }
62
+ function assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options) {
63
+ const providerId = options.providerId;
60
64
  const metrics = aggregator.getMetrics();
61
65
  const model = options.model ?? metrics.currentModel ?? metrics.modelStats[0]?.model;
62
66
  const contextWindow = options.contextWindow ??
@@ -100,24 +104,46 @@ function buildSessionContextSnapshot(events, options = {}) {
100
104
  };
101
105
  }
102
106
  function createSessionContextProjector(options = {}) {
103
- let events = [];
107
+ let aggregator = createProjectorAggregator(options);
108
+ let sourceState = createSourceExtractionState(options);
109
+ let lastPermissionMode;
110
+ let lastRateLimits;
111
+ const process = (event) => {
112
+ aggregator.processEvent(event);
113
+ extractSourcesFromEvent(event, sourceState);
114
+ if (event.permissionMode)
115
+ lastPermissionMode = event.permissionMode;
116
+ if (event.rateLimits)
117
+ lastRateLimits = event.rateLimits;
118
+ };
119
+ const snapshot = () => assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options);
104
120
  return {
105
121
  processEvent(event) {
106
- events.push(event);
107
- return buildSessionContextSnapshot(events, options);
122
+ process(event);
123
+ return snapshot();
108
124
  },
109
125
  processEvents(newEvents) {
110
- events.push(...newEvents);
111
- return buildSessionContextSnapshot(events, options);
126
+ for (const event of newEvents)
127
+ process(event);
128
+ return snapshot();
112
129
  },
113
130
  getSnapshot() {
114
- return buildSessionContextSnapshot(events, options);
131
+ return snapshot();
115
132
  },
116
133
  reset() {
117
- events = [];
134
+ aggregator = createProjectorAggregator(options);
135
+ sourceState = createSourceExtractionState(options);
136
+ lastPermissionMode = undefined;
137
+ lastRateLimits = undefined;
118
138
  },
119
139
  };
120
140
  }
141
+ function createProjectorAggregator(options) {
142
+ return new EventAggregator_1.EventAggregator({
143
+ providerId: options.providerId,
144
+ computeContextSize: options.computeContextSize,
145
+ });
146
+ }
121
147
  function readSessionContextSnapshot(provider, sessionPath, options = {}) {
122
148
  const reader = provider.createReader(sessionPath);
123
149
  const events = reader.readAll();
@@ -177,7 +203,7 @@ function extractSourcesFromEvent(event, state) {
177
203
  layer: 'summary',
178
204
  sourceType: 'summary',
179
205
  title: 'Context summary',
180
- text: extractText(event.message.content) || 'Context compacted',
206
+ text: extractText(event.message?.content) || 'Context compacted',
181
207
  });
182
208
  break;
183
209
  }
@@ -394,7 +420,7 @@ function limitSources(sources, limit) {
394
420
  }
395
421
  function latestModel(events) {
396
422
  for (let i = events.length - 1; i >= 0; i--) {
397
- const model = events[i].message.model;
423
+ const model = events[i].message?.model;
398
424
  if (model)
399
425
  return model;
400
426
  }