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