dsh-agy 0.4.4 → 0.4.6

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 (33) hide show
  1. package/README.md +8 -3
  2. package/lib/{accounts-DW-BL9c3.mjs → accounts-DtIsWCeF.mjs} +15 -11
  3. package/lib/{accounts-DW-BL9c3.mjs.map → accounts-DtIsWCeF.mjs.map} +1 -1
  4. package/lib/cli/index.mjs +6 -6
  5. package/lib/client.js +181 -23
  6. package/lib/{constants-CmA9wXNe.mjs → constants-AdsD4sKh.mjs} +3 -3
  7. package/lib/{constants-CmA9wXNe.mjs.map → constants-AdsD4sKh.mjs.map} +1 -1
  8. package/lib/{exchange-C-tndv7j.mjs → exchange-B_rHhU8K.mjs} +3 -3
  9. package/lib/{exchange-C-tndv7j.mjs.map → exchange-B_rHhU8K.mjs.map} +1 -1
  10. package/lib/{import-SxPDjH3u.mjs → import-BqyA9tzt.mjs} +4 -4
  11. package/lib/{import-SxPDjH3u.mjs.map → import-BqyA9tzt.mjs.map} +1 -1
  12. package/lib/index.mjs +2 -2
  13. package/lib/{models-CjB0rXtI.mjs → models-D_54Ro5z.mjs} +3 -3
  14. package/lib/{models-CjB0rXtI.mjs.map → models-D_54Ro5z.mjs.map} +1 -1
  15. package/lib/{parse-38V1zT1B.mjs → parse-CeJc7cHo.mjs} +141 -19
  16. package/lib/parse-CeJc7cHo.mjs.map +1 -0
  17. package/lib/{plugin-common-CKTYXxSc.mjs → plugin-common-BlGbNHxX.mjs} +426 -73
  18. package/lib/plugin-common-BlGbNHxX.mjs.map +1 -0
  19. package/lib/{proxy-BbuQX5U9.mjs → proxy-B7VnbAfO.mjs} +5 -2
  20. package/lib/proxy-B7VnbAfO.mjs.map +1 -0
  21. package/lib/{quota-summary-DEA3P3Zh.mjs → quota-summary-DarIZkmo.mjs} +3 -3
  22. package/lib/{quota-summary-DEA3P3Zh.mjs.map → quota-summary-DarIZkmo.mjs.map} +1 -1
  23. package/lib/{translate-BR1kuCaz.mjs → translate-vh3M5H_9.mjs} +51 -9
  24. package/lib/translate-vh3M5H_9.mjs.map +1 -0
  25. package/lib/web/plugin.d.mts +23 -1
  26. package/lib/web/plugin.d.mts.map +1 -1
  27. package/lib/web/plugin.mjs +86 -19
  28. package/lib/web/plugin.mjs.map +1 -1
  29. package/package.json +1 -1
  30. package/lib/parse-38V1zT1B.mjs.map +0 -1
  31. package/lib/plugin-common-CKTYXxSc.mjs.map +0 -1
  32. package/lib/proxy-BbuQX5U9.mjs.map +0 -1
  33. package/lib/translate-BR1kuCaz.mjs.map +0 -1
@@ -1,10 +1,10 @@
1
- import { m as fetchAgyFirstOk, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-CmA9wXNe.mjs";
2
- import { a as probeFetch, o as proxiedFetch, t as accountFetch } from "./proxy-BbuQX5U9.mjs";
3
- import { C as isSessionAccumulationOverflow, S as describeFetchError, _ as persistMasterKey, b as classifyFetchError, f as migrateToAgyDir, g as loadMasterKey, h as deriveKey, m as createAesGcmCodec, p as MASTER_KEY_REF, r as AgySessionManager, s as resolveAntigravityVersion, t as JsonAccountStore, u as pickProbeProxyUrl, v as resolveDshHome, x as classifyHttpError, y as resolveMasterKeyCodec } from "./accounts-DW-BL9c3.mjs";
1
+ import { m as fetchAgyFirstOk, v as AgyAuthError, y as AgyPoolBlockedError } from "./constants-AdsD4sKh.mjs";
2
+ import { a as probeFetch, o as proxiedFetch, t as accountFetch } from "./proxy-B7VnbAfO.mjs";
3
+ import { C as isSessionAccumulationOverflow, S as describeFetchError, _ as persistMasterKey, b as classifyFetchError, f as migrateToAgyDir, g as loadMasterKey, h as deriveKey, m as createAesGcmCodec, p as MASTER_KEY_REF, r as AgySessionManager, s as resolveAntigravityVersion, t as JsonAccountStore, u as pickProbeProxyUrl, v as resolveDshHome, x as classifyHttpError, y as resolveMasterKeyCodec } from "./accounts-DtIsWCeF.mjs";
4
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-BR1kuCaz.mjs";
6
- import { n as parseAgySse, t as UnmappedFinishReasonError } from "./parse-38V1zT1B.mjs";
7
- import { i as resolveAgyModel, n as listAgyModels, t as catalogModelList } from "./models-CjB0rXtI.mjs";
5
+ import { i as setThoughtSignature, r as resolveMultimodalFiles, t as toAgyRequestBody } from "./translate-vh3M5H_9.mjs";
6
+ import { n as UnmappedFinishReasonError, r as parseAgySse, t as AgyStreamIdleTimeoutError } from "./parse-CeJc7cHo.mjs";
7
+ import { i as resolveAgyModel, n as listAgyModels, t as catalogModelList } from "./models-D_54Ro5z.mjs";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from "@deepseek-ai/dsh-llm";
10
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -29,6 +29,22 @@ function collectImageRefs(options) {
29
29
  const UPSTREAM_ERROR_CODE = "UPSTREAM";
30
30
  /** First-class DSH retryable code: the default retry policy honors SERVER (5xx), not UPSTREAM. */
31
31
  const SERVER_ERROR_CODE = "SERVER";
32
+ /** Abort-aware delay helper for transient stream retries. */
33
+ async function sleepWithSignal(ms, signal) {
34
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError");
35
+ await new Promise((resolve, reject) => {
36
+ const onAbort = () => {
37
+ clearTimeout(timer);
38
+ reject(new DOMException("aborted", "AbortError"));
39
+ };
40
+ const timer = setTimeout(() => {
41
+ signal?.removeEventListener("abort", onAbort);
42
+ resolve();
43
+ }, ms);
44
+ if (!signal) return;
45
+ signal.addEventListener("abort", onAbort, { once: true });
46
+ });
47
+ }
32
48
  /** Stable ledger key for an account: email when present, else the generated id. */
33
49
  function ledgerAccountKey(session) {
34
50
  return session.account.email ?? session.account.id;
@@ -215,12 +231,10 @@ var AgyAdapter = class extends LlmAdapter {
215
231
  throw error;
216
232
  }
217
233
  if (!session) throw new LlmError("No agy account configured — run `dsh-agy login` to authenticate.", "NO_CREDENTIAL");
218
- const multimodalFiles = await resolveMultimodalFiles(options);
234
+ const multimodalFiles = await resolveMultimodalFiles(options, { maxBytes: this.options.maxInlineBytes?.() });
219
235
  holder.account = session.account;
220
236
  this.options.noteRequestStarted?.(session.account);
221
237
  const conversationAccount = ledgerAccountKey(session);
222
- /** Wall-clock origin for this attempt's latency figures. */
223
- const startedAt = Date.now();
224
238
  const routing = {
225
239
  proxyUrl: session.account.proxy,
226
240
  streaming: true
@@ -235,8 +249,11 @@ var AgyAdapter = class extends LlmAdapter {
235
249
  * names a fresh upstream session and recovers the conversation. This is not
236
250
  * an account fault, so it does not go through `reportFailure`: the account
237
251
  * stays healthy and only the derived id changes.
252
+ *
253
+ * `attemptStartedAt` is the clock of the outer attempt that owns this send,
254
+ * so a transport failure is billed to that attempt rather than to the call.
238
255
  */
239
- const sendAttempt = async () => {
256
+ const sendAttempt = async (attemptStartedAt) => {
240
257
  for (let attempt = 0;; attempt++) {
241
258
  const generation = conversationKey !== void 0 && conversationAccount !== void 0 ? currentSessionGeneration(conversationAccount, conversationKey) : 0;
242
259
  const requestId = generateAntigravityRequestId();
@@ -264,7 +281,7 @@ var AgyAdapter = class extends LlmAdapter {
264
281
  this.recordUsage(session, options.model, {
265
282
  ok: false,
266
283
  reason: "network-error"
267
- }, startedAt);
284
+ }, attemptStartedAt);
268
285
  throw new LlmError(classified.message ?? "agy fetch failed", "TRANSPORT", { cause: error });
269
286
  }
270
287
  if (response.ok) return { response };
@@ -279,74 +296,171 @@ var AgyAdapter = class extends LlmAdapter {
279
296
  };
280
297
  }
281
298
  };
282
- const { response, bodyText } = await sendAttempt();
283
- if (!response.ok) {
284
- const classified = classifyHttpError(response.status, response.headers, bodyText);
285
- await this.options.reportFailure(classified.kind, session, {
286
- retryAfterMs: classified.retryAfterMs,
287
- status: response.status,
288
- rateLimitCategory: classified.rateLimitCategory,
289
- resetTime: classified.resetTime,
290
- model: options.model,
291
- verificationUrl: classified.verificationUrl
292
- });
293
- this.recordUsage(session, options.model, {
294
- ok: false,
295
- rateLimited: classified.kind === "rate-limit",
296
- reason: classified.kind
297
- }, startedAt);
298
- if (classified.kind === "rate-limit") {
299
- if (classified.rateLimitCategory === "quota_exhausted") throw new LlmError(`agy daily quota exhausted (${response.status}): ${classified.message ?? ""}`, QUOTA_EXCEEDED_CODE);
300
- throw new LlmError(`agy rate-limited (${response.status}): ${classified.message ?? ""}`, "RATE_LIMIT", {
299
+ /**
300
+ * Maximum internal retries when the upstream ends a stream with
301
+ * `MALFORMED_FUNCTION_CALL` before any user-facing text chunks have been
302
+ * yielded to DSH. Gemini models occasionally glitch on tool-call JSON syntax;
303
+ * re-sampling cleanly recovers the turn without crashing the conversation.
304
+ *
305
+ * This deliberately generalises the `sendAttempt` accumulation-wall resend
306
+ * into the stream path: the invariant that one DSH call is one provider
307
+ * attempt now has this second, documented exception (see AGENTS.md, "the
308
+ * adapter does not silent-retry"). Both exceptions share the same shape —
309
+ * the account is healthy and the request was accepted — and neither goes
310
+ * through `reportFailure`, so the pool sees nothing.
311
+ */
312
+ const MAX_MALFORMED_RETRIES = 3;
313
+ for (let malformedAttempt = 0;; malformedAttempt++) {
314
+ const attemptStartedAt = Date.now();
315
+ const { response, bodyText } = await sendAttempt(attemptStartedAt);
316
+ if (!response.ok) {
317
+ const classified = classifyHttpError(response.status, response.headers, bodyText);
318
+ await this.options.reportFailure(classified.kind, session, {
319
+ retryAfterMs: classified.retryAfterMs,
320
+ status: response.status,
321
+ rateLimitCategory: classified.rateLimitCategory,
322
+ resetTime: classified.resetTime,
323
+ model: options.model,
324
+ verificationUrl: classified.verificationUrl
325
+ });
326
+ this.recordUsage(session, options.model, {
327
+ ok: false,
328
+ rateLimited: classified.kind === "rate-limit",
329
+ reason: classified.kind
330
+ }, attemptStartedAt);
331
+ if (classified.kind === "rate-limit") {
332
+ if (classified.rateLimitCategory === "quota_exhausted") throw new LlmError(`agy daily quota exhausted (${response.status}): ${classified.message ?? ""}`, QUOTA_EXCEEDED_CODE);
333
+ throw new LlmError(`agy rate-limited (${response.status}): ${classified.message ?? ""}`, "RATE_LIMIT", {
334
+ providerRetryAfterMs: classified.retryAfterMs ?? void 0,
335
+ requestId: ProviderRequestId(generateAntigravityRequestId())
336
+ });
337
+ }
338
+ if (classified.kind === "verification-required") {
339
+ const appeal = classified.verificationUrl ? ` Verify at: ${classified.verificationUrl}` : "";
340
+ throw new LlmError(`agy account needs verification (${response.status}): ${classified.message ?? ""}${appeal}`, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
341
+ }
342
+ if (classified.kind === "auth-failure") throw new LlmError(`agy authentication failed (${response.status}) — run \`dsh-agy login\``, "INVALID_CREDENTIAL");
343
+ if (classified.status !== void 0 && classified.status >= 500) throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, SERVER_ERROR_CODE, {
301
344
  providerRetryAfterMs: classified.retryAfterMs ?? void 0,
302
345
  requestId: ProviderRequestId(generateAntigravityRequestId())
303
346
  });
347
+ throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, UPSTREAM_ERROR_CODE);
304
348
  }
305
- if (classified.kind === "verification-required") {
306
- const appeal = classified.verificationUrl ? ` Verify at: ${classified.verificationUrl}` : "";
307
- throw new LlmError(`agy account needs verification (${response.status}): ${classified.message ?? ""}${appeal}`, "RATE_LIMIT", { requestId: ProviderRequestId(generateAntigravityRequestId()) });
308
- }
309
- if (classified.kind === "auth-failure") throw new LlmError(`agy authentication failed (${response.status}) — run \`dsh-agy login\``, "INVALID_CREDENTIAL");
310
- if (classified.status !== void 0 && classified.status >= 500) throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, SERVER_ERROR_CODE, {
311
- providerRetryAfterMs: classified.retryAfterMs ?? void 0,
312
- requestId: ProviderRequestId(generateAntigravityRequestId())
313
- });
314
- throw new LlmError(`agy upstream error (${response.status}): ${classified.message ?? ""}`, UPSTREAM_ERROR_CODE);
315
- }
316
- if (!response.body) throw new LlmError("agy stream returned no body", UPSTREAM_ERROR_CODE);
317
- try {
349
+ if (!response.body) throw new LlmError("agy stream returned no body", UPSTREAM_ERROR_CODE);
350
+ const bufferedChunks = [];
351
+ /**
352
+ * Thought signatures observed during this attempt, held back with the
353
+ * chunks that carried them. `onToolSignature` fires as soon as the parser
354
+ * sees a signature, i.e. BEFORE the attempt is known to be keepable, so
355
+ * caching eagerly would let a discarded attempt write the process-global
356
+ * signature cache (keyed by `toolCallId` — and issue #99 records those ids
357
+ * colliding across distant turns, which would poison the very retry it
358
+ * triggered).
359
+ */
360
+ const pendingSignatures = [];
361
+ let yieldedDirect = false;
318
362
  let usage;
319
363
  let ttftMs;
320
- for await (const chunk of parseAgySse(response.body, {
321
- signal: options.signal,
322
- onToolSignature: (toolCallId, signature) => {
323
- setThoughtSignature(toolCallId, signature);
364
+ /**
365
+ * Persist the signatures observed so far.
366
+ *
367
+ * Called at every point where the chunks that carried them have been
368
+ * handed off — never on the discard path, so a thrown-away attempt leaves
369
+ * no trace. The hand-off is the only sound anchor: a `functionCall` can
370
+ * arrive AFTER the first `text-delta` (text-then-tool is a common shape),
371
+ * so anchoring at stream end would drop the signature of an already
372
+ * delivered call the moment the stream dies mid-body, silently degrading
373
+ * the next round to the sentinel.
374
+ */
375
+ const commitSignatures = () => {
376
+ for (const [toolCallId, signature] of pendingSignatures) setThoughtSignature(toolCallId, signature);
377
+ pendingSignatures.length = 0;
378
+ };
379
+ /** Hand the held chunks to the consumer. */
380
+ const drainBuffer = () => {
381
+ const drained = bufferedChunks.slice();
382
+ bufferedChunks.length = 0;
383
+ return drained;
384
+ };
385
+ try {
386
+ for await (const chunk of parseAgySse(response.body, {
387
+ signal: options.signal,
388
+ onToolSignature: (toolCallId, signature) => {
389
+ pendingSignatures.push([toolCallId, signature]);
390
+ }
391
+ })) {
392
+ if (chunk.type === "usage") usage = {
393
+ input: chunk.usage.inputTokens,
394
+ output: chunk.usage.outputTokens,
395
+ cacheRead: chunk.usage.cacheReadTokens ?? 0,
396
+ cacheWrite: chunk.usage.cacheWriteTokens ?? 0
397
+ };
398
+ if (yieldedDirect) {
399
+ if (pendingSignatures.length !== 0) commitSignatures();
400
+ yield chunk;
401
+ } else if (chunk.type === "text-delta") {
402
+ yieldedDirect = true;
403
+ commitSignatures();
404
+ ttftMs = Date.now() - attemptStartedAt;
405
+ for (const buffered of drainBuffer()) yield buffered;
406
+ yield chunk;
407
+ } else bufferedChunks.push(chunk);
324
408
  }
325
- })) {
326
- if (chunk.type === "usage") usage = {
327
- input: chunk.usage.inputTokens,
328
- output: chunk.usage.outputTokens,
329
- cacheRead: chunk.usage.cacheReadTokens ?? 0,
330
- cacheWrite: chunk.usage.cacheWriteTokens ?? 0
331
- };
332
- else if (ttftMs === void 0 && (chunk.type === "text-delta" || chunk.type === "reasoning-delta")) ttftMs = Date.now() - startedAt;
333
- yield chunk;
409
+ if (ttftMs === void 0) ttftMs = Date.now() - attemptStartedAt;
410
+ commitSignatures();
411
+ for (const buffered of drainBuffer()) yield buffered;
412
+ await this.options.markSuccess?.(session);
413
+ this.recordUsage(session, options.model, {
414
+ ok: true,
415
+ usage,
416
+ ttftMs
417
+ }, attemptStartedAt);
418
+ return;
419
+ } catch (error) {
420
+ if (error instanceof DOMException && error.name === "AbortError") {
421
+ this.recordUsage(session, options.model, {
422
+ ok: false,
423
+ reason: "aborted",
424
+ usage
425
+ }, attemptStartedAt);
426
+ await response.body?.cancel().catch(() => {});
427
+ throw new LlmError("agy stream aborted", "ABORTED", { cause: error });
428
+ }
429
+ if (error instanceof AgyStreamIdleTimeoutError && !yieldedDirect) {
430
+ await this.options.reportFailure("network-error", session);
431
+ this.recordUsage(session, options.model, {
432
+ ok: false,
433
+ reason: "network-error",
434
+ usage
435
+ }, attemptStartedAt);
436
+ await response.body?.cancel().catch(() => {});
437
+ throw new LlmError(`${error.message} — nothing was delivered, retrying`, "TIMEOUT", { cause: error });
438
+ }
439
+ if (error instanceof UnmappedFinishReasonError && error.reason === "MALFORMED_FUNCTION_CALL" && !yieldedDirect && malformedAttempt < MAX_MALFORMED_RETRIES) {
440
+ this.recordUsage(session, options.model, {
441
+ ok: false,
442
+ reason: "request-error",
443
+ usage
444
+ }, attemptStartedAt);
445
+ await response.body?.cancel().catch(() => {});
446
+ try {
447
+ await sleepWithSignal(200 * (malformedAttempt + 1), options.signal);
448
+ } catch (sleepError) {
449
+ if (sleepError instanceof DOMException && sleepError.name === "AbortError") throw new LlmError("agy stream aborted", "ABORTED", { cause: sleepError });
450
+ throw sleepError;
451
+ }
452
+ continue;
453
+ }
454
+ const unmappedFinish = error instanceof UnmappedFinishReasonError;
455
+ await this.options.reportFailure(unmappedFinish ? "request-error" : "network-error", session);
456
+ this.recordUsage(session, options.model, {
457
+ ok: false,
458
+ reason: unmappedFinish ? "request-error" : "network-error",
459
+ usage
460
+ }, attemptStartedAt);
461
+ await response.body?.cancel().catch(() => {});
462
+ throw new LlmError(error instanceof Error ? describeFetchError(error) : "agy stream parse failed", UPSTREAM_ERROR_CODE, { cause: error });
334
463
  }
335
- await this.options.markSuccess?.(session);
336
- this.recordUsage(session, options.model, {
337
- ok: true,
338
- usage,
339
- ttftMs
340
- }, startedAt);
341
- } catch (error) {
342
- if (error instanceof DOMException && error.name === "AbortError") throw new LlmError("agy stream aborted", "ABORTED", { cause: error });
343
- const unmappedFinish = error instanceof UnmappedFinishReasonError;
344
- await this.options.reportFailure(unmappedFinish ? "request-error" : "network-error", session);
345
- this.recordUsage(session, options.model, {
346
- ok: false,
347
- reason: unmappedFinish ? "request-error" : "network-error"
348
- }, startedAt);
349
- throw new LlmError(error instanceof Error ? describeFetchError(error) : "agy stream parse failed", UPSTREAM_ERROR_CODE, { cause: error });
350
464
  }
351
465
  }
352
466
  /**
@@ -557,6 +671,10 @@ var ModelVisibility = class ModelVisibility {
557
671
  this.reload();
558
672
  }
559
673
  };
674
+ /** Whether `value` may be stored as a per-file inline cap: an integer MB in range. */
675
+ function isValidMultimodalMb(value) {
676
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
677
+ }
560
678
  //#endregion
561
679
  //#region src/thinking-types.ts
562
680
  /**
@@ -823,6 +941,238 @@ var ThinkingBudgetStore = class ThinkingBudgetStore {
823
941
  return this.snapshot();
824
942
  }
825
943
  };
944
+ /** Environment override for the cap, in MB. */
945
+ const MULTIMODAL_ENV_VAR = "DSH_AGY_MULTIMODAL_MAX_INLINE_MB";
946
+ /**
947
+ * Rebuild the document, keeping only fields that are set.
948
+ *
949
+ * ONE place builds a document, so `parse`/`snapshot`/`write` cannot disagree
950
+ * about which fields exist: `thinking-budget.ts` spells its document out in each
951
+ * of the three, and a field added to only two of them is silently dropped by the
952
+ * third (that is why its `write` and `snapshot` both enumerate `claudeBudget`
953
+ * and `tieredBudget` by hand). `undefined` is dropped rather than serialized as
954
+ * `null`, so "unset" has exactly one on-disk form.
955
+ */
956
+ function toDocument(doc) {
957
+ return {
958
+ version: 1,
959
+ ...doc.maxInlineMb === void 0 ? {} : { maxInlineMb: doc.maxInlineMb }
960
+ };
961
+ }
962
+ /**
963
+ * Keep the stored value only when it is usable (tolerates hand edits).
964
+ *
965
+ * Dropped rather than clamped: a clamped cap would silently inline files the
966
+ * user asked to keep out, and the fallback — no stored value — is a valid state
967
+ * that simply means the built-in default.
968
+ */
969
+ function sanitizeMultimodalDocument(raw) {
970
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return toDocument({ version: 1 });
971
+ const value = raw.maxInlineMb;
972
+ return toDocument({
973
+ version: 1,
974
+ ...isValidMultimodalMb(value) ? { maxInlineMb: value } : {}
975
+ });
976
+ }
977
+ function parseMultimodalDocument(text) {
978
+ let raw;
979
+ try {
980
+ raw = JSON.parse(text);
981
+ } catch {
982
+ return toDocument({ version: 1 });
983
+ }
984
+ return sanitizeMultimodalDocument(raw);
985
+ }
986
+ /**
987
+ * The env override, or undefined when unset or unusable.
988
+ *
989
+ * `DSH_AGY_*` rather than `AGY_*`: that prefix is this plugin's own runtime
990
+ * knobs (`risk.ts`'s kill switch and fingerprint mode, `plugin-common.ts`'s
991
+ * health-probe interval), while `AGY_CLIENT_ID`/`AGY_CLIENT_SECRET` are the
992
+ * OAuth client-credential namespace — a knob that changes request behavior
993
+ * belongs to the former.
994
+ *
995
+ * This is the codebase's first NUMERIC env knob, and it inherits `envFlag`'s
996
+ * posture: anything not recognized yields the fallback (here "no override")
997
+ * rather than throwing or warning, because the fallback chain (store, then
998
+ * default) is always a valid state. Recognized means a plain decimal integer —
999
+ * `30MB`, `1.5` and `1e2` are ignored rather than guessed at, so the value that
1000
+ * takes effect is the one written.
1001
+ *
1002
+ * Read per call, not cached at module load, so a test (or a process that sets it
1003
+ * late) sees the change without a restart.
1004
+ */
1005
+ function envMaxInlineMb() {
1006
+ const raw = process.env[MULTIMODAL_ENV_VAR];
1007
+ if (raw === void 0) return void 0;
1008
+ const trimmed = raw.trim();
1009
+ if (!/^\d+$/.test(trimmed)) return void 0;
1010
+ const value = Number(trimmed);
1011
+ return isValidMultimodalMb(value) ? value : void 0;
1012
+ }
1013
+ /**
1014
+ * In-memory authoritative copy of the cap, backed by a JSON file.
1015
+ *
1016
+ * `maxInlineBytes` sits on the GENERATION HOT PATH (the adapter resolves the cap
1017
+ * for every request), so the cross-instance revalidation it needs is
1018
+ * rate-limited — the same window, shared with `thinking-budget.ts` rather than
1019
+ * re-declared: two settings read on one hot path should not drift into two
1020
+ * different staleness bounds. Writes go to the file immediately and bypass the
1021
+ * limit so a concurrent edit is never merged into a stale copy.
1022
+ *
1023
+ * No file lock: a settings write is atomic (tmp+rename) and the accepted loss is
1024
+ * one concurrent toggle, which is the same trade `thinking-budget.ts` makes.
1025
+ */
1026
+ var MultimodalConfigStore = class MultimodalConfigStore {
1027
+ file;
1028
+ revalidateIntervalMs;
1029
+ doc;
1030
+ /** Raw text this instance last read or wrote; see `reloadNow`. */
1031
+ raw;
1032
+ /** When `reloadNow` last ran, for the hot-path throttle. */
1033
+ checkedAt;
1034
+ constructor(options = {}) {
1035
+ this.file = options.file ?? migrateToAgyDir("agy-multimodal.json").file;
1036
+ this.revalidateIntervalMs = options.revalidateIntervalMs ?? 1e3;
1037
+ const initial = this.readWithRaw();
1038
+ this.doc = initial.doc;
1039
+ this.raw = initial.raw;
1040
+ this.checkedAt = Date.now();
1041
+ }
1042
+ /** Path of the backing file. */
1043
+ get path() {
1044
+ return this.file;
1045
+ }
1046
+ static serialize(doc) {
1047
+ return JSON.stringify(doc, null, 2) + "\n";
1048
+ }
1049
+ /**
1050
+ * Read the document together with the raw text it came from.
1051
+ *
1052
+ * Content, not mtime: two writers can land in the same filesystem timestamp
1053
+ * tick (Windows resolves it coarsely enough to have failed CI in this repo),
1054
+ * and a missed change is exactly the bug this check exists to prevent. A
1055
+ * missing or unreadable file means "nothing configured" — and must NOT create
1056
+ * it, because a settings read runs in processes that never write.
1057
+ */
1058
+ readWithRaw() {
1059
+ try {
1060
+ const raw = readFileSync(this.file, "utf8");
1061
+ return {
1062
+ doc: parseMultimodalDocument(raw),
1063
+ raw
1064
+ };
1065
+ } catch {
1066
+ return {
1067
+ doc: toDocument({ version: 1 }),
1068
+ raw: ""
1069
+ };
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Re-read the file if another writer changed it.
1074
+ *
1075
+ * Unconditional — callers on the hot path use `reloadIfChanged` instead.
1076
+ */
1077
+ reloadNow() {
1078
+ let current;
1079
+ try {
1080
+ current = readFileSync(this.file, "utf8");
1081
+ } catch {
1082
+ current = "";
1083
+ }
1084
+ this.checkedAt = Date.now();
1085
+ if (current === this.raw) return;
1086
+ const fresh = this.readWithRaw();
1087
+ this.doc = fresh.doc;
1088
+ this.raw = fresh.raw;
1089
+ }
1090
+ /**
1091
+ * Hot-path revalidation: `reloadNow`, rate-limited.
1092
+ *
1093
+ * Without the limit every generation pays a blocking `readFileSync` plus a
1094
+ * UTF-8 decode for a value that changes only when a user saves the settings
1095
+ * card, so the worst case here is that a change takes up to
1096
+ * `revalidateIntervalMs` to reach an already-running adapter — the right trade
1097
+ * for a setting, and not right for the write path, which is why `write` calls
1098
+ * `reloadNow` directly.
1099
+ */
1100
+ reloadIfChanged() {
1101
+ if (Date.now() - this.checkedAt < this.revalidateIntervalMs) return;
1102
+ this.reloadNow();
1103
+ }
1104
+ /**
1105
+ * Write the whole document atomically.
1106
+ *
1107
+ * Always re-reads first, so a concurrent edit by the OTHER instance in this
1108
+ * process (main plugin + web entry) is merged rather than overwritten: the
1109
+ * caller passes a mutation, not a full replacement built from a stale copy.
1110
+ */
1111
+ write(mutate) {
1112
+ this.reloadNow();
1113
+ const next = toDocument(this.doc);
1114
+ mutate(next);
1115
+ const text = MultimodalConfigStore.serialize(next);
1116
+ mkdirSync(dirname(this.file), { recursive: true });
1117
+ const tmp = `${this.file}.tmp`;
1118
+ writeFileSync(tmp, text, { mode: 384 });
1119
+ renameSync(tmp, this.file);
1120
+ this.doc = next;
1121
+ this.raw = text;
1122
+ }
1123
+ /** The STORED cap in MB, or undefined when nothing is stored. */
1124
+ maxInlineMb() {
1125
+ this.reloadIfChanged();
1126
+ return this.doc.maxInlineMb;
1127
+ }
1128
+ /**
1129
+ * The cap actually in force: env overrides the stored value, which overrides
1130
+ * the built-in default.
1131
+ *
1132
+ * The env var is read HERE rather than at construction so the override is
1133
+ * live for a long-running process and flippable in a test.
1134
+ */
1135
+ effectiveMaxInlineMb() {
1136
+ const fromEnv = envMaxInlineMb();
1137
+ if (fromEnv !== void 0) return {
1138
+ value: fromEnv,
1139
+ source: "env"
1140
+ };
1141
+ const stored = this.maxInlineMb();
1142
+ if (stored !== void 0) return {
1143
+ value: stored,
1144
+ source: "stored"
1145
+ };
1146
+ return {
1147
+ value: 20,
1148
+ source: "default"
1149
+ };
1150
+ }
1151
+ /** The effective cap in bytes, as the multimodal resolver wants it. */
1152
+ maxInlineBytes() {
1153
+ return this.effectiveMaxInlineMb().value * 1024 * 1024;
1154
+ }
1155
+ /**
1156
+ * The settings state: the stored value (null when unset) plus which source is
1157
+ * effective. See `MultimodalView` for why the two are separate fields.
1158
+ */
1159
+ snapshot() {
1160
+ return {
1161
+ value: this.maxInlineMb() ?? null,
1162
+ source: this.effectiveMaxInlineMb().source,
1163
+ max: 100
1164
+ };
1165
+ }
1166
+ /** Replace the cap, or clear it when `value` is undefined. */
1167
+ setMaxInlineMb(value) {
1168
+ if (value !== void 0 && !isValidMultimodalMb(value)) throw new Error(`multimodal inline cap must be an integer in [1, 100] MB`);
1169
+ this.write((doc) => {
1170
+ if (value === void 0) delete doc.maxInlineMb;
1171
+ else doc.maxInlineMb = value;
1172
+ });
1173
+ return this.snapshot();
1174
+ }
1175
+ };
826
1176
  //#endregion
827
1177
  //#region src/usage-types.ts
828
1178
  /** Zeroed counters, for accumulation. */
@@ -1590,6 +1940,7 @@ async function buildAgyRuntime(ctx) {
1590
1940
  });
1591
1941
  const modelVisibility = new ModelVisibility();
1592
1942
  const thinkingBudget = new ThinkingBudgetStore();
1943
+ const multimodalConfig = new MultimodalConfigStore();
1593
1944
  let invalidateModelCache = () => {};
1594
1945
  const sessions = new AgySessionManager({
1595
1946
  store,
@@ -1613,6 +1964,7 @@ async function buildAgyRuntime(ctx) {
1613
1964
  thinkingBudgetFor: (level) => thinkingBudget.budgetFor(level),
1614
1965
  claudeBudgetFor: () => thinkingBudget.claudeBudget(),
1615
1966
  tieredBudgetFor: () => thinkingBudget.tieredBudget(),
1967
+ maxInlineBytes: () => multimodalConfig.maxInlineBytes(),
1616
1968
  recordUsage: (record) => {
1617
1969
  stats.record({
1618
1970
  ...record,
@@ -1635,10 +1987,11 @@ async function buildAgyRuntime(ctx) {
1635
1987
  stats,
1636
1988
  recentStore,
1637
1989
  modelVisibility,
1638
- thinkingBudget
1990
+ thinkingBudget,
1991
+ multimodalConfig
1639
1992
  };
1640
1993
  }
1641
1994
  //#endregion
1642
1995
  export { CLAUDE_BUDGET_MIN as a, CLAUDE_BUDGET_MAX as i, dayKey as n, THINKING_BUDGET_MAX as o, foldWindowBreakdown as r, createAgyRuntime as t };
1643
1996
 
1644
- //# sourceMappingURL=plugin-common-CKTYXxSc.mjs.map
1997
+ //# sourceMappingURL=plugin-common-BlGbNHxX.mjs.map