quotacap 0.0.34 → 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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/advisory/engine.d.ts +8 -3
  3. package/dist/advisory/engine.js +80 -48
  4. package/dist/advisory/snapshot.js +37 -21
  5. package/dist/advisory/types.d.ts +28 -2
  6. package/dist/advisory/types.js +3 -0
  7. package/dist/catalog/agy.d.ts +4 -0
  8. package/dist/catalog/agy.js +59 -0
  9. package/dist/catalog/claude.d.ts +3 -0
  10. package/dist/catalog/claude.js +70 -0
  11. package/dist/catalog/codex.d.ts +3 -0
  12. package/dist/catalog/codex.js +55 -0
  13. package/dist/catalog/grok.d.ts +3 -0
  14. package/dist/catalog/grok.js +36 -0
  15. package/dist/catalog/index.d.ts +17 -0
  16. package/dist/catalog/index.js +152 -0
  17. package/dist/catalog/kimi.d.ts +3 -0
  18. package/dist/catalog/kimi.js +51 -0
  19. package/dist/catalog/muse.d.ts +4 -0
  20. package/dist/catalog/muse.js +67 -0
  21. package/dist/catalog/types.d.ts +21 -0
  22. package/dist/catalog/types.js +3 -0
  23. package/dist/cli/advise.js +20 -0
  24. package/dist/cli/clients.d.ts +3 -0
  25. package/dist/cli/clients.js +2 -0
  26. package/dist/cli/models.d.ts +3 -0
  27. package/dist/cli/models.js +134 -0
  28. package/dist/cli/snapshot-source.js +1 -0
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +2 -0
  31. package/dist/format/markdown.d.ts +1 -0
  32. package/dist/format/markdown.js +14 -0
  33. package/dist/format/rows.d.ts +1 -1
  34. package/dist/format/rows.js +24 -4
  35. package/dist/format/terminal.js +18 -1
  36. package/dist/http/server.d.ts +2 -0
  37. package/dist/http/server.js +100 -0
  38. package/dist/mcp/server.d.ts +13 -0
  39. package/dist/mcp/server.js +31 -3
  40. package/dist/runtime/poll.d.ts +13 -0
  41. package/dist/runtime/poll.js +34 -2
  42. package/dist/runtime/service.js +9 -0
  43. package/dist/store/catalogs.d.ts +10 -0
  44. package/dist/store/catalogs.js +64 -0
  45. package/dist/store/db.js +27 -1
  46. package/dist/store/quotas.d.ts +22 -0
  47. package/dist/store/quotas.js +93 -8
  48. package/dist/version.js +1 -1
  49. package/dist/web/src/components/ClosedWeeks.d.ts +16 -0
  50. package/dist/web/src/components/ClosedWeeks.js +102 -0
  51. package/dist/web/src/components/PaceBar.d.ts +17 -5
  52. package/dist/web/src/components/PaceBar.js +51 -8
  53. package/dist/web/src/components/PacingLegend.js +1 -0
  54. package/dist/web/src/components/ProviderCard.js +3 -1
  55. package/dist/web/src/components/ProviderDrawer.js +7 -1
  56. package/dist/web/src/components/ProviderRow.js +3 -1
  57. package/dist/web/src/components/Recommendation.js +12 -3
  58. package/dist/web/src/components/ResetRail.js +2 -0
  59. package/dist/web/src/state.d.ts +20 -1
  60. package/dist/web/src/state.js +1 -0
  61. package/dist/webAssets.js +1 -1
  62. package/dist/webHtml.js +1 -1
  63. package/package.json +1 -1
  64. package/web/dist/assets/index-Cm_QtC0G.js +12 -0
  65. package/web/dist/assets/index-DiOyUuQc.css +1 -0
  66. package/web/dist/index.html +2 -2
  67. package/web/dist/assets/index-CjNVp2Yx.css +0 -1
  68. package/web/dist/assets/index-irnos0UG.js +0 -12
@@ -61,7 +61,9 @@ export function railCells(ps, now) {
61
61
  return cells;
62
62
  }
63
63
  // Shared STATE vocabulary (locked badge mapping): any exclusion reads
64
- // Not reporting; at-risk status beats urgency; urgency maps to pace words.
64
+ // Not reporting; at-risk status beats urgency, then watch, then unknown;
65
+ // on-track rows ahead of cumulative elapsed time revive Ahead of pace;
66
+ // urgency maps to the remaining pace words.
65
67
  export function stateWord(ps) {
66
68
  if (ps.exclusionReason !== null)
67
69
  return "Not reporting";
@@ -70,8 +72,12 @@ export function stateWord(ps) {
70
72
  return "Not reporting";
71
73
  if (adv.status === "at risk")
72
74
  return "Cap risk";
75
+ if (adv.status === "watch")
76
+ return "Watch";
73
77
  if (adv.status === "unknown")
74
78
  return "Measuring";
79
+ if (adv.aheadOfElapsed === true)
80
+ return "Ahead of pace";
75
81
  switch (adv.urgency) {
76
82
  case "burn now":
77
83
  case "use soon":
@@ -124,6 +130,12 @@ export function forecastText(ps, now) {
124
130
  else if (adv.paceSource === "unknown" || adv.burnRate === null) {
125
131
  text = `Measuring pace; ${Math.round(adv.remaining)}% remains with ${adv.daysLeft.toFixed(1)}d until reset`;
126
132
  }
133
+ else if (adv.status === "watch") {
134
+ text =
135
+ adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust)
136
+ ? `Exhausts in ${adv.daysToExhaust.toFixed(1)}d — watch`
137
+ : "Near the cap — watch";
138
+ }
127
139
  else if (adv.wastePct !== null && adv.wastePct > 0) {
128
140
  text = `${Math.round(adv.wastePct)}% waste in ${adv.daysLeft.toFixed(1)}d`;
129
141
  }
@@ -141,8 +153,8 @@ export function forecastText(ps, now) {
141
153
  }
142
154
  return text;
143
155
  }
144
- // Compact legend: none = On track; ! = Cap risk; ~ = Behind/Ahead pace or
145
- // estimated reset; ? = unknown pace or any excluded row.
156
+ // Compact legend: none = On track; ! = Cap risk; ~ = Watch or Behind/Ahead
157
+ // pace or estimated reset; ? = unknown pace or any excluded row.
146
158
  export function compactSuffix(ps) {
147
159
  if (ps.exclusionReason !== null)
148
160
  return "?";
@@ -151,6 +163,8 @@ export function compactSuffix(ps) {
151
163
  return "?";
152
164
  if (adv.status === "at risk")
153
165
  return "!";
166
+ if (adv.status === "watch")
167
+ return "~";
154
168
  if (adv.urgency !== "on track" || ps.quota?.resetsAtEstimated)
155
169
  return "~";
156
170
  return "";
@@ -243,7 +257,13 @@ export function paceText(ps) {
243
257
  return "—";
244
258
  // avgPace is absent on advisories from a daemon older than the split.
245
259
  const avg = adv.avgPace ?? null;
246
- const recent = adv.paceSource === "recent" ? adv.burnRate : null;
260
+ // The 24h figure is the measured recentRate, never the blended forecast
261
+ // input; an older daemon omits the field and burnRate was the raw rate then.
262
+ const recent = adv.recentRate !== undefined
263
+ ? adv.recentRate
264
+ : adv.paceSource === "recent"
265
+ ? adv.burnRate
266
+ : null;
247
267
  if (avg !== null && recent !== null && avg.toFixed(1) !== recent.toFixed(1)) {
248
268
  return `${avg.toFixed(1)} avg · ${recent.toFixed(1)} 24h`;
249
269
  }
@@ -28,6 +28,7 @@ const STATE_COLORS = {
28
28
  "On track": "\x1b[32m",
29
29
  "Behind pace": "\x1b[33m",
30
30
  "Ahead of pace": "\x1b[93m",
31
+ "Watch": "\x1b[38;5;214m",
31
32
  "Cap risk": "\x1b[31m",
32
33
  "Measuring": "\x1b[90m",
33
34
  "Not reporting": "\x1b[90m",
@@ -76,6 +77,14 @@ export function renderWide(snapshot, opts) {
76
77
  const rows = sorted.map((p) => ({ p, row: buildRow(p, opts.now) }));
77
78
  const paceWidth = Math.max("PACE (%/DAY)".length, ...rows.map(({ row }) => row.pace.length));
78
79
  const paint = (s, code) => (color ? `${code}${s}${RESET}` : s);
80
+ const resetsIndent = " ".repeat(width + GAP.length + 6 + GAP.length + 8 + GAP.length + paceWidth + GAP.length + 20 + GAP.length);
81
+ const leftoverLine = (p) => {
82
+ const close = p.lastCloses?.[0];
83
+ if (!close)
84
+ return null;
85
+ const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
86
+ return `${resetsIndent}${paint(tail, DIM)}`;
87
+ };
79
88
  const header = [
80
89
  "PROVIDER".padEnd(width),
81
90
  "USED".padStart(6),
@@ -96,7 +105,7 @@ export function renderWide(snapshot, opts) {
96
105
  const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
97
106
  const state = row.state.padEnd(15);
98
107
  const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
99
- return [
108
+ const main = [
100
109
  color ? colorProviderField(name, recommended, row.state) : name,
101
110
  used,
102
111
  elapsed,
@@ -106,6 +115,8 @@ export function renderWide(snapshot, opts) {
106
115
  paint(state, STATE_COLORS[row.state]),
107
116
  forecast,
108
117
  ].join(GAP);
118
+ const leftover = leftoverLine(p);
119
+ return leftover ? `${main}\n${leftover}` : main;
109
120
  });
110
121
  return [paint(header, DIM), ...lines].join("\n");
111
122
  }
@@ -129,6 +140,12 @@ export function renderNarrow(snapshot, opts) {
129
140
  const paceBits = row.pace === "—" ? pace : `${pace} %/day`;
130
141
  const state = color ? `${STATE_COLORS[row.state]}${row.state}${RESET}` : row.state;
131
142
  lines.push(`${color ? colorProviderField(name, recommended, row.state) : name} ${used} used ${g.sep} ${elapsed} elapsed ${g.sep} ${paceBits}`, `${rail} resets ${countdown} ${g.sep} ${state} ${g.sep} ${forecast}`);
143
+ const close = p.lastCloses?.[0];
144
+ if (close) {
145
+ const indent = " ".repeat(row.railCells.length + " resets ".length);
146
+ const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
147
+ lines.push(`${indent}${color ? `${DIM}${tail}${RESET}` : tail}`);
148
+ }
132
149
  }
133
150
  return lines.join("\n");
134
151
  }
@@ -30,6 +30,8 @@ export interface RuntimeContext {
30
30
  exec?: string;
31
31
  }) => void;
32
32
  configPath?: string;
33
+ catalogTtlHours?: number;
34
+ catalogFetchers?: Record<string, any>;
33
35
  }
34
36
  export declare function testCtx(db: any, overrides?: Partial<RuntimeContext>): RuntimeContext;
35
37
  export declare function buildApp(ctx: RuntimeContext): FastifyInstance;
@@ -16,6 +16,8 @@ import { classifyFailure } from "../diagnostics/failure.js";
16
16
  import { validateDisplayName } from "../advisory/validation.js";
17
17
  import { providerIdentity } from "../advisory/provider-names.js";
18
18
  import { setProviderNameOverride } from "../config.js";
19
+ import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
20
+ import { getAllLatest } from "../store/quotas.js";
19
21
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
20
22
  export function hostnameFromHostHeader(hostHeader) {
21
23
  if (typeof hostHeader !== "string" || !hostHeader.trim())
@@ -208,6 +210,104 @@ export function buildApp(ctx) {
208
210
  };
209
211
  }
210
212
  });
213
+ // --- Model catalog ---
214
+ const catalogTtl = ctx.catalogTtlHours ?? 6;
215
+ function projectModels(catalogs, quotaMap) {
216
+ const out = [];
217
+ for (const [id, catalog] of catalogs) {
218
+ const identity = providerIdentity(id, ctx.providerNames);
219
+ const quota = quotaMap.get(id);
220
+ const leftoverPct = quota?.usedPct != null ? Math.round((100 - quota.usedPct) * 10) / 10 : null;
221
+ out.push({
222
+ id,
223
+ displayName: identity.displayName,
224
+ harness: identity.harness,
225
+ vendor: identity.vendor,
226
+ leftoverPct,
227
+ resetsAt: quota?.resetsAt ?? null,
228
+ catalog,
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+ app.get("/api/models", async (req, reply) => {
234
+ const providerParam = req.query?.provider;
235
+ const allProviders = catalogProviders(ctx.enabledProviders);
236
+ const providers = providerParam
237
+ ? [String(providerParam)]
238
+ : allProviders;
239
+ // Unknown provider: 400 before the (expensive) waiting ensure.
240
+ if (providerParam) {
241
+ const known = new Set(allProviders);
242
+ if (!known.has(providerParam)) {
243
+ return reply.status(400).send({ error: `unknown provider: ${providerParam}` });
244
+ }
245
+ }
246
+ const catalogs = await ensureCatalogs({
247
+ db: ctx.db,
248
+ wait: true,
249
+ providers,
250
+ ttlHours: catalogTtl,
251
+ now: ctx.now,
252
+ fetchers: ctx.catalogFetchers,
253
+ });
254
+ const quotas = getAllLatest(ctx.db);
255
+ const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
256
+ return projectModels(catalogs, quotaMap);
257
+ });
258
+ let lastModelsRefreshAt = 0;
259
+ app.post("/api/models/refresh", async (req, reply) => {
260
+ const headerToken = req.headers["x-quotacap-token"];
261
+ if (!isValidToken(headerToken, ctx.token)) {
262
+ return reply.status(401).send({ error: "unauthorized: missing or invalid X-QuotaCap-Token header" });
263
+ }
264
+ const providerParam = req.query?.provider ?? req.body?.provider;
265
+ const allProviders = catalogProviders(ctx.enabledProviders);
266
+ if (providerParam) {
267
+ const known = new Set(allProviders);
268
+ if (!known.has(providerParam)) {
269
+ return reply.status(400).send({ error: `unknown provider: ${providerParam}` });
270
+ }
271
+ }
272
+ const providers = providerParam ? [String(providerParam)] : allProviders;
273
+ const nowMs = (ctx.now?.() ?? new Date()).getTime();
274
+ if (lastModelsRefreshAt > 0 && nowMs - lastModelsRefreshAt < CATALOG_FAIL_COOLDOWN_MS) {
275
+ const catalogs = await ensureCatalogs({
276
+ db: ctx.db,
277
+ wait: false,
278
+ providers,
279
+ ttlHours: catalogTtl,
280
+ now: ctx.now,
281
+ fetchers: ctx.catalogFetchers,
282
+ });
283
+ const quotas = getAllLatest(ctx.db);
284
+ const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
285
+ const models = projectModels(catalogs, quotaMap);
286
+ return {
287
+ cooldown: true,
288
+ models,
289
+ results: models,
290
+ };
291
+ }
292
+ const catalogs = await ensureCatalogs({
293
+ db: ctx.db,
294
+ wait: true,
295
+ refresh: true,
296
+ providers,
297
+ ttlHours: catalogTtl,
298
+ now: ctx.now,
299
+ fetchers: ctx.catalogFetchers,
300
+ });
301
+ const quotas = getAllLatest(ctx.db);
302
+ const quotaMap = new Map(quotas.map((q) => [q.provider, q]));
303
+ const models = projectModels(catalogs, quotaMap);
304
+ lastModelsRefreshAt = nowMs;
305
+ return {
306
+ cooldown: false,
307
+ models,
308
+ results: models,
309
+ };
310
+ });
211
311
  app.patch("/api/providers/:id", async (req, reply) => {
212
312
  const headerToken = req.headers["x-quotacap-token"];
213
313
  if (!isValidToken(headerToken, ctx.token)) {
@@ -23,6 +23,19 @@ export declare const tools: ({
23
23
  };
24
24
  required?: undefined;
25
25
  };
26
+ } | {
27
+ name: string;
28
+ description: string;
29
+ inputSchema: {
30
+ type: string;
31
+ properties: {
32
+ provider: {
33
+ type: string;
34
+ };
35
+ task?: undefined;
36
+ };
37
+ required?: undefined;
38
+ };
26
39
  } | {
27
40
  name: string;
28
41
  description: string;
@@ -3,13 +3,15 @@ import { projectQuotasResponse, projectRecommendationResponse, } from "../adviso
3
3
  import { validateForecastProvider, validateTask } from "../advisory/validate.js";
4
4
  import { getDbPath, readConfig } from "../config.js";
5
5
  import { forecastText, stateWord } from "../format/rows.js";
6
- import { renderMarkdownTable, renderRecommendationSummary } from "../format/markdown.js";
7
- import { createServiceClientForBase } from "../runtime/client.js";
6
+ import { renderMarkdownTable, renderRecommendationSummary, renderModelsMarkdownTable } from "../format/markdown.js";
7
+ import { createServiceClientForBase, ServiceUnavailable } from "../runtime/client.js";
8
+ import { CATALOG_CLIENT_TIMEOUT_MS } from "../catalog/index.js";
8
9
  import { VERSION } from "../version.js";
9
10
  import { ClientError, OFFLINE_LABEL, resolveSnapshot, } from "../cli/snapshot-source.js";
10
11
  export const tools = [
11
12
  { name: "get_quotas", description: "All quotas with resets and health", inputSchema: { type: "object", properties: {}, required: [] } },
12
13
  { name: "get_recommendation", description: "Which provider to use next (same advice for every task)", inputSchema: { type: "object", properties: { task: { type: "string", enum: ["any", "heavy", "light"] } } } },
14
+ { name: "get_models", description: "Currently listed models per quota bucket (inventory, not a task ranking)", inputSchema: { type: "object", properties: { provider: { type: "string" } } } },
13
15
  { name: "forecast", description: "Burn (24h + window avg) vs ideal + waste for a provider", inputSchema: { type: "object", properties: { provider: { type: "string" } }, required: ["provider"] } },
14
16
  ];
15
17
  // QUOTACAP_URL is used as configured: scheme, authority, effective port and
@@ -72,6 +74,28 @@ export async function handleTool(name, args) {
72
74
  ],
73
75
  };
74
76
  }
77
+ if (name === "get_models") {
78
+ const provider = args?.provider;
79
+ const base = process.env.QUOTACAP_URL ?? "http://localhost:8787";
80
+ const client = createServiceClientForBase(base, { timeoutMs: CATALOG_CLIENT_TIMEOUT_MS });
81
+ const url = provider ? `/api/models?provider=${encodeURIComponent(provider)}` : "/api/models";
82
+ try {
83
+ const body = await client.get(url);
84
+ const list = Array.isArray(body) ? body : (body?.models ?? []);
85
+ return {
86
+ content: [
87
+ { type: "text", text: renderModelsMarkdownTable(list) },
88
+ { type: "text", text: JSON.stringify(body, null, 2) },
89
+ ],
90
+ };
91
+ }
92
+ catch (err) {
93
+ if (err instanceof ServiceUnavailable) {
94
+ throw new Error("model catalog unavailable (daemon unreachable)");
95
+ }
96
+ throw err;
97
+ }
98
+ }
75
99
  if (name === "forecast") {
76
100
  rejectMissingProvider(args);
77
101
  const { snapshot, source } = await resolveState();
@@ -91,6 +115,7 @@ export async function handleTool(name, args) {
91
115
  state: stateWord(ps),
92
116
  forecast: forecastText(ps, new Date()),
93
117
  lastAttempt: ps.lastAttempt,
118
+ lastCloses: ps.lastCloses,
94
119
  };
95
120
  return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
96
121
  }
@@ -137,8 +162,11 @@ export async function runMcpServer() {
137
162
  try {
138
163
  const result = await handleTool(toolName, toolArgs);
139
164
  const content = Array.isArray(result?.content) ? result.content : [{ type: "text", text: JSON.stringify(result, null, 2) }];
165
+ const res = { content };
166
+ if (result?.isError)
167
+ res.isError = true;
140
168
  if (!isNotification)
141
- respond(id, { content });
169
+ respond(id, res);
142
170
  }
143
171
  catch (e) {
144
172
  const content = [{ type: "text", text: e?.message ?? String(e) }];
@@ -54,5 +54,18 @@ export interface Coordinator {
54
54
  setClosing(): void;
55
55
  isClosing(): boolean;
56
56
  }
57
+ export declare const PRE_RESET_LEAD_MS: number;
58
+ export declare const PRE_RESET_MIN_REMAINING_MS: number;
59
+ export declare function nextPollDelayMs(opts: {
60
+ nowMs: number;
61
+ intervalMs: number;
62
+ latest: Array<{
63
+ provider: string;
64
+ resetsAt?: string | null;
65
+ resetsAtEstimated?: boolean;
66
+ }>;
67
+ lastCompletedPollAtMs: number | null;
68
+ enabledProviders: string[];
69
+ }): number;
57
70
  export declare function mapFailureCategory(reason: unknown): Exclude<FailureCategory, null>;
58
71
  export declare function createCoordinator(opts: CoordinatorOptions): Coordinator;
@@ -1,7 +1,7 @@
1
1
  // Poll coordinator: scheduled and manual refresh share one in-flight poll
2
2
  // with a completion-measured cooldown (decisions D3/D4).
3
3
  import { pollAll } from "../adapters/index.js";
4
- import { upsertQuota } from "../store/quotas.js";
4
+ import { getAllLatest, upsertQuota } from "../store/quotas.js";
5
5
  import { recordAttempt, getAttempt, } from "../store/attempts.js";
6
6
  import { classifyFailure, } from "../diagnostics/failure.js";
7
7
  export class ServiceClosing extends Error {
@@ -13,6 +13,32 @@ export class ServiceClosing extends Error {
13
13
  function defaultPollFn(enabled) {
14
14
  return pollAll(enabled);
15
15
  }
16
+ export const PRE_RESET_LEAD_MS = 5 * 60 * 1000;
17
+ export const PRE_RESET_MIN_REMAINING_MS = 25 * 1000;
18
+ export function nextPollDelayMs(opts) {
19
+ const { nowMs, intervalMs, latest, lastCompletedPollAtMs, enabledProviders } = opts;
20
+ let best = intervalMs;
21
+ for (const row of latest) {
22
+ const enabled = enabledProviders.includes(row.provider) ||
23
+ (row.provider.startsWith("agy:") && enabledProviders.includes("agy"));
24
+ if (!enabled || row.resetsAtEstimated || !row.resetsAt)
25
+ continue;
26
+ const resetsMs = new Date(row.resetsAt).getTime();
27
+ if (Number.isNaN(resetsMs) || resetsMs <= nowMs || resetsMs - nowMs < PRE_RESET_MIN_REMAINING_MS) {
28
+ continue;
29
+ }
30
+ const target = resetsMs - PRE_RESET_LEAD_MS;
31
+ if (lastCompletedPollAtMs !== null &&
32
+ lastCompletedPollAtMs >= target &&
33
+ lastCompletedPollAtMs < resetsMs) {
34
+ continue;
35
+ }
36
+ const raw = Math.max(0, target - nowMs);
37
+ const delay = raw === 0 ? 1000 : raw;
38
+ best = Math.min(best, Math.min(intervalMs, delay));
39
+ }
40
+ return best;
41
+ }
16
42
  export function mapFailureCategory(reason) {
17
43
  return classifyFailure("Provider", reason).category;
18
44
  }
@@ -42,7 +68,13 @@ export function createCoordinator(opts) {
42
68
  const detail = classifyFailure("all", e).errorDetail;
43
69
  console.warn("[quotacap] scheduled poll failed", detail);
44
70
  });
45
- }, intervalMs);
71
+ }, nextPollDelayMs({
72
+ nowMs: now(),
73
+ intervalMs,
74
+ latest: getAllLatest(db),
75
+ lastCompletedPollAtMs: lastCompletedPollAt ? completedAtMs : null,
76
+ enabledProviders,
77
+ }));
46
78
  }
47
79
  async function runGeneration() {
48
80
  const attemptedAt = new Date(now()).toISOString();
@@ -17,6 +17,7 @@ import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
18
18
  import { claudeAdapter } from "../adapters/claude.js";
19
19
  import { classifyFailure } from "../diagnostics/failure.js";
20
+ import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
20
21
  export function resolveClaudeExecPath() {
21
22
  try {
22
23
  const resolved = execFileSync("which", ["claude"], {
@@ -198,6 +199,7 @@ export async function startService(opts) {
198
199
  coordinator,
199
200
  enabledProviders: config.enabledProviders,
200
201
  providerNames: config.providerNames,
202
+ catalogTtlHours: config.catalogTtlHours,
201
203
  version: VERSION,
202
204
  exec: process.execPath,
203
205
  canWrite: () => claim.verify(),
@@ -231,6 +233,13 @@ export async function startService(opts) {
231
233
  console.warn("[quotacap] initial poll failed", detail);
232
234
  });
233
235
  coordinator.start(config.pollMinutes * 60 * 1000);
236
+ // Background catalog warm: wait: true after listen, never blocks /health ready.
237
+ void ensureCatalogs({
238
+ db,
239
+ wait: true,
240
+ providers: catalogProviders(config.enabledProviders),
241
+ ttlHours: config.catalogTtlHours,
242
+ }).catch(() => { });
234
243
  let stopping = false;
235
244
  let stopped = false;
236
245
  async function stop() {
@@ -0,0 +1,10 @@
1
+ import { type CatalogView, type ListedModel } from "../catalog/types.js";
2
+ export declare function getCatalog(db: any, provider: string): CatalogView;
3
+ export declare function getCatalogs(db: any): Map<string, CatalogView>;
4
+ export declare function upsertCatalog(db: any, provider: string, listed: ListedModel[], fetchedAt: string): void;
5
+ export declare function markCatalogFailure(db: any, provider: string, diag: {
6
+ diagnosticCode: string;
7
+ summary: string;
8
+ action: string;
9
+ errorDetail: string;
10
+ }): void;
@@ -0,0 +1,64 @@
1
+ import { emptyCatalog } from "../catalog/types.js";
2
+ function isMissingTable(e) {
3
+ return /no such table/i.test(e?.message ?? "");
4
+ }
5
+ function mapCatalog(r) {
6
+ return {
7
+ status: r.status,
8
+ fetchedAt: r.fetched_at ?? null,
9
+ listed: JSON.parse(r.models_json ?? "[]"),
10
+ diagnosticCode: r.diagnostic_code ?? null,
11
+ summary: r.summary ?? null,
12
+ action: r.action ?? null,
13
+ };
14
+ }
15
+ export function getCatalog(db, provider) {
16
+ let r;
17
+ try {
18
+ r = db.prepare(`SELECT * FROM model_catalogs WHERE provider=?`).get(provider);
19
+ }
20
+ catch (e) {
21
+ // Pre-catalog DB or an offline file the daemon has not migrated: read as
22
+ // unfetched. Never migrate or throw from a read helper.
23
+ if (isMissingTable(e))
24
+ return emptyCatalog();
25
+ throw e;
26
+ }
27
+ if (!r)
28
+ return emptyCatalog();
29
+ return mapCatalog(r);
30
+ }
31
+ export function getCatalogs(db) {
32
+ let rows;
33
+ try {
34
+ rows = db.prepare(`SELECT * FROM model_catalogs`).all();
35
+ }
36
+ catch (e) {
37
+ if (isMissingTable(e))
38
+ return new Map();
39
+ throw e;
40
+ }
41
+ return new Map(rows.map((r) => [r.provider, mapCatalog(r)]));
42
+ }
43
+ export function upsertCatalog(db, provider, listed, fetchedAt) {
44
+ db.prepare(`INSERT INTO model_catalogs(provider, status, models_json, fetched_at, diagnostic_code, summary, action, error_detail)
45
+ VALUES(?,?,?,?,NULL,NULL,NULL,NULL)
46
+ ON CONFLICT(provider) DO UPDATE SET status=excluded.status, models_json=excluded.models_json, fetched_at=excluded.fetched_at,
47
+ diagnostic_code=NULL, summary=NULL, action=NULL, error_detail=NULL`)
48
+ .run(provider, "ok", JSON.stringify(listed), fetchedAt);
49
+ }
50
+ export function markCatalogFailure(db, provider, diag) {
51
+ const row = db.prepare(`SELECT status FROM model_catalogs WHERE provider=?`).get(provider);
52
+ if (row) {
53
+ // A prior success degrades to stale and keeps models_json/fetched_at; a
54
+ // repeated failure on an error row stays error.
55
+ const status = row.status === "ok" ? "stale" : row.status;
56
+ db.prepare(`UPDATE model_catalogs SET status=?, diagnostic_code=?, summary=?, action=?, error_detail=? WHERE provider=?`)
57
+ .run(status, diag.diagnosticCode, diag.summary, diag.action, diag.errorDetail, provider);
58
+ }
59
+ else {
60
+ db.prepare(`INSERT INTO model_catalogs(provider, status, models_json, fetched_at, diagnostic_code, summary, action, error_detail)
61
+ VALUES(?, 'error', '[]', NULL, ?,?,?,?)`)
62
+ .run(provider, diag.diagnosticCode, diag.summary, diag.action, diag.errorDetail);
63
+ }
64
+ }
package/dist/store/db.js CHANGED
@@ -39,7 +39,33 @@ export function migrate(db) {
39
39
  db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL);
40
40
  CREATE TABLE IF NOT EXISTS snapshots(day TEXT, provider TEXT, used_pct REAL, burn_rate REAL, ideal_rate REAL, PRIMARY KEY(day, provider));
41
41
  CREATE TABLE IF NOT EXISTS adapter_attempts(provider TEXT PRIMARY KEY, attempted_at TEXT NOT NULL, completed_at TEXT, succeeded_at TEXT, success INTEGER NOT NULL, failure_category TEXT, diagnostic_code TEXT, summary TEXT, action TEXT, error_detail TEXT);
42
- CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider);`);
42
+ CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider);
43
+ CREATE TABLE IF NOT EXISTS model_catalogs(
44
+ provider TEXT PRIMARY KEY,
45
+ fetched_at TEXT,
46
+ status TEXT NOT NULL,
47
+ models_json TEXT NOT NULL,
48
+ diagnostic_code TEXT,
49
+ summary TEXT,
50
+ action TEXT,
51
+ error_detail TEXT
52
+ );
53
+ CREATE TABLE IF NOT EXISTS window_closes(
54
+ id INTEGER PRIMARY KEY,
55
+ provider TEXT NOT NULL,
56
+ plan TEXT,
57
+ used_pct REAL NOT NULL,
58
+ leftover_pct REAL NOT NULL,
59
+ sampled_at TEXT NOT NULL,
60
+ sampled_quota_id INTEGER NOT NULL,
61
+ period_start TEXT,
62
+ resets_at TEXT,
63
+ resets_at_estimated INTEGER,
64
+ detected_at TEXT NOT NULL,
65
+ reason TEXT NOT NULL,
66
+ UNIQUE(provider, sampled_quota_id)
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_window_closes_provider_detected ON window_closes(provider, detected_at DESC);`);
43
69
  try {
44
70
  const cols = db.prepare(`PRAGMA table_info(quotas)`).all();
45
71
  const names = new Set(cols.map((c) => c.name));
@@ -1,6 +1,28 @@
1
+ export declare function isWeeklyCycleBreak(prev: {
2
+ usedPct: number;
3
+ t: number;
4
+ resetsAt: string | null;
5
+ }, cur: {
6
+ usedPct: number;
7
+ t: number;
8
+ resetsAt: string | null;
9
+ }): boolean;
1
10
  export declare function upsertQuota(db: any, q: any): void;
11
+ export declare function getWindowCloses(db: any, provider: string, limit?: number): Array<{
12
+ provider: string;
13
+ plan: string | null;
14
+ usedPct: number;
15
+ leftoverPct: number;
16
+ sampledAt: string;
17
+ periodStart: string | null;
18
+ resetsAt: string | null;
19
+ resetsAtEstimated: boolean;
20
+ detectedAt: string;
21
+ reason: "usage-drop" | "resets-at-rolled" | "both";
22
+ }>;
2
23
  export declare function getLatestByProvider(db: any, provider: string): any;
3
24
  export declare function getAllLatest(db: any): any;
4
25
  export declare const getQuotas: typeof getAllLatest;
5
26
  export declare function getSnapshots(db: any): any;
6
27
  export declare function getBurnRates(db: any, now?: number): Map<string, number>;
28
+ export declare function getHistoryBaseline(db: any, limit?: number): Map<string, number>;