quotacap 0.0.52 → 0.0.53

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.
@@ -29,6 +29,8 @@ export interface RecoveryDeps {
29
29
  */
30
30
  export declare function recoverSubscription(deps: RecoveryDeps): Promise<ParsedQuota>;
31
31
  export declare function museProbeDir(): string;
32
+ export declare function parseMusePlan(cleaned: string): string;
33
+ export declare function parseMuseQuotaExhausted(text: string, plan?: string, now?: Date): ParsedQuota | null;
32
34
  export declare function parseMuseTui(text: string, now?: Date, dims?: Partial<GridDims>): ParsedQuota;
33
35
  export declare const museAdapter: {
34
36
  id: string;
@@ -5,7 +5,7 @@ import { parseResetText } from "./parse.js";
5
5
  import { runPty } from "./pty.js";
6
6
  import { parseScreens } from "./vt.js";
7
7
  import { adapterSignal, trackedExecFile } from "../runtime/spawn.js";
8
- import { attachEvidence } from "../diagnostics/failure.js";
8
+ import { attachEvidence, getErrorEvidence } from "../diagnostics/failure.js";
9
9
  /** Canonical unavailability error: parseMuseTui throws it, poll() recognises it
10
10
  * to start recovery, and failure.ts classifies its text as service_unavailable. */
11
11
  export const MUSE_UNAVAILABLE_MESSAGE = "muse: subscription currently unavailable in TUI output";
@@ -48,6 +48,19 @@ export async function recoverSubscription(deps) {
48
48
  catch (warmError) {
49
49
  if (aborted())
50
50
  throw warmError;
51
+ const ev = getErrorEvidence(warmError);
52
+ const text = [
53
+ warmError?.message,
54
+ ev?.stdout,
55
+ ev?.stderr,
56
+ ].filter(Boolean).join("\n");
57
+ const unavailEv = getErrorEvidence(unavailable);
58
+ const plan = parseMusePlan(unavailEv?.stdout ?? "");
59
+ const quota = parseMuseQuotaExhausted(text, plan, new Date(now()));
60
+ if (quota) {
61
+ log(`subscription exhausted; parsed 100% quota from warm turn (resets ${quota.resetsAt})`);
62
+ return quota;
63
+ }
51
64
  log(`warm attempt ${attempt} failed: ${warmError?.message}`);
52
65
  continue;
53
66
  }
@@ -76,6 +89,32 @@ export async function recoverSubscription(deps) {
76
89
  export function museProbeDir() {
77
90
  return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "muse-probe");
78
91
  }
92
+ export function parseMusePlan(cleaned) {
93
+ const planMatch = cleaned.match(/Subscription\s*·\s*Muse Code\s+(.+?)(?:\s{2,}|\r?\n|$)/i);
94
+ return planMatch ? planMatch[1].trim() : "unknown";
95
+ }
96
+ export function parseMuseQuotaExhausted(text, plan = "unknown", now = new Date()) {
97
+ const match = text.match(/Subscription quota exhausted\.?\s+Your usage window resets at\s+(\S+?)(?:\.|\s|$)/i);
98
+ if (!match)
99
+ return null;
100
+ const resetIso = match[1];
101
+ const resetMs = Date.parse(resetIso);
102
+ if (!Number.isFinite(resetMs) || resetMs <= now.getTime())
103
+ return null;
104
+ const resetsAt = new Date(resetMs).toISOString();
105
+ const periodStart = new Date(resetMs - 7 * 86400000).toISOString();
106
+ return {
107
+ provider: "muse",
108
+ plan,
109
+ weeklyPct: 100,
110
+ fiveHourPct: 0,
111
+ resetsAt,
112
+ periodStart,
113
+ source: "tui",
114
+ fetchedAt: now.toISOString(),
115
+ raw: text.slice(0, 4096),
116
+ };
117
+ }
79
118
  export function parseMuseTui(text, now = new Date(), dims) {
80
119
  return parseScreens(text, dims, (cleaned) => parseMuseCleaned(cleaned, now));
81
120
  }
@@ -101,10 +140,7 @@ function parseMuseCleaned(cleaned, now) {
101
140
  // First match wins; overdrawn transcripts carry two identical copies.
102
141
  // Vendor casing preserved (grok precedent: SuperGrok); the Muse Code
103
142
  // product prefix is stripped because the product is already the row label.
104
- let plan = "unknown";
105
- const planMatch = cleaned.match(/Subscription\s*·\s*Muse Code\s+(.+?)\s{2,}/i);
106
- if (planMatch)
107
- plan = planMatch[1].trim();
143
+ const plan = parseMusePlan(cleaned);
108
144
  // The Current-window reset ("Resets at 3:51 PM") is a bare clock time
109
145
  // with no date: resolve it against the panel's Brisbane date, rolling to
110
146
  // tomorrow once today's time has passed. Display-only; a missing or
@@ -157,18 +157,18 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
157
157
  if (/\btrust this folder\b/i.test(text)) {
158
158
  return { code: "trust_prompt", phrase: "trust prompt" };
159
159
  }
160
- // Order 5: rate_limit — rate limit, rate_limit, too many requests, quota exceeded, or explicit HTTP status 429
160
+ // Order 5: rate_limit — rate limit, rate_limit, rate_limit_error, too many requests, quota exceeded/exhausted, or explicit HTTP/API status 429
161
161
  // A bare number 429 is insufficient
162
- if (/\brate[ _]limit\b/i.test(text)) {
162
+ if (/\brate[ _]limit(?:_error)?\b/i.test(text)) {
163
163
  return { code: "rate_limit", phrase: "rate limit" };
164
164
  }
165
165
  if (/\btoo many requests\b/i.test(text)) {
166
166
  return { code: "rate_limit", phrase: "too many requests" };
167
167
  }
168
- if (/\bquota exceeded\b/i.test(text)) {
168
+ if (/\bquota (?:exceeded|exhausted)\b/i.test(text)) {
169
169
  return { code: "rate_limit", phrase: "quota exceeded" };
170
170
  }
171
- if (/\b(?:status|code|http)\s*:?\s*429\b/i.test(text) || /\b429\s+too many requests\b/i.test(text)) {
171
+ if (/\b(?:status|code|http|api error)\s*:?\s*429\b/i.test(text) || /\b429\s+too many requests\b/i.test(text)) {
172
172
  return { code: "rate_limit", phrase: "HTTP 429" };
173
173
  }
174
174
  // Order 6: auth — login required, not logged in, logged out, unauthorized, unauthorised, forbidden,
@@ -141,9 +141,10 @@ export function usedPctOf(ps) {
141
141
  }
142
142
  export function railCells(ps, now) {
143
143
  const cells = new Array(RAIL_CELLS).fill("unused");
144
- if (ps.exclusionReason !== null)
145
- return cells;
146
144
  const u = bindingUsedPct(ps);
145
+ const is100Exhausted = typeof u === "number" && u >= 100 && !ps.resetPassed;
146
+ if (ps.exclusionReason !== null && !is100Exhausted)
147
+ return cells;
147
148
  const used = typeof u === "number" && Number.isFinite(u)
148
149
  ? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
149
150
  : 0;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.52";
2
+ export const VERSION = "0.0.53";
@@ -190,12 +190,13 @@ export interface PaceHeadline {
190
190
  /** True when the projected-unused hatch belongs on the bar. */
191
191
  hatch: boolean;
192
192
  }
193
+ export declare function isQuotaExhaustedBeforeReset(provider: ProviderView, asOfMs?: number): boolean;
193
194
  /**
194
195
  * Headline forecast shared by the card bar and the drawer quota row:
195
196
  * "expires unused" wording when a waste hatch applies, otherwise the
196
197
  * one-line forecast. Null when there is nothing to forecast.
197
198
  */
198
- export declare function paceHeadline(provider: ProviderView): PaceHeadline | null;
199
+ export declare function paceHeadline(provider: ProviderView, asOfMs?: number): PaceHeadline | null;
199
200
  /**
200
201
  * Two-tier pacing rail: used fill, elapsed tick, projected-unused hatch.
201
202
  * Unknown pace shows fill and tick but no hatch and no numeric rate.
@@ -503,16 +503,41 @@ export function Badge({ provider, compact = false, }) {
503
503
  : badgeTitle(badge, quotaResetEstimated(provider));
504
504
  return (_jsxs("span", { "data-testid": "pace-badge", className: `pace ${badgeClass}`, title: title, children: [badge, !compact && detail ? ` · ${detail}` : ""] }));
505
505
  }
506
+ export function isQuotaExhaustedBeforeReset(provider, asOfMs) {
507
+ if (provider.resetPassed)
508
+ return false;
509
+ const resetIso = quotaResetsAt(provider);
510
+ if (!resetIso)
511
+ return false;
512
+ const resetMs = Date.parse(resetIso);
513
+ if (Number.isFinite(resetMs) && asOfMs !== undefined && resetMs <= asOfMs)
514
+ return false;
515
+ const used = quotaUsedPct(provider);
516
+ if (used !== null && used >= 100)
517
+ return true;
518
+ if (isMonthlyBound(provider) && provider.quota?.monthlyStatus === "exhausted")
519
+ return true;
520
+ return false;
521
+ }
506
522
  /**
507
523
  * Headline forecast shared by the card bar and the drawer quota row:
508
524
  * "expires unused" wording when a waste hatch applies, otherwise the
509
525
  * one-line forecast. Null when there is nothing to forecast.
510
526
  */
511
- export function paceHeadline(provider) {
527
+ export function paceHeadline(provider, asOfMs) {
512
528
  const badge = paceBadge(provider);
513
529
  const monthlyBinds = provider.advisory?.bindingWindow === "monthly";
514
530
  const wastePct = provider.advisory?.wastePct;
515
531
  const hatch = !monthlyBinds && wastePct !== null && wastePct !== undefined && Number.isFinite(wastePct) && wastePct >= 3;
532
+ const is100Exhausted = isQuotaExhaustedBeforeReset(provider, asOfMs);
533
+ if (provider.exclusionReason !== null && is100Exhausted) {
534
+ const line = forecastLine(provider);
535
+ return {
536
+ text: line ? `Exhausted · ${line}` : "Exhausted · quota fully used",
537
+ kind: "cap",
538
+ hatch: false,
539
+ };
540
+ }
516
541
  const text = hatch && badge === "Behind pace" ? `${Math.round(wastePct)}% expires unused` : forecastLine(provider);
517
542
  if (!text)
518
543
  return null;
@@ -532,17 +557,20 @@ export function PaceBar({ provider, asOf, showHeadline = true, }) {
532
557
  const asOfMs = Date.parse(asOf);
533
558
  const { usedPct, elapsedPct } = barGeometry(provider, asOfMs);
534
559
  const wastePct = provider.advisory?.wastePct;
535
- const headline = paceHeadline(provider);
560
+ const headline = paceHeadline(provider, asOfMs);
536
561
  // A weekly-sized hatch under a month-bound card restates issue 109.
537
562
  const showHatch = headline?.hatch ?? false;
538
563
  const quotaUsed = quotaUsedPct(provider);
539
564
  const label = provider.quota
540
565
  ? `${provider.id}: ${quotaUsed ?? provider.quota.weeklyPct}% used, ${badge}`
541
566
  : `${provider.id}: no readings, ${badge}`;
567
+ const is100Exhausted = isQuotaExhaustedBeforeReset(provider, asOfMs);
568
+ const fillBg = is100Exhausted ? "var(--fill-cap)" : fillToken(badge);
569
+ const edgeBg = is100Exhausted ? "var(--edge-cap)" : edgeToken(badge);
542
570
  return (_jsxs("div", { "data-testid": "pace-bar", role: "img", "aria-label": label, className: "trackwrap", children: [showHeadline && provider.quota && (_jsxs("div", { className: "tline", children: [_jsxs("span", { className: "used", children: [quotaUsed ?? provider.quota.weeklyPct, "% used"] }), headline && headline.kind !== "none" ? (_jsxs("span", { className: `out out-${headline.kind}`, children: ["\u2192 ", headline.text] })) : headline ? (_jsxs("span", { className: "out out-none", children: ["\u2192 ", headline.text] })) : null] })), _jsxs("div", { className: "track", children: [_jsx("div", { "aria-hidden": "true", className: "fill", style: {
543
571
  width: `${usedPct}%`,
544
- background: fillToken(badge),
545
- ["--fill-edge"]: edgeToken(badge),
572
+ background: fillBg,
573
+ ["--fill-edge"]: edgeBg,
546
574
  } }), showHatch && (_jsx("div", { "aria-hidden": "true", "data-testid": "pace-hatch", className: "band band-waste", style: {
547
575
  left: `${100 - wastePct}%`,
548
576
  width: `${wastePct}%`,
@@ -67,7 +67,7 @@ export function drawerModel(provider) {
67
67
  lastSuccessAt: provider.lastSuccessAt,
68
68
  ageMs: provider.ageMs,
69
69
  failureCategory: provider.lastAttempt?.failureCategory ?? null,
70
- failureWords: failureWords(provider.lastAttempt?.failureCategory ?? null),
70
+ failureWords: provider.lastAttempt?.summary ?? failureWords(provider.lastAttempt?.failureCategory ?? null),
71
71
  evidence: [...provider.evidence],
72
72
  exclusionReason: provider.exclusionReason,
73
73
  exclusionWords: exclusionWords(provider.exclusionReason),
@@ -484,21 +484,35 @@ export function ProviderDrawer({ provider, asOf, recommendation, onClose, onRena
484
484
  const limitForecast = compactLimitForecast(provider);
485
485
  const wastePct = provider.advisory?.wastePct ?? null;
486
486
  const weeklyHatchPct = !monthlyPrimary && headline?.hatch ? wastePct : null;
487
+ const isWeekly100 = provider.quota &&
488
+ !provider.resetPassed &&
489
+ provider.quota.weeklyPct >= 100 &&
490
+ (!provider.quota.resetsAt || Date.parse(provider.quota.resetsAt) > asOfMs);
487
491
  const weeklyRow = provider.quota ? (_jsx(LimitRow, { name: "Weekly limit", pct: provider.quota.weeklyPct, quota: !monthlyPrimary, resetLine: weeklyResetLine, forecast: monthlyPrimary ? null : limitForecast, hatchPct: weeklyHatchPct, fillStyle: monthlyPrimary
488
492
  ? limitFill(provider.quota.weeklyPct)
489
- : {
490
- width: `${Math.min(100, Math.max(0, provider.quota.weeklyPct))}%`,
491
- background: fillToken(model.badge),
492
- ["--fill-edge"]: edgeToken(model.badge),
493
- }, elapsedPct: weeklyElapsed })) : null;
493
+ : isWeekly100
494
+ ? {
495
+ width: `${Math.min(100, Math.max(0, provider.quota.weeklyPct))}%`,
496
+ background: "var(--fill-cap)",
497
+ ["--fill-edge"]: "var(--edge-cap)",
498
+ }
499
+ : {
500
+ width: `${Math.min(100, Math.max(0, provider.quota.weeklyPct))}%`,
501
+ background: fillToken(model.badge),
502
+ ["--fill-edge"]: edgeToken(model.badge),
503
+ }, elapsedPct: weeklyElapsed })) : null;
494
504
  const monthlyRow = model.monthlyKind === "included" &&
495
505
  model.monthlyPct !== undefined &&
496
506
  Number.isFinite(model.monthlyPct) ? (_jsx(LimitRow, { name: "Monthly limit", pct: model.monthlyPct, quota: monthlyPrimary, resetLine: monthlyResetLine, forecast: monthlyPrimary ? limitForecast : null, fillStyle: monthlyPrimary
497
507
  ? trackFillForPct(model.monthlyPct, model.monthlyStatus === "exhausted")
498
508
  : limitFill(model.monthlyPct, model.monthlyStatus === "exhausted"), elapsedPct: monthlyElapsed, testId: "drawer-monthly" })) : null;
509
+ const fiveResetPassed = model.fiveHourResetsAt !== undefined &&
510
+ Number.isFinite(Date.parse(model.fiveHourResetsAt)) &&
511
+ Date.parse(model.fiveHourResetsAt) <= asOfMs;
512
+ const effectiveFivePct = fiveResetPassed ? 0 : model.fiveHourPct;
499
513
  const fiveRow = model.fiveHourPct !== undefined &&
500
514
  model.fiveHourPct !== null &&
501
- Number.isFinite(model.fiveHourPct) ? (_jsx(LimitRow, { name: shortWindowLabel(provider.id) ?? "5h limit", pct: model.fiveHourPct, resetLine: fiveResetLineNode, fillStyle: limitFill(model.fiveHourPct), elapsedPct: fiveElapsed })) : null;
515
+ Number.isFinite(model.fiveHourPct) ? (_jsx(LimitRow, { name: shortWindowLabel(provider.id) ?? "5h limit", pct: effectiveFivePct ?? 0, resetLine: fiveResetLineNode, fillStyle: limitFill(effectiveFivePct ?? 0), elapsedPct: fiveResetPassed ? null : fiveElapsed })) : null;
502
516
  const anyElapsed = [weeklyElapsed, monthlyElapsed, fiveElapsed].some((v) => v !== null);
503
517
  return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "drawer-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "provider-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), children: _jsx(ProviderIcon, { id: provider.id, title: model.name }) }), _jsxs("div", { className: "who", style: { flex: 1, minWidth: 0 }, children: [_jsxs("h2", { id: titleId, style: isEditing
504
518
  ? {
@@ -43,9 +43,10 @@ export function windowUnits(provider, asOfMs) {
43
43
  reset: left ? `in ${left}` : null });
44
44
  }
45
45
  if (typeof q.fiveHourPct === "number" && Number.isFinite(q.fiveHourPct)) {
46
- // No adapter reports a 5h reset, so the 5h tag carries no time.
46
+ const passed = q.fiveHourResetsAt ? Date.parse(q.fiveHourResetsAt) <= asOfMs : false;
47
+ const effectivePct = passed ? 0 : q.fiveHourPct;
47
48
  units.push({ win: "five", tag: "5h", cardLabel: "5h limit", rowLabel: "5h",
48
- pct: q.fiveHourPct, heat: heatOf(q.fiveHourPct, false), time: null, reset: null });
49
+ pct: effectivePct, heat: heatOf(effectivePct, false), time: null, reset: null });
49
50
  }
50
51
  if (!monthlyBound && q.monthlyKind === "included" && typeof q.monthlyPct === "number" && q.monthlyResetsAt) {
51
52
  const exhausted = q.monthlyStatus === "exhausted";