quotacap 0.0.39 → 0.0.41
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 +29 -20
- package/dist/adapters/grok.js +1 -1
- package/dist/adapters/kimi.d.ts +41 -0
- package/dist/adapters/kimi.js +453 -21
- package/dist/adapters/manual.js +2 -2
- package/dist/adapters/muse.js +3 -3
- package/dist/adapters/opencode-go.js +30 -2
- package/dist/adapters/pty.d.ts +9 -0
- package/dist/adapters/pty.js +16 -0
- 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/advise.js +1 -0
- package/dist/cli/clients.d.ts +4 -0
- package/dist/cli/models.js +5 -1
- package/dist/cli/progress.d.ts +32 -0
- package/dist/cli/progress.js +70 -0
- package/dist/cli/runtime.d.ts +5 -0
- package/dist/cli/runtime.js +18 -8
- package/dist/cli/status.js +1 -0
- package/dist/cli/takeover.d.ts +3 -0
- package/dist/cli/takeover.js +7 -1
- package/dist/cli/update.d.ts +4 -0
- package/dist/cli/update.js +8 -1
- package/dist/diagnostics/failure.js +46 -11
- 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/service/index.js +3 -1
- package/dist/service/macos.d.ts +2 -0
- package/dist/service/macos.js +4 -1
- package/dist/service/systemd.d.ts +2 -0
- package/dist/service/systemd.js +5 -3
- 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/FaultBanner.d.ts +10 -5
- package/dist/web/src/components/FaultBanner.js +19 -9
- 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/pages/Onboarding.js +2 -1
- 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-BUK8xUHm.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/dist/adapters/manual.js
CHANGED
package/dist/adapters/muse.js
CHANGED
|
@@ -115,8 +115,8 @@ export function parseMuseTui(text, now = new Date()) {
|
|
|
115
115
|
return {
|
|
116
116
|
provider: "muse",
|
|
117
117
|
plan,
|
|
118
|
-
usedPct,
|
|
119
|
-
sessionPct,
|
|
118
|
+
weeklyPct: usedPct,
|
|
119
|
+
fiveHourPct: sessionPct,
|
|
120
120
|
resetsAt,
|
|
121
121
|
periodStart,
|
|
122
122
|
source: "tui",
|
|
@@ -177,7 +177,7 @@ export const museAdapter = {
|
|
|
177
177
|
unavailable: e,
|
|
178
178
|
usagePass: (timeoutMs) => usagePass(timeoutMs),
|
|
179
179
|
warmTurn: async (timeoutMs) => {
|
|
180
|
-
await trackedExecFile("muse", "muse", ["exec", "
|
|
180
|
+
await trackedExecFile("muse", "muse", ["exec", "1+1"], {
|
|
181
181
|
cwd: museProbeDir(),
|
|
182
182
|
env: { ...process.env, MUSE_NO_AUTO_UPDATE: "1" },
|
|
183
183
|
timeout: timeoutMs,
|
|
@@ -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/pty.d.ts
CHANGED
|
@@ -29,6 +29,15 @@ export interface PtyRunOptions {
|
|
|
29
29
|
*/
|
|
30
30
|
respondToQueries?: boolean;
|
|
31
31
|
completionRegex?: RegExp;
|
|
32
|
+
/**
|
|
33
|
+
* Optional initial prompt responder before readyRegex matches.
|
|
34
|
+
* If matched against the cleaned transcript during the pre-ready poll loop,
|
|
35
|
+
* sends `reply` once to dismiss or answer the prompt (e.g. workspace trust in a probe directory).
|
|
36
|
+
*/
|
|
37
|
+
preReadyResponse?: {
|
|
38
|
+
match: RegExp;
|
|
39
|
+
reply: string;
|
|
40
|
+
};
|
|
32
41
|
/** If matched, abort immediately with a clear error (e.g. trust prompts). */
|
|
33
42
|
abortOn?: RegExp;
|
|
34
43
|
timeoutMs: number;
|
package/dist/adapters/pty.js
CHANGED
|
@@ -329,6 +329,7 @@ async function runPtyBun(opts) {
|
|
|
329
329
|
if (opts.readyRegex) {
|
|
330
330
|
const deadline = Date.now() + readyTimeoutMs;
|
|
331
331
|
let matched = false;
|
|
332
|
+
let preReadySent = false;
|
|
332
333
|
while (Date.now() < deadline) {
|
|
333
334
|
checkCap();
|
|
334
335
|
checkAborted();
|
|
@@ -341,6 +342,13 @@ async function runPtyBun(opts) {
|
|
|
341
342
|
});
|
|
342
343
|
}
|
|
343
344
|
const clean = stripAnsi(transcript);
|
|
345
|
+
if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
|
|
346
|
+
preReadySent = true;
|
|
347
|
+
try {
|
|
348
|
+
proc.terminal.write(opts.preReadyResponse.reply);
|
|
349
|
+
}
|
|
350
|
+
catch { }
|
|
351
|
+
}
|
|
344
352
|
checkAbort(clean);
|
|
345
353
|
if (regexTest(opts.readyRegex, clean)) {
|
|
346
354
|
matched = true;
|
|
@@ -633,6 +641,7 @@ async function runPtyNode(opts) {
|
|
|
633
641
|
if (opts.readyRegex) {
|
|
634
642
|
const deadline = Date.now() + readyTimeoutMs;
|
|
635
643
|
let matched = false;
|
|
644
|
+
let preReadySent = false;
|
|
636
645
|
while (Date.now() < deadline) {
|
|
637
646
|
checkCap();
|
|
638
647
|
checkAborted();
|
|
@@ -645,6 +654,13 @@ async function runPtyNode(opts) {
|
|
|
645
654
|
});
|
|
646
655
|
}
|
|
647
656
|
const clean = stripAnsi(transcript);
|
|
657
|
+
if (opts.preReadyResponse && !preReadySent && regexTest(opts.preReadyResponse.match, clean)) {
|
|
658
|
+
preReadySent = true;
|
|
659
|
+
try {
|
|
660
|
+
ptyProcess.write(opts.preReadyResponse.reply);
|
|
661
|
+
}
|
|
662
|
+
catch { }
|
|
663
|
+
}
|
|
648
664
|
checkAbort(clean);
|
|
649
665
|
if (regexTest(opts.readyRegex, clean)) {
|
|
650
666
|
matched = true;
|
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/advise.js
CHANGED
package/dist/cli/clients.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ServiceClient } from "../runtime/client.js";
|
|
|
3
3
|
import type { CatalogFetcher } from "../catalog/index.js";
|
|
4
4
|
import type { SleepFn } from "./takeover.js";
|
|
5
5
|
import type { UpdateCache } from "../runtime/updates.js";
|
|
6
|
+
import type { PhaseFn } from "./progress.js";
|
|
6
7
|
export interface CreateClientOptions {
|
|
7
8
|
port: number;
|
|
8
9
|
token?: string;
|
|
@@ -24,8 +25,11 @@ export interface ClientCommandDeps {
|
|
|
24
25
|
sleep?: SleepFn;
|
|
25
26
|
timeoutMs?: number;
|
|
26
27
|
readToken?: () => string | undefined;
|
|
28
|
+
phase?: PhaseFn;
|
|
29
|
+
json?: boolean;
|
|
27
30
|
};
|
|
28
31
|
checkUpdates?: () => Promise<UpdateCache | null>;
|
|
29
32
|
catalogFetchers?: Record<string, CatalogFetcher>;
|
|
33
|
+
phase?: PhaseFn;
|
|
30
34
|
}
|
|
31
35
|
export declare function registerClientCommands(program: Command, deps?: ClientCommandDeps): void;
|
package/dist/cli/models.js
CHANGED
|
@@ -6,12 +6,13 @@ import { providerIdentity } from "../advisory/provider-names.js";
|
|
|
6
6
|
import { readToken } from "../runtime/token.js";
|
|
7
7
|
import { createServiceClient } from "../runtime/client.js";
|
|
8
8
|
import { OFFLINE_LABEL } from "./snapshot-source.js";
|
|
9
|
+
import { phase } from "./progress.js";
|
|
9
10
|
function projectModels(catalogs, quotaMap, providerNames) {
|
|
10
11
|
const out = [];
|
|
11
12
|
for (const [id, catalog] of catalogs) {
|
|
12
13
|
const identity = providerIdentity(id, providerNames);
|
|
13
14
|
const quota = quotaMap.get(id);
|
|
14
|
-
const leftoverPct = quota?.
|
|
15
|
+
const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
|
|
15
16
|
out.push({
|
|
16
17
|
id,
|
|
17
18
|
displayName: identity.displayName,
|
|
@@ -70,6 +71,9 @@ export function registerModelsCommand(program, deps = {}) {
|
|
|
70
71
|
});
|
|
71
72
|
let body;
|
|
72
73
|
try {
|
|
74
|
+
if (o.refresh) {
|
|
75
|
+
(deps.phase ?? phase)("refreshing model catalogs…", { json: !!o.json });
|
|
76
|
+
}
|
|
73
77
|
const url = o.refresh
|
|
74
78
|
? (o.provider ? `/api/models/refresh?provider=${encodeURIComponent(o.provider)}` : "/api/models/refresh")
|
|
75
79
|
: (o.provider ? `/api/models?provider=${encodeURIComponent(o.provider)}` : "/api/models");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ProgressOptions {
|
|
2
|
+
/** Suppress output when machine-readable JSON is requested. */
|
|
3
|
+
json?: boolean;
|
|
4
|
+
/** Explicitly silence progress (e.g. quiet mode). */
|
|
5
|
+
quiet?: boolean;
|
|
6
|
+
/** Whether the output stream is interactive. Defaults to process.stderr.isTTY. */
|
|
7
|
+
isTTY?: boolean;
|
|
8
|
+
/** Custom writer for stderr (defaults to console.error). */
|
|
9
|
+
stderr?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export type PhaseFn = (message: string, opts?: ProgressOptions) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Format a progress phase string ensuring consistent trailing ellipsis without duplication.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatPhase(message: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Determine whether progress messages should be printed given the options.
|
|
18
|
+
*/
|
|
19
|
+
export declare function shouldEmitProgress(opts?: ProgressOptions): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Emit a progress phase message strictly to stderr if interactive and non-json.
|
|
22
|
+
*/
|
|
23
|
+
export declare function phase(message: string, opts?: ProgressOptions): void;
|
|
24
|
+
/**
|
|
25
|
+
* Await work, calling emit only if it is still pending after graceMs, so an
|
|
26
|
+
* already-settled fast path stays silent.
|
|
27
|
+
*/
|
|
28
|
+
export declare function phaseIfSlow<T>(work: Promise<T>, emit: () => void, graceMs?: number): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* Create a phase reporter with pre-bound default options.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createPhase(baseOpts?: ProgressOptions): (message: string, overrideOpts?: ProgressOptions) => void;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Shared progress helper for multi-second CLI operations.
|
|
2
|
+
// Strictly writes to stderr with a consistent lowercase phrasing ending in '…'.
|
|
3
|
+
// Automatically suppressed under --json, quiet mode, and non-interactive (non-TTY) runs.
|
|
4
|
+
/**
|
|
5
|
+
* Format a progress phase string ensuring consistent trailing ellipsis without duplication.
|
|
6
|
+
*/
|
|
7
|
+
export function formatPhase(message) {
|
|
8
|
+
const trimmed = message.trim();
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return "";
|
|
11
|
+
if (trimmed.endsWith("...")) {
|
|
12
|
+
return `${trimmed.slice(0, -3)}…`;
|
|
13
|
+
}
|
|
14
|
+
if (trimmed.endsWith("…")) {
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
return `${trimmed}…`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Determine whether progress messages should be printed given the options.
|
|
21
|
+
*/
|
|
22
|
+
export function shouldEmitProgress(opts = {}) {
|
|
23
|
+
if (opts.json || opts.quiet)
|
|
24
|
+
return false;
|
|
25
|
+
if (opts.isTTY !== undefined)
|
|
26
|
+
return Boolean(opts.isTTY);
|
|
27
|
+
return Boolean(process.stderr.isTTY);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Emit a progress phase message strictly to stderr if interactive and non-json.
|
|
31
|
+
*/
|
|
32
|
+
export function phase(message, opts = {}) {
|
|
33
|
+
const formatted = formatPhase(message);
|
|
34
|
+
if (!formatted)
|
|
35
|
+
return;
|
|
36
|
+
if (!shouldEmitProgress(opts))
|
|
37
|
+
return;
|
|
38
|
+
const write = opts.stderr ?? console.error;
|
|
39
|
+
write(formatted);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Await work, calling emit only if it is still pending after graceMs, so an
|
|
43
|
+
* already-settled fast path stays silent.
|
|
44
|
+
*/
|
|
45
|
+
export async function phaseIfSlow(work, emit, graceMs = 250) {
|
|
46
|
+
let timer;
|
|
47
|
+
const slow = new Promise((r) => {
|
|
48
|
+
timer = setTimeout(() => r("slow"), graceMs);
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const first = await Promise.race([
|
|
52
|
+
work.then(() => "done", () => "done"),
|
|
53
|
+
slow,
|
|
54
|
+
]);
|
|
55
|
+
if (first === "slow")
|
|
56
|
+
emit();
|
|
57
|
+
return await work;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Create a phase reporter with pre-bound default options.
|
|
65
|
+
*/
|
|
66
|
+
export function createPhase(baseOpts = {}) {
|
|
67
|
+
return (message, overrideOpts = {}) => {
|
|
68
|
+
phase(message, { ...baseOpts, ...overrideOpts });
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/cli/runtime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|
|
2
2
|
import { startService, type StartServiceOptions } from "../runtime/service.js";
|
|
3
3
|
import { type ServiceClient } from "../runtime/client.js";
|
|
4
4
|
import { type SleepFn } from "./takeover.js";
|
|
5
|
+
import { type PhaseFn } from "./progress.js";
|
|
5
6
|
export type StartServiceFn = (opts?: StartServiceOptions) => ReturnType<typeof startService>;
|
|
6
7
|
export type OpenBrowserFn = (url: string) => Promise<void> | void;
|
|
7
8
|
export type ExecServiceFn = (args: string[], opts?: Record<string, any>) => Promise<number>;
|
|
@@ -20,10 +21,13 @@ export interface RuntimeCommandDeps {
|
|
|
20
21
|
sleep?: SleepFn;
|
|
21
22
|
timeoutMs?: number;
|
|
22
23
|
readToken?: () => string | undefined;
|
|
24
|
+
phase?: PhaseFn;
|
|
25
|
+
json?: boolean;
|
|
23
26
|
};
|
|
24
27
|
checkUpdates?: () => Promise<unknown>;
|
|
25
28
|
isInstalled?: () => Promise<boolean>;
|
|
26
29
|
serviceSupported?: () => boolean;
|
|
30
|
+
phase?: PhaseFn;
|
|
27
31
|
}
|
|
28
32
|
export interface WebLaunchOptions {
|
|
29
33
|
port?: string;
|
|
@@ -38,5 +42,6 @@ export declare function waitForInitialPoll(port: number, opts?: {
|
|
|
38
42
|
}) => any;
|
|
39
43
|
sleep?: SleepFn;
|
|
40
44
|
timeoutMs?: number;
|
|
45
|
+
phase?: PhaseFn;
|
|
41
46
|
}): Promise<void>;
|
|
42
47
|
export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;
|