limn-review 0.1.135 → 0.1.136
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/dist/ai/budget.js +495 -0
- package/dist/ai/runtime.js +46 -1
- package/dist/api/plugins/rate-limit.js +7 -0
- package/dist/api/routes/ai-budget.js +36 -0
- package/dist/app.js +5 -0
- package/dist/coding/ai-fix/auto-fix.js +4 -0
- package/dist/db/export-account.js +2 -0
- package/dist/db/migrations/0125_account_ai_budget.sql +10 -0
- package/dist/db/migrations/0126_workspace_auto_diagrams.sql +8 -0
- package/dist/db/migrations/meta/_journal.json +14 -0
- package/dist/db/schema.sqlite.js +15 -0
- package/dist/platform/context.js +2 -0
- package/dist/platform/insights/sprint-report.js +5 -0
- package/dist/platform/migrations/0041_workspace_hourly_cost.sql +11 -0
- package/dist/platform/settings/routes.js +8 -0
- package/dist/platform/settings/schema.sqlite.js +3 -0
- package/dist/platform/settings/store.js +19 -0
- package/dist/platform/slack/cron.js +6 -1
- package/dist/platform/slack/report.js +8 -3
- package/dist/review/agent-context.js +2 -0
- package/dist/review/budget-gate.js +15 -0
- package/dist/review/ci-review/sweep.js +6 -0
- package/dist/review/claude-review/auto-settings.js +23 -2
- package/dist/review/claude-review/auto.js +14 -1
- package/dist/review/claude-review/manager.js +5 -0
- package/dist/review/diagrams/persist.js +17 -0
- package/dist/review/diagrams/trigger.js +20 -2
- package/dist/review/sprint-briefs/index.js +9 -3
- package/dist/review/ticket-review/sweep.js +8 -0
- package/dist/shared/ai-budget.js +426 -0
- package/dist/shared/flow-trend.js +7 -0
- package/dist/shared/index.js +1 -0
- package/dist/tracker/sprint/hooks.js +15 -2
- package/dist/tracker/sprint/routes.js +3 -1
- package/package.json +1 -1
- package/public/assets/AiFixTab-FwTFw_Ub.js +3 -0
- package/public/assets/ClaudeReviewTab-CJ6BRXTq.js +20 -0
- package/public/assets/{PrBotBehaviourTab-C4haeRPP.js → PrBotBehaviourTab-BkY9gzPl.js} +1 -1
- package/public/assets/index-BB6qXSAO.css +1 -0
- package/public/assets/index-Cconixya.js +80 -0
- package/public/index.html +2 -2
- package/public/assets/AiFixTab-CF2ALoVQ.js +0 -3
- package/public/assets/ClaudeReviewTab-2Tbx3UFN.js +0 -20
- package/public/assets/index-DDzOcB3p.js +0 -79
- package/public/assets/index-QULbVZYy.css +0 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// THE CLAUDE SPEND BUDGET — the ONE decision and the ONE gate (packages/shared ai-budget.ts holds
|
|
2
|
+
// the types, the defaults, the subscription rule and the estimate table).
|
|
3
|
+
//
|
|
4
|
+
// decideAiBudget(input) PURE. Settings + credential route + the latest usage reading +
|
|
5
|
+
// Limn's own recorded spend (ai_usage) + its typical cycle cost →
|
|
6
|
+
// AiBudgetStatus.
|
|
7
|
+
// aiBudgetStatus(accountId) gathers those inputs (and, when useful, runs the cached usage
|
|
8
|
+
// probe) and calls decideAiBudget. Never throws.
|
|
9
|
+
// allowAutomaticRun(accountId) THE GATE. Every AUTOMATIC model run asks it first (auto review and
|
|
10
|
+
// its thread checks, auto fix, the CI and ticket review sweepers,
|
|
11
|
+
// the post-review diagrams, the sprint after-read chain, the Slack
|
|
12
|
+
// digest's sprint summary). Over budget ⇒ the run is SKIPPED, not
|
|
13
|
+
// queued: the sweepers re-derive what is due every tick, so it
|
|
14
|
+
// becomes due again by itself once the week resets.
|
|
15
|
+
// Logged once per pause, never per tick.
|
|
16
|
+
//
|
|
17
|
+
// A run the READER starts is never refused here — the SPA reads GET /api/ai/budget and asks for a
|
|
18
|
+
// second press.
|
|
19
|
+
//
|
|
20
|
+
// SUBSCRIPTION: Limn's SHARE of the week = its own ai_usage spend in the current week ÷ the
|
|
21
|
+
// subscription's estimated weekly allowance (SUBSCRIPTION_ESTIMATES); likewise of the 5-hour session
|
|
22
|
+
// (the setting follows the weekly share unless the reader set its own). Over when EITHER share reaches
|
|
23
|
+
// its setting. Each window is [reset − length, reset) when Claude's reset has been read and is still
|
|
24
|
+
// ahead, otherwise rolling. The pure rules live in packages/shared (`decideAiBudget`, `aiUsageView`)
|
|
25
|
+
// so the static demo runs the same ones; the status carries percentages only.
|
|
26
|
+
//
|
|
27
|
+
// THE USAGE READING (login route only) gives the subscription type, the reset times and the header's
|
|
28
|
+
// account-wide figures — it never blocks. Two sources fold into ONE latest reading, kept in memory and
|
|
29
|
+
// persisted on `accounts.ai_budget_reading` so a restart keeps it:
|
|
30
|
+
// • every agent run's `rate_limit_event` (ai/runtime.ts taps every Query): the 5-hour, weekly and
|
|
31
|
+
// per-model weekly windows' resets and utilisation — a 0–1 FRACTION on this path (the CLI's
|
|
32
|
+
// header parsing), converted to 0–100 HERE, explicitly;
|
|
33
|
+
// • a cheap probe through the Agent SDK's EXPERIMENTAL usage method (login route, at most every
|
|
34
|
+
// PROBE_MIN_INTERVAL_MS, a 20s timeout, in the BACKGROUND — a request never waits for it): the
|
|
35
|
+
// subscription type and every window, utilisation ALREADY 0–100 (measured on a real login) and
|
|
36
|
+
// kept as-is. A failure keeps the last reading.
|
|
37
|
+
// There is no shared "≤ 1 means a fraction" guess: each path knows its own scale. Any throw or shape
|
|
38
|
+
// mismatch is "no reading" — the windows are then rolling and the subscription is the reader's pick.
|
|
39
|
+
//
|
|
40
|
+
// One budget per ACCOUNT (= per machine), never per workspace.
|
|
41
|
+
import { and, eq, gte, inArray } from 'drizzle-orm';
|
|
42
|
+
import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiUsageView, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
|
|
43
|
+
import { db, schema } from '../db/client.js';
|
|
44
|
+
import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
|
|
45
|
+
import { resolveAiCredential, agentAuthOptions } from './credentials.js';
|
|
46
|
+
import { ISOLATED_MCP } from './agent-isolation.js';
|
|
47
|
+
import { claudeExecutableOptions, getAiRuntimeStatus, loadAgentSdk, setAgentMessageObserver, } from './runtime.js';
|
|
48
|
+
export const DAY_MS = AI_BUDGET_DAY_MS;
|
|
49
|
+
export const WEEK_MS = AI_BUDGET_WEEK_MS;
|
|
50
|
+
/** The window "typical cycle cost" is averaged over. */
|
|
51
|
+
export const CYCLE_COST_WINDOW_MS = 28 * DAY_MS;
|
|
52
|
+
/** The usage probe runs at most this often (the header polls every ~2 minutes while visible). */
|
|
53
|
+
export const PROBE_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
54
|
+
const PROBE_TIMEOUT_MS = 20_000;
|
|
55
|
+
/** At most this many per-model windows are kept from one reading. */
|
|
56
|
+
const MAX_MODEL_WINDOWS = 8;
|
|
57
|
+
export { decideAiBudget, rollingSpend, liveWeekReset, liveFiveHourReset, aiUsageView };
|
|
58
|
+
// ---- folding the SDK's usage reports (pure) ---------------------------------------------------
|
|
59
|
+
function num(v) {
|
|
60
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
61
|
+
}
|
|
62
|
+
/** Epoch seconds or ms → ms. */
|
|
63
|
+
function epochMs(v) {
|
|
64
|
+
const n = num(v);
|
|
65
|
+
if (n == null || n <= 0)
|
|
66
|
+
return null;
|
|
67
|
+
return n < 1e12 ? n * 1000 : n;
|
|
68
|
+
}
|
|
69
|
+
/** A `rate_limit_event` utilisation is a 0–1 FRACTION (the CLI parses it from the rate-limit header):
|
|
70
|
+
* → 0–100, explicitly. Out of range or not a number → null. */
|
|
71
|
+
function eventFractionToPct(v) {
|
|
72
|
+
const n = num(v);
|
|
73
|
+
if (n == null || n < 0 || n > 1.5)
|
|
74
|
+
return null;
|
|
75
|
+
return n * 100;
|
|
76
|
+
}
|
|
77
|
+
/** Claude's window keys this module keeps: the 5-hour, the week, and per-model weeks. */
|
|
78
|
+
const MODEL_WINDOW_KEY = /^seven_day_[a-z0-9_]{1,40}$/;
|
|
79
|
+
/** Put one window into a reading under Claude's key. Unknown keys are ignored. Pure. */
|
|
80
|
+
function withWindow(reading, key, w) {
|
|
81
|
+
if (key === 'five_hour')
|
|
82
|
+
return { ...reading, fiveHour: w };
|
|
83
|
+
if (key === 'seven_day')
|
|
84
|
+
return { ...reading, week: w };
|
|
85
|
+
if (MODEL_WINDOW_KEY.test(key)) {
|
|
86
|
+
const models = { ...(reading.models ?? {}) };
|
|
87
|
+
if (!(key in models) && Object.keys(models).length >= MAX_MODEL_WINDOWS)
|
|
88
|
+
return reading;
|
|
89
|
+
models[key] = w;
|
|
90
|
+
return { ...reading, models };
|
|
91
|
+
}
|
|
92
|
+
return reading;
|
|
93
|
+
}
|
|
94
|
+
const isWindowKey = (k) => k === 'five_hour' || k === 'seven_day' || MODEL_WINDOW_KEY.test(k);
|
|
95
|
+
/**
|
|
96
|
+
* Fold one Agent SDK message into the reading. Returns the NEW reading (the windows this message names
|
|
97
|
+
* merged over the rest), or null when the message names none. Pure.
|
|
98
|
+
*
|
|
99
|
+
* Reads `rate_limit_event.rate_limit_info`: every window in `unifiedWindows` (when the CLI sends
|
|
100
|
+
* them) and otherwise the one named by `rateLimitType` ('five_hour', 'seven_day', 'seven_day_opus',
|
|
101
|
+
* …). Reset times arrive in epoch seconds. Utilisation is a fraction on this path.
|
|
102
|
+
*/
|
|
103
|
+
export function foldRateLimitEvent(reading, message, nowMs) {
|
|
104
|
+
if (!message || typeof message !== 'object')
|
|
105
|
+
return null;
|
|
106
|
+
const m = message;
|
|
107
|
+
if (m.type !== 'rate_limit_event' || !m.rate_limit_info || typeof m.rate_limit_info !== 'object')
|
|
108
|
+
return null;
|
|
109
|
+
const info = m.rate_limit_info;
|
|
110
|
+
const unified = info.unifiedWindows && typeof info.unifiedWindows === 'object'
|
|
111
|
+
? info.unifiedWindows
|
|
112
|
+
: null;
|
|
113
|
+
const sources = [];
|
|
114
|
+
if (unified) {
|
|
115
|
+
for (const [k, v] of Object.entries(unified)) {
|
|
116
|
+
if (isWindowKey(k) && v && typeof v === 'object')
|
|
117
|
+
sources.push([k, v]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (typeof info.rateLimitType === 'string' && isWindowKey(info.rateLimitType)) {
|
|
121
|
+
sources.push([info.rateLimitType, info]);
|
|
122
|
+
}
|
|
123
|
+
let out = {};
|
|
124
|
+
let any = false;
|
|
125
|
+
for (const [key, src] of sources) {
|
|
126
|
+
let utilization = eventFractionToPct(src.utilization);
|
|
127
|
+
if (utilization == null && src.status === 'rejected')
|
|
128
|
+
utilization = 100;
|
|
129
|
+
const resetsAtMs = epochMs(src.resetsAt);
|
|
130
|
+
if (utilization == null && resetsAtMs == null)
|
|
131
|
+
continue;
|
|
132
|
+
out = withWindow(out, key, { utilization, resetsAtMs, observedAtMs: nowMs });
|
|
133
|
+
any = true;
|
|
134
|
+
}
|
|
135
|
+
return any ? mergeReadings(reading, out) : null;
|
|
136
|
+
}
|
|
137
|
+
/** Parse the experimental usage method's answer. Its utilisation is ALREADY 0–100 (measured on a
|
|
138
|
+
* real login: `seven_day.utilization: 31`) and is kept as-is. Null on any shape mismatch. Pure. */
|
|
139
|
+
export function parseUsageProbe(resp, nowMs) {
|
|
140
|
+
if (!resp || typeof resp !== 'object')
|
|
141
|
+
return null;
|
|
142
|
+
const r = resp;
|
|
143
|
+
const subscriptionType = typeof r.subscription_type === 'string' && r.subscription_type !== '' ? r.subscription_type : null;
|
|
144
|
+
let out = {};
|
|
145
|
+
let any = false;
|
|
146
|
+
if (r.rate_limits_available === true && r.rate_limits && typeof r.rate_limits === 'object') {
|
|
147
|
+
for (const [key, v] of Object.entries(r.rate_limits)) {
|
|
148
|
+
if (!isWindowKey(key) || !v || typeof v !== 'object')
|
|
149
|
+
continue;
|
|
150
|
+
const o = v;
|
|
151
|
+
const u = num(o.utilization);
|
|
152
|
+
const t = typeof o.resets_at === 'string' ? Date.parse(o.resets_at) : NaN;
|
|
153
|
+
const utilization = u != null && u >= 0 && u <= 150 ? u : null;
|
|
154
|
+
const resetsAtMs = Number.isFinite(t) ? t : null;
|
|
155
|
+
if (utilization == null && resetsAtMs == null)
|
|
156
|
+
continue;
|
|
157
|
+
out = withWindow(out, key, { utilization, resetsAtMs, observedAtMs: nowMs });
|
|
158
|
+
any = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!any && subscriptionType == null)
|
|
162
|
+
return null;
|
|
163
|
+
return { ...out, subscriptionType };
|
|
164
|
+
}
|
|
165
|
+
function storedWindow(v) {
|
|
166
|
+
if (!v || typeof v !== 'object')
|
|
167
|
+
return null;
|
|
168
|
+
const w = v;
|
|
169
|
+
const observedAtMs = num(w.observedAtMs);
|
|
170
|
+
return observedAtMs == null ? null : { utilization: num(w.utilization), resetsAtMs: num(w.resetsAtMs), observedAtMs };
|
|
171
|
+
}
|
|
172
|
+
/** A stored reading of any age → today's shape. Never throws. */
|
|
173
|
+
export function normaliseStoredReading(raw) {
|
|
174
|
+
if (!raw || typeof raw !== 'object')
|
|
175
|
+
return null;
|
|
176
|
+
const r = raw;
|
|
177
|
+
const week = storedWindow(r.week);
|
|
178
|
+
const fiveHour = storedWindow(r.fiveHour);
|
|
179
|
+
let models = null;
|
|
180
|
+
if (r.models && typeof r.models === 'object') {
|
|
181
|
+
for (const [k, v] of Object.entries(r.models).slice(0, MAX_MODEL_WINDOWS)) {
|
|
182
|
+
const w = MODEL_WINDOW_KEY.test(k) ? storedWindow(v) : null;
|
|
183
|
+
if (w)
|
|
184
|
+
(models ??= {})[k] = w;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const subscriptionType = typeof r.subscriptionType === 'string' ? r.subscriptionType : null;
|
|
188
|
+
if (week == null && fiveHour == null && models == null && subscriptionType == null)
|
|
189
|
+
return null;
|
|
190
|
+
return { week, fiveHour, models, subscriptionType };
|
|
191
|
+
}
|
|
192
|
+
// ---- the reading: memory + persistence --------------------------------------------------------
|
|
193
|
+
let memReading; // undefined = not loaded yet
|
|
194
|
+
async function loadReading() {
|
|
195
|
+
if (memReading !== undefined)
|
|
196
|
+
return memReading;
|
|
197
|
+
try {
|
|
198
|
+
const rows = await db
|
|
199
|
+
.select({ r: schema.accounts.aiBudgetReading })
|
|
200
|
+
.from(schema.accounts)
|
|
201
|
+
.where(eq(schema.accounts.id, LOCAL_ACCOUNT_ID))
|
|
202
|
+
.limit(1)
|
|
203
|
+
.execute();
|
|
204
|
+
memReading = normaliseStoredReading(rows[0]?.r);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
memReading = null;
|
|
208
|
+
}
|
|
209
|
+
return memReading;
|
|
210
|
+
}
|
|
211
|
+
/** One window merged: the new one wins, but keeps the known reset when it read none. */
|
|
212
|
+
function mergeWindow(prev, next) {
|
|
213
|
+
if (next == null)
|
|
214
|
+
return prev ?? null;
|
|
215
|
+
if (next.resetsAtMs == null && prev?.resetsAtMs != null)
|
|
216
|
+
return { ...next, resetsAtMs: prev.resetsAtMs };
|
|
217
|
+
return next;
|
|
218
|
+
}
|
|
219
|
+
/** Merge two readings (pure): each window independently; what `next` does not mention is kept (an
|
|
220
|
+
* event never names the subscription, and names one or two windows). */
|
|
221
|
+
export function mergeReadings(prev, next) {
|
|
222
|
+
const models = { ...(prev?.models ?? {}) };
|
|
223
|
+
for (const [k, w] of Object.entries(next.models ?? {})) {
|
|
224
|
+
const merged = mergeWindow(models[k], w);
|
|
225
|
+
if (merged && (k in models || Object.keys(models).length < MAX_MODEL_WINDOWS))
|
|
226
|
+
models[k] = merged;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
week: mergeWindow(prev?.week, next.week),
|
|
230
|
+
fiveHour: mergeWindow(prev?.fiveHour, next.fiveHour),
|
|
231
|
+
models: Object.keys(models).length === 0 ? null : models,
|
|
232
|
+
subscriptionType: next.subscriptionType ?? prev?.subscriptionType ?? null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function storeReading(next) {
|
|
236
|
+
memReading = mergeReadings(memReading ?? null, next);
|
|
237
|
+
const value = memReading;
|
|
238
|
+
void db
|
|
239
|
+
.update(schema.accounts)
|
|
240
|
+
.set({ aiBudgetReading: value })
|
|
241
|
+
.where(eq(schema.accounts.id, LOCAL_ACCOUNT_ID))
|
|
242
|
+
.execute()
|
|
243
|
+
.catch(() => {
|
|
244
|
+
/* in memory still; the next reading tries again */
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** The observer ai/runtime.ts calls with every agent message. */
|
|
248
|
+
export function noteAgentMessage(message, nowMs = Date.now()) {
|
|
249
|
+
const next = foldRateLimitEvent(null, message, nowMs);
|
|
250
|
+
if (next == null)
|
|
251
|
+
return;
|
|
252
|
+
// ⚠ Merge over the PERSISTED reading, never over "not loaded yet": a run's event arriving before
|
|
253
|
+
// the boot load finished would otherwise overwrite the stored subscription type and the other
|
|
254
|
+
// windows (storeReading merges `next` over what is in memory).
|
|
255
|
+
if (memReading === undefined) {
|
|
256
|
+
void loadReading().then(() => storeReading(next));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
storeReading(next);
|
|
260
|
+
}
|
|
261
|
+
/** Install the observer. Called once at boot when AI is on. */
|
|
262
|
+
export function installAiBudgetObserver() {
|
|
263
|
+
void loadReading();
|
|
264
|
+
setAgentMessageObserver((m) => noteAgentMessage(m));
|
|
265
|
+
}
|
|
266
|
+
// ---- the usage probe ----------------------------------------------------------------------------
|
|
267
|
+
let lastProbeAtMs = 0;
|
|
268
|
+
let probeInFlight = null;
|
|
269
|
+
/** Ask the Agent SDK's experimental usage method. Spawns the CLI with an empty input stream (no model
|
|
270
|
+
* call), asks, closes. Throws on anything unexpected; the caller swallows. */
|
|
271
|
+
const sdkUsageProbe = async () => {
|
|
272
|
+
const sdk = await loadAgentSdk();
|
|
273
|
+
let release = () => { };
|
|
274
|
+
const held = new Promise((r) => {
|
|
275
|
+
release = r;
|
|
276
|
+
});
|
|
277
|
+
// An input stream that never yields a turn: the CLI starts, answers control requests, and spends
|
|
278
|
+
// nothing.
|
|
279
|
+
async function* noTurns() {
|
|
280
|
+
await held;
|
|
281
|
+
}
|
|
282
|
+
const q = sdk.query({
|
|
283
|
+
prompt: noTurns(),
|
|
284
|
+
options: {
|
|
285
|
+
allowedTools: [],
|
|
286
|
+
settingSources: [],
|
|
287
|
+
...ISOLATED_MCP,
|
|
288
|
+
...claudeExecutableOptions(),
|
|
289
|
+
...agentAuthOptions(),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
const method = q
|
|
294
|
+
.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET;
|
|
295
|
+
if (typeof method !== 'function')
|
|
296
|
+
throw new Error('usage method not available');
|
|
297
|
+
let timer;
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([
|
|
300
|
+
method.call(q, { skipBehaviors: true }),
|
|
301
|
+
new Promise((_, reject) => {
|
|
302
|
+
timer = setTimeout(() => reject(new Error('usage probe timed out')), PROBE_TIMEOUT_MS);
|
|
303
|
+
}),
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (timer)
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
release();
|
|
313
|
+
try {
|
|
314
|
+
q.close?.();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* already gone */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
let probeImpl = sdkUsageProbe;
|
|
322
|
+
/** Test hook. */
|
|
323
|
+
export function _setUsageProbeForTest(p) {
|
|
324
|
+
probeImpl = p ?? sdkUsageProbe;
|
|
325
|
+
lastProbeAtMs = 0;
|
|
326
|
+
probeInFlight = null;
|
|
327
|
+
}
|
|
328
|
+
/** Test hook. */
|
|
329
|
+
export function _resetAiBudgetForTest() {
|
|
330
|
+
memReading = undefined;
|
|
331
|
+
lastProbeAtMs = 0;
|
|
332
|
+
probeInFlight = null;
|
|
333
|
+
pausedAccounts.clear();
|
|
334
|
+
}
|
|
335
|
+
/** Start the probe IN THE BACKGROUND when it can help: login route (the caller checks), runtime
|
|
336
|
+
* ready, not run in the last PROBE_MIN_INTERVAL_MS. Single-flight. Never throws, never awaited by a
|
|
337
|
+
* request: the caller serves the cached reading. Returns the in-flight promise for tests. */
|
|
338
|
+
export function kickUsageProbe(nowMs = Date.now()) {
|
|
339
|
+
if (probeInFlight)
|
|
340
|
+
return probeInFlight;
|
|
341
|
+
if (nowMs - lastProbeAtMs < PROBE_MIN_INTERVAL_MS)
|
|
342
|
+
return null;
|
|
343
|
+
if (probeImpl === sdkUsageProbe && getAiRuntimeStatus().runtime !== 'ready')
|
|
344
|
+
return null;
|
|
345
|
+
lastProbeAtMs = nowMs;
|
|
346
|
+
probeInFlight = (async () => {
|
|
347
|
+
try {
|
|
348
|
+
await loadReading();
|
|
349
|
+
const parsed = parseUsageProbe(await probeImpl(), Date.now());
|
|
350
|
+
if (parsed)
|
|
351
|
+
storeReading(parsed);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* no reading — the last one stays */
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
probeInFlight = null;
|
|
358
|
+
}
|
|
359
|
+
})();
|
|
360
|
+
return probeInFlight;
|
|
361
|
+
}
|
|
362
|
+
// ---- settings -----------------------------------------------------------------------------------
|
|
363
|
+
export async function readAiBudgetSettings(accountId) {
|
|
364
|
+
const rows = await db
|
|
365
|
+
.select({ s: schema.accounts.aiBudgetSettings })
|
|
366
|
+
.from(schema.accounts)
|
|
367
|
+
.where(eq(schema.accounts.id, accountId))
|
|
368
|
+
.limit(1)
|
|
369
|
+
.execute();
|
|
370
|
+
const raw = rows[0]?.s ?? null;
|
|
371
|
+
return { settings: resolveAiBudgetSettings(raw), isDefault: raw == null };
|
|
372
|
+
}
|
|
373
|
+
/** Merge a VALIDATED body over the stored settings and store the whole resolved object. */
|
|
374
|
+
export async function writeAiBudgetSettings(accountId, body) {
|
|
375
|
+
const { settings: current } = await readAiBudgetSettings(accountId);
|
|
376
|
+
const next = resolveAiBudgetSettings({ ...current, ...body });
|
|
377
|
+
await db
|
|
378
|
+
.update(schema.accounts)
|
|
379
|
+
.set({ aiBudgetSettings: next })
|
|
380
|
+
.where(eq(schema.accounts.id, accountId))
|
|
381
|
+
.execute();
|
|
382
|
+
return next;
|
|
383
|
+
}
|
|
384
|
+
// ---- the status and the gate ----------------------------------------------------------------------
|
|
385
|
+
async function readLedger(accountId, sinceMs) {
|
|
386
|
+
const { aiUsage } = schema;
|
|
387
|
+
const rows = await db
|
|
388
|
+
.select({ at: aiUsage.occurredAt, usd: aiUsage.costUsd, inTok: aiUsage.inputTokens, outTok: aiUsage.outputTokens })
|
|
389
|
+
.from(aiUsage)
|
|
390
|
+
.where(and(eq(aiUsage.accountId, accountId), gte(aiUsage.occurredAt, new Date(sinceMs))))
|
|
391
|
+
.execute();
|
|
392
|
+
return rows.map((r) => ({
|
|
393
|
+
atMs: r.at.getTime(),
|
|
394
|
+
usd: Number(r.usd) || 0,
|
|
395
|
+
inputTokens: r.inTok ?? null,
|
|
396
|
+
outputTokens: r.outTok ?? null,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
/** The account's typical automatic-review cycle: the mean recorded cost of a Claude review and of a
|
|
400
|
+
* diagram run over the last 28 days, and whether any of its workspaces draws diagrams by itself. */
|
|
401
|
+
export async function readCycleCosts(accountId, nowMs) {
|
|
402
|
+
const { aiUsage, workspaces } = schema;
|
|
403
|
+
const [rows, ws] = await Promise.all([
|
|
404
|
+
db
|
|
405
|
+
.select({ feature: aiUsage.feature, usd: aiUsage.costUsd })
|
|
406
|
+
.from(aiUsage)
|
|
407
|
+
.where(and(eq(aiUsage.accountId, accountId), inArray(aiUsage.feature, ['claude_review', 'sequence_diagram']), gte(aiUsage.occurredAt, new Date(nowMs - CYCLE_COST_WINDOW_MS))))
|
|
408
|
+
.execute(),
|
|
409
|
+
db
|
|
410
|
+
.select({ id: workspaces.id })
|
|
411
|
+
.from(workspaces)
|
|
412
|
+
.where(and(eq(workspaces.accountId, accountId), eq(workspaces.autoDiagramsEnabled, true)))
|
|
413
|
+
.limit(1)
|
|
414
|
+
.execute(),
|
|
415
|
+
]);
|
|
416
|
+
const mean = (feature) => {
|
|
417
|
+
const xs = rows.filter((r) => r.feature === feature).map((r) => Number(r.usd)).filter((n) => Number.isFinite(n));
|
|
418
|
+
return xs.length === 0 ? null : xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
419
|
+
};
|
|
420
|
+
return { reviewMeanUsd: mean('claude_review'), diagramMeanUsd: mean('sequence_diagram'), autoDiagrams: ws.length > 0 };
|
|
421
|
+
}
|
|
422
|
+
const NO_CYCLE_COSTS = { reviewMeanUsd: null, diagramMeanUsd: null, autoDiagrams: false };
|
|
423
|
+
const OFF_STATUS = (route) => ({
|
|
424
|
+
enabled: false,
|
|
425
|
+
mode: 'off',
|
|
426
|
+
route,
|
|
427
|
+
subscription: null,
|
|
428
|
+
apiKey: null,
|
|
429
|
+
overBudget: false,
|
|
430
|
+
limitedBy: null,
|
|
431
|
+
reason: null,
|
|
432
|
+
resumesAt: null,
|
|
433
|
+
});
|
|
434
|
+
/** Local midnight on this machine (the server IS the reader's machine), for "today". */
|
|
435
|
+
function localMidnightMs(nowMs) {
|
|
436
|
+
const d = new Date(nowMs);
|
|
437
|
+
d.setHours(0, 0, 0, 0);
|
|
438
|
+
return d.getTime();
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Everything GET /api/ai/budget returns, from ONE set of reads: the settings, the decision and the
|
|
442
|
+
* header's usage view. Serves the CACHED reading; the probe (when asked) refreshes it in the
|
|
443
|
+
* background for the next call.
|
|
444
|
+
*/
|
|
445
|
+
export async function aiBudgetSnapshot(accountId, opts = {}) {
|
|
446
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
447
|
+
const route = resolveAiCredential().route;
|
|
448
|
+
const { settings, isDefault } = await readAiBudgetSettings(accountId);
|
|
449
|
+
const probe = opts.probe ?? 'when_on';
|
|
450
|
+
if (route === 'login' && (probe === 'always' || (probe === 'when_on' && settings.enabled)))
|
|
451
|
+
kickUsageProbe(nowMs);
|
|
452
|
+
const [reading, ledger, costs] = await Promise.all([
|
|
453
|
+
route === 'login' ? loadReading() : Promise.resolve(null),
|
|
454
|
+
readLedger(accountId, Math.min(nowMs - WEEK_MS, localMidnightMs(nowMs))),
|
|
455
|
+
settings.enabled && route === 'login' ? readCycleCosts(accountId, nowMs) : Promise.resolve(NO_CYCLE_COSTS),
|
|
456
|
+
]);
|
|
457
|
+
return {
|
|
458
|
+
settings,
|
|
459
|
+
isDefault,
|
|
460
|
+
status: decideAiBudget({ settings, route, reading, ledger, costs, nowMs }),
|
|
461
|
+
usage: aiUsageView({ settings, route, reading, ledger, nowMs, todayFromMs: localMidnightMs(nowMs) }),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** The budget's status for an account. Never throws: a failed read is "off" (never blocks). */
|
|
465
|
+
export async function aiBudgetStatus(accountId, opts = {}) {
|
|
466
|
+
try {
|
|
467
|
+
return (await aiBudgetSnapshot(accountId, opts)).status;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return OFF_STATUS(resolveAiCredential().route);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// account → when its current pause was first logged (log once per pause).
|
|
474
|
+
const pausedAccounts = new Map();
|
|
475
|
+
/**
|
|
476
|
+
* THE GATE before every AUTOMATIC model run. True ⇒ go. False ⇒ skip this run (do not queue it);
|
|
477
|
+
* the caller's next tick asks again. `what` names the run for the one log line.
|
|
478
|
+
*/
|
|
479
|
+
export async function allowAutomaticRun(accountId, what, log, statusOf = (a) => aiBudgetStatus(a)) {
|
|
480
|
+
const status = await statusOf(accountId);
|
|
481
|
+
if (!status.overBudget) {
|
|
482
|
+
if (pausedAccounts.delete(accountId))
|
|
483
|
+
log?.info('claude budget: back under the limit, automatic runs resume');
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
if (!pausedAccounts.has(accountId)) {
|
|
487
|
+
pausedAccounts.set(accountId, Date.now());
|
|
488
|
+
log?.info(`claude budget: automatic runs paused (${what} skipped)${status.resumesAt ? `; resumes about ${status.resumesAt}` : ''}`);
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
export function buildAiBudgetGate(log) {
|
|
493
|
+
return { allowAutomaticRun: (accountId, what) => allowAutomaticRun(accountId, what, log) };
|
|
494
|
+
}
|
|
495
|
+
//# sourceMappingURL=budget.js.map
|
package/dist/ai/runtime.js
CHANGED
|
@@ -548,7 +548,52 @@ export function installAiRuntime(onProgress, opts) {
|
|
|
548
548
|
export function aiRuntimePins() {
|
|
549
549
|
return runtime.pins();
|
|
550
550
|
}
|
|
551
|
-
export const loadAgentSdk = () => runtime.load('@anthropic-ai/claude-agent-sdk');
|
|
551
|
+
export const loadAgentSdk = async () => tapAgentSdk(await runtime.load('@anthropic-ai/claude-agent-sdk'));
|
|
552
|
+
let agentMessageObserver = null;
|
|
553
|
+
/** Install (or clear) the one observer every agent run's messages pass through. */
|
|
554
|
+
export function setAgentMessageObserver(fn) {
|
|
555
|
+
agentMessageObserver = fn;
|
|
556
|
+
}
|
|
557
|
+
function observe(message) {
|
|
558
|
+
if (!agentMessageObserver)
|
|
559
|
+
return;
|
|
560
|
+
try {
|
|
561
|
+
agentMessageObserver(message);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
/* never into the run */
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Wrap a Query (an AsyncGenerator with extra methods) so each yielded message is observed. */
|
|
568
|
+
export function tapQuery(q) {
|
|
569
|
+
const proxy = new Proxy(q, {
|
|
570
|
+
get(target, prop) {
|
|
571
|
+
if (prop === 'next') {
|
|
572
|
+
return async (...args) => {
|
|
573
|
+
const r = await target.next(...args);
|
|
574
|
+
if (!r.done)
|
|
575
|
+
observe(r.value);
|
|
576
|
+
return r;
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (prop === Symbol.asyncIterator)
|
|
580
|
+
return () => proxy;
|
|
581
|
+
const v = Reflect.get(target, prop, target);
|
|
582
|
+
return typeof v === 'function' ? v.bind(target) : v;
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
return proxy;
|
|
586
|
+
}
|
|
587
|
+
const tappedSdks = new WeakMap();
|
|
588
|
+
function tapAgentSdk(mod) {
|
|
589
|
+
const hit = tappedSdks.get(mod);
|
|
590
|
+
if (hit)
|
|
591
|
+
return hit;
|
|
592
|
+
const query = ((params) => tapQuery(mod.query(params)));
|
|
593
|
+
const tapped = { ...mod, query };
|
|
594
|
+
tappedSdks.set(mod, tapped);
|
|
595
|
+
return tapped;
|
|
596
|
+
}
|
|
552
597
|
export const loadAnthropicSdk = () => runtime.load('@anthropic-ai/sdk');
|
|
553
598
|
export const loadMcpSdk = () => runtime.load('@modelcontextprotocol/sdk');
|
|
554
599
|
export const loadZodModule = () => runtime.load('zod');
|
|
@@ -65,6 +65,13 @@ function tierFor(method, path) {
|
|
|
65
65
|
// over the user's own bandwidth, single-flight in ml/runtime.ts), the same bucket, spelled exactly.
|
|
66
66
|
if (path === '/api/ml/install')
|
|
67
67
|
return [TIERS.sync];
|
|
68
|
+
// THE CLAUDE SPEND BUDGET: GET/PUT /api/ai/budget (ai/budget.ts). FOLLOWING THE TOKEN: no model
|
|
69
|
+
// call and no GitHub call. The PUT is one validated UPDATE of one column; the GET (the header's usage
|
|
70
|
+
// item polls it every ~2 minutes) reads the ledger and may START the usage probe in the background,
|
|
71
|
+
// which is single-flight and at most once per 5 minutes inside ai/budget.ts — a loop cannot
|
|
72
|
+
// multiply it. `read`, decided rather than inherited; EXACT `===`. No other usage route exists.
|
|
73
|
+
if (path === '/api/ai/budget')
|
|
74
|
+
return [TIERS.read];
|
|
68
75
|
// ---- Workspace CRUD: `read`, RECORDED rather than inherited ----
|
|
69
76
|
//
|
|
70
77
|
// The six surviving routes (list / create / rename+reassign / delete / move-one-repo-in /
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { aiBudgetSettingsView, validateAiBudgetBody, } from '../../shared/index.js';
|
|
2
|
+
import { config } from '../../config.js';
|
|
3
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
4
|
+
import { aiBudgetSnapshot, installAiBudgetObserver, writeAiBudgetSettings } from '../../ai/budget.js';
|
|
5
|
+
// THE CLAUDE SPEND BUDGET (ai/budget.ts; types in packages/shared ai-budget.ts).
|
|
6
|
+
//
|
|
7
|
+
// GET /api/ai/budget → AiBudgetResponse: the settings (defaults filled in; `weeklyUsd` null off
|
|
8
|
+
// the API-key route), whether anything is stored, the ONE decision
|
|
9
|
+
// (`status`) and the header's usage view (`usage`). Serves the CACHED usage
|
|
10
|
+
// reading and, on the login route, starts the usage probe IN THE BACKGROUND
|
|
11
|
+
// (at most every 5 minutes, 20s timeout, no model call) — it never waits.
|
|
12
|
+
// The header polls it every ~2 minutes while the tab is visible.
|
|
13
|
+
// PUT /api/ai/budget AiBudgetBody (any subset; validated) → AiBudgetResponse.
|
|
14
|
+
//
|
|
15
|
+
// Registered only when `config.aiEnabled`. Registering it also installs the message observer every
|
|
16
|
+
// agent run's `rate_limit_event`s pass through, so the reading exists before anyone opens Settings.
|
|
17
|
+
export async function aiBudgetRoutes(app) {
|
|
18
|
+
if (!config.aiEnabled)
|
|
19
|
+
return;
|
|
20
|
+
installAiBudgetObserver();
|
|
21
|
+
const respond = async (accountId) => {
|
|
22
|
+
const { settings, isDefault, status, usage } = await aiBudgetSnapshot(accountId, { probe: 'always' });
|
|
23
|
+
// A subscription carries no US$ on the wire: the API key's weekly figure is sent only on that route.
|
|
24
|
+
return { settings: aiBudgetSettingsView(settings, status.route), isDefault, status, usage };
|
|
25
|
+
};
|
|
26
|
+
app.get('/api/ai/budget', async (req) => respond(accountIdOf(req)));
|
|
27
|
+
app.put('/api/ai/budget', async (req, reply) => {
|
|
28
|
+
const accountId = accountIdOf(req);
|
|
29
|
+
const problem = validateAiBudgetBody(req.body);
|
|
30
|
+
if (problem != null)
|
|
31
|
+
return reply.code(400).send({ error: 'InvalidAiBudget', message: problem });
|
|
32
|
+
await writeAiBudgetSettings(accountId, req.body);
|
|
33
|
+
return respond(accountId);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=ai-budget.js.map
|
package/dist/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { corsOriginDelegate, registerCrossOriginGuard, registerHostGuard, regist
|
|
|
8
8
|
import { registerRateLimit } from './api/plugins/rate-limit.js';
|
|
9
9
|
import { healthRoutes } from './api/routes/health.js';
|
|
10
10
|
import { aiRuntimeRoutes } from './api/routes/ai-runtime.js';
|
|
11
|
+
import { aiBudgetRoutes } from './api/routes/ai-budget.js';
|
|
11
12
|
import { mlRuntimeRoutes } from './api/routes/ml-runtime.js';
|
|
12
13
|
import { registerAgenticRoutes } from './review/agentic.js';
|
|
13
14
|
import { registerTracker } from './tracker/index.js';
|
|
@@ -200,6 +201,10 @@ export async function buildApp() {
|
|
|
200
201
|
// LIMN_AI_DISABLED.
|
|
201
202
|
if (config.aiEnabled)
|
|
202
203
|
await app.register(aiRuntimeRoutes);
|
|
204
|
+
// The Claude spend budget (ai/budget.ts): GET/PUT /api/ai/budget. Not registered under
|
|
205
|
+
// LIMN_AI_DISABLED.
|
|
206
|
+
if (config.aiEnabled)
|
|
207
|
+
await app.register(aiBudgetRoutes);
|
|
203
208
|
// "Set up" for local bot-comment scoring (ml/runtime.ts). Not registered under LIMN_ML_DISABLED.
|
|
204
209
|
if (config.mlLocalEnabled)
|
|
205
210
|
await app.register(mlRuntimeRoutes);
|
|
@@ -48,6 +48,7 @@ import { isFixRunning, loadReviewSeed, startReviewFix } from './manager.js';
|
|
|
48
48
|
import { parseChangeReport, parseReviewItems } from './persist.js';
|
|
49
49
|
import { getFixPrContext } from './pr-context.js';
|
|
50
50
|
import { readWorkspaceAutoFixSettingsForPr } from '../../review/claude-review/auto-settings.js';
|
|
51
|
+
import { automaticRunAllowed } from '../../review/budget-gate.js';
|
|
51
52
|
// At most AUTO_FIX_DAILY_CAP (shared) AUTO fixes per PR in any rolling AUTO_FIX_WINDOW_MS.
|
|
52
53
|
export { AUTO_FIX_DAILY_CAP };
|
|
53
54
|
export const AUTO_FIX_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
@@ -137,6 +138,9 @@ export async function maybeStartAutoFix(ctx, input, deps = defaultDeps, nowMs =
|
|
|
137
138
|
const ws = await readWorkspaceAutoFixSettingsForPr(ctx, accountId, prId);
|
|
138
139
|
if (!ws?.enabled)
|
|
139
140
|
return skip('off');
|
|
141
|
+
// THE CLAUDE SPEND BUDGET (ai/budget.ts): over it, no auto fix — the next auto review decides again.
|
|
142
|
+
if (!(await automaticRunAllowed(ctx, accountId, 'auto fix')))
|
|
143
|
+
return skip('over_budget');
|
|
140
144
|
// WHAT an auto fix carries is the workspace's "Always include" sections (AutoFixSettings).
|
|
141
145
|
const include = ws.settings.include;
|
|
142
146
|
const loaded = await deps.loadReviewSeed(ctx, {
|
|
@@ -51,6 +51,8 @@ export async function exportAccountData(accountId) {
|
|
|
51
51
|
largePrCodeLocThreshold: accounts.largePrCodeLocThreshold,
|
|
52
52
|
blastRadiusConfig: accounts.blastRadiusConfig,
|
|
53
53
|
myTurnSettings: accounts.myTurnSettings,
|
|
54
|
+
aiBudgetSettings: accounts.aiBudgetSettings,
|
|
55
|
+
aiBudgetReading: accounts.aiBudgetReading,
|
|
54
56
|
lastLoginAt: accounts.lastLoginAt,
|
|
55
57
|
lastActiveAt: accounts.lastActiveAt,
|
|
56
58
|
createdAt: accounts.createdAt,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- THE CLAUDE SPEND BUDGET (core; packages/shared ai-budget.ts, ai/budget.ts).
|
|
2
|
+
--
|
|
3
|
+
-- accounts.ai_budget_settings: the optional per-machine budget that pauses AUTOMATIC model runs —
|
|
4
|
+
-- the subscription thresholds (5-hour / 7-day %), the subscription picked for the estimate, and the
|
|
5
|
+
-- API-key US$ budget. NULL = never set = off. One writer: PUT /api/ai/budget.
|
|
6
|
+
-- accounts.ai_budget_reading: the latest subscription usage reading (utilisation + reset time per
|
|
7
|
+
-- window), so a restart keeps it. Both nullable, no backfill.
|
|
8
|
+
ALTER TABLE `accounts` ADD `ai_budget_settings` text;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
ALTER TABLE `accounts` ADD `ai_budget_reading` text;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- AUTOMATIC DESIGN DIAGRAMS, PER WORKSPACE — `workspaces.auto_diagrams_enabled`. After every
|
|
2
|
+
-- succeeded Claude review (manual or auto), review/diagrams/trigger.ts may queue the PR's design
|
|
3
|
+
-- diagram and its tickets' story diagrams. This is that step's switch, edited in Settings beside
|
|
4
|
+
-- auto review and written by `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
|
|
5
|
+
-- ⚠ DEFAULT TRUE (1) and NOT NULL: diagrams were drawn after every review before this column
|
|
6
|
+
-- existed, so every workspace, existing and new, keeps that until someone switches it off. Off
|
|
7
|
+
-- stops only the AUTOMATIC draw; the Draw / Redraw button still works.
|
|
8
|
+
ALTER TABLE `workspaces` ADD `auto_diagrams_enabled` integer DEFAULT 1 NOT NULL;
|