quotacap 0.0.39 → 0.0.40
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.
- package/README.md +1 -1
- package/dist/adapters/agy.js +2 -2
- package/dist/adapters/claude.js +1 -1
- package/dist/adapters/codex.js +2 -2
- package/dist/adapters/grok.js +1 -1
- package/dist/adapters/kimi.js +2 -2
- package/dist/adapters/manual.js +2 -2
- package/dist/adapters/muse.js +2 -2
- package/dist/adapters/opencode-go.js +30 -2
- package/dist/adapters/types.d.ts +17 -2
- package/dist/advisory/engine.d.ts +4 -2
- package/dist/advisory/engine.js +55 -14
- package/dist/advisory/snapshot.js +11 -3
- package/dist/advisory/types.d.ts +11 -3
- package/dist/cli/models.js +1 -1
- package/dist/diagnostics/failure.js +9 -0
- package/dist/format/rows.d.ts +6 -1
- package/dist/format/rows.js +27 -4
- package/dist/format/terminal.js +5 -4
- package/dist/http/server.js +3 -3
- package/dist/runtime/poll.js +1 -1
- package/dist/store/db.js +12 -1
- package/dist/store/quotas.js +33 -8
- package/dist/version.js +1 -1
- package/dist/web/src/components/AdviceDrawer.js +10 -5
- package/dist/web/src/components/PaceBar.d.ts +9 -5
- package/dist/web/src/components/PaceBar.js +32 -10
- package/dist/web/src/components/ProviderCard.js +4 -2
- package/dist/web/src/components/ProviderDrawer.d.ts +7 -1
- package/dist/web/src/components/ProviderDrawer.js +46 -9
- package/dist/web/src/components/ProviderRow.js +4 -4
- package/dist/web/src/components/Recommendation.d.ts +5 -2
- package/dist/web/src/components/Recommendation.js +8 -4
- package/dist/web/src/components/ResetRail.js +3 -2
- package/dist/web/src/components/SubscriptionList.d.ts +2 -1
- package/dist/web/src/components/SubscriptionList.js +8 -5
- package/dist/web/src/components/WindowsRow.d.ts +30 -0
- package/dist/web/src/components/WindowsRow.js +68 -0
- package/dist/web/src/state.d.ts +12 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/index-CsOiHZyD.js +12 -0
- package/web/dist/assets/{index-NtT9XTrm.css → index-Dk6l5h27.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CD8jaN87.js +0 -12
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ QuotaCap helps you get more from the AI coding subscriptions you already pay for
|
|
|
24
24
|
| Kimi Code | `pty` — `kimi` then `/usage`, parse `Weekly/5h limit: Y% used` | Live |
|
|
25
25
|
| Grok | `pty` — `grok` then `/usage`, parse `Weekly limit (plan)` + `Credits: $X` | Live |
|
|
26
26
|
| Muse Code | `pty` — `muse --trust-workspace` then `/usage`, parse `Subscription · Muse Code <plan>` + `Weekly/Current N% used`; on `Currently unavailable`, up to three headless `muse exec` warm turns and re-reads inside the poll | Live |
|
|
27
|
-
| OpenCode Go | `api` — `GET https://opencode.ai/zen/go/v1/usage` with the OpenCode auth key; opt-in via `quotacap providers enable opencode-go` (consent recorded, disable revokes it) | Live (opt-in) |
|
|
27
|
+
| OpenCode Go | `api` — `GET https://opencode.ai/zen/go/v1/usage` with the OpenCode auth key; reports 5h, weekly and monthly windows; opt-in via `quotacap providers enable opencode-go` (consent recorded, disable revokes it) | Live (opt-in) |
|
|
28
28
|
|
|
29
29
|
Exec adapters run via `execFile` with an argv list. PTY adapters run via `node-pty` (`src/adapters/pty.ts`). They are TUI-fragile: a vendor text change breaks the parser and the row degrades fail-closed until the regex is fixed. Poll latency is 2–10 s per PTY provider (settle plus completion); `muse` can reach about 75 s when it has to warm an unavailable subscription. It dominates `POST /api/refresh` and the first poll, not the steady-state 15 m timer.
|
|
30
30
|
|
package/dist/adapters/agy.js
CHANGED
package/dist/adapters/claude.js
CHANGED
|
@@ -13,7 +13,7 @@ export function parseClaudeUsage(result, now = new Date()) {
|
|
|
13
13
|
const resets = new Date(resetsAt).getTime();
|
|
14
14
|
const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
|
|
15
15
|
return {
|
|
16
|
-
provider: "claude", plan: "max", usedPct, sessionPct,
|
|
16
|
+
provider: "claude", plan: "max", weeklyPct: usedPct, fiveHourPct: sessionPct,
|
|
17
17
|
resetsAt, periodStart, source: "cli", fetchedAt: now.toISOString(), raw: result,
|
|
18
18
|
};
|
|
19
19
|
}
|
package/dist/adapters/codex.js
CHANGED
package/dist/adapters/grok.js
CHANGED
package/dist/adapters/kimi.js
CHANGED
package/dist/adapters/manual.js
CHANGED
package/dist/adapters/muse.js
CHANGED
|
@@ -46,6 +46,32 @@ function readBearerToken(env = process.env) {
|
|
|
46
46
|
}
|
|
47
47
|
return entry.key;
|
|
48
48
|
}
|
|
49
|
+
// `rate-limited` is the binding state, not a failure: it is exactly the
|
|
50
|
+
// reading issue 109 needed. Anything unreadable fails closed.
|
|
51
|
+
function parseMonthly(usage) {
|
|
52
|
+
const monthly = usage.monthly;
|
|
53
|
+
if (typeof monthly !== "object" || monthly === null)
|
|
54
|
+
return {};
|
|
55
|
+
const m = monthly;
|
|
56
|
+
if (m.status !== "ok" && m.status !== "rate-limited") {
|
|
57
|
+
throw new Error("opencode-go: unknown monthly status");
|
|
58
|
+
}
|
|
59
|
+
const percent = m.percent;
|
|
60
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) {
|
|
61
|
+
throw new Error("opencode-go: bad monthly pct");
|
|
62
|
+
}
|
|
63
|
+
const resetsMs = Date.parse(String(m.resetsAt ?? ""));
|
|
64
|
+
if (!Number.isFinite(resetsMs)) {
|
|
65
|
+
throw new Error("opencode-go: bad monthly reset");
|
|
66
|
+
}
|
|
67
|
+
const exhausted = m.status === "rate-limited" || percent > 100;
|
|
68
|
+
return {
|
|
69
|
+
monthlyKind: "included",
|
|
70
|
+
monthlyStatus: exhausted ? "exhausted" : "ok",
|
|
71
|
+
monthlyPct: exhausted ? 100 : percent,
|
|
72
|
+
monthlyResetsAt: new Date(resetsMs).toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
49
75
|
export function parseOpencodeGoUsage(body, now = new Date()) {
|
|
50
76
|
const usage = body?.usage;
|
|
51
77
|
if (typeof usage !== "object" || usage === null) {
|
|
@@ -77,15 +103,17 @@ export function parseOpencodeGoUsage(body, now = new Date()) {
|
|
|
77
103
|
if (typeof sessionPct !== "number" || !Number.isFinite(sessionPct) || sessionPct < 0 || sessionPct > 100) {
|
|
78
104
|
throw new Error("opencode-go: bad rolling pct");
|
|
79
105
|
}
|
|
106
|
+
const monthly = parseMonthly(usage);
|
|
80
107
|
return {
|
|
81
108
|
provider: "opencode-go",
|
|
82
109
|
plan: "unknown",
|
|
83
|
-
usedPct,
|
|
84
|
-
sessionPct,
|
|
110
|
+
weeklyPct: usedPct,
|
|
111
|
+
fiveHourPct: sessionPct,
|
|
85
112
|
resetsAt: new Date(resetsMs).toISOString(),
|
|
86
113
|
periodStart: new Date(resetsMs - 7 * 86400000).toISOString(),
|
|
87
114
|
source: "api",
|
|
88
115
|
fetchedAt: now.toISOString(),
|
|
116
|
+
...monthly,
|
|
89
117
|
};
|
|
90
118
|
}
|
|
91
119
|
export const opencodeGoAdapter = {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
export interface Quota {
|
|
2
2
|
provider: string;
|
|
3
3
|
plan: string;
|
|
4
|
-
usedPct
|
|
5
|
-
|
|
4
|
+
/** The paced window. 0..100. Named `usedPct` before this release. */
|
|
5
|
+
weeklyPct: number;
|
|
6
|
+
/** 5-hour rolling window. 0..100. Display only — never ranks. Was `sessionPct`. */
|
|
7
|
+
fiveHourPct?: number;
|
|
8
|
+
/** Included monthly envelope. 0..100. Absent means the provider has no monthly window. */
|
|
9
|
+
monthlyPct?: number;
|
|
10
|
+
monthlyResetsAt?: string;
|
|
11
|
+
monthlyStatus?: "ok" | "exhausted";
|
|
12
|
+
/** "included" only this slice. A spend cap or PAYG ceiling will carry its own value. */
|
|
13
|
+
monthlyKind?: "included";
|
|
6
14
|
resetsAt: string;
|
|
7
15
|
periodStart: string;
|
|
8
16
|
source: "cli" | "api" | "scrape" | "manual" | "tui";
|
|
@@ -10,6 +18,13 @@ export interface Quota {
|
|
|
10
18
|
creditsUsd?: number;
|
|
11
19
|
resetsAtEstimated?: boolean;
|
|
12
20
|
}
|
|
21
|
+
/** Store and wire shape: `Quota` plus the one-release aliases. */
|
|
22
|
+
export interface QuotaWire extends Quota {
|
|
23
|
+
/** @deprecated alias for weeklyPct. Removed the release after next. */
|
|
24
|
+
usedPct: number;
|
|
25
|
+
/** @deprecated alias for fiveHourPct. Removed the release after next. */
|
|
26
|
+
sessionPct?: number;
|
|
27
|
+
}
|
|
13
28
|
export interface ParsedQuota extends Quota {
|
|
14
29
|
raw?: string;
|
|
15
30
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Quota } from "../adapters/types.js";
|
|
2
|
-
import type { Advisory, RecommendationBasis } from "./types.js";
|
|
2
|
+
import type { Advisory, RecommendationBasis, BindingWindow } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Pace from a single reading.
|
|
5
5
|
*
|
|
6
|
-
* `
|
|
6
|
+
* `weeklyPct` is a running total since the window opened, and `periodStart` is
|
|
7
7
|
* a required field every adapter sets, so a pace is always computable from
|
|
8
8
|
* one poll. Returns null when `periodStart` is missing or unparseable, or
|
|
9
9
|
* when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
|
|
@@ -22,6 +22,7 @@ export declare function recommend(quotas: Quota[], _task: string, paceByProvider
|
|
|
22
22
|
wastePct: number;
|
|
23
23
|
idealRate: number;
|
|
24
24
|
recommendationBasis: RecommendationBasis;
|
|
25
|
+
bindingWindow: BindingWindow;
|
|
25
26
|
alternatives: Quota[];
|
|
26
27
|
advisories: Advisory[];
|
|
27
28
|
} | {
|
|
@@ -30,6 +31,7 @@ export declare function recommend(quotas: Quota[], _task: string, paceByProvider
|
|
|
30
31
|
wastePct: null;
|
|
31
32
|
idealRate: number;
|
|
32
33
|
recommendationBasis: RecommendationBasis;
|
|
34
|
+
bindingWindow: BindingWindow;
|
|
33
35
|
alternatives: Quota[];
|
|
34
36
|
advisories: Advisory[];
|
|
35
37
|
};
|
package/dist/advisory/engine.js
CHANGED
|
@@ -3,7 +3,7 @@ const DAY_MS = 86400000;
|
|
|
3
3
|
/**
|
|
4
4
|
* Pace from a single reading.
|
|
5
5
|
*
|
|
6
|
-
* `
|
|
6
|
+
* `weeklyPct` is a running total since the window opened, and `periodStart` is
|
|
7
7
|
* a required field every adapter sets, so a pace is always computable from
|
|
8
8
|
* one poll. Returns null when `periodStart` is missing or unparseable, or
|
|
9
9
|
* when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
|
|
@@ -18,7 +18,7 @@ export function averagePace(q, now = new Date()) {
|
|
|
18
18
|
const elapsedDays = (now.getTime() - start) / DAY_MS;
|
|
19
19
|
if (elapsedDays < MIN_PACE_SPAN_DAYS)
|
|
20
20
|
return null;
|
|
21
|
-
const pace = q.
|
|
21
|
+
const pace = q.weeklyPct / elapsedDays;
|
|
22
22
|
if (!Number.isFinite(pace))
|
|
23
23
|
return null;
|
|
24
24
|
return Math.max(0, pace);
|
|
@@ -60,13 +60,29 @@ function elapsedPctOf(q, now) {
|
|
|
60
60
|
export function computeAdvisory(q, recent, baseline, now = new Date()) {
|
|
61
61
|
const resets = new Date(q.resetsAt);
|
|
62
62
|
const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / DAY_MS);
|
|
63
|
-
const remaining = 100 - q.
|
|
63
|
+
const remaining = 100 - q.weeklyPct;
|
|
64
64
|
const idealRate = remaining / daysLeft;
|
|
65
65
|
const avgPace = averagePace(q, now);
|
|
66
66
|
// Nothing left to burn: force the capped verdict even when the recent
|
|
67
67
|
// window is flat (burn 0 at 100% used must not read "on track").
|
|
68
68
|
const exhausted = remaining <= 0;
|
|
69
69
|
const burnRate = blendForecast(recent, baseline);
|
|
70
|
+
// Above every return: the unknown-pace path below is exactly what pool 2
|
|
71
|
+
// ranks, so a binding computed later would leave it on weekly numbers.
|
|
72
|
+
const weeklyHeadroom = remaining / daysLeft;
|
|
73
|
+
let bindingWindow = "weekly";
|
|
74
|
+
let bindingRemaining = remaining;
|
|
75
|
+
let bindingDaysLeft = daysLeft;
|
|
76
|
+
if (q.monthlyKind === "included" && typeof q.monthlyPct === "number" && q.monthlyResetsAt) {
|
|
77
|
+
const monthlyRemaining = q.monthlyStatus === "exhausted" ? 0 : Math.max(0, 100 - q.monthlyPct);
|
|
78
|
+
const monthlyDaysLeft = Math.max(0.1, (new Date(q.monthlyResetsAt).getTime() - now.getTime()) / DAY_MS);
|
|
79
|
+
const monthlyHeadroom = monthlyRemaining / monthlyDaysLeft;
|
|
80
|
+
if (monthlyHeadroom < weeklyHeadroom || monthlyRemaining === 0) {
|
|
81
|
+
bindingWindow = "monthly";
|
|
82
|
+
bindingRemaining = monthlyRemaining;
|
|
83
|
+
bindingDaysLeft = monthlyDaysLeft;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
70
86
|
// paceSource describes recent availability; the blend's composition is
|
|
71
87
|
// carried by recentRate and baselineRate.
|
|
72
88
|
const paceSource = recent !== null ? "recent" : burnRate !== null ? "window-average" : "unknown";
|
|
@@ -88,6 +104,9 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
|
|
|
88
104
|
status: exhausted ? "at risk" : "unknown",
|
|
89
105
|
wastePct: exhausted ? 0 : null,
|
|
90
106
|
urgency: exhausted ? "slow down" : "on track",
|
|
107
|
+
bindingWindow,
|
|
108
|
+
bindingRemaining,
|
|
109
|
+
bindingDaysLeft,
|
|
91
110
|
};
|
|
92
111
|
}
|
|
93
112
|
if (exhausted) {
|
|
@@ -107,14 +126,17 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
|
|
|
107
126
|
status: "at risk",
|
|
108
127
|
wastePct: 0,
|
|
109
128
|
urgency: "slow down",
|
|
129
|
+
bindingWindow,
|
|
130
|
+
bindingRemaining,
|
|
131
|
+
bindingDaysLeft,
|
|
110
132
|
};
|
|
111
133
|
}
|
|
112
134
|
// Red needs a deadband margin and a cumulatively-ahead position on a
|
|
113
135
|
// verified clock; a missing or estimated clock caps at Watch.
|
|
114
136
|
const elapsed = elapsedPctOf(q, now);
|
|
115
137
|
const estimated = q.resetsAtEstimated === true;
|
|
116
|
-
const gate = elapsed !== null && !estimated && q.
|
|
117
|
-
const aheadOfElapsed = elapsed !== null && !estimated && q.
|
|
138
|
+
const gate = elapsed !== null && !estimated && q.weeklyPct >= elapsed * (1 - GATE_TOL);
|
|
139
|
+
const aheadOfElapsed = elapsed !== null && !estimated && q.weeklyPct > elapsed * (1 + GATE_TOL);
|
|
118
140
|
const wastePct = Math.max(0, remaining - burnRate * daysLeft);
|
|
119
141
|
const daysToExhaust = burnRate > 0 ? remaining / burnRate : Infinity;
|
|
120
142
|
const over = daysToExhaust < daysLeft * (1 - RISK_MARGIN);
|
|
@@ -137,7 +159,18 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
|
|
|
137
159
|
urgency = "save";
|
|
138
160
|
if (estimated && urgency === "burn now")
|
|
139
161
|
urgency = "use soon";
|
|
140
|
-
return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
|
|
162
|
+
return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency, bindingWindow, bindingRemaining, bindingDaysLeft };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Known-waste sort key. `wastePct` is a flow (budget forecast to go unspent
|
|
166
|
+
* by the weekly deadline); `bindingRemaining` is a stock. The binding window's
|
|
167
|
+
* headroom per day, priced over the weekly days-left the pool sorts on, caps
|
|
168
|
+
* the claim at what the account can actually absorb. Weekly-bound providers
|
|
169
|
+
* are untouched: the ratio is 1 and `wastePct <= remaining` always.
|
|
170
|
+
*/
|
|
171
|
+
function rankWaste(a) {
|
|
172
|
+
const affordable = a.bindingRemaining * (a.daysLeft / a.bindingDaysLeft);
|
|
173
|
+
return Math.min(a.wastePct, affordable);
|
|
141
174
|
}
|
|
142
175
|
export function recommend(quotas, _task, paceByProvider = new Map(), now = new Date()) {
|
|
143
176
|
if (!quotas.length) {
|
|
@@ -147,6 +180,7 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
|
|
|
147
180
|
wastePct: 0,
|
|
148
181
|
idealRate: 0,
|
|
149
182
|
recommendationBasis: "none",
|
|
183
|
+
bindingWindow: "weekly",
|
|
150
184
|
alternatives: [],
|
|
151
185
|
advisories: [],
|
|
152
186
|
};
|
|
@@ -162,32 +196,38 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
|
|
|
162
196
|
// are a fallback pool, read from the flags on the quotas already received.
|
|
163
197
|
const estimatedProviders = new Set(quotas.filter(q => q.resetsAtEstimated === true).map(q => q.provider));
|
|
164
198
|
// 1. Prefer measured providers with positive avoidable waste (actionable "Use more" candidates).
|
|
165
|
-
const positiveWaste = advisories.filter((a) => a.paceSource !== "unknown" && a.wastePct !== null && a.wastePct > 0);
|
|
199
|
+
const positiveWaste = advisories.filter((a) => a.paceSource !== "unknown" && a.wastePct !== null && a.wastePct > 0 && a.bindingRemaining > 0);
|
|
166
200
|
if (positiveWaste.length > 0) {
|
|
167
201
|
const verified = positiveWaste.filter(a => !estimatedProviders.has(a.provider));
|
|
168
202
|
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
|
|
203
|
+
const burnNow = pool.filter(a => a.urgency === "burn now" && a.bindingWindow === "weekly");
|
|
204
|
+
const use = (burnNow.length ? burnNow : pool).sort((a, b) => rankWaste(b) - rankWaste(a))[0];
|
|
171
205
|
return {
|
|
172
206
|
use: use.provider,
|
|
173
|
-
reason:
|
|
174
|
-
|
|
207
|
+
reason: use.bindingWindow === "monthly"
|
|
208
|
+
? `${Math.round(use.bindingRemaining)}% of month left in ${use.bindingDaysLeft.toFixed(1)}d`
|
|
209
|
+
: `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`,
|
|
210
|
+
wastePct: rankWaste(use),
|
|
175
211
|
idealRate: use.idealRate,
|
|
176
212
|
recommendationBasis: "known-waste",
|
|
213
|
+
bindingWindow: use.bindingWindow,
|
|
177
214
|
alternatives: quotas,
|
|
178
215
|
advisories,
|
|
179
216
|
};
|
|
180
217
|
}
|
|
181
218
|
// 2. Low-confidence fallback: rank unknown providers by remaining headroom (remaining / daysLeft).
|
|
182
|
-
const unknownProviders = advisories.filter(a => a.paceSource === "unknown" && a.
|
|
219
|
+
const unknownProviders = advisories.filter(a => a.paceSource === "unknown" && a.bindingRemaining > 0);
|
|
183
220
|
if (unknownProviders.length > 0) {
|
|
184
|
-
const use = unknownProviders.sort((a, b) => (b.
|
|
221
|
+
const use = unknownProviders.sort((a, b) => (b.bindingRemaining / b.bindingDaysLeft) - (a.bindingRemaining / a.bindingDaysLeft))[0];
|
|
185
222
|
return {
|
|
186
223
|
use: use.provider,
|
|
187
|
-
reason:
|
|
224
|
+
reason: use.bindingWindow === "monthly"
|
|
225
|
+
? `Measuring pace; ${Math.round(use.bindingRemaining)}% of month left with ${use.bindingDaysLeft.toFixed(1)}d until reset`
|
|
226
|
+
: `Measuring pace; ${Math.round(use.remaining)}% remains with ${use.daysLeft.toFixed(1)}d until reset`,
|
|
188
227
|
wastePct: null,
|
|
189
228
|
idealRate: use.idealRate,
|
|
190
229
|
recommendationBasis: "unknown-headroom",
|
|
230
|
+
bindingWindow: use.bindingWindow,
|
|
191
231
|
alternatives: quotas,
|
|
192
232
|
advisories,
|
|
193
233
|
};
|
|
@@ -199,6 +239,7 @@ export function recommend(quotas, _task, paceByProvider = new Map(), now = new D
|
|
|
199
239
|
wastePct: 0,
|
|
200
240
|
idealRate: 0,
|
|
201
241
|
recommendationBasis: "none",
|
|
242
|
+
bindingWindow: "weekly",
|
|
202
243
|
alternatives: quotas,
|
|
203
244
|
advisories,
|
|
204
245
|
};
|
|
@@ -20,6 +20,13 @@ function normalizeAdvisory(adv) {
|
|
|
20
20
|
avgPace: adv.avgPace !== null && Number.isFinite(adv.avgPace) ? adv.avgPace : null,
|
|
21
21
|
daysToExhaust: adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust) ? adv.daysToExhaust : null,
|
|
22
22
|
wastePct: adv.wastePct !== null && Number.isFinite(adv.wastePct) ? adv.wastePct : null,
|
|
23
|
+
bindingWindow: adv.bindingWindow === "monthly" ? "monthly" : "weekly",
|
|
24
|
+
bindingRemaining: Number.isFinite(adv.bindingRemaining)
|
|
25
|
+
? adv.bindingRemaining
|
|
26
|
+
: Number.isFinite(adv.remaining) ? adv.remaining : 0,
|
|
27
|
+
bindingDaysLeft: Number.isFinite(adv.bindingDaysLeft)
|
|
28
|
+
? adv.bindingDaysLeft
|
|
29
|
+
: Number.isFinite(adv.daysLeft) ? adv.daysLeft : 0,
|
|
23
30
|
};
|
|
24
31
|
}
|
|
25
32
|
export function buildSnapshot(db, opts) {
|
|
@@ -67,9 +74,9 @@ export function buildSnapshot(db, opts) {
|
|
|
67
74
|
if (quota !== null) {
|
|
68
75
|
const resetsMs = new Date(quota.resetsAt).getTime();
|
|
69
76
|
const fetchedMs = new Date(quota.fetchedAt).getTime();
|
|
70
|
-
if (!Number.isFinite(quota.
|
|
71
|
-
quota.
|
|
72
|
-
quota.
|
|
77
|
+
if (!Number.isFinite(quota.weeklyPct) ||
|
|
78
|
+
quota.weeklyPct < 0 ||
|
|
79
|
+
quota.weeklyPct > 100 ||
|
|
73
80
|
Number.isNaN(resetsMs) ||
|
|
74
81
|
Number.isNaN(fetchedMs)) {
|
|
75
82
|
isInvalid = true;
|
|
@@ -168,6 +175,7 @@ export function buildSnapshot(db, opts) {
|
|
|
168
175
|
wastePct: null,
|
|
169
176
|
idealRate: 0,
|
|
170
177
|
recommendationBasis: "none",
|
|
178
|
+
bindingWindow: "weekly",
|
|
171
179
|
models: [],
|
|
172
180
|
catalogStatus: "unfetched",
|
|
173
181
|
catalogFetchedAt: null,
|
package/dist/advisory/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { QuotaWire } from "../adapters/types.js";
|
|
2
2
|
import type { AttemptRecord } from "../store/attempts.js";
|
|
3
3
|
import type { CatalogStatus, CatalogView, ListedModel } from "../catalog/types.js";
|
|
4
4
|
/**
|
|
@@ -15,6 +15,7 @@ export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track
|
|
|
15
15
|
export type BurnStatus = "at risk" | "watch" | "on track" | "unknown";
|
|
16
16
|
export type PaceSource = "recent" | "window-average" | "unknown";
|
|
17
17
|
export type RecommendationBasis = "known-waste" | "unknown-headroom" | "none";
|
|
18
|
+
export type BindingWindow = "weekly" | "monthly";
|
|
18
19
|
export type ExclusionReason = "not-reporting" | "stale" | "reset-passed" | "invalid" | "provider-failed" | null;
|
|
19
20
|
export type { AttemptRecord };
|
|
20
21
|
export interface Advisory {
|
|
@@ -39,6 +40,12 @@ export interface Advisory {
|
|
|
39
40
|
status: BurnStatus;
|
|
40
41
|
wastePct: number | null;
|
|
41
42
|
urgency: Urgency;
|
|
43
|
+
/** Which included window is scarcer per day left. "weekly" when there is no included monthly. */
|
|
44
|
+
bindingWindow: BindingWindow;
|
|
45
|
+
/** Remaining percent of the binding window. Equals `remaining` when weekly binds. */
|
|
46
|
+
bindingRemaining: number;
|
|
47
|
+
/** Days until the binding window resets. Equals `daysLeft` when weekly binds. */
|
|
48
|
+
bindingDaysLeft: number;
|
|
42
49
|
}
|
|
43
50
|
export interface Recommendation {
|
|
44
51
|
use: string;
|
|
@@ -46,10 +53,11 @@ export interface Recommendation {
|
|
|
46
53
|
wastePct: number | null;
|
|
47
54
|
idealRate: number;
|
|
48
55
|
recommendationBasis: RecommendationBasis;
|
|
56
|
+
bindingWindow: BindingWindow;
|
|
49
57
|
models: ListedModel[];
|
|
50
58
|
catalogStatus: CatalogStatus;
|
|
51
59
|
catalogFetchedAt: string | null;
|
|
52
|
-
alternatives: Array<
|
|
60
|
+
alternatives: Array<QuotaWire & {
|
|
53
61
|
catalog: CatalogView;
|
|
54
62
|
}>;
|
|
55
63
|
advisories: Advisory[];
|
|
@@ -74,7 +82,7 @@ export interface ProviderSnapshot {
|
|
|
74
82
|
harness: string | null;
|
|
75
83
|
description: string | null;
|
|
76
84
|
enabled: boolean;
|
|
77
|
-
quota:
|
|
85
|
+
quota: QuotaWire | null;
|
|
78
86
|
lastAttempt: AttemptRecord | null;
|
|
79
87
|
lastSuccessAt: string | null;
|
|
80
88
|
reporting: boolean;
|
package/dist/cli/models.js
CHANGED
|
@@ -11,7 +11,7 @@ function projectModels(catalogs, quotaMap, providerNames) {
|
|
|
11
11
|
for (const [id, catalog] of catalogs) {
|
|
12
12
|
const identity = providerIdentity(id, providerNames);
|
|
13
13
|
const quota = quotaMap.get(id);
|
|
14
|
-
const leftoverPct = quota?.
|
|
14
|
+
const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
|
|
15
15
|
out.push({
|
|
16
16
|
id,
|
|
17
17
|
displayName: identity.displayName,
|
|
@@ -232,6 +232,15 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
|
|
|
232
232
|
if (/\bbad rolling pct\b/i.test(text)) {
|
|
233
233
|
return { code: "parse_error", phrase: "bad rolling pct" };
|
|
234
234
|
}
|
|
235
|
+
if (/\bbad monthly pct\b/i.test(text)) {
|
|
236
|
+
return { code: "parse_error", phrase: "bad monthly pct" };
|
|
237
|
+
}
|
|
238
|
+
if (/\bbad monthly reset\b/i.test(text)) {
|
|
239
|
+
return { code: "parse_error", phrase: "bad monthly reset" };
|
|
240
|
+
}
|
|
241
|
+
if (/\bunknown monthly status\b/i.test(text)) {
|
|
242
|
+
return { code: "parse_error", phrase: "unknown monthly status" };
|
|
243
|
+
}
|
|
235
244
|
if (/\busage status not ok\b/i.test(text)) {
|
|
236
245
|
return { code: "parse_error", phrase: "usage status not ok" };
|
|
237
246
|
}
|
package/dist/format/rows.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderSnapshot } from "../advisory/types.js";
|
|
2
|
-
export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting";
|
|
2
|
+
export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting" | "Monthly exhausted";
|
|
3
3
|
export type RailCell = "used" | "tick" | "unused";
|
|
4
4
|
export type SortKey = "recommended" | "reset-asc" | "reset-desc" | "used-desc" | "used-asc";
|
|
5
5
|
export type CompactSuffix = "" | "!" | "~" | "?";
|
|
@@ -16,7 +16,12 @@ export interface Row {
|
|
|
16
16
|
}
|
|
17
17
|
export declare const RAIL_CELLS = 20;
|
|
18
18
|
export declare const SORT_KEYS: SortKey[];
|
|
19
|
+
export declare function weeklyPctOf(q: {
|
|
20
|
+
weeklyPct?: number;
|
|
21
|
+
usedPct?: number;
|
|
22
|
+
} | null | undefined): number | undefined;
|
|
19
23
|
export declare function fmtDuration(ms: number): string;
|
|
24
|
+
export declare function weekdayOf(iso: string | undefined): string;
|
|
20
25
|
export declare function countdownText(ps: ProviderSnapshot, now: Date): string;
|
|
21
26
|
export declare function elapsedPct(ps: ProviderSnapshot, now: Date): number | null;
|
|
22
27
|
export declare function usedPctOf(ps: ProviderSnapshot): number | null;
|
package/dist/format/rows.js
CHANGED
|
@@ -9,6 +9,11 @@ function msOf(iso) {
|
|
|
9
9
|
const ms = new Date(iso).getTime();
|
|
10
10
|
return Number.isNaN(ms) ? null : ms;
|
|
11
11
|
}
|
|
12
|
+
// The CLI talks HTTP to a daemon that may predate the rename, which sent only
|
|
13
|
+
// `usedPct`. Read both for one release; drop the fallback with the aliases.
|
|
14
|
+
export function weeklyPctOf(q) {
|
|
15
|
+
return q?.weeklyPct ?? q?.usedPct;
|
|
16
|
+
}
|
|
12
17
|
// Compact duration: 7d / 12h / 45m. Countdown convention floors; sub-minute
|
|
13
18
|
// spans read 1m so a live window never prints 0m.
|
|
14
19
|
export function fmtDuration(ms) {
|
|
@@ -18,6 +23,12 @@ export function fmtDuration(ms) {
|
|
|
18
23
|
return `${Math.floor(ms / HOUR_MS)}h`;
|
|
19
24
|
return `${Math.max(1, Math.floor(ms / MIN_MS))}m`;
|
|
20
25
|
}
|
|
26
|
+
// Short weekday of an ISO instant in the locked English copy, e.g. "Tue".
|
|
27
|
+
// Pinned to en-US: the sentence it feeds ("unused until Tue") is English, so
|
|
28
|
+
// a non-English machine must not print a localized weekday inside it.
|
|
29
|
+
export function weekdayOf(iso) {
|
|
30
|
+
return new Date(iso ?? "").toLocaleDateString("en-US", { weekday: "short" });
|
|
31
|
+
}
|
|
21
32
|
export function countdownText(ps, now) {
|
|
22
33
|
const resetsMs = msOf(ps.quota?.resetsAt);
|
|
23
34
|
let text;
|
|
@@ -40,14 +51,14 @@ export function elapsedPct(ps, now) {
|
|
|
40
51
|
return Math.round(Math.min(100, Math.max(0, pct)));
|
|
41
52
|
}
|
|
42
53
|
export function usedPctOf(ps) {
|
|
43
|
-
const u = ps.quota
|
|
54
|
+
const u = weeklyPctOf(ps.quota);
|
|
44
55
|
return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
|
|
45
56
|
}
|
|
46
57
|
export function railCells(ps, now) {
|
|
47
58
|
const cells = new Array(RAIL_CELLS).fill("unused");
|
|
48
59
|
if (ps.exclusionReason !== null)
|
|
49
60
|
return cells;
|
|
50
|
-
const u = ps.quota
|
|
61
|
+
const u = weeklyPctOf(ps.quota);
|
|
51
62
|
const used = typeof u === "number" && Number.isFinite(u)
|
|
52
63
|
? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
|
|
53
64
|
: 0;
|
|
@@ -70,6 +81,10 @@ export function stateWord(ps) {
|
|
|
70
81
|
const adv = ps.advisory;
|
|
71
82
|
if (!adv)
|
|
72
83
|
return "Not reporting";
|
|
84
|
+
// A blocked month outranks every weekly verdict: weekly leftover cannot be
|
|
85
|
+
// spent until the month resets.
|
|
86
|
+
if (adv.bindingWindow === "monthly" && adv.bindingRemaining === 0)
|
|
87
|
+
return "Monthly exhausted";
|
|
73
88
|
if (adv.status === "at risk")
|
|
74
89
|
return "Cap risk";
|
|
75
90
|
if (adv.status === "watch")
|
|
@@ -124,7 +139,13 @@ export function forecastText(ps, now) {
|
|
|
124
139
|
if (!adv)
|
|
125
140
|
return "no readings yet";
|
|
126
141
|
let text;
|
|
127
|
-
if (adv.
|
|
142
|
+
if (adv.bindingWindow === "monthly" && adv.bindingRemaining <= 0) {
|
|
143
|
+
text = `unused until ${weekdayOf(ps.quota?.monthlyResetsAt)}`;
|
|
144
|
+
}
|
|
145
|
+
else if (adv.bindingWindow === "monthly") {
|
|
146
|
+
text = `${Math.round(adv.bindingRemaining)}% of month left`;
|
|
147
|
+
}
|
|
148
|
+
else if (adv.remaining <= 0) {
|
|
128
149
|
text = "Exhausted";
|
|
129
150
|
}
|
|
130
151
|
else if (adv.paceSource === "unknown" || adv.burnRate === null) {
|
|
@@ -179,7 +200,7 @@ function wasteOf(p) {
|
|
|
179
200
|
return typeof w === "number" && Number.isFinite(w) ? w : null;
|
|
180
201
|
}
|
|
181
202
|
function finiteUsed(p) {
|
|
182
|
-
const u = p.quota
|
|
203
|
+
const u = weeklyPctOf(p.quota);
|
|
183
204
|
return typeof u === "number" && Number.isFinite(u) ? u : null;
|
|
184
205
|
}
|
|
185
206
|
export function sortProviders(providers, key, use) {
|
|
@@ -192,6 +213,8 @@ export function sortProviders(providers, key, use) {
|
|
|
192
213
|
return 0;
|
|
193
214
|
if (p.exclusionReason !== null)
|
|
194
215
|
return 2;
|
|
216
|
+
if (p.advisory?.bindingWindow === "monthly" && p.advisory.bindingRemaining === 0)
|
|
217
|
+
return 2;
|
|
195
218
|
return 1;
|
|
196
219
|
};
|
|
197
220
|
indexed.sort((a, b) => {
|
package/dist/format/terminal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildRow, compactSuffix, sortProviders, } from "./rows.js";
|
|
1
|
+
import { buildRow, compactSuffix, sortProviders, weeklyPctOf, } from "./rows.js";
|
|
2
2
|
const GAP = " ";
|
|
3
3
|
const MIN_PROV_WIDTH = 12;
|
|
4
4
|
const MAX_PROV_WIDTH = 22;
|
|
@@ -32,6 +32,7 @@ const STATE_COLORS = {
|
|
|
32
32
|
"Cap risk": "\x1b[31m",
|
|
33
33
|
"Measuring": "\x1b[90m",
|
|
34
34
|
"Not reporting": "\x1b[90m",
|
|
35
|
+
"Monthly exhausted": "\x1b[31m",
|
|
35
36
|
};
|
|
36
37
|
// provWidth covers the 2-char prefix plus name, measured before ANSI.
|
|
37
38
|
export function provWidthFor(ids) {
|
|
@@ -92,7 +93,7 @@ export function renderWide(snapshot, opts) {
|
|
|
92
93
|
"PACE (%/DAY)".padEnd(paceWidth),
|
|
93
94
|
"USED VS TIME".padEnd(20),
|
|
94
95
|
"RESETS".padEnd(9),
|
|
95
|
-
"STATE".padEnd(
|
|
96
|
+
"STATE".padEnd(19),
|
|
96
97
|
"FORECAST",
|
|
97
98
|
].join(GAP);
|
|
98
99
|
const lines = rows.map(({ p, row }) => {
|
|
@@ -103,7 +104,7 @@ export function renderWide(snapshot, opts) {
|
|
|
103
104
|
const pace = (opts.ascii ? asciiText(row.pace) : row.pace).padEnd(paceWidth);
|
|
104
105
|
const rail = renderRail(row.railCells, g, row.state, color);
|
|
105
106
|
const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
|
|
106
|
-
const state = row.state.padEnd(
|
|
107
|
+
const state = row.state.padEnd(19);
|
|
107
108
|
const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
|
|
108
109
|
const main = [
|
|
109
110
|
color ? colorProviderField(name, recommended, row.state) : name,
|
|
@@ -158,7 +159,7 @@ export function renderCompact(snapshot, _now) {
|
|
|
158
159
|
const entries = sorted
|
|
159
160
|
.filter((p) => p.quota !== null)
|
|
160
161
|
.map((p) => {
|
|
161
|
-
const u = p.quota
|
|
162
|
+
const u = weeklyPctOf(p.quota);
|
|
162
163
|
const used = typeof u === "number" && Number.isFinite(u) ? String(Math.round(u)) : "?";
|
|
163
164
|
return `[${p.id}:${used}%${compactSuffix(p)}]`;
|
|
164
165
|
});
|
package/dist/http/server.js
CHANGED
|
@@ -220,7 +220,7 @@ export function buildApp(ctx) {
|
|
|
220
220
|
for (const [id, catalog] of catalogs) {
|
|
221
221
|
const identity = providerIdentity(id, ctx.providerNames);
|
|
222
222
|
const quota = quotaMap.get(id);
|
|
223
|
-
const leftoverPct = quota?.
|
|
223
|
+
const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
|
|
224
224
|
out.push({
|
|
225
225
|
id,
|
|
226
226
|
displayName: identity.displayName,
|
|
@@ -452,8 +452,8 @@ export function buildApp(ctx) {
|
|
|
452
452
|
upsertQuota(ctx.db, {
|
|
453
453
|
provider: parsed.provider,
|
|
454
454
|
plan: parsed.plan,
|
|
455
|
-
|
|
456
|
-
|
|
455
|
+
weeklyPct: parsed.weeklyPct,
|
|
456
|
+
fiveHourPct: parsed.fiveHourPct,
|
|
457
457
|
resetsAt: parsed.resetsAt,
|
|
458
458
|
periodStart: parsed.periodStart,
|
|
459
459
|
source: "manual",
|
package/dist/runtime/poll.js
CHANGED
|
@@ -138,7 +138,7 @@ export function createCoordinator(opts) {
|
|
|
138
138
|
success: true,
|
|
139
139
|
failureCategory: null,
|
|
140
140
|
});
|
|
141
|
-
const usedPct = row.value?.
|
|
141
|
+
const usedPct = row.value?.weeklyPct;
|
|
142
142
|
const pctSuffix = typeof usedPct === "number" && Number.isFinite(usedPct)
|
|
143
143
|
? ` (${usedPct}% used)`
|
|
144
144
|
: "";
|