quotacap 0.0.33 → 0.0.35

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 (79) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/codex.js +7 -1
  3. package/dist/adapters/muse.js +3 -0
  4. package/dist/advisory/engine.d.ts +8 -3
  5. package/dist/advisory/engine.js +80 -48
  6. package/dist/advisory/snapshot.js +37 -21
  7. package/dist/advisory/types.d.ts +28 -2
  8. package/dist/advisory/types.js +3 -0
  9. package/dist/catalog/agy.d.ts +4 -0
  10. package/dist/catalog/agy.js +59 -0
  11. package/dist/catalog/claude.d.ts +3 -0
  12. package/dist/catalog/claude.js +70 -0
  13. package/dist/catalog/codex.d.ts +3 -0
  14. package/dist/catalog/codex.js +55 -0
  15. package/dist/catalog/grok.d.ts +3 -0
  16. package/dist/catalog/grok.js +36 -0
  17. package/dist/catalog/index.d.ts +17 -0
  18. package/dist/catalog/index.js +152 -0
  19. package/dist/catalog/kimi.d.ts +3 -0
  20. package/dist/catalog/kimi.js +51 -0
  21. package/dist/catalog/muse.d.ts +4 -0
  22. package/dist/catalog/muse.js +67 -0
  23. package/dist/catalog/types.d.ts +21 -0
  24. package/dist/catalog/types.js +3 -0
  25. package/dist/cli/advise.js +20 -0
  26. package/dist/cli/clients.d.ts +3 -0
  27. package/dist/cli/clients.js +2 -0
  28. package/dist/cli/models.d.ts +3 -0
  29. package/dist/cli/models.js +134 -0
  30. package/dist/cli/runtime.d.ts +8 -0
  31. package/dist/cli/runtime.js +32 -0
  32. package/dist/cli/snapshot-source.js +1 -0
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +2 -0
  35. package/dist/diagnostics/failure.d.ts +1 -1
  36. package/dist/diagnostics/failure.js +22 -0
  37. package/dist/format/markdown.d.ts +1 -0
  38. package/dist/format/markdown.js +14 -0
  39. package/dist/format/rows.d.ts +1 -1
  40. package/dist/format/rows.js +24 -4
  41. package/dist/format/terminal.js +18 -1
  42. package/dist/http/server.d.ts +2 -0
  43. package/dist/http/server.js +100 -0
  44. package/dist/mcp/server.d.ts +13 -0
  45. package/dist/mcp/server.js +31 -3
  46. package/dist/runtime/poll.d.ts +13 -0
  47. package/dist/runtime/poll.js +34 -2
  48. package/dist/runtime/service.js +9 -0
  49. package/dist/store/catalogs.d.ts +10 -0
  50. package/dist/store/catalogs.js +64 -0
  51. package/dist/store/db.js +27 -1
  52. package/dist/store/quotas.d.ts +22 -0
  53. package/dist/store/quotas.js +93 -8
  54. package/dist/version.js +1 -1
  55. package/dist/web/src/App.js +5 -7
  56. package/dist/web/src/components/ClosedWeeks.d.ts +16 -0
  57. package/dist/web/src/components/ClosedWeeks.js +102 -0
  58. package/dist/web/src/components/FaultBanner.d.ts +2 -1
  59. package/dist/web/src/components/FaultBanner.js +10 -2
  60. package/dist/web/src/components/Header.d.ts +3 -3
  61. package/dist/web/src/components/Header.js +5 -5
  62. package/dist/web/src/components/PaceBar.d.ts +17 -5
  63. package/dist/web/src/components/PaceBar.js +51 -8
  64. package/dist/web/src/components/PacingLegend.js +1 -0
  65. package/dist/web/src/components/ProviderCard.js +3 -1
  66. package/dist/web/src/components/ProviderDrawer.js +7 -1
  67. package/dist/web/src/components/ProviderRow.js +3 -1
  68. package/dist/web/src/components/Recommendation.js +12 -3
  69. package/dist/web/src/components/ResetRail.js +2 -0
  70. package/dist/web/src/state.d.ts +20 -1
  71. package/dist/web/src/state.js +1 -0
  72. package/dist/webAssets.js +1 -1
  73. package/dist/webHtml.js +1 -1
  74. package/package.json +1 -1
  75. package/web/dist/assets/index-Cm_QtC0G.js +12 -0
  76. package/web/dist/assets/index-DiOyUuQc.css +1 -0
  77. package/web/dist/index.html +2 -2
  78. package/web/dist/assets/index-4-7ERxB2.js +0 -12
  79. package/web/dist/assets/index-CjNVp2Yx.css +0 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
10
10
  ## Features
11
11
 
12
12
  - **Visibility**: remaining usage and reset time for one current window per connected plan
13
- - **Pacing**: recent usage when history is available, or an estimated pace while QuotaCap collects it
13
+ - **Pacing**: a blend of the last 24h and past closed windows, or a window-average pace while QuotaCap collects history
14
14
  - **Advice**: an estimate of which plan to use next so you can use more of each allowance without exhausting one early
15
15
  - **Dashboard, CLI, and MCP**: the same data and advice on every surface
16
16
 
@@ -52,6 +52,12 @@ function parseFiveReset(raw, now) {
52
52
  }
53
53
  export function parseCodexTui(text, now = new Date()) {
54
54
  const cleaned = stripAnsi(text);
55
+ if (/refresh token was already used|please log out and sign in again|access token could not be refreshed/i.test(cleaned)) {
56
+ throw new Error("codex: your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.");
57
+ }
58
+ if (/Limits:\s*refresh requested/i.test(cleaned)) {
59
+ throw new Error("codex: limits refresh requested, run /status again shortly");
60
+ }
55
61
  let weeklyLeft = null;
56
62
  let fiveLeft = null;
57
63
  let weeklyRaw = null;
@@ -146,7 +152,7 @@ export const codexAdapter = {
146
152
  // Complete on the /status panel only. The startup statusline footer
147
153
  // ("· 5h N% left · weekly N% left") carries no reset timestamps and
148
154
  // would otherwise win the race, forcing a drifting now+7d estimate.
149
- completionRegex: /Weekly limit:/i,
155
+ completionRegex: /Weekly limit:|Limits:\s*refresh requested|refresh token was already used|log out and sign in again/i,
150
156
  abortOn: /Do you trust|Trust.*folder|trust the files in this folder/i,
151
157
  timeoutMs: 12000,
152
158
  maxBytes: 256 * 1024,
@@ -14,6 +14,9 @@ export function parseMuseTui(text, now = new Date()) {
14
14
  // After ANSI stripping the panel is ONE line (cursor-addressed, no
15
15
  // newlines), so no pattern here may anchor on ^, $ or \n.
16
16
  const cleaned = stripAnsi(text);
17
+ if (/currently unavailable|subscriptions aren't currently available|subscription_unavailable/i.test(cleaned)) {
18
+ throw new Error("muse: subscription currently unavailable in TUI output");
19
+ }
17
20
  const weekly = cleaned.match(/Weekly\s+(\d+)%\s+used/i);
18
21
  if (!weekly)
19
22
  throw new Error("muse: weekly usage not found in TUI output");
@@ -1,5 +1,5 @@
1
1
  import type { Quota } from "../adapters/types.js";
2
- import type { Advisory, PaceSource, RecommendationBasis } from "./types.js";
2
+ import type { Advisory, RecommendationBasis } from "./types.js";
3
3
  /**
4
4
  * Pace from a single reading.
5
5
  *
@@ -10,8 +10,13 @@ import type { Advisory, PaceSource, RecommendationBasis } from "./types.js";
10
10
  * denominator fabricates verdicts).
11
11
  */
12
12
  export declare function averagePace(q: Quota, now?: Date): number | null;
13
- export declare function computeAdvisory(q: Quota, burnRate: number | null, now?: Date, paceSourceOrMeasured?: PaceSource | boolean): Advisory;
14
- export declare function recommend(quotas: Quota[], _task: string, burnByProvider?: Map<string, number>, now?: Date): {
13
+ export declare function blendForecast(recent: number | null, baseline: number | null): number | null;
14
+ export declare function baselineFor(historyAvg: number | null, q: Quota, now: Date): number | null;
15
+ export declare function computeAdvisory(q: Quota, recent: number | null, baseline: number | null, now?: Date): Advisory;
16
+ export declare function recommend(quotas: Quota[], _task: string, paceByProvider?: Map<string, {
17
+ recent: number | null;
18
+ baseline: number | null;
19
+ }>, now?: Date): {
15
20
  use: string;
16
21
  reason: string;
17
22
  wastePct: number;
@@ -1,4 +1,4 @@
1
- import { MIN_PACE_SPAN_DAYS } from "./types.js";
1
+ import { MIN_PACE_SPAN_DAYS, BLEND_K, RISK_MARGIN, GATE_TOL } from "./types.js";
2
2
  const DAY_MS = 86400000;
3
3
  /**
4
4
  * Pace from a single reading.
@@ -23,7 +23,41 @@ export function averagePace(q, now = new Date()) {
23
23
  return null;
24
24
  return Math.max(0, pace);
25
25
  }
26
- export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasured = "unknown") {
26
+ export function blendForecast(recent, baseline) {
27
+ if (recent === null)
28
+ return baseline;
29
+ if (baseline === null)
30
+ return recent;
31
+ return Math.max(0, baseline + BLEND_K * (recent - baseline));
32
+ }
33
+ export function baselineFor(historyAvg, q, now) {
34
+ if (historyAvg !== null)
35
+ return historyAvg;
36
+ return averagePace(q, now);
37
+ }
38
+ /**
39
+ * Position in the window from raw timestamps, clamped to 0..100. Derived
40
+ * elapsed time, never the clamped `daysLeft`: an estimated reset makes this
41
+ * fictional, which is exactly why the gate and the ahead test also check
42
+ * `resetsAtEstimated`. Returns null when either timestamp is missing or
43
+ * unparseable, the window has no positive span, or no time has elapsed.
44
+ */
45
+ function elapsedPctOf(q, now) {
46
+ if (!q.periodStart)
47
+ return null;
48
+ const start = new Date(q.periodStart).getTime();
49
+ const resets = new Date(q.resetsAt).getTime();
50
+ if (Number.isNaN(start) || Number.isNaN(resets))
51
+ return null;
52
+ const span = resets - start;
53
+ if (!(span > 0))
54
+ return null;
55
+ const elapsed = now.getTime() - start;
56
+ if (!(elapsed > 0))
57
+ return null;
58
+ return Math.min(100, Math.max(0, (elapsed / span) * 100));
59
+ }
60
+ export function computeAdvisory(q, recent, baseline, now = new Date()) {
27
61
  const resets = new Date(q.resetsAt);
28
62
  const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / DAY_MS);
29
63
  const remaining = 100 - q.usedPct;
@@ -32,20 +66,11 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
32
66
  // Nothing left to burn: force the capped verdict even when the recent
33
67
  // window is flat (burn 0 at 100% used must not read "on track").
34
68
  const exhausted = remaining <= 0;
35
- let paceSource;
36
- let burnMeasured;
37
- if (burnRate === null) {
38
- paceSource = "unknown";
39
- burnMeasured = false;
40
- }
41
- else if (typeof paceSourceOrMeasured === "string") {
42
- paceSource = paceSourceOrMeasured;
43
- burnMeasured = paceSource === "recent";
44
- }
45
- else {
46
- burnMeasured = paceSourceOrMeasured;
47
- paceSource = burnMeasured ? "recent" : "window-average";
48
- }
69
+ const burnRate = blendForecast(recent, baseline);
70
+ // paceSource describes recent availability; the blend's composition is
71
+ // carried by recentRate and baselineRate.
72
+ const paceSource = recent !== null ? "recent" : burnRate !== null ? "window-average" : "unknown";
73
+ const burnMeasured = recent !== null;
49
74
  if (burnRate === null) {
50
75
  return {
51
76
  provider: q.provider,
@@ -53,6 +78,9 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
53
78
  remaining,
54
79
  idealRate,
55
80
  burnRate: null,
81
+ recentRate: null,
82
+ baselineRate: null,
83
+ aheadOfElapsed: null,
56
84
  avgPace,
57
85
  burnMeasured: false,
58
86
  paceSource: "unknown",
@@ -69,6 +97,9 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
69
97
  remaining,
70
98
  idealRate,
71
99
  burnRate,
100
+ recentRate: null,
101
+ baselineRate: null,
102
+ aheadOfElapsed: null,
72
103
  avgPace,
73
104
  burnMeasured,
74
105
  paceSource,
@@ -78,10 +109,23 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
78
109
  urgency: "slow down",
79
110
  };
80
111
  }
112
+ // Red needs a deadband margin and a cumulatively-ahead position on a
113
+ // verified clock; a missing or estimated clock caps at Watch.
114
+ const elapsed = elapsedPctOf(q, now);
115
+ const estimated = q.resetsAtEstimated === true;
116
+ const gate = elapsed !== null && !estimated && q.usedPct >= elapsed * (1 - GATE_TOL);
117
+ const aheadOfElapsed = elapsed !== null && !estimated && q.usedPct > elapsed * (1 + GATE_TOL);
81
118
  const wastePct = Math.max(0, remaining - burnRate * daysLeft);
82
- // burn > ideal is exactly "quota exhausts before reset": remaining/burn < daysLeft
83
119
  const daysToExhaust = burnRate > 0 ? remaining / burnRate : Infinity;
84
- const status = daysToExhaust < daysLeft ? "at risk" : "on track";
120
+ const over = daysToExhaust < daysLeft * (1 - RISK_MARGIN);
121
+ const marginal = !over && daysToExhaust < daysLeft * (1 + RISK_MARGIN);
122
+ let status;
123
+ if (over && gate)
124
+ status = "at risk";
125
+ else if (over || marginal)
126
+ status = "watch";
127
+ else
128
+ status = "on track";
85
129
  let urgency = "on track";
86
130
  if (wastePct > 30 && daysLeft < 3)
87
131
  urgency = "burn now";
@@ -91,9 +135,11 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
91
135
  urgency = "slow down";
92
136
  else if (wastePct > 10)
93
137
  urgency = "save";
94
- return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
138
+ if (estimated && urgency === "burn now")
139
+ urgency = "use soon";
140
+ return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
95
141
  }
96
- export function recommend(quotas, _task, burnByProvider = new Map(), now = new Date()) {
142
+ export function recommend(quotas, _task, paceByProvider = new Map(), now = new Date()) {
97
143
  if (!quotas.length) {
98
144
  return {
99
145
  use: "none",
@@ -105,24 +151,23 @@ export function recommend(quotas, _task, burnByProvider = new Map(), now = new D
105
151
  advisories: [],
106
152
  };
107
153
  }
108
- // Build advisories for every quota with honest pace sources.
154
+ // Callers own the blend inputs; an entry without pace stays honest about it.
109
155
  const advisories = quotas.map(q => {
110
- const recent = burnByProvider.get(q.provider);
111
- if (recent !== undefined) {
112
- return computeAdvisory(q, recent, now, "recent");
113
- }
114
- const avg = averagePace(q, now);
115
- if (avg !== null) {
116
- return computeAdvisory(q, avg, now, "window-average");
117
- }
118
- return computeAdvisory(q, null, now, "unknown");
156
+ const pace = paceByProvider.get(q.provider);
157
+ if (pace)
158
+ return computeAdvisory(q, pace.recent, pace.baseline, now);
159
+ return computeAdvisory(q, null, null, now);
119
160
  });
161
+ // Ranking must not assert a fictional deadline: estimated-clock candidates
162
+ // are a fallback pool, read from the flags on the quotas already received.
163
+ const estimatedProviders = new Set(quotas.filter(q => q.resetsAtEstimated === true).map(q => q.provider));
120
164
  // 1. Prefer measured providers with positive avoidable waste (actionable "Use more" candidates).
121
165
  const positiveWaste = advisories.filter((a) => a.paceSource !== "unknown" && a.wastePct !== null && a.wastePct > 0);
122
166
  if (positiveWaste.length > 0) {
123
- const burnNow = positiveWaste.filter(a => a.urgency === "burn now");
124
- const pool = burnNow.length ? burnNow : positiveWaste;
125
- const use = pool.sort((a, b) => b.wastePct - a.wastePct)[0];
167
+ const verified = positiveWaste.filter(a => !estimatedProviders.has(a.provider));
168
+ const pool = verified.length ? verified : positiveWaste.filter(a => estimatedProviders.has(a.provider));
169
+ const burnNow = pool.filter(a => a.urgency === "burn now");
170
+ const use = (burnNow.length ? burnNow : pool).sort((a, b) => b.wastePct - a.wastePct)[0];
126
171
  return {
127
172
  use: use.provider,
128
173
  reason: `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`,
@@ -147,23 +192,10 @@ export function recommend(quotas, _task, burnByProvider = new Map(), now = new D
147
192
  advisories,
148
193
  };
149
194
  }
150
- // 3. Check if all measured providers are healthy and on pace (status "on track").
151
- const onTrack = advisories.filter(a => a.paceSource !== "unknown" && a.status === "on track" && a.remaining > 0);
152
- if (onTrack.length > 0) {
153
- return {
154
- use: "none",
155
- reason: "No subscription needs priority",
156
- wastePct: 0,
157
- idealRate: 0,
158
- recommendationBasis: "none",
159
- alternatives: quotas,
160
- advisories,
161
- };
162
- }
163
- // 4. No meaningful candidate (e.g. all quotas at risk or exhausted).
195
+ // 3. No meaningful candidate (e.g. all quotas at risk, watch, or exhausted).
164
196
  return {
165
197
  use: "none",
166
- reason: "all quotas at risk or exhausted",
198
+ reason: "all quotas at risk, watch, or exhausted",
167
199
  wastePct: 0,
168
200
  idealRate: 0,
169
201
  recommendationBasis: "none",
@@ -1,6 +1,8 @@
1
- import { getAllLatest, getBurnRates } from "../store/quotas.js";
1
+ import { getAllLatest, getBurnRates, getHistoryBaseline, getWindowCloses } from "../store/quotas.js";
2
2
  import { getAttempts } from "../store/attempts.js";
3
- import { recommend, averagePace, computeAdvisory } from "./engine.js";
3
+ import { getCatalogs } from "../store/catalogs.js";
4
+ import { emptyCatalog } from "../catalog/types.js";
5
+ import { recommend, baselineFor, computeAdvisory } from "./engine.js";
4
6
  import { providerIdentity } from "./provider-names.js";
5
7
  export const STALE_MS = 60 * 60 * 1000;
6
8
  function normalizeAdvisory(adv) {
@@ -12,6 +14,9 @@ function normalizeAdvisory(adv) {
12
14
  remaining: Number.isFinite(adv.remaining) ? adv.remaining : 0,
13
15
  idealRate: Number.isFinite(adv.idealRate) ? adv.idealRate : 0,
14
16
  burnRate: adv.burnRate !== null && Number.isFinite(adv.burnRate) ? adv.burnRate : null,
17
+ recentRate: adv.recentRate != null && Number.isFinite(adv.recentRate) ? adv.recentRate : null,
18
+ baselineRate: adv.baselineRate != null && Number.isFinite(adv.baselineRate) ? adv.baselineRate : null,
19
+ aheadOfElapsed: typeof adv.aheadOfElapsed === "boolean" ? adv.aheadOfElapsed : null,
15
20
  avgPace: adv.avgPace !== null && Number.isFinite(adv.avgPace) ? adv.avgPace : null,
16
21
  daysToExhaust: adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust) ? adv.daysToExhaust : null,
17
22
  wastePct: adv.wastePct !== null && Number.isFinite(adv.wastePct) ? adv.wastePct : null,
@@ -37,9 +42,11 @@ export function buildSnapshot(db, opts) {
37
42
  }
38
43
  const unionIds = Array.from(new Set([...opts.enabledProviders, ...storedIds])).sort((a, b) => a.localeCompare(b));
39
44
  const burnRates = getBurnRates(db, asOfMs);
45
+ const history = getHistoryBaseline(db);
46
+ const catalogMap = getCatalogs(db);
40
47
  const providerSnapshots = [];
41
48
  const eligibleQuotas = [];
42
- const burnSubset = new Map();
49
+ const paceByProvider = new Map();
43
50
  const allValidAdvisories = [];
44
51
  for (const id of unionIds) {
45
52
  const quota = quotaMap.get(id) ?? null;
@@ -103,22 +110,15 @@ export function buildSnapshot(db, opts) {
103
110
  // Reporting requires a quota row with no stale, reset-passed, invalid, or failed flags.
104
111
  // Offline (runtime.available=false) forces reporting=false for stale rows.
105
112
  const reporting = quota !== null && exclusionReason === null;
106
- // Advisory computation
113
+ // Advisory computation: the engine blends recent toward the history
114
+ // baseline internally; this loop is the single place both inputs form.
107
115
  let rawAdvisory = null;
116
+ let pace = null;
108
117
  if (quota !== null && !isInvalid) {
109
- const recentBurn = burnRates.get(id);
110
- if (recentBurn !== undefined) {
111
- rawAdvisory = computeAdvisory(quota, recentBurn, now, "recent");
112
- }
113
- else {
114
- const avg = averagePace(quota, now);
115
- if (avg !== null) {
116
- rawAdvisory = computeAdvisory(quota, avg, now, "window-average");
117
- }
118
- else {
119
- rawAdvisory = computeAdvisory(quota, null, now, "unknown");
120
- }
121
- }
118
+ const recent = burnRates.get(id) ?? null;
119
+ const baseline = baselineFor(history.get(id) ?? null, quota, now);
120
+ pace = { recent, baseline };
121
+ rawAdvisory = computeAdvisory(quota, recent, baseline, now);
122
122
  }
123
123
  const advisory = normalizeAdvisory(rawAdvisory);
124
124
  // Controlled evidence vocabulary
@@ -137,10 +137,10 @@ export function buildSnapshot(db, opts) {
137
137
  }
138
138
  if (enabled && quota !== null && exclusionReason === null) {
139
139
  eligibleQuotas.push(quota);
140
- if (burnRates.has(id)) {
141
- burnSubset.set(id, burnRates.get(id));
142
- }
140
+ if (pace)
141
+ paceByProvider.set(id, pace);
143
142
  }
143
+ const catalog = catalogMap.get(id) ?? emptyCatalog();
144
144
  providerSnapshots.push({
145
145
  id,
146
146
  ...providerIdentity(id, opts.providerNames),
@@ -155,6 +155,8 @@ export function buildSnapshot(db, opts) {
155
155
  evidence,
156
156
  exclusionReason,
157
157
  advisory,
158
+ lastCloses: getWindowCloses(db, id, 4),
159
+ catalog,
158
160
  });
159
161
  }
160
162
  // 2. Recommendation: ranked from eligible providers only; advisories cover all valid rows
@@ -166,12 +168,15 @@ export function buildSnapshot(db, opts) {
166
168
  wastePct: null,
167
169
  idealRate: 0,
168
170
  recommendationBasis: "none",
171
+ models: [],
172
+ catalogStatus: "unfetched",
173
+ catalogFetchedAt: null,
169
174
  alternatives: [],
170
175
  advisories: allValidAdvisories,
171
176
  };
172
177
  }
173
178
  else {
174
- const rec = recommend(eligibleQuotas, "any", burnSubset, now);
179
+ const rec = recommend(eligibleQuotas, "any", paceByProvider, now);
175
180
  const chosenProvider = eligibleQuotas.find((q) => q.provider === rec.use);
176
181
  let reason = rec.reason;
177
182
  if (chosenProvider?.resetsAtEstimated) {
@@ -185,11 +190,20 @@ export function buildSnapshot(db, opts) {
185
190
  reason = `${reason} (estimated)`;
186
191
  }
187
192
  }
193
+ const pickCatalog = catalogMap.get(rec.use) ?? emptyCatalog();
194
+ const alternativesWithCatalog = (rec.alternatives ?? []).map((alt) => ({
195
+ ...alt,
196
+ catalog: catalogMap.get(alt.provider) ?? emptyCatalog(),
197
+ }));
188
198
  recommendation = {
189
199
  ...rec,
190
200
  reason,
191
201
  wastePct: rec.wastePct !== null && Number.isFinite(rec.wastePct) ? rec.wastePct : null,
192
202
  idealRate: Number.isFinite(rec.idealRate) ? rec.idealRate : 0,
203
+ models: pickCatalog.listed,
204
+ catalogStatus: rec.use === "none" ? "unfetched" : pickCatalog.status,
205
+ catalogFetchedAt: pickCatalog.fetchedAt,
206
+ alternatives: alternativesWithCatalog,
193
207
  advisories: allValidAdvisories,
194
208
  };
195
209
  }
@@ -214,6 +228,8 @@ export function projectQuotasResponse(s) {
214
228
  ageMs: p.ageMs,
215
229
  evidence: p.evidence,
216
230
  exclusionReason: p.exclusionReason,
231
+ lastCloses: p.lastCloses,
232
+ catalog: p.catalog,
217
233
  }));
218
234
  }
219
235
  export function projectRecommendationResponse(s, _task = "any") {
@@ -1,5 +1,6 @@
1
1
  import type { Quota } from "../adapters/types.js";
2
2
  import type { AttemptRecord } from "../store/attempts.js";
3
+ import type { CatalogStatus, CatalogView, ListedModel } from "../catalog/types.js";
3
4
  /**
4
5
  * Minimum data span before a pace annualizes, in days. Under 6h a single
5
6
  * 1% quantum annualizes to 4%/day of noise — enough to fabricate Cap-risk
@@ -7,8 +8,11 @@ import type { AttemptRecord } from "../store/attempts.js";
7
8
  * (elapsed time) and the recent rate (reading span) alike.
8
9
  */
9
10
  export declare const MIN_PACE_SPAN_DAYS: number;
11
+ export declare const BLEND_K = 0.5;
12
+ export declare const RISK_MARGIN = 0.15;
13
+ export declare const GATE_TOL = 0.1;
10
14
  export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track";
11
- export type BurnStatus = "at risk" | "on track" | "unknown";
15
+ export type BurnStatus = "at risk" | "watch" | "on track" | "unknown";
12
16
  export type PaceSource = "recent" | "window-average" | "unknown";
13
17
  export type RecommendationBasis = "known-waste" | "unknown-headroom" | "none";
14
18
  export type ExclusionReason = "not-reporting" | "stale" | "reset-passed" | "invalid" | "provider-failed" | null;
@@ -19,6 +23,9 @@ export interface Advisory {
19
23
  remaining: number;
20
24
  idealRate: number;
21
25
  burnRate: number | null;
26
+ recentRate?: number | null;
27
+ baselineRate?: number | null;
28
+ aheadOfElapsed?: boolean | null;
22
29
  /**
23
30
  * Window-average pace (used % ÷ days elapsed), always computed when the
24
31
  * window start is known — independent of `burnRate`, which is the forecast
@@ -39,9 +46,26 @@ export interface Recommendation {
39
46
  wastePct: number | null;
40
47
  idealRate: number;
41
48
  recommendationBasis: RecommendationBasis;
42
- alternatives: any[];
49
+ models: ListedModel[];
50
+ catalogStatus: CatalogStatus;
51
+ catalogFetchedAt: string | null;
52
+ alternatives: Array<Quota & {
53
+ catalog: CatalogView;
54
+ }>;
43
55
  advisories: Advisory[];
44
56
  }
57
+ export interface WindowClose {
58
+ provider: string;
59
+ plan: string | null;
60
+ usedPct: number;
61
+ leftoverPct: number;
62
+ sampledAt: string;
63
+ periodStart: string | null;
64
+ resetsAt: string | null;
65
+ resetsAtEstimated: boolean;
66
+ detectedAt: string;
67
+ reason: "usage-drop" | "resets-at-rolled" | "both";
68
+ }
45
69
  export interface ProviderSnapshot {
46
70
  id: string;
47
71
  displayName: string;
@@ -60,6 +84,8 @@ export interface ProviderSnapshot {
60
84
  evidence: string[];
61
85
  exclusionReason: ExclusionReason;
62
86
  advisory: Advisory | null;
87
+ lastCloses: WindowClose[];
88
+ catalog: CatalogView;
63
89
  }
64
90
  export interface UpdateStatus {
65
91
  current: string;
@@ -5,3 +5,6 @@
5
5
  * (elapsed time) and the recent rate (reading span) alike.
6
6
  */
7
7
  export const MIN_PACE_SPAN_DAYS = 6 / 24;
8
+ export const BLEND_K = 0.5;
9
+ export const RISK_MARGIN = 0.15;
10
+ export const GATE_TOL = 0.10;
@@ -0,0 +1,4 @@
1
+ import type { CatalogFetcher, ListedModel } from "./types.js";
2
+ export declare function agyBucketForModelId(id: string): "agy" | "agy:3p" | null;
3
+ export declare function parseAgyModels(stdout: string): Record<"agy" | "agy:3p", ListedModel[]>;
4
+ export declare const agyCatalogFetcher: CatalogFetcher;
@@ -0,0 +1,59 @@
1
+ import { trackedExecFile } from "../runtime/spawn.js";
2
+ // Pinned split rule (design §6.1): one `agy models` list covers both quota
3
+ // buckets; the id prefix decides which. Unmatched ids are omitted from both.
4
+ export function agyBucketForModelId(id) {
5
+ const s = id.trim().toLowerCase();
6
+ if (s.startsWith("gemini"))
7
+ return "agy";
8
+ if (s.startsWith("claude") || s.startsWith("gpt"))
9
+ return "agy:3p";
10
+ return null;
11
+ }
12
+ export function parseAgyModels(stdout) {
13
+ const out = { agy: [], "agy:3p": [] };
14
+ const rows = [];
15
+ for (const line of stdout.split(/\r?\n/)) {
16
+ const t = line.trim();
17
+ if (!t)
18
+ continue;
19
+ if (/^Fetching available models/i.test(t))
20
+ continue;
21
+ const tab = t.indexOf("\t");
22
+ const id = (tab < 0 ? t : t.slice(0, tab)).trim();
23
+ const displayName = (tab < 0 ? "" : t.slice(tab + 1).trim()) || id;
24
+ if (id)
25
+ rows.push({ id, displayName });
26
+ }
27
+ // Empty stdout after a successful exit is a parse failure, not an empty picker.
28
+ if (rows.length === 0)
29
+ throw new Error("agy: no model rows in `agy models` output");
30
+ let matched = 0;
31
+ for (const row of rows) {
32
+ const bucket = agyBucketForModelId(row.id);
33
+ if (!bucket)
34
+ continue;
35
+ matched++;
36
+ out[bucket].push(row);
37
+ }
38
+ // Rows came back but the classifier recognized none: the picker drifted.
39
+ if (matched === 0)
40
+ throw new Error("agy: no `agy models` row matched the bucket classifier");
41
+ return out;
42
+ }
43
+ const EXEC_TIMEOUT_MS = 8000;
44
+ export const agyCatalogFetcher = {
45
+ id: "agy",
46
+ async fetch() {
47
+ // Per-fetch catalog-owned signal: never the usage poll's shared controller.
48
+ const { stdout } = await trackedExecFile("agy", "agy", ["models"], {
49
+ timeout: EXEC_TIMEOUT_MS,
50
+ signal: new AbortController().signal,
51
+ });
52
+ try {
53
+ return parseAgyModels(stdout);
54
+ }
55
+ catch (e) {
56
+ throw new Error(`agy: failed to parse \`agy models\` output: ${e?.message ?? e}`);
57
+ }
58
+ },
59
+ };
@@ -0,0 +1,3 @@
1
+ import type { CatalogFetcher, ListedModel } from "./types.js";
2
+ export declare function parseClaudeModelSlash(jsonText: string): ListedModel[];
3
+ export declare const claudeCatalogFetcher: CatalogFetcher;
@@ -0,0 +1,70 @@
1
+ import { trackedExecFile } from "../runtime/spawn.js";
2
+ import { claudeAdapter } from "../adapters/claude.js";
3
+ // `Current model: `Name`` short names onto picker aliases (design §6.4).
4
+ const CLAUDE_SHORT_NAMES = {
5
+ opus: "opus",
6
+ sonnet: "sonnet",
7
+ haiku: "haiku",
8
+ fable: "fable",
9
+ };
10
+ // `claude -p /model --output-format json` with no extra arg lists aliases in
11
+ // the result text. A positive num_turns / total_cost_usd means a real model
12
+ // turn ran (the print-mode failure mode), not the slash listing.
13
+ export function parseClaudeModelSlash(jsonText) {
14
+ const data = JSON.parse(jsonText);
15
+ if (typeof data?.num_turns === "number" && data.num_turns > 0) {
16
+ throw new Error("claude: /model JSON is a model turn (num_turns > 0), not a catalog");
17
+ }
18
+ if (typeof data?.total_cost_usd === "number" && data.total_cost_usd > 0) {
19
+ throw new Error("claude: /model JSON is a model turn (total_cost_usd > 0), not a catalog");
20
+ }
21
+ const result = data?.result;
22
+ if (typeof result !== "string" || !result) {
23
+ throw new Error("claude: /model JSON has no result text");
24
+ }
25
+ const availIdx = result.indexOf("Available:");
26
+ if (availIdx < 0)
27
+ throw new Error("claude: /model result has no Available list");
28
+ let tail = result.slice(availIdx + "Available:".length);
29
+ const stop = tail.search(/,\s*or a full model ID/i);
30
+ if (stop >= 0)
31
+ tail = tail.slice(0, stop);
32
+ const ids = tail
33
+ .split(",")
34
+ .map((t) => t.trim().replace(/[.\s]+$/g, ""))
35
+ .filter((t) => t.length > 0 && t.toLowerCase() !== "default");
36
+ if (ids.length === 0)
37
+ throw new Error("claude: /model Available list had no usable aliases");
38
+ let defaultAlias = null;
39
+ const cur = result.match(/Current model:\s*`([^`]+)`/i);
40
+ if (cur) {
41
+ const first = cur[1].trim().split(/\s+/)[0]?.toLowerCase();
42
+ if (first && CLAUDE_SHORT_NAMES[first])
43
+ defaultAlias = CLAUDE_SHORT_NAMES[first];
44
+ }
45
+ return ids.map((id) => {
46
+ const entry = { id, displayName: id };
47
+ if (defaultAlias !== null && id === defaultAlias)
48
+ entry.default = true;
49
+ return entry;
50
+ });
51
+ }
52
+ const EXEC_TIMEOUT_MS = 8000;
53
+ export const claudeCatalogFetcher = {
54
+ id: "claude",
55
+ async fetch() {
56
+ // Reuse the pinned claude path the daemon resolves (claudeAdapter.execPath).
57
+ const bin = claudeAdapter.execPath ?? "claude";
58
+ // Per-fetch catalog-owned signal: never the usage poll's shared controller.
59
+ const { stdout } = await trackedExecFile("claude", bin, ["-p", "/model", "--output-format", "json"], {
60
+ timeout: EXEC_TIMEOUT_MS,
61
+ signal: new AbortController().signal,
62
+ });
63
+ try {
64
+ return { claude: parseClaudeModelSlash(stdout) };
65
+ }
66
+ catch (e) {
67
+ throw new Error(`claude: failed to parse \`claude -p /model\` output: ${e?.message ?? e}`);
68
+ }
69
+ },
70
+ };
@@ -0,0 +1,3 @@
1
+ import type { CatalogFetcher, ListedModel } from "./types.js";
2
+ export declare function parseCodexDebugModels(jsonText: string): ListedModel[];
3
+ export declare const codexCatalogFetcher: CatalogFetcher;