dsh-agy 0.2.12 → 0.3.0

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.
Files changed (46) hide show
  1. package/README.md +67 -11
  2. package/cordis.patch.yml +4 -2
  3. package/lib/{accounts-DB1tT9Gy.mjs → accounts-CkaHhb5H.mjs} +1155 -646
  4. package/lib/accounts-CkaHhb5H.mjs.map +1 -0
  5. package/lib/{catalog-bfGIyoba.mjs → catalog-BgdDVJEB.mjs} +17 -2
  6. package/lib/{catalog-bfGIyoba.mjs.map → catalog-BgdDVJEB.mjs.map} +1 -1
  7. package/lib/cli/index.d.mts +10 -1
  8. package/lib/cli/index.d.mts.map +1 -1
  9. package/lib/cli/index.mjs +21 -8
  10. package/lib/cli/index.mjs.map +1 -1
  11. package/lib/client.js +2168 -14
  12. package/lib/{constants-BFKYjOmh.mjs → constants-Db4tmyfb.mjs} +79 -14
  13. package/lib/constants-Db4tmyfb.mjs.map +1 -0
  14. package/lib/{exchange-dQm2mjJ5.mjs → exchange-yZPIZumk.mjs} +27 -12
  15. package/lib/exchange-yZPIZumk.mjs.map +1 -0
  16. package/lib/identity-Ci8EGc1F.mjs +96 -0
  17. package/lib/identity-Ci8EGc1F.mjs.map +1 -0
  18. package/lib/{import-D7R0GpRr.mjs → import-BaFULQnH.mjs} +11 -7
  19. package/lib/import-BaFULQnH.mjs.map +1 -0
  20. package/lib/index.mjs +2 -2
  21. package/lib/{models-ClGTQBLY.mjs → models-BGuwl50d.mjs} +35 -22
  22. package/lib/models-BGuwl50d.mjs.map +1 -0
  23. package/lib/plugin-common-B1MG00JT.mjs +1313 -0
  24. package/lib/plugin-common-B1MG00JT.mjs.map +1 -0
  25. package/lib/{proxy-DLvX8yxv.mjs → proxy-DfaL73mL.mjs} +52 -3
  26. package/lib/proxy-DfaL73mL.mjs.map +1 -0
  27. package/lib/quota-summary-5CypVffd.mjs +127 -0
  28. package/lib/quota-summary-5CypVffd.mjs.map +1 -0
  29. package/lib/{translate-D3GFUpw4.mjs → translate-BQu8HLvB.mjs} +25 -6
  30. package/lib/translate-BQu8HLvB.mjs.map +1 -0
  31. package/lib/web/plugin.d.mts +1 -0
  32. package/lib/web/plugin.d.mts.map +1 -1
  33. package/lib/web/plugin.mjs +651 -1701
  34. package/lib/web/plugin.mjs.map +1 -1
  35. package/package.json +16 -4
  36. package/lib/accounts-DB1tT9Gy.mjs.map +0 -1
  37. package/lib/constants-BFKYjOmh.mjs.map +0 -1
  38. package/lib/exchange-dQm2mjJ5.mjs.map +0 -1
  39. package/lib/identity-DsftHTJC.mjs +0 -35
  40. package/lib/identity-DsftHTJC.mjs.map +0 -1
  41. package/lib/import-D7R0GpRr.mjs.map +0 -1
  42. package/lib/models-ClGTQBLY.mjs.map +0 -1
  43. package/lib/plugin-common-B63uavj7.mjs +0 -254
  44. package/lib/plugin-common-B63uavj7.mjs.map +0 -1
  45. package/lib/proxy-DLvX8yxv.mjs.map +0 -1
  46. package/lib/translate-D3GFUpw4.mjs.map +0 -1
@@ -0,0 +1,1313 @@
1
+ import { m as fetchAgyFirstOk, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-Db4tmyfb.mjs";
2
+ import { a as probeFetch, o as proxiedFetch, t as accountFetch } from "./proxy-DfaL73mL.mjs";
3
+ import { _ as resolveMasterKeyCodec, b as describeFetchError, d as MASTER_KEY_REF, f as createAesGcmCodec, g as resolveDshHome, h as persistMasterKey, m as loadMasterKey, p as deriveKey, r as AgySessionManager, s as resolveAntigravityVersion, t as JsonAccountStore, u as pickProbeProxyUrl, v as classifyFetchError, x as isSessionAccumulationOverflow, y as classifyHttpError } from "./accounts-CkaHhb5H.mjs";
4
+ import { i as generateAntigravityRequestId, n as currentSessionGeneration, r as deriveAntigravitySessionId, t as bumpSessionGeneration } from "./identity-Ci8EGc1F.mjs";
5
+ import { i as setThoughtSignature, r as resolveMultimodalFiles, t as toAgyRequestBody } from "./translate-BQu8HLvB.mjs";
6
+ import { t as parseAgySse } from "./parse-Bs4rPkHA.mjs";
7
+ import { i as resolveAgyModel, n as listAgyModels, t as catalogModelList } from "./models-BGuwl50d.mjs";
8
+ import { randomBytes } from "node:crypto";
9
+ import { LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from "@deepseek-ai/dsh-llm";
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import lockfile from "proper-lockfile";
13
+ //#region src/adapter/adapter.ts
14
+ /**
15
+ * AgyAdapter: the DSH seam. A thin orchestrator over the deep modules —
16
+ * account session resolution (shell-provided), request translation, SSE
17
+ * parsing, failure classification, and rotation reporting. All wire details
18
+ * live in translate.ts / parse.ts / models.ts.
19
+ */
20
+ /** Collect image refs from user-message content only (spec scope: user images; tool-result nesting out of scope). */
21
+ function collectImageRefs(options) {
22
+ const refs = [];
23
+ for (const message of options.messages) {
24
+ if (message.role !== "user") continue;
25
+ for (const block of message.content) if (block.type === "image") refs.push(block.attachment);
26
+ }
27
+ return refs;
28
+ }
29
+ const UPSTREAM_ERROR_CODE = "UPSTREAM";
30
+ /** First-class DSH retryable code: the default retry policy honors SERVER (5xx), not UPSTREAM. */
31
+ const SERVER_ERROR_CODE = "SERVER";
32
+ /** Stable ledger key for an account: email when present, else the generated id. */
33
+ function ledgerAccountKey(session) {
34
+ return session.account.email ?? session.account.id;
35
+ }
36
+ /**
37
+ * Build the impersonation headers for one request.
38
+ *
39
+ * There is NO request-id header. `x-goog-request-id` used to be sent here and is
40
+ * present in neither official binary; the id the backend correlates on is the
41
+ * body's `requestId` field, which this request already carries. An invented
42
+ * header is a shape no official client emits, which is precisely the anomaly the
43
+ * impersonation set exists to avoid.
44
+ *
45
+ * `User-Agent` carries the Antigravity client string and NOTHING else. The
46
+ * harness's `attributionHeaders()` is deliberately not merged in: it returns a
47
+ * lowercase `user-agent` key, so spreading it alongside the camel-case
48
+ * `session.impersonation` produced TWO distinct object properties that `fetch`
49
+ * folded into one comma-joined value —
50
+ *
51
+ * `deepseek-harness/<v> (+url), antigravity/<v> <platform>`
52
+ *
53
+ * — a header no official client can emit, byte-identical for every dsh-agy
54
+ * user, and therefore a stronger fingerprint than the one it was trying to
55
+ * avoid. There is exactly one `User-Agent` field on the wire and the upstream
56
+ * requires it to be the client identity, so this request cannot carry both.
57
+ *
58
+ * This adapter performs its own dispatch (see the `fetchAgyFirstOk` call in
59
+ * `stream()`); nothing downstream re-adds the header.
60
+ */
61
+ function buildRequestHeaders(session) {
62
+ return {
63
+ authorization: `Bearer ${session.auth.access}`,
64
+ "content-type": "application/json",
65
+ accept: "text/event-stream",
66
+ "User-Agent": session.impersonation["User-Agent"],
67
+ "X-Goog-Api-Client": session.impersonation["X-Goog-Api-Client"]
68
+ };
69
+ }
70
+ var AgyAdapter = class extends LlmAdapter {
71
+ options;
72
+ constructor(options) {
73
+ super();
74
+ this.options = options;
75
+ }
76
+ providerInfo(_provider) {
77
+ return {
78
+ id: "agy",
79
+ name: "Antigravity (agy)"
80
+ };
81
+ }
82
+ /**
83
+ * The catalog as DSH's model selector sees it: discovered models minus the
84
+ * user's hidden set. Filtering here is what makes "turn a model off" hide it
85
+ * from the picker — the selector reads this, and DSH itself is untouched.
86
+ */
87
+ async listModels(_provider) {
88
+ const all = await this.listAllModels();
89
+ const hidden = this.options.modelVisibility?.disabledFor("agy");
90
+ if (hidden === void 0 || hidden.size === 0) return all;
91
+ return all.filter((model) => !hidden.has(model.id));
92
+ }
93
+ /**
94
+ * The complete catalog, ignoring the user's hidden set.
95
+ *
96
+ * The settings page lists models through this rather than `listModels`: if it
97
+ * read the filtered list, a hidden model would vanish from the page along
98
+ * with the switch that hides it, leaving no way to turn it back on without
99
+ * hand-editing the file.
100
+ */
101
+ async listAllModels() {
102
+ try {
103
+ const session = await this.options.getSession();
104
+ const routing = { proxyUrl: session?.account.proxy };
105
+ return await listAgyModels(session?.auth.access, session?.account.projectId, accountFetch(routing));
106
+ } catch (error) {
107
+ if (error instanceof AgyPoolBlockedError || error instanceof AgyAuthError) return catalogModelList();
108
+ throw error;
109
+ }
110
+ }
111
+ async resolveModel(provider, model) {
112
+ return resolveAgyModel(provider, model);
113
+ }
114
+ /**
115
+ * Pre-resolve every image attachment into base64 bytes before translation.
116
+ * Image input hard-fails with UNSUPPORTED_CONTENT (terminal, never retried)
117
+ * when the store is missing or a read fails — silently dropping images and
118
+ * sending text-only is the exact failure mode this path exists to prevent.
119
+ */
120
+ async resolveRequestImages(options) {
121
+ const refs = collectImageRefs(options);
122
+ const images = /* @__PURE__ */ new Map();
123
+ if (refs.length === 0) return images;
124
+ const store = this.options.resolveAttachments?.();
125
+ if (!store) throw new LlmError("agy image input requires the durable attachment service (in-harness plugin only)", "UNSUPPORTED_CONTENT");
126
+ const settled = await Promise.allSettled(refs.map(async (ref) => {
127
+ const stored = await store.readImage(ref);
128
+ return {
129
+ attachmentId: ref.attachmentId,
130
+ image: {
131
+ mediaType: stored.ref.mediaType,
132
+ data: Buffer.from(stored.data).toString("base64")
133
+ }
134
+ };
135
+ }));
136
+ const failedIndex = settled.findIndex((outcome) => outcome.status === "rejected");
137
+ if (failedIndex !== -1) {
138
+ const ref = refs[failedIndex];
139
+ const cause = settled[failedIndex].reason;
140
+ throw new LlmError(`agy image attachment "${ref.attachmentId}" could not be loaded: ${cause instanceof Error ? cause.message : String(cause)}`, "UNSUPPORTED_CONTENT", { cause: cause instanceof Error ? cause : void 0 });
141
+ }
142
+ for (const outcome of settled) if (outcome.status === "fulfilled") images.set(outcome.value.attachmentId, outcome.value.image);
143
+ return images;
144
+ }
145
+ async *stream(options) {
146
+ const holder = {};
147
+ try {
148
+ yield* this.streamInner(options, holder);
149
+ } finally {
150
+ if (holder.account !== void 0) this.options.noteRequestSettled?.(holder.account);
151
+ }
152
+ }
153
+ async *streamInner(options, holder) {
154
+ const images = await this.resolveRequestImages(options);
155
+ /**
156
+ * The DSH agent loop stamps `options.sessionId` on every request it builds,
157
+ * so it is the conversation identity — used both to scope account affinity
158
+ * here and to derive the upstream `sessionId` below.
159
+ */
160
+ const conversationKey = options.sessionId === void 0 ? void 0 : String(options.sessionId);
161
+ let session;
162
+ try {
163
+ session = await this.options.getSession(options.model, conversationKey);
164
+ } catch (error) {
165
+ if (error instanceof AgyAuthError) {
166
+ if (error.kind === "transport") throw new LlmError(error.message, "TRANSPORT", { cause: error });
167
+ if (error.kind === "rate-limit") throw new LlmError(error.message, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
168
+ throw new LlmError(error.message, "INVALID_CREDENTIAL", { cause: error });
169
+ }
170
+ if (error instanceof AgyPoolBlockedError) {
171
+ if (error.kind === "quota-exhausted") throw new LlmError(error.message, QUOTA_EXCEEDED_CODE);
172
+ const delta = Math.ceil(error.blockedUntil - Date.now());
173
+ const providerRetryAfterMs = Number.isFinite(delta) && delta > 0 ? delta : 1;
174
+ throw new LlmError(error.message, "RATE_LIMIT", {
175
+ providerRetryAfterMs,
176
+ requestId: ProviderRequestId(generateAntigravityRequestId())
177
+ });
178
+ }
179
+ throw error;
180
+ }
181
+ if (!session) throw new LlmError("No agy account configured — run `dsh-agy login` to authenticate.", "NO_CREDENTIAL");
182
+ const multimodalFiles = await resolveMultimodalFiles(options);
183
+ holder.account = session.account;
184
+ this.options.noteRequestStarted?.(session.account);
185
+ const conversationAccount = ledgerAccountKey(session);
186
+ /** Wall-clock origin for this attempt's latency figures. */
187
+ const startedAt = Date.now();
188
+ const routing = {
189
+ proxyUrl: session.account.proxy,
190
+ streaming: true
191
+ };
192
+ /**
193
+ * Send one request, resending at most once when the upstream reports the
194
+ * per-`sessionId` accumulation wall.
195
+ *
196
+ * The upstream accumulates a conversation's input server-side per
197
+ * `sessionId`; once that passes 1M tokens every request reusing the id fails
198
+ * with a 400 until the upstream session expires. Bumping the generation
199
+ * names a fresh upstream session and recovers the conversation. This is not
200
+ * an account fault, so it does not go through `reportFailure`: the account
201
+ * stays healthy and only the derived id changes.
202
+ */
203
+ const sendAttempt = async () => {
204
+ for (let attempt = 0;; attempt++) {
205
+ const generation = conversationKey !== void 0 && conversationAccount !== void 0 ? currentSessionGeneration(conversationAccount, conversationKey) : 0;
206
+ const requestId = generateAntigravityRequestId();
207
+ const body = toAgyRequestBody(options, {
208
+ projectId: session.account.projectId,
209
+ sessionId: deriveAntigravitySessionId(session.account.email, conversationKey, generation) ?? void 0,
210
+ requestId,
211
+ ...this.options.thinkingBudgetFor === void 0 ? {} : { thinkingBudgetFor: this.options.thinkingBudgetFor },
212
+ ...this.options.claudeBudgetFor === void 0 ? {} : { claudeBudgetFor: this.options.claudeBudgetFor },
213
+ ...this.options.tieredBudgetFor === void 0 ? {} : { tieredBudgetFor: this.options.tieredBudgetFor },
214
+ ...images.size > 0 ? { images } : {},
215
+ ...multimodalFiles.size > 0 ? { multimodalFiles } : {}
216
+ });
217
+ let response;
218
+ try {
219
+ response = await fetchAgyFirstOk("/v1internal:streamGenerateContent?alt=sse", {
220
+ method: "POST",
221
+ headers: buildRequestHeaders(session),
222
+ body: JSON.stringify(body),
223
+ signal: options.signal
224
+ }, accountFetch(routing), routing);
225
+ } catch (error) {
226
+ const classified = classifyFetchError(error, { proxyUrl: session.account.proxy });
227
+ await this.options.reportFailure(classified.kind, session);
228
+ this.recordUsage(session, options.model, { ok: false }, startedAt);
229
+ throw new LlmError(classified.message ?? "agy fetch failed", "TRANSPORT", { cause: error });
230
+ }
231
+ if (response.ok) return { response };
232
+ const bodyText = await response.text().catch(() => void 0);
233
+ if (attempt === 0 && conversationKey !== void 0 && conversationAccount !== void 0 && isSessionAccumulationOverflow(response.status, bodyText)) {
234
+ bumpSessionGeneration(conversationAccount, conversationKey);
235
+ continue;
236
+ }
237
+ return {
238
+ response,
239
+ bodyText
240
+ };
241
+ }
242
+ };
243
+ const { response, bodyText } = await sendAttempt();
244
+ if (!response.ok) {
245
+ const classified = classifyHttpError(response.status, response.headers, bodyText);
246
+ await this.options.reportFailure(classified.kind, session, {
247
+ retryAfterMs: classified.retryAfterMs,
248
+ status: response.status,
249
+ rateLimitCategory: classified.rateLimitCategory,
250
+ resetTime: classified.resetTime,
251
+ model: options.model,
252
+ verificationUrl: classified.verificationUrl
253
+ });
254
+ this.recordUsage(session, options.model, {
255
+ ok: false,
256
+ rateLimited: classified.kind === "rate-limit"
257
+ }, startedAt);
258
+ if (classified.kind === "rate-limit") {
259
+ if (classified.rateLimitCategory === "quota_exhausted") throw new LlmError(`agy daily quota exhausted (${response.status}): ${classified.message ?? ""}`, QUOTA_EXCEEDED_CODE);
260
+ throw new LlmError(`agy rate-limited (${response.status}): ${classified.message ?? ""}`, "RATE_LIMIT", {
261
+ providerRetryAfterMs: classified.retryAfterMs ?? void 0,
262
+ requestId: ProviderRequestId(generateAntigravityRequestId())
263
+ });
264
+ }
265
+ if (classified.kind === "verification-required") {
266
+ const appeal = classified.verificationUrl ? ` Verify at: ${classified.verificationUrl}` : "";
267
+ throw new LlmError(`agy account needs verification (${response.status}): ${classified.message ?? ""}${appeal}`, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
268
+ }
269
+ if (classified.kind === "auth-failure") throw new LlmError(`agy authentication failed (${response.status}) — run \`dsh-agy login\``, "INVALID_CREDENTIAL");
270
+ if (classified.status !== void 0 && classified.status >= 500) throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, SERVER_ERROR_CODE, {
271
+ providerRetryAfterMs: classified.retryAfterMs ?? void 0,
272
+ requestId: ProviderRequestId(generateAntigravityRequestId())
273
+ });
274
+ throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, UPSTREAM_ERROR_CODE);
275
+ }
276
+ if (!response.body) throw new LlmError("agy stream returned no body", UPSTREAM_ERROR_CODE);
277
+ try {
278
+ let usage;
279
+ let ttftMs;
280
+ for await (const chunk of parseAgySse(response.body, {
281
+ signal: options.signal,
282
+ onToolSignature: (toolCallId, signature) => {
283
+ setThoughtSignature(toolCallId, signature);
284
+ }
285
+ })) {
286
+ if (chunk.type === "usage") usage = {
287
+ input: chunk.usage.inputTokens,
288
+ output: chunk.usage.outputTokens,
289
+ cacheRead: chunk.usage.cacheReadTokens ?? 0,
290
+ cacheWrite: chunk.usage.cacheWriteTokens ?? 0
291
+ };
292
+ else if (ttftMs === void 0 && (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) ttftMs = Date.now() - startedAt;
293
+ yield chunk;
294
+ }
295
+ await this.options.markSuccess?.(session);
296
+ this.recordUsage(session, options.model, {
297
+ ok: true,
298
+ usage,
299
+ ttftMs
300
+ }, startedAt);
301
+ } catch (error) {
302
+ if (error instanceof DOMException && error.name === "AbortError") throw new LlmError("agy stream aborted", "ABORTED", { cause: error });
303
+ await this.options.reportFailure("network-error", session);
304
+ this.recordUsage(session, options.model, { ok: false }, startedAt);
305
+ throw new LlmError(error instanceof Error ? describeFetchError(error) : "agy stream parse failed", UPSTREAM_ERROR_CODE, { cause: error });
306
+ }
307
+ }
308
+ /**
309
+ * Fold one attempt into the usage ledger.
310
+ *
311
+ * Statistics are diagnostics, never load-bearing: a ledger failure must not
312
+ * fail a generation, so this swallows its own errors.
313
+ */
314
+ recordUsage(session, model, result, startedAt) {
315
+ const record = this.options.recordUsage;
316
+ if (record === void 0) return;
317
+ try {
318
+ record({
319
+ ...ledgerAccountKey(session) === void 0 ? {} : { account: ledgerAccountKey(session) },
320
+ ...model === void 0 ? {} : { model },
321
+ ok: result.ok,
322
+ ...result.rateLimited === true ? { rateLimited: true } : {},
323
+ ...result.usage === void 0 ? {} : { usage: result.usage },
324
+ ...result.ttftMs === void 0 ? {} : { ttftMs: result.ttftMs },
325
+ latencyMs: Math.max(0, Date.now() - startedAt)
326
+ });
327
+ } catch {}
328
+ }
329
+ };
330
+ const EMPTY_SET = /* @__PURE__ */ new Set();
331
+ /** Keep only well-formed `provider -> { modelId: true }` entries (tolerates hand edits). */
332
+ function sanitizeDisabledModels(raw) {
333
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
334
+ const out = {};
335
+ for (const [provider, value] of Object.entries(raw)) {
336
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
337
+ const perProvider = {};
338
+ for (const [modelId, flag] of Object.entries(value)) if (flag === true) perProvider[modelId] = true;
339
+ if (Object.keys(perProvider).length > 0) out[provider] = perProvider;
340
+ }
341
+ return out;
342
+ }
343
+ function parseModelVisibility(text) {
344
+ let raw;
345
+ try {
346
+ raw = JSON.parse(text);
347
+ } catch {
348
+ return {
349
+ version: 1,
350
+ disabled: {}
351
+ };
352
+ }
353
+ return {
354
+ version: 1,
355
+ disabled: typeof raw === "object" && raw !== null ? sanitizeDisabledModels(raw.disabled) : {}
356
+ };
357
+ }
358
+ /**
359
+ * In-memory authoritative copy of the blacklist, backed by a JSON file.
360
+ *
361
+ * The adapter calls `disabledFor()` on every `listModels()`, so that read is a
362
+ * pure in-memory lookup: a toggle takes effect on the next catalog refresh
363
+ * without rebuilding the adapter or restarting the host. Writes go through the
364
+ * file immediately (toggles are rare, user-initiated).
365
+ *
366
+ * `disabledFor()` also revalidates against the file's mtime and reloads when it
367
+ * moved. Two instances of this class routinely coexist in ONE process — the
368
+ * main plugin entry and the web entry each build their own runtime — and a
369
+ * toggle is written by the web instance while the model selector reads the main
370
+ * instance. Without the mtime check the selector would keep serving its stale
371
+ * in-memory copy, so switching a model off appeared to do nothing until the
372
+ * host restarted (the `llm/adapters-updated` notification fires, the picker
373
+ * refreshes, and the model is still listed). The same check is what lets a CLI
374
+ * toggle be seen by a running server.
375
+ */
376
+ var ModelVisibility = class ModelVisibility {
377
+ file;
378
+ disabled;
379
+ /** Raw text this instance last read or wrote; see `reloadIfChanged`. */
380
+ raw;
381
+ /** Reusable frozen empty set, so the common "nothing disabled" read allocates nothing. */
382
+ sets = /* @__PURE__ */ new Map();
383
+ constructor(options = {}) {
384
+ this.file = options.file ?? join(resolveDshHome(), "agy-models.json");
385
+ const initial = this.readWithRaw();
386
+ this.disabled = initial.disabled;
387
+ this.raw = initial.raw;
388
+ }
389
+ /** Path of the backing file. */
390
+ get path() {
391
+ return this.file;
392
+ }
393
+ /** The exact bytes `write()` would persist, so `raw` can track what this instance wrote. */
394
+ static serialize(next) {
395
+ return JSON.stringify({
396
+ version: 1,
397
+ disabled: next
398
+ }, null, 2) + "\n";
399
+ }
400
+ /**
401
+ * Read the map together with the raw text it came from.
402
+ *
403
+ * The TEXT (not an mtime) is what detects another writer's change. An mtime
404
+ * cannot: a toggle and a re-read can land in the same filesystem timestamp
405
+ * tick, and Windows resolves that coarsely enough to have failed CI — two
406
+ * distinct writes reported an identical mtime, so the second change was never
407
+ * seen and a disabled model stayed selectable. Content comparison cannot miss
408
+ * a change, whatever the clock resolution.
409
+ */
410
+ readWithRaw() {
411
+ try {
412
+ const raw = readFileSync(this.file, "utf8");
413
+ return {
414
+ disabled: parseModelVisibility(raw).disabled,
415
+ raw
416
+ };
417
+ } catch {
418
+ return {
419
+ disabled: {},
420
+ raw: ""
421
+ };
422
+ }
423
+ }
424
+ write(next) {
425
+ mkdirSync(dirname(this.file), { recursive: true });
426
+ const tmp = `${this.file}.tmp`;
427
+ writeFileSync(tmp, ModelVisibility.serialize(next), { mode: 384 });
428
+ renameSync(tmp, this.file);
429
+ }
430
+ /**
431
+ * Disabled model ids for one provider. Cheap enough for every catalog refresh:
432
+ * one `stat` plus an in-memory map lookup, and the stat is what keeps two
433
+ * same-process instances (and a CLI writer) in agreement.
434
+ */
435
+ disabledFor(provider) {
436
+ this.reloadIfChanged();
437
+ const cacheKey = provider;
438
+ const cached = this.sets.get(cacheKey);
439
+ if (cached !== void 0) return cached;
440
+ const perProvider = this.disabled[provider];
441
+ if (perProvider === void 0) return EMPTY_SET;
442
+ const ids = Object.keys(perProvider);
443
+ if (ids.length === 0) return EMPTY_SET;
444
+ const set = new Set(ids);
445
+ this.sets.set(cacheKey, set);
446
+ return set;
447
+ }
448
+ /** Whether one model is currently hidden. Also revalidates, for the same reason. */
449
+ isDisabled(provider, modelId) {
450
+ this.reloadIfChanged();
451
+ return this.disabled[provider]?.[modelId] === true;
452
+ }
453
+ /** The raw map, for the settings UI. */
454
+ all() {
455
+ const out = {};
456
+ for (const [provider, models] of Object.entries(this.disabled)) out[provider] = { ...models };
457
+ return out;
458
+ }
459
+ /**
460
+ * Hide or show one model. Persists immediately.
461
+ *
462
+ * The edit is applied to a freshly re-read map, not to this instance's copy:
463
+ * with two instances in one process (main plugin + web entry) and a CLI in
464
+ * another, editing the stale copy would silently drop whatever the other
465
+ * writer had toggled since this instance last read. Toggles are rare and
466
+ * user-initiated, so a read-modify-write costs nothing here — unlike the
467
+ * ledger, this is not a hot path.
468
+ *
469
+ * Not locked: a simultaneous toggle from two processes can still lose one of
470
+ * the two updates. The write itself is atomic (tmp + rename), so the file is
471
+ * never corrupt — losing one toggle under a genuine race is an acceptable
472
+ * trade for not introducing a lock file into a settings toggle.
473
+ */
474
+ setDisabled(provider, modelId, disabled) {
475
+ if (modelId === "") throw new Error("setDisabled: modelId must not be empty");
476
+ this.reload();
477
+ const next = {};
478
+ for (const [key, models] of Object.entries(this.disabled)) next[key] = { ...models };
479
+ const perProvider = { ...next[provider] ?? {} };
480
+ if (disabled) perProvider[modelId] = true;
481
+ else delete perProvider[modelId];
482
+ if (Object.keys(perProvider).length === 0) delete next[provider];
483
+ else next[provider] = perProvider;
484
+ this.write(next);
485
+ this.disabled = next;
486
+ this.raw = ModelVisibility.serialize(next);
487
+ this.sets.delete(provider);
488
+ }
489
+ /** Re-read the file (multi-process: another writer may have toggled a model). */
490
+ reload() {
491
+ const fresh = this.readWithRaw();
492
+ this.disabled = fresh.disabled;
493
+ this.raw = fresh.raw;
494
+ this.sets.clear();
495
+ }
496
+ /**
497
+ * Reload when the file's contents differ from what this instance last read.
498
+ *
499
+ * Reads rather than stats: `disabledFor()` runs once per catalog refresh, not
500
+ * per request, and the call it feeds is a network request to the model
501
+ * endpoint — one small local read is not the cost that matters here, while a
502
+ * missed change is the bug this whole mechanism exists to prevent.
503
+ */
504
+ reloadIfChanged() {
505
+ let current;
506
+ try {
507
+ current = readFileSync(this.file, "utf8");
508
+ } catch {
509
+ current = "";
510
+ }
511
+ if (current === this.raw) return;
512
+ this.reload();
513
+ }
514
+ };
515
+ //#endregion
516
+ //#region src/thinking-types.ts
517
+ /**
518
+ * Thinking-budget vocabulary shared by the host store, the RPC wire contract,
519
+ * and the browser UI.
520
+ *
521
+ * Split out for the same reason `usage-types.ts` exists: these types live on
522
+ * their own, free of any `node:*` import, so the browser bundle can describe the
523
+ * settings surface without pulling in the host's storage module. Putting them in
524
+ * `thinking-budget.ts` instead made `rpc-contract.ts` (which the client
525
+ * typechecks) reach `node:fs` through the transitive import — caught by
526
+ * `tsconfig.client.json`, which includes the contract and excludes `src/store`.
527
+ */
528
+ /** The three levels a tiered model exposes, in display order. */
529
+ const THINKING_LEVELS = [
530
+ "low",
531
+ "medium",
532
+ "high"
533
+ ];
534
+ const THINKING_BUDGET_MAX = 65535;
535
+ /**
536
+ * The Claude family's `thinkingBudget` bounds, measured separately from Gemini's.
537
+ *
538
+ * The two families do NOT share a contract, which is why one interval cannot
539
+ * serve both:
540
+ * - Claude's floor is **1024**, not `-1`. A budget of 1 or 512 is rejected with
541
+ * `thinking.enabled.budget_tokens: Input should be greater than or equal to
542
+ * 1024`; `-1` and `0` are accepted as special values.
543
+ * - Claude additionally requires **`max_tokens` strictly greater than the
544
+ * budget**: `budget=1024, max_tokens=1024` is a 400, and a budget sent with
545
+ * no `maxOutputTokens` at all also fails. So the margin is at least one token.
546
+ *
547
+ * `CLAUDE_BUDGET_MAX` is therefore one below `AGY_CLAUDE_MAX_OUTPUT_TOKENS` — the
548
+ * largest budget that can still leave room for a strictly greater `max_tokens`.
549
+ */
550
+ const CLAUDE_BUDGET_MIN = 1024;
551
+ const CLAUDE_BUDGET_MAX = 63999;
552
+ /** Whether `value` may be sent as a Claude `thinkingBudget`. */
553
+ function isValidClaudeBudget(value) {
554
+ if (typeof value !== "number" || !Number.isInteger(value)) return false;
555
+ if (value === -1 || value === 0) return true;
556
+ return value >= 1024 && value <= 63999;
557
+ }
558
+ /** Whether `value` may be sent as a `thinkingBudget`. */
559
+ function isValidThinkingBudget(value) {
560
+ return typeof value === "number" && Number.isInteger(value) && value >= -1 && value <= 65535;
561
+ }
562
+ /**
563
+ * Keep only well-formed entries (tolerates hand edits).
564
+ *
565
+ * Rejects out-of-range and non-integer values rather than clamping them: a
566
+ * clamped value would silently mean something the user did not ask for, and
567
+ * upstream rejects it anyway, so dropping the entry falls back to the
568
+ * `thinkingLevel` path — which is valid.
569
+ */
570
+ function sanitizeThinkingBudgets(raw) {
571
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
572
+ const out = {};
573
+ for (const level of THINKING_LEVELS) {
574
+ const value = raw[level];
575
+ if (isValidThinkingBudget(value)) out[level] = value;
576
+ }
577
+ return out;
578
+ }
579
+ function parseThinkingDocument(text) {
580
+ let raw;
581
+ try {
582
+ raw = JSON.parse(text);
583
+ } catch {
584
+ return {
585
+ version: 1,
586
+ budgets: {}
587
+ };
588
+ }
589
+ const record = typeof raw === "object" && raw !== null ? raw : {};
590
+ const budgets = sanitizeThinkingBudgets(record.budgets);
591
+ const claude = record.claudeBudget;
592
+ const tiered = record.tieredBudget;
593
+ return {
594
+ version: 1,
595
+ budgets,
596
+ ...isValidClaudeBudget(claude) ? { claudeBudget: claude } : {},
597
+ ...isValidThinkingBudget(tiered) ? { tieredBudget: tiered } : {}
598
+ };
599
+ }
600
+ /**
601
+ * In-memory authoritative copy of the budgets, backed by a JSON file.
602
+ *
603
+ * `budgetFor` sits on the GENERATION HOT PATH (`toAgyRequestBody` calls it for
604
+ * every request), so the cross-instance revalidation it needs is rate-limited:
605
+ * see `revalidateIntervalMs`. Writes go to the file immediately, since edits are
606
+ * rare and user-initiated, and they bypass the limit so a concurrent edit is
607
+ * never overwritten.
608
+ */
609
+ var ThinkingBudgetStore = class ThinkingBudgetStore {
610
+ file;
611
+ revalidateIntervalMs;
612
+ doc;
613
+ /** Raw text this instance last read or wrote; see `reloadNow`. */
614
+ raw;
615
+ /** When `reloadNow` last ran, for the hot-path throttle. */
616
+ checkedAt;
617
+ constructor(options = {}) {
618
+ this.file = options.file ?? join(resolveDshHome(), "agy-thinking.json");
619
+ this.revalidateIntervalMs = options.revalidateIntervalMs ?? 1e3;
620
+ const initial = this.readWithRaw();
621
+ this.doc = initial.doc;
622
+ this.raw = initial.raw;
623
+ this.checkedAt = Date.now();
624
+ }
625
+ /** Path of the backing file. */
626
+ get path() {
627
+ return this.file;
628
+ }
629
+ static serialize(doc) {
630
+ return JSON.stringify(doc, null, 2) + "\n";
631
+ }
632
+ /**
633
+ * Read the document together with the raw text it came from.
634
+ *
635
+ * Content, not mtime: two writers can land in the same filesystem timestamp
636
+ * tick (Windows resolves it coarsely enough to have failed CI in this repo),
637
+ * and a missed change is exactly the bug this check exists to prevent.
638
+ */
639
+ readWithRaw() {
640
+ try {
641
+ const raw = readFileSync(this.file, "utf8");
642
+ return {
643
+ doc: parseThinkingDocument(raw),
644
+ raw
645
+ };
646
+ } catch {
647
+ return {
648
+ doc: {
649
+ version: 1,
650
+ budgets: {}
651
+ },
652
+ raw: ""
653
+ };
654
+ }
655
+ }
656
+ /**
657
+ * Re-read the file if another writer changed it.
658
+ *
659
+ * Compares CONTENT, not mtime: a toggle and a re-read can land in the same
660
+ * filesystem timestamp tick, and Windows resolves that coarsely enough to have
661
+ * failed CI in this repo (`model-visibility.ts` records the incident). Content
662
+ * comparison cannot miss a change, whatever the clock resolution.
663
+ *
664
+ * Unconditional — callers on the hot path use `reloadIfChanged` instead.
665
+ */
666
+ reloadNow() {
667
+ let current;
668
+ try {
669
+ current = readFileSync(this.file, "utf8");
670
+ } catch {
671
+ current = "";
672
+ }
673
+ this.checkedAt = Date.now();
674
+ if (current === this.raw) return;
675
+ const fresh = this.readWithRaw();
676
+ this.doc = fresh.doc;
677
+ this.raw = fresh.raw;
678
+ }
679
+ /**
680
+ * Hot-path revalidation: `reloadNow`, rate-limited.
681
+ *
682
+ * Without the limit every generation pays a blocking `readFileSync` plus a
683
+ * UTF-8 decode — the module used to claim these reads were "in-memory", which
684
+ * an unconditional read made false. The file is written only by `setBudget`
685
+ * (a rare, user-initiated action), so the worst case here is that a settings
686
+ * change takes up to `revalidateIntervalMs` to reach an already-running
687
+ * adapter. That is the right trade for a setting; it is not right for the write
688
+ * path, which is why `write` calls `reloadNow` directly.
689
+ */
690
+ reloadIfChanged() {
691
+ if (Date.now() - this.checkedAt < this.revalidateIntervalMs) return;
692
+ this.reloadNow();
693
+ }
694
+ /**
695
+ * Write the whole document atomically.
696
+ *
697
+ * Always re-reads first, so a concurrent edit by the OTHER instance in this
698
+ * process (main plugin + web entry) is merged rather than overwritten: each
699
+ * caller passes a mutation, not a full replacement built from a stale copy.
700
+ */
701
+ write(mutate) {
702
+ this.reloadNow();
703
+ const next = {
704
+ version: 1,
705
+ budgets: { ...this.doc.budgets },
706
+ ...this.doc.claudeBudget === void 0 ? {} : { claudeBudget: this.doc.claudeBudget },
707
+ ...this.doc.tieredBudget === void 0 ? {} : { tieredBudget: this.doc.tieredBudget }
708
+ };
709
+ mutate(next);
710
+ const text = ThinkingBudgetStore.serialize(next);
711
+ mkdirSync(dirname(this.file), { recursive: true });
712
+ const tmp = `${this.file}.tmp`;
713
+ writeFileSync(tmp, text, { mode: 384 });
714
+ renameSync(tmp, this.file);
715
+ this.doc = next;
716
+ this.raw = text;
717
+ }
718
+ /** The configured budget for one level, or undefined when unset. */
719
+ budgetFor(level) {
720
+ this.reloadIfChanged();
721
+ if (level === void 0) return void 0;
722
+ const key = level.toLowerCase();
723
+ if (!THINKING_LEVELS.includes(key)) return void 0;
724
+ return this.doc.budgets[key];
725
+ }
726
+ /** The Claude-family budget, or undefined when unset. */
727
+ claudeBudget() {
728
+ this.reloadIfChanged();
729
+ return this.doc.claudeBudget;
730
+ }
731
+ /** The tiered-slot budget (the selector's Default effort), or undefined. */
732
+ tieredBudget() {
733
+ this.reloadIfChanged();
734
+ return this.doc.tieredBudget;
735
+ }
736
+ /** The raw map, for the settings UI. */
737
+ all() {
738
+ this.reloadIfChanged();
739
+ return { ...this.doc.budgets };
740
+ }
741
+ /** The whole document, for the settings UI. */
742
+ snapshot() {
743
+ this.reloadIfChanged();
744
+ return {
745
+ version: 1,
746
+ budgets: { ...this.doc.budgets },
747
+ ...this.doc.claudeBudget === void 0 ? {} : { claudeBudget: this.doc.claudeBudget },
748
+ ...this.doc.tieredBudget === void 0 ? {} : { tieredBudget: this.doc.tieredBudget }
749
+ };
750
+ }
751
+ /** Replace one level's budget, or clear it when `value` is undefined. */
752
+ setBudget(level, value) {
753
+ const key = level.toLowerCase();
754
+ if (!THINKING_LEVELS.includes(key)) throw new Error(`unknown thinking level: ${level}`);
755
+ if (value !== void 0 && !isValidThinkingBudget(value)) throw new Error(`thinking budget must be an integer in [-1, ${THINKING_BUDGET_MAX}]`);
756
+ this.write((doc) => {
757
+ if (value === void 0) delete doc.budgets[key];
758
+ else doc.budgets[key] = value;
759
+ });
760
+ return this.all();
761
+ }
762
+ /** Replace the tiered-slot budget, or clear it when `value` is undefined. */
763
+ setTieredBudget(value) {
764
+ if (value !== void 0 && !isValidThinkingBudget(value)) throw new Error(`thinking budget must be an integer in [-1, ${THINKING_BUDGET_MAX}]`);
765
+ this.write((doc) => {
766
+ if (value === void 0) delete doc.tieredBudget;
767
+ else doc.tieredBudget = value;
768
+ });
769
+ return this.snapshot();
770
+ }
771
+ /** Replace the Claude budget, or clear it when `value` is undefined. */
772
+ setClaudeBudget(value) {
773
+ if (value !== void 0 && !isValidClaudeBudget(value)) throw new Error(`Claude thinking budget must be -1, 0, or an integer in [${CLAUDE_BUDGET_MIN}, ${CLAUDE_BUDGET_MAX}]`);
774
+ this.write((doc) => {
775
+ if (value === void 0) delete doc.claudeBudget;
776
+ else doc.claudeBudget = value;
777
+ });
778
+ return this.snapshot();
779
+ }
780
+ };
781
+ //#endregion
782
+ //#region src/usage-types.ts
783
+ /** Zeroed counters, for accumulation. */
784
+ function zeroCounters() {
785
+ return {
786
+ input: 0,
787
+ output: 0,
788
+ cacheRead: 0,
789
+ cacheWrite: 0,
790
+ requests: 0,
791
+ succeeded: 0,
792
+ failed: 0,
793
+ rateLimited: 0,
794
+ rotations: 0,
795
+ latencyMs: 0,
796
+ latencyN: 0,
797
+ ttftMs: 0,
798
+ ttftN: 0
799
+ };
800
+ }
801
+ function zeroAccount(now) {
802
+ return {
803
+ totals: zeroCounters(),
804
+ models: {},
805
+ sources: {
806
+ chat: 0,
807
+ cli: 0,
808
+ verify: 0,
809
+ test: 0
810
+ },
811
+ lastUsedAt: now
812
+ };
813
+ }
814
+ function emptyDocument(now) {
815
+ return {
816
+ version: 2,
817
+ since: now,
818
+ totals: zeroCounters(),
819
+ accounts: {},
820
+ days: {}
821
+ };
822
+ }
823
+ /** Local-time day key. Local (not UTC) so "today" matches the user's clock. */
824
+ function dayKey(time) {
825
+ const d = new Date(time);
826
+ const month = String(d.getMonth() + 1).padStart(2, "0");
827
+ const day = String(d.getDate()).padStart(2, "0");
828
+ return `${d.getFullYear()}-${month}-${day}`;
829
+ }
830
+ /** Add `add` into `into`, in place. */
831
+ function addInto(into, add) {
832
+ into.input += add.input;
833
+ into.output += add.output;
834
+ into.cacheRead += add.cacheRead;
835
+ into.cacheWrite += add.cacheWrite;
836
+ into.requests += add.requests;
837
+ into.succeeded += add.succeeded;
838
+ into.failed += add.failed;
839
+ into.rateLimited += add.rateLimited;
840
+ into.rotations += add.rotations;
841
+ into.latencyMs += add.latencyMs;
842
+ into.latencyN += add.latencyN;
843
+ into.ttftMs += add.ttftMs;
844
+ into.ttftN += add.ttftN;
845
+ }
846
+ /** Coerce arbitrary parsed JSON into a valid counter block (tolerates hand edits). */
847
+ function sanitizeCounters(raw) {
848
+ const out = zeroCounters();
849
+ if (typeof raw !== "object" || raw === null) return out;
850
+ for (const key of Object.keys(out)) {
851
+ const value = raw[key];
852
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) out[key] = value;
853
+ }
854
+ return out;
855
+ }
856
+ function sanitizeAccount(raw, now) {
857
+ const out = zeroAccount(now);
858
+ if (typeof raw !== "object" || raw === null) return out;
859
+ const record = raw;
860
+ out.totals = sanitizeCounters(record.totals);
861
+ if (typeof record.lastUsedAt === "number" && Number.isFinite(record.lastUsedAt)) out.lastUsedAt = record.lastUsedAt;
862
+ if (typeof record.models === "object" && record.models !== null) for (const [id, value] of Object.entries(record.models)) out.models[id] = sanitizeCounters(value);
863
+ if (typeof record.sources === "object" && record.sources !== null) for (const key of [
864
+ "chat",
865
+ "cli",
866
+ "verify",
867
+ "test"
868
+ ]) {
869
+ const value = record.sources[key];
870
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) out.sources[key] = value;
871
+ }
872
+ return out;
873
+ }
874
+ /** Parse a persisted document defensively; a corrupt file degrades to empty. */
875
+ function parseStatsDocument(text, now) {
876
+ let raw;
877
+ try {
878
+ raw = JSON.parse(text);
879
+ } catch {
880
+ return emptyDocument(now);
881
+ }
882
+ if (typeof raw !== "object" || raw === null) return emptyDocument(now);
883
+ const record = raw;
884
+ const doc = emptyDocument(now);
885
+ if (typeof record.since === "number" && Number.isFinite(record.since)) doc.since = record.since;
886
+ doc.totals = sanitizeCounters(record.totals);
887
+ if (typeof record.accounts === "object" && record.accounts !== null) for (const [key, value] of Object.entries(record.accounts)) doc.accounts[key] = sanitizeAccount(value, now);
888
+ if (typeof record.days === "object" && record.days !== null) {
889
+ for (const [key, value] of Object.entries(record.days)) if (/^\d{4}-\d{2}-\d{2}$/.test(key)) doc.days[key] = sanitizeDayBucket(value);
890
+ }
891
+ return doc;
892
+ }
893
+ /**
894
+ * Coerce one day's stored value into a `DayBucket`.
895
+ *
896
+ * Accepts the version-1 shape too: that stored a bare counter block, so a
897
+ * document written before the upgrade keeps its day counts as `totals` with no
898
+ * breakdown. Losing the breakdown for those days is the correct trade — the
899
+ * alternative (dropping the days) would erase real history, and recomputing a
900
+ * dimension split that was never recorded is impossible. The all-time
901
+ * `accounts`/`models` maps are unaffected, so only range-scoped breakdown tables
902
+ * are thinner for pre-upgrade days.
903
+ */
904
+ function sanitizeDayBucket(raw) {
905
+ const out = {
906
+ totals: zeroCounters(),
907
+ models: {},
908
+ accounts: {}
909
+ };
910
+ if (typeof raw !== "object" || raw === null) return out;
911
+ const record = raw;
912
+ if (typeof record.totals === "object" && record.totals !== null) {
913
+ out.totals = sanitizeCounters(record.totals);
914
+ if (typeof record.models === "object" && record.models !== null) for (const [id, value] of Object.entries(record.models)) out.models[id] = sanitizeCounters(value);
915
+ if (typeof record.accounts === "object" && record.accounts !== null) for (const [key, value] of Object.entries(record.accounts)) out.accounts[key] = sanitizeCounters(value);
916
+ return out;
917
+ }
918
+ out.totals = sanitizeCounters(raw);
919
+ return out;
920
+ }
921
+ /** Drop day buckets older than the window; their counts already live in `totals`. */
922
+ function pruneDays(doc, now) {
923
+ const cutoff = dayKey(now - 2592e6);
924
+ for (const key of Object.keys(doc.days)) if (key < cutoff) delete doc.days[key];
925
+ }
926
+ /**
927
+ * Fold the day buckets for a window into the three views the Usage tab renders.
928
+ *
929
+ * The breakdown tables read THIS rather than the all-time `accounts`/`models`
930
+ * maps, so one range selection governs the whole page. Rows are sorted by
931
+ * request count descending, matching the all-time view; the caller re-sorts if
932
+ * it wants another order (the UI shows them as-is).
933
+ *
934
+ * @param doc - the ledger snapshot.
935
+ * @param days - window length in days, at least 1.
936
+ * @param now - current time (Unix ms).
937
+ * @returns totals and their per-model / per-account partitions.
938
+ */
939
+ function foldWindowBreakdown(doc, days, now) {
940
+ const cutoff = dayKey(now - (Math.max(1, Math.floor(days)) - 1) * 864e5);
941
+ const totals = zeroCounters();
942
+ const models = /* @__PURE__ */ new Map();
943
+ const accounts = /* @__PURE__ */ new Map();
944
+ for (const [key, bucket] of Object.entries(doc.days)) {
945
+ if (key < cutoff) continue;
946
+ addInto(totals, bucket.totals);
947
+ for (const [model, counters] of Object.entries(bucket.models)) {
948
+ const current = models.get(model);
949
+ if (current === void 0) models.set(model, { ...counters });
950
+ else addInto(current, counters);
951
+ }
952
+ for (const [account, counters] of Object.entries(bucket.accounts)) {
953
+ const current = accounts.get(account);
954
+ if (current === void 0) accounts.set(account, { ...counters });
955
+ else addInto(current, counters);
956
+ }
957
+ }
958
+ const byRequests = (a, b) => b.counters.requests - a.counters.requests;
959
+ return {
960
+ totals,
961
+ models: [...models.entries()].map(([model, counters]) => ({
962
+ model,
963
+ counters
964
+ })).sort(byRequests),
965
+ accounts: [...accounts.entries()].map(([account, counters]) => ({
966
+ account,
967
+ counters
968
+ })).sort(byRequests)
969
+ };
970
+ }
971
+ /** Merge one record into a document, in place. Shared by the live store and tests. */
972
+ function applyRecord(doc, record, now) {
973
+ const delta = zeroCounters();
974
+ if (record.poolEvent !== true) {
975
+ delta.requests = 1;
976
+ if (record.ok) delta.succeeded = 1;
977
+ else delta.failed = 1;
978
+ if (record.rateLimited === true) delta.rateLimited = 1;
979
+ }
980
+ if (record.rotated === true) delta.rotations = 1;
981
+ if (record.usage !== void 0) {
982
+ delta.input = record.usage.input;
983
+ delta.output = record.usage.output;
984
+ delta.cacheRead = record.usage.cacheRead;
985
+ delta.cacheWrite = record.usage.cacheWrite;
986
+ }
987
+ if (typeof record.latencyMs === "number" && Number.isFinite(record.latencyMs) && record.latencyMs >= 0) {
988
+ delta.latencyMs = record.latencyMs;
989
+ delta.latencyN = 1;
990
+ }
991
+ if (typeof record.ttftMs === "number" && Number.isFinite(record.ttftMs) && record.ttftMs >= 0) {
992
+ delta.ttftMs = record.ttftMs;
993
+ delta.ttftN = 1;
994
+ }
995
+ addInto(doc.totals, delta);
996
+ if (record.account !== void 0 && record.account !== "") {
997
+ const account = doc.accounts[record.account] ?? zeroAccount(now);
998
+ addInto(account.totals, delta);
999
+ if (record.poolEvent !== true) account.sources[record.source] += 1;
1000
+ account.lastUsedAt = now;
1001
+ if (record.model !== void 0 && record.model !== "") {
1002
+ const model = account.models[record.model] ?? zeroCounters();
1003
+ addInto(model, delta);
1004
+ account.models[record.model] = model;
1005
+ }
1006
+ doc.accounts[record.account] = account;
1007
+ }
1008
+ const key = dayKey(now);
1009
+ const bucket = doc.days[key] ?? {
1010
+ totals: zeroCounters(),
1011
+ models: {},
1012
+ accounts: {}
1013
+ };
1014
+ addInto(bucket.totals, delta);
1015
+ if (record.model !== void 0 && record.model !== "") {
1016
+ const model = bucket.models[record.model] ?? zeroCounters();
1017
+ addInto(model, delta);
1018
+ bucket.models[record.model] = model;
1019
+ }
1020
+ if (record.account !== void 0 && record.account !== "") {
1021
+ const account = bucket.accounts[record.account] ?? zeroCounters();
1022
+ addInto(account, delta);
1023
+ bucket.accounts[record.account] = account;
1024
+ }
1025
+ doc.days[key] = bucket;
1026
+ pruneDays(doc, now);
1027
+ }
1028
+ const properStatsLock = { withLock(file, fn) {
1029
+ const release = lockfile.lockSync(file, {
1030
+ stale: 8e3,
1031
+ update: 4e3,
1032
+ realpath: false
1033
+ });
1034
+ try {
1035
+ return fn();
1036
+ } finally {
1037
+ release();
1038
+ }
1039
+ } };
1040
+ /**
1041
+ * Accumulating usage ledger. `record()` is the hot path and performs no I/O:
1042
+ * it folds the record into an in-memory delta. `flush()` merges that delta into
1043
+ * the persisted document under the file lock.
1044
+ */
1045
+ var UsageStats = class {
1046
+ file;
1047
+ lock;
1048
+ now;
1049
+ flushEvery;
1050
+ flushIntervalMs;
1051
+ maxPending;
1052
+ onFlushError;
1053
+ /** This process's un-flushed records. */
1054
+ pending = [];
1055
+ timer;
1056
+ lastFlushError;
1057
+ /**
1058
+ * Whether the current run of consecutive failures has already been reported.
1059
+ * Count-based flushing is also suspended while set: see `record()`.
1060
+ */
1061
+ flushFailedSince;
1062
+ constructor(options = {}) {
1063
+ this.file = options.file ?? join(resolveDshHome(), "agy-stats.json");
1064
+ this.lock = options.lock ?? properStatsLock;
1065
+ this.now = options.now ?? (() => Date.now());
1066
+ this.flushEvery = options.flushEvery ?? 50;
1067
+ this.flushIntervalMs = options.flushIntervalMs ?? 5e3;
1068
+ this.maxPending = options.maxPending ?? 500;
1069
+ this.onFlushError = options.onFlushError;
1070
+ }
1071
+ /** Path of the backing file. */
1072
+ get path() {
1073
+ return this.file;
1074
+ }
1075
+ /**
1076
+ * Pre-create the ledger so `proper-lockfile` can lock it. The lock throws
1077
+ * ENOENT on a nonexistent target, so the empty document must exist (0600,
1078
+ * tmp+rename) before the first flush.
1079
+ */
1080
+ ensureFile(now) {
1081
+ if (existsSync(this.file)) return;
1082
+ mkdirSync(dirname(this.file), { recursive: true });
1083
+ const tmp = `${this.file}.tmp-init`;
1084
+ writeFileSync(tmp, JSON.stringify(emptyDocument(now), null, 2) + "\n", { mode: 384 });
1085
+ renameSync(tmp, this.file);
1086
+ }
1087
+ readFile() {
1088
+ const now = this.now();
1089
+ try {
1090
+ return parseStatsDocument(readFileSync(this.file, "utf8"), now);
1091
+ } catch {
1092
+ return emptyDocument(now);
1093
+ }
1094
+ }
1095
+ /** Atomic replace (tmp + rename), owner-only, so a reader never sees a partial file. */
1096
+ writeFile(doc) {
1097
+ mkdirSync(dirname(this.file), { recursive: true });
1098
+ const tmp = `${this.file}.tmp`;
1099
+ writeFileSync(tmp, JSON.stringify(doc, null, 2) + "\n", { mode: 384 });
1100
+ renameSync(tmp, this.file);
1101
+ }
1102
+ /** Record one request. Hot path: no I/O. */
1103
+ record(record) {
1104
+ this.pending.push(record);
1105
+ if (this.maxPending > 0 && this.pending.length > this.maxPending) this.pending.splice(0, this.pending.length - this.maxPending);
1106
+ if (this.flushFailedSince === void 0 && this.flushEvery > 0 && this.pending.length >= this.flushEvery) {
1107
+ this.flush();
1108
+ return;
1109
+ }
1110
+ this.armTimer();
1111
+ }
1112
+ armTimer() {
1113
+ if (this.timer !== void 0 || this.flushIntervalMs <= 0) return;
1114
+ this.timer = setTimeout(() => {
1115
+ this.timer = void 0;
1116
+ this.flush();
1117
+ }, this.flushIntervalMs);
1118
+ this.timer.unref?.();
1119
+ }
1120
+ /**
1121
+ * Merge pending records into the persisted document.
1122
+ *
1123
+ * Under the lock, the fresh on-disk state is re-read and this process's
1124
+ * records are replayed onto it. That is what keeps concurrently-running
1125
+ * writers (Desktop, a web-profile server, a CLI invocation) from clobbering
1126
+ * each other: a plain overwrite of the in-memory document would drop every
1127
+ * count another process added since this one loaded the file.
1128
+ */
1129
+ flush() {
1130
+ if (this.timer !== void 0) {
1131
+ clearTimeout(this.timer);
1132
+ this.timer = void 0;
1133
+ }
1134
+ const batch = this.pending;
1135
+ if (batch.length === 0) return;
1136
+ this.pending = [];
1137
+ const now = this.now();
1138
+ try {
1139
+ this.ensureFile(now);
1140
+ this.lock.withLock(this.file, () => {
1141
+ const doc = this.readFile();
1142
+ for (const record of batch) applyRecord(doc, record, now);
1143
+ this.writeFile(doc);
1144
+ });
1145
+ this.lastFlushError = void 0;
1146
+ this.flushFailedSince = void 0;
1147
+ } catch (error) {
1148
+ this.pending = [...batch, ...this.pending];
1149
+ if (this.maxPending > 0 && this.pending.length > this.maxPending) this.pending.splice(0, this.pending.length - this.maxPending);
1150
+ this.lastFlushError = error;
1151
+ if (this.flushFailedSince === void 0) {
1152
+ this.flushFailedSince = now;
1153
+ try {
1154
+ this.onFlushError?.(error);
1155
+ } catch {}
1156
+ }
1157
+ this.armTimer();
1158
+ }
1159
+ }
1160
+ /** Flush if anything is pending; safe to call on turn end and process exit. */
1161
+ flushSync() {
1162
+ this.flush();
1163
+ }
1164
+ /**
1165
+ * A snapshot for reading (the Usage tab).
1166
+ *
1167
+ * Reads the file rather than an in-memory copy: the writer is not always this
1168
+ * process. A CLI `login`/`verify` runs in its own process, and the browser may
1169
+ * be served by a different plugin instance than the one that generated, so an
1170
+ * in-memory cache would show a stale or empty ledger. This process's own
1171
+ * un-flushed records are layered on top so the UI never lags behind its own
1172
+ * activity.
1173
+ *
1174
+ * Called once per UI open/refresh, never on the generation hot path, so the
1175
+ * read is cheap enough.
1176
+ */
1177
+ snapshot() {
1178
+ const now = this.now();
1179
+ const doc = this.readFile();
1180
+ if (this.pending.length > 0) for (const record of this.pending) applyRecord(doc, record, now);
1181
+ return doc;
1182
+ }
1183
+ /** The most recent flush failure, if any (diagnostics). */
1184
+ get flushError() {
1185
+ return this.lastFlushError;
1186
+ }
1187
+ /** Number of records awaiting flush. */
1188
+ get pendingCount() {
1189
+ return this.pending.length;
1190
+ }
1191
+ /** Whether the ledger is currently failing to persist (a run of failures). */
1192
+ get flushFailing() {
1193
+ return this.flushFailedSince !== void 0;
1194
+ }
1195
+ };
1196
+ //#endregion
1197
+ //#region src/plugin-common.ts
1198
+ /**
1199
+ * Shared runtime construction for the in-harness plugin entries: master-key
1200
+ * codec resolution (credentials seam first, credentials document fallback),
1201
+ * account store, session manager, and adapter. Used by the main plugin
1202
+ * (adapter registration) and the web plugin (route registration) so both
1203
+ * entries operate on the same store.
1204
+ */
1205
+ function codecFrom(masterKey) {
1206
+ return createAesGcmCodec(deriveKey(masterKey));
1207
+ }
1208
+ /** Resolve or create the master key, preferring the credentials seam when available. */
1209
+ async function resolveCodec(ctx) {
1210
+ const dshHome = resolveDshHome();
1211
+ const credentials = ctx.get("credentials");
1212
+ if (credentials) {
1213
+ const resolved = await credentials.resolve(MASTER_KEY_REF);
1214
+ if (resolved) return {
1215
+ codec: codecFrom(resolved.value),
1216
+ created: false
1217
+ };
1218
+ const fileKey = loadMasterKey(dshHome);
1219
+ if (fileKey) return {
1220
+ codec: codecFrom(fileKey),
1221
+ created: false
1222
+ };
1223
+ const fresh = randomBytes(32).toString("hex");
1224
+ try {
1225
+ await credentials.set(MASTER_KEY_REF, fresh);
1226
+ return {
1227
+ codec: codecFrom(fresh),
1228
+ created: true
1229
+ };
1230
+ } catch {
1231
+ persistMasterKey(dshHome, fresh);
1232
+ return {
1233
+ codec: codecFrom(fresh),
1234
+ created: true
1235
+ };
1236
+ }
1237
+ }
1238
+ return resolveMasterKeyCodec(dshHome);
1239
+ }
1240
+ /**
1241
+ * Fire-and-forget version warm-up so fingerprint generation inside the
1242
+ * rate-limit path never waits on a cold feed.
1243
+ *
1244
+ * Routed through `pickProbeProxyUrl`: the release feeds are not account-scoped,
1245
+ * but the host IP is exactly what a per-account-proxy user asked to hide, and a
1246
+ * bare `proxiedFetch` sends this boot-time request directly. Never throws — an
1247
+ * unreadable store or a dead feed must not be the reason plugin boot fails.
1248
+ */
1249
+ async function warmVersionCache(store) {
1250
+ let fetchImpl = proxiedFetch;
1251
+ try {
1252
+ const storage = await store.load();
1253
+ const proxyUrl = pickProbeProxyUrl(storage.accounts, storage.activeIndex);
1254
+ fetchImpl = probeFetch(proxyUrl);
1255
+ } catch {}
1256
+ await resolveAntigravityVersion(fetchImpl).catch(() => {});
1257
+ }
1258
+ /** Build the store, session manager, and adapter for one plugin entry. */
1259
+ async function createAgyRuntime(ctx) {
1260
+ const { codec } = await resolveCodec(ctx);
1261
+ const dshHome = resolveDshHome();
1262
+ const store = new JsonAccountStore({
1263
+ file: `${dshHome}/agy-accounts.json`,
1264
+ codec
1265
+ });
1266
+ warmVersionCache(store);
1267
+ const stats = new UsageStats({ onFlushError: (error) => {
1268
+ ctx.logger.warn(`[dsh-agy] usage ledger could not be written (${stats.path}): ${error instanceof Error ? error.message : String(error)} — counts are buffered and will retry`);
1269
+ } });
1270
+ const modelVisibility = new ModelVisibility();
1271
+ const thinkingBudget = new ThinkingBudgetStore();
1272
+ const sessions = new AgySessionManager({
1273
+ store,
1274
+ recordUsage: (record) => {
1275
+ stats.record(record);
1276
+ }
1277
+ });
1278
+ const healthIntervalMs = Number(process.env.DSH_AGY_HEALTH_INTERVAL_MS ?? 0);
1279
+ if (Number.isFinite(healthIntervalMs) && healthIntervalMs > 0) sessions.startHealthProbe(healthIntervalMs);
1280
+ const adapter = new AgyAdapter({
1281
+ getSession: (model, conversationKey) => sessions.getSession(model, void 0, conversationKey),
1282
+ reportFailure: (kind, session, info) => sessions.reportFailure(kind, session, info),
1283
+ markSuccess: (session) => sessions.markSuccess(session),
1284
+ resolveAttachments: () => ctx.get("attachments"),
1285
+ noteRequestStarted: (account) => sessions.noteRequestStarted(account),
1286
+ noteRequestSettled: (account) => sessions.noteRequestSettled(account),
1287
+ modelVisibility,
1288
+ thinkingBudgetFor: (level) => thinkingBudget.budgetFor(level),
1289
+ claudeBudgetFor: () => thinkingBudget.claudeBudget(),
1290
+ tieredBudgetFor: () => thinkingBudget.tieredBudget(),
1291
+ recordUsage: (record) => {
1292
+ stats.record({
1293
+ ...record,
1294
+ source: "chat"
1295
+ });
1296
+ }
1297
+ });
1298
+ process.once("exit", () => {
1299
+ stats.flushSync();
1300
+ });
1301
+ return {
1302
+ store,
1303
+ sessions,
1304
+ adapter,
1305
+ stats,
1306
+ modelVisibility,
1307
+ thinkingBudget
1308
+ };
1309
+ }
1310
+ //#endregion
1311
+ export { THINKING_BUDGET_MAX as a, CLAUDE_BUDGET_MIN as i, foldWindowBreakdown as n, CLAUDE_BUDGET_MAX as r, createAgyRuntime as t };
1312
+
1313
+ //# sourceMappingURL=plugin-common-B1MG00JT.mjs.map