dsh-packycode-api 1.1.8 → 1.2.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.
package/lib/index.js CHANGED
@@ -2,19 +2,22 @@ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { dirname, isAbsolute, join, posix, resolve, sep, win32 } from "node:path";
4
4
  import z from "@deepseek-ai/schemastery";
5
- import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
+ import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
6
  import { spawn } from "node:child_process";
7
7
  import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
8
- import { mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
8
+ import { mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
9
9
  import { createServer } from "node:http";
10
10
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
11
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
14
- import { createProvider } from "@earendil-works/pi-ai";
14
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
15
+ import { lazyStream } from "@earendil-works/pi-ai/api/lazy";
16
+ import { clampThinkingLevel, createProvider } from "@earendil-works/pi-ai";
15
17
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
16
18
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
17
19
  import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
20
+ import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
18
21
  import { AsyncLocalStorage } from "node:async_hooks";
19
22
  //#region src/request.js
20
23
  /**
@@ -181,6 +184,8 @@ const CHANNEL = "/packycode-api";
181
184
  const SETTINGS_NAMESPACE = "packycode-api";
182
185
  /** Credential record id inside the plugin's own scope. */
183
186
  const KEY_RECORD_ID = "keys";
187
+ /** Image credentials never participate in the conversation key ring. */
188
+ const IMAGE_KEY_RECORD_ID = "image-keys";
184
189
  /**
185
190
  * Model-traffic host. Each model declares the protocol its gateway route
186
191
  * actually serves: `cf.api.fan/v1` answers chat/completions and responses,
@@ -267,6 +272,51 @@ function accountHostOrder(hosts) {
267
272
  ]);
268
273
  }
269
274
  //#endregion
275
+ //#region src/secret-redactor.js
276
+ /**
277
+ * Redaction of plugin-owned credentials and ephemeral bridge capabilities.
278
+ * Work on decoded strings where possible; JSON-escaped forms also cover
279
+ * diagnostics containing serialized data. Delta redaction holds only the
280
+ * longest suffix that could still become a secret in the next chunk.
281
+ */
282
+ const strings = (values) => [...new Set(values.filter((value) => typeof value === "string" && value !== ""))].sort((a, b) => b.length - a.length);
283
+ const variants = (values) => strings(values.flatMap((value) => typeof value === "string" ? [value, JSON.stringify(value).slice(1, -1)] : []));
284
+ function redactSecrets(text, values) {
285
+ let safe = String(text);
286
+ for (const secret of variants(values)) safe = safe.split(secret).join("[redacted]");
287
+ return safe;
288
+ }
289
+ /** Protect secrets fragmented across consecutive decoded text deltas. */
290
+ function createDeltaRedactor(values) {
291
+ const secrets = variants(values);
292
+ let pending = "";
293
+ return {
294
+ write(text) {
295
+ const safe = redactSecrets(pending + text, secrets);
296
+ let held = 0;
297
+ for (const secret of secrets) {
298
+ const maximum = Math.min(safe.length, secret.length - 1);
299
+ for (let length = maximum; length > held; length--) if (safe.endsWith(secret.slice(0, length))) {
300
+ held = length;
301
+ break;
302
+ }
303
+ }
304
+ pending = held === 0 ? "" : safe.slice(-held);
305
+ return held === 0 ? safe : safe.slice(0, -held);
306
+ },
307
+ /**
308
+ * At a block/tool boundary, mask a still-ambiguous prefix so a subsequent
309
+ * block cannot reconstruct the credential. At end of output it is safe
310
+ * to flush an incomplete prefix literally; no later delta can complete it.
311
+ */
312
+ flush({ boundary = false } = {}) {
313
+ const result = boundary && pending !== "" ? "[redacted]" : pending;
314
+ pending = "";
315
+ return result;
316
+ }
317
+ };
318
+ }
319
+ //#endregion
270
320
  //#region src/balance.js
271
321
  /**
272
322
  * Account and per-key balance.
@@ -373,7 +423,11 @@ async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
373
423
  throw new Error(error.message, { cause: error });
374
424
  }
375
425
  if (!response.ok) throw new Error(statusMessage(response.status));
376
- return response.json();
426
+ try {
427
+ return await response.json();
428
+ } catch {
429
+ throw new Error("the gateway returned unreadable balance data");
430
+ }
377
431
  }
378
432
  async function withFallback(hosts, path, key, options) {
379
433
  const tried = [];
@@ -398,7 +452,7 @@ async function withFallback(hosts, path, key, options) {
398
452
  * @param options - key listing, host resolution, and transport knobs.
399
453
  * @returns `{ read({force, signal}), invalidate() }`.
400
454
  */
401
- function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
455
+ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, includeAccount = true } = {}) {
402
456
  if (typeof listKeys !== "function") throw new Error("PackyCode balance reader requires listKeys");
403
457
  if (typeof getHosts !== "function") throw new Error("PackyCode balance reader requires getHosts");
404
458
  /** Last successful per-key usage, by key id, and the last account figures. */
@@ -455,17 +509,21 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
455
509
  signal?.throwIfAborted();
456
510
  const hosts = getHosts();
457
511
  const keys = await listKeys();
512
+ const secrets = keys.map((entry) => entry.key);
513
+ const safe = (value) => redactSecrets(value, secrets);
458
514
  for (const id of lastGood.keys()) if (!keys.some((entry) => entry.id === id)) lastGood.delete(id);
459
515
  const entries = [];
460
516
  const usableIds = [];
461
517
  for (const entry of keys) {
462
518
  const base = {
463
519
  id: entry.id,
464
- label: entry.label,
520
+ label: safe(entry.label),
465
521
  disabled: entry.disabled === true
466
522
  };
467
523
  try {
468
524
  const usage = await loadKey(entry, hosts, signal);
525
+ if (usage.name !== void 0) usage.name = safe(usage.name);
526
+ if (usage.resetPeriod !== void 0) usage.resetPeriod = safe(usage.resetPeriod);
469
527
  lastGood.set(entry.id, {
470
528
  usage,
471
529
  fetchedAt: now()
@@ -477,7 +535,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
477
535
  usableIds.push(entry.id);
478
536
  } catch (error) {
479
537
  signal?.throwIfAborted();
480
- const message = error instanceof Error ? error.message : String(error);
538
+ const message = safe(error instanceof Error ? error.message : String(error));
481
539
  const previous = lastGood.get(entry.id);
482
540
  entries.push(previous === void 0 ? {
483
541
  ...base,
@@ -493,7 +551,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
493
551
  }
494
552
  }
495
553
  let account;
496
- try {
554
+ if (includeAccount) try {
497
555
  account = await loadAccount(keys, hosts, signal, usableIds);
498
556
  lastAccount = {
499
557
  account,
@@ -501,7 +559,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
501
559
  };
502
560
  } catch (error) {
503
561
  signal?.throwIfAborted();
504
- const message = error instanceof Error ? error.message : String(error);
562
+ const message = safe(error instanceof Error ? error.message : String(error));
505
563
  account = lastAccount === void 0 || !keys.some((entry) => entry.id === lastAccount.account.keyId) ? { error: message } : {
506
564
  ...lastAccount.account,
507
565
  stale: {
@@ -512,7 +570,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
512
570
  }
513
571
  return {
514
572
  keys: entries,
515
- account,
573
+ ...includeAccount ? { account } : {},
516
574
  hosts,
517
575
  fetchedAt: now()
518
576
  };
@@ -544,8 +602,9 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
544
602
  * model does not support is refused before the request is sent: Claude Opus
545
603
  * 5.5 (platform.claude.com effort docs), GPT-6.1 Sol and GPT-6 Astra
546
604
  * `reasoning.effort` (developers.openai.com model pages), DeepSeek thinking
547
- * mode (`thinking` toggle plus `low|high|max` effort), and Grok 4.7
548
- * (docs.x.ai/developers/grok-4-7).
605
+ * mode (`thinking` toggle plus `low|high|max` effort), Grok 4.7
606
+ * (docs.x.ai/developers/grok-4-7), and Gemini 4 Argon
607
+ * (ai.google.dev/gemini-api/docs/models/gemini-4-argon and docs/openai).
549
608
  *
550
609
  * @module dsh-packycode-api/catalog
551
610
  */
@@ -559,6 +618,7 @@ const NO_COST = Object.freeze({
559
618
  const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
560
619
  const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
561
620
  const MESSAGES_BASE = DEFAULT_API_HOST;
621
+ const GEMINI_BASE = `${DEFAULT_API_HOST}/v1beta`;
562
622
  /**
563
623
  * pi-ai thinking-level maps. A level pinned to `null` is unsupported; an
564
624
  * absent key keeps pi-ai's own default for that level, which is how `off`
@@ -592,6 +652,12 @@ const GROK_LEVELS = Object.freeze({
592
652
  ...ALWAYS_THINKING_LEVELS,
593
653
  max: null
594
654
  });
655
+ /** Gemini 4 Argon and 3.8 Flash support low | medium | high, with no off. */
656
+ const GEMINI_LEVELS = Object.freeze({
657
+ ...ALWAYS_THINKING_LEVELS,
658
+ xhigh: null,
659
+ max: null
660
+ });
595
661
  const PACKYCODE_MODELS = Object.freeze([
596
662
  Object.freeze({
597
663
  id: "deepseek-flash",
@@ -652,6 +718,32 @@ const PACKYCODE_MODELS = Object.freeze([
652
718
  thinkingLevelMap: GROK_LEVELS,
653
719
  compat: Object.freeze({ supportsLongCacheRetention: false })
654
720
  }),
721
+ Object.freeze({
722
+ id: "gemini-4-argon",
723
+ name: "Gemini 4 Argon",
724
+ api: "google-generative-ai",
725
+ provider: PROVIDER,
726
+ baseUrl: GEMINI_BASE,
727
+ reasoning: true,
728
+ input: Object.freeze(["text", "image"]),
729
+ cost: NO_COST,
730
+ contextWindow: 1048576,
731
+ maxTokens: 65536,
732
+ thinkingLevelMap: GEMINI_LEVELS
733
+ }),
734
+ Object.freeze({
735
+ id: "gemini-3.8-flash",
736
+ name: "Gemini 3.8 Flash",
737
+ api: "google-generative-ai",
738
+ provider: PROVIDER,
739
+ baseUrl: GEMINI_BASE,
740
+ reasoning: true,
741
+ input: Object.freeze(["text", "image"]),
742
+ cost: NO_COST,
743
+ contextWindow: 1048576,
744
+ maxTokens: 65536,
745
+ thinkingLevelMap: GEMINI_LEVELS
746
+ }),
655
747
  Object.freeze({
656
748
  id: "claude-opus-5-5",
657
749
  name: "Claude Opus 5.5",
@@ -2188,51 +2280,6 @@ function withProgramDirOnPath(env, program, platform = process.platform) {
2188
2280
  };
2189
2281
  }
2190
2282
  //#endregion
2191
- //#region src/secret-redactor.js
2192
- /**
2193
- * Redaction of plugin-owned credentials and ephemeral bridge capabilities.
2194
- * Work on decoded strings where possible; JSON-escaped forms also cover
2195
- * diagnostics containing serialized data. Delta redaction holds only the
2196
- * longest suffix that could still become a secret in the next chunk.
2197
- */
2198
- const strings = (values) => [...new Set(values.filter((value) => typeof value === "string" && value !== ""))].sort((a, b) => b.length - a.length);
2199
- const variants = (values) => strings(values.flatMap((value) => typeof value === "string" ? [value, JSON.stringify(value).slice(1, -1)] : []));
2200
- function redactSecrets(text, values) {
2201
- let safe = String(text);
2202
- for (const secret of variants(values)) safe = safe.split(secret).join("[redacted]");
2203
- return safe;
2204
- }
2205
- /** Protect secrets fragmented across consecutive decoded text deltas. */
2206
- function createDeltaRedactor(values) {
2207
- const secrets = variants(values);
2208
- let pending = "";
2209
- return {
2210
- write(text) {
2211
- const safe = redactSecrets(pending + text, secrets);
2212
- let held = 0;
2213
- for (const secret of secrets) {
2214
- const maximum = Math.min(safe.length, secret.length - 1);
2215
- for (let length = maximum; length > held; length--) if (safe.endsWith(secret.slice(0, length))) {
2216
- held = length;
2217
- break;
2218
- }
2219
- }
2220
- pending = held === 0 ? "" : safe.slice(-held);
2221
- return held === 0 ? safe : safe.slice(0, -held);
2222
- },
2223
- /**
2224
- * At a block/tool boundary, mask a still-ambiguous prefix so a subsequent
2225
- * block cannot reconstruct the credential. At end of output it is safe
2226
- * to flush an incomplete prefix literally; no later delta can complete it.
2227
- */
2228
- flush({ boundary = false } = {}) {
2229
- const result = boundary && pending !== "" ? "[redacted]" : pending;
2230
- pending = "";
2231
- return result;
2232
- }
2233
- };
2234
- }
2235
- //#endregion
2236
2283
  //#region src/claude-cli.js
2237
2284
  /**
2238
2285
  * Bridge to the local Claude Code CLI.
@@ -4542,68 +4589,760 @@ function createClaudeCodeTool({ runner, modeCeiling, planActive }) {
4542
4589
  });
4543
4590
  }
4544
4591
  //#endregion
4545
- //#region src/key-chooser.js
4592
+ //#region src/image-models.js
4593
+ /** Images API models are separate from the conversational pi-ai catalog. */
4594
+ const DEFAULT_IMAGE_MODEL = "gpt-image-2";
4595
+ const IMAGE_MODELS = Object.freeze({
4596
+ "gpt-image-2": Object.freeze([
4597
+ "auto",
4598
+ "low",
4599
+ "medium",
4600
+ "high"
4601
+ ]),
4602
+ "gpt-image-2.5-flare": Object.freeze([
4603
+ "auto",
4604
+ "low",
4605
+ "medium",
4606
+ "high",
4607
+ "xhigh",
4608
+ "max"
4609
+ ]),
4610
+ "gpt-image-2.5-sunburst": Object.freeze([
4611
+ "auto",
4612
+ "low",
4613
+ "medium",
4614
+ "high",
4615
+ "xhigh",
4616
+ "max"
4617
+ ])
4618
+ });
4619
+ const IMAGE_MODEL_IDS = Object.freeze(Object.keys(IMAGE_MODELS));
4620
+ const IMAGE_QUALITIES = Object.freeze([
4621
+ "auto",
4622
+ "low",
4623
+ "medium",
4624
+ "high",
4625
+ "xhigh",
4626
+ "max"
4627
+ ]);
4628
+ function validateImageOptions({ model = DEFAULT_IMAGE_MODEL, quality = "auto", size = "auto", background = "auto" } = {}) {
4629
+ if (!IMAGE_MODEL_IDS.includes(model)) throw new Error("PackyCode image model is not supported");
4630
+ if (!IMAGE_MODELS[model].includes(quality)) throw new Error("PackyCode image quality is not supported by the selected model");
4631
+ if (![
4632
+ "auto",
4633
+ "transparent",
4634
+ "opaque"
4635
+ ].includes(background)) throw new Error("PackyCode image background is not supported");
4636
+ if (size !== "auto") {
4637
+ const match = typeof size === "string" ? /^(\d{1,4})x(\d{1,4})$/u.exec(size) : null;
4638
+ const width = Number(match?.[1]);
4639
+ const height = Number(match?.[2]);
4640
+ const pixels = width * height;
4641
+ if (match === null || width % 16 !== 0 || height % 16 !== 0 || Math.max(width, height) > 3840 || Math.max(width, height) > Math.min(width, height) * 3 || pixels < 655360 || pixels > 8294400) throw new Error("PackyCode image size must be auto or a supported widthxheight resolution");
4642
+ }
4643
+ return {
4644
+ model,
4645
+ quality,
4646
+ size,
4647
+ background
4648
+ };
4649
+ }
4650
+ //#endregion
4651
+ //#region src/preferences.js
4546
4652
  /**
4547
- * Which stored key a request runs with, and what happens when the gateway
4548
- * rejects one.
4653
+ * Where the plugin's preferences are read from and written to, on both
4654
+ * settings generations DSH has shipped.
4549
4655
  *
4550
- * The ring's active key serves every request it can. A request for a model
4551
- * the active key's groups cannot reach — as the last group reading shows —
4552
- * runs on the next stored key that can; the active pointer itself does not
4553
- * move. A key the gateway reports as exhausted or invalid is marked unusable
4554
- * and the ring moves on, so the retry of the failed step resolves the next
4555
- * key. Only keys stored in this plugin's ring are ever chosen.
4656
+ * - DSH 0.1.5 installs a settings *section*: the section hands back a live
4657
+ * source and writes go through `settings.update(namespace, patch)`.
4658
+ * - DSH 0.2 has no sections: a preference is a *volatile* Config field of the
4659
+ * plugin's own row, read through a live reference, written through
4660
+ * `settings.update(rowId, patch)`, and announced by `loader/volatile-update`.
4661
+ * Only volatile fields are writable there, so the host overrides stay
4662
+ * read-only while web search and model visibility are editable.
4556
4663
  *
4557
- * @module dsh-packycode-api/key-chooser
4664
+ * @module dsh-packycode-api/preferences
4558
4665
  */
4559
- /** How long a request with a choice of keys waits for the first group reading. */
4560
- const DEFAULT_GROUP_WAIT_MS = 6e3;
4561
4666
  /**
4562
- * @param options - the key ring, the group reader, what to do after a key
4563
- * moved aside, a logger, and how long to wait for the first group reading.
4564
- * @returns the chooser.
4667
+ * Mark a preference editable without restarting the plugin. DSH 0.2 stores
4668
+ * plugin preferences as volatile Config fields and writes only those, so the
4669
+ * schema version that implements `volatile()` is a peer requirement.
4670
+ *
4671
+ * Name the cause when it is missing instead of letting a bare
4672
+ * `schema.volatile is not a function` reach the host log: the Loader creates
4673
+ * no fiber for a plugin whose module throws while it is imported, so DSH shows
4674
+ * nothing at all — no settings section, no model route, no RPC channel — and
4675
+ * the only explanation is that one log line.
4565
4676
  */
4566
- function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GROUP_WAIT_MS }) {
4567
- /**
4568
- * Key material to ring id. A failure is reported from inside a stream, which
4569
- * carries the request's key but not its id; this index is refreshed on every
4570
- * resolution so the right key is the one that moves aside.
4571
- */
4572
- const idByKey = /* @__PURE__ */ new Map();
4573
- /** The ring as of the last resolution, for the synchronous per-request choice. */
4574
- let snapshot = [];
4575
- const remember = async () => {
4576
- const entries = await ring.secrets();
4577
- snapshot = entries;
4578
- for (const entry of entries) idByKey.set(entry.key, entry.id);
4677
+ const live = (schema) => {
4678
+ if (typeof schema?.volatile !== "function") throw new Error("PackyCode needs @deepseek-ai/schemastery ^3.18.4, which implements Schema.volatile(); the copy resolved for this plugin is older. A plugin installed from a registry has no node_modules of its own, so this usually means a stale dependency tree inside the installed package directory (for example <profile>/node_modules/dsh-packycode-api/node_modules, left by an earlier local-path install) is shadowing the DSH installation. Remove that directory, or uninstall the plugin and install it again, then restart DSH.");
4679
+ return schema.volatile();
4680
+ };
4681
+ /** Read a Config field that may arrive as a volatile reference. */
4682
+ const fieldValue = (value) => value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
4683
+ /** A preference target nothing can write to yet. */
4684
+ function unwritable(message) {
4685
+ return {
4686
+ writable: false,
4687
+ set: async () => {
4688
+ throw new Error(message);
4689
+ }
4579
4690
  };
4580
- const usable = () => snapshot.filter((entry) => entry.disabled !== true);
4581
- const covers = (keyId, modelId) => groups.covers(keyId, modelId);
4582
- /**
4583
- * Keep the group reading fresh for the choice. It only matters with two
4584
- * usable keys or more; the first reading is awaited for at most `waitMs`,
4585
- * later ones refresh in the background.
4586
- */
4587
- const warm = async () => {
4588
- if (usable().length < 2) return;
4589
- const pending = groups.read().catch(() => void 0);
4590
- if (groups.hasReading()) return;
4591
- let timer;
4592
- await Promise.race([pending, new Promise((resolve) => {
4593
- timer = setTimeout(resolve, waitMs);
4594
- })]);
4595
- clearTimeout(timer);
4691
+ }
4692
+ /**
4693
+ * Connect the preferences to the settings service the host provides.
4694
+ * @param settingsCtx - the scope the `settings` inject handed over.
4695
+ * @param options - the plugin context and Config, the section namespace, the
4696
+ * current source and how to replace it, what to do when the section changes,
4697
+ * and the preference targets this fills in (host overrides, search, models).
4698
+ */
4699
+ function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference, modelPreference, imagePreference }) {
4700
+ const settings = settingsCtx.settings;
4701
+ if (typeof settings?.installSection === "function") {
4702
+ try {
4703
+ settings.installSection(ctx, namespace, Config, source(), {
4704
+ setSource: (current) => setSource(typeof current === "function" ? () => current() ?? {} : () => current ?? {}),
4705
+ onChange
4706
+ });
4707
+ if (typeof settings.update === "function") {
4708
+ const write = (patch) => settings.update(namespace, patch);
4709
+ const target = {
4710
+ writable: settings.writable !== false,
4711
+ set: write
4712
+ };
4713
+ Object.assign(hostPreference, target);
4714
+ Object.assign(searchPreference, target);
4715
+ Object.assign(modelPreference, target);
4716
+ if (imagePreference !== void 0) Object.assign(imagePreference, target);
4717
+ }
4718
+ } catch (error) {
4719
+ ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
4720
+ }
4721
+ return;
4722
+ }
4723
+ if (typeof settings?.update !== "function") return;
4724
+ const rowId = ctx.fiber?.entry?.options?.id ?? namespace;
4725
+ const target = {
4726
+ writable: settings.writable !== false,
4727
+ set: (patch) => settings.update(rowId, patch)
4596
4728
  };
4597
- /** The active key, after refreshing the ring and, when it matters, the groups. */
4598
- const active = async () => {
4599
- await remember();
4600
- const key = await ring.activeKey();
4601
- if (key !== void 0) await warm();
4602
- return key;
4729
+ Object.assign(searchPreference, target);
4730
+ Object.assign(modelPreference, target);
4731
+ if (imagePreference !== void 0) Object.assign(imagePreference, target);
4732
+ if (typeof settings.configure === "function") {
4733
+ const scope = typeof settingsCtx.effect === "function" ? settingsCtx : ctx;
4734
+ try {
4735
+ scope.effect(() => settings.configure({ auto: false }, ctx.fiber), "packycode-api: settings page policy");
4736
+ } catch (error) {
4737
+ ctx.logger?.warn?.("packycode-api: could not hide the generated settings page: %s", error.message);
4738
+ }
4739
+ }
4740
+ }
4741
+ //#endregion
4742
+ //#region src/image-settings.js
4743
+ function readImagePreference(value = {}) {
4744
+ const model = fieldValue(value.imageModel);
4745
+ const imageModel = IMAGE_MODEL_IDS.includes(model) ? model : DEFAULT_IMAGE_MODEL;
4746
+ const quality = fieldValue(value.imageQuality);
4747
+ return {
4748
+ imageEnabled: fieldValue(value.imageEnabled) !== false,
4749
+ imageModel,
4750
+ imageQuality: IMAGE_MODELS[imageModel].includes(quality) ? quality : "auto"
4603
4751
  };
4604
- return Object.freeze({
4605
- remember,
4606
- covers,
4752
+ }
4753
+ /** No request is sent by a settings or credential mutation. */
4754
+ function createImageController({ ring, balance, readPreference, writePreference, isWritable }) {
4755
+ let mounted;
4756
+ let disposeTool;
4757
+ let generation = 0;
4758
+ let closed = false;
4759
+ const preference = () => readImagePreference(readPreference());
4760
+ const reconcile = async () => {
4761
+ const current = ++generation;
4762
+ const key = await ring.activeKey();
4763
+ if (closed || current !== generation) return;
4764
+ const enabled = preference().imageEnabled && key !== void 0 && mounted !== void 0;
4765
+ if (enabled && disposeTool === void 0) disposeTool = mounted();
4766
+ if (!enabled && disposeTool !== void 0) {
4767
+ disposeTool();
4768
+ disposeTool = void 0;
4769
+ }
4770
+ };
4771
+ const status = async () => ({
4772
+ ...preference(),
4773
+ ...await ring.list(),
4774
+ writable: isWritable() === true,
4775
+ available: mounted !== void 0,
4776
+ toolRegistered: disposeTool !== void 0,
4777
+ models: IMAGE_MODEL_IDS.map((id) => ({
4778
+ id,
4779
+ qualities: [...IMAGE_MODELS[id]]
4780
+ }))
4781
+ });
4782
+ return {
4783
+ preference,
4784
+ status,
4785
+ invalidateBalance: () => balance?.invalidate(),
4786
+ async readBalance({ force = false, signal } = {}) {
4787
+ if (balance === void 0) throw new Error("PackyCode image balance is unavailable in this runtime");
4788
+ try {
4789
+ const [value, state] = await Promise.all([balance.read({
4790
+ force,
4791
+ signal
4792
+ }), ring.list()]);
4793
+ return {
4794
+ ...value,
4795
+ activeId: state.activeId
4796
+ };
4797
+ } catch {
4798
+ throw new Error("PackyCode image balance could not be read");
4799
+ }
4800
+ },
4801
+ reconcile,
4802
+ mount(register) {
4803
+ mounted = register;
4804
+ closed = false;
4805
+ reconcile().catch(() => void 0);
4806
+ return () => {
4807
+ closed = true;
4808
+ generation += 1;
4809
+ mounted = void 0;
4810
+ disposeTool?.();
4811
+ disposeTool = void 0;
4812
+ };
4813
+ },
4814
+ async update(value = {}) {
4815
+ if (isWritable() !== true) throw new Error("PackyCode image settings are not writable in this profile");
4816
+ const patch = {};
4817
+ if (Object.hasOwn(value, "imageEnabled")) {
4818
+ if (typeof value.imageEnabled !== "boolean") throw new Error("PackyCode image switch must be a boolean");
4819
+ patch.imageEnabled = value.imageEnabled;
4820
+ }
4821
+ if (Object.hasOwn(value, "imageModel")) patch.imageModel = value.imageModel;
4822
+ if (Object.hasOwn(value, "imageQuality")) patch.imageQuality = value.imageQuality;
4823
+ const next = {
4824
+ ...preference(),
4825
+ ...patch
4826
+ };
4827
+ if (patch.imageModel !== void 0 && patch.imageQuality === void 0 && !IMAGE_MODELS[patch.imageModel]?.includes(next.imageQuality)) patch.imageQuality = next.imageQuality = "auto";
4828
+ validateImageOptions({
4829
+ model: next.imageModel,
4830
+ quality: next.imageQuality
4831
+ });
4832
+ try {
4833
+ await writePreference(patch);
4834
+ } catch {
4835
+ throw new Error("PackyCode could not save the image settings");
4836
+ }
4837
+ await reconcile();
4838
+ return status();
4839
+ },
4840
+ async mutate(operation, payload = {}) {
4841
+ const operations = {
4842
+ add: () => ring.add(payload.key, payload.label),
4843
+ remove: () => ring.remove(String(payload.id ?? "")),
4844
+ select: () => ring.select(String(payload.id ?? "")),
4845
+ enable: () => ring.enable(String(payload.id ?? ""))
4846
+ };
4847
+ if (!Object.hasOwn(operations, operation)) throw new Error("Unknown PackyCode image key operation");
4848
+ await operations[operation]();
4849
+ balance?.invalidate();
4850
+ await reconcile();
4851
+ return status();
4852
+ }
4853
+ };
4854
+ }
4855
+ //#endregion
4856
+ //#region src/images.js
4857
+ /** Host-only Images API transport and DSH tool. No subscription/LLM key fallback. */
4858
+ const IMAGE_TOOL_NAME = "packycode_image_generate";
4859
+ const IMAGE_TIMEOUT_MS = 5 * 6e4;
4860
+ const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
4861
+ const MAX_REFERENCE_IMAGES = 16;
4862
+ const ENVELOPE_BYTES = 1024 * 1024;
4863
+ const EDIT_MEDIA_TYPES = [
4864
+ "image/png",
4865
+ "image/jpeg",
4866
+ "image/webp"
4867
+ ];
4868
+ const SIGNATURE = "89504e470d0a1a0a";
4869
+ const ATTACHMENT_ID = /^sha256:[0-9a-f]{64}$/u;
4870
+ var ImageRequestError = class extends Error {};
4871
+ const nonempty = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
4872
+ /** Some model calls fill unused object fields with a zero-valued reference. */
4873
+ function emptyMaskReference(value) {
4874
+ return value !== null && typeof value === "object" && (value.attachmentId === "" || value.attachmentId === `sha256:${"0".repeat(64)}`) && value.bytes === 0 && value.width === 0 && value.height === 0 && (value.name === void 0 || value.name === "") && (value.originalDimensions === void 0 || value.originalDimensions.width === 0 && value.originalDimensions.height === 0);
4875
+ }
4876
+ /** A bounded reader applies to both successful payloads and error envelopes. */
4877
+ async function readJson(response, maximum, signal) {
4878
+ const length = Number(response.headers.get("content-length"));
4879
+ if (Number.isFinite(length) && length > maximum || response.body === null) {
4880
+ await response.body?.cancel().catch(() => void 0);
4881
+ throw new ImageRequestError("PackyCode image response is unreadable or exceeds the size limit");
4882
+ }
4883
+ const reader = response.body.getReader();
4884
+ const cancel = () => {
4885
+ reader.cancel().catch(() => void 0);
4886
+ };
4887
+ signal.addEventListener("abort", cancel, { once: true });
4888
+ const chunks = [];
4889
+ let total = 0;
4890
+ try {
4891
+ signal.throwIfAborted();
4892
+ while (true) {
4893
+ const { done, value } = await reader.read();
4894
+ signal.throwIfAborted();
4895
+ if (done) break;
4896
+ total += value.byteLength;
4897
+ if (total > maximum) throw new ImageRequestError("PackyCode image response exceeds the size limit");
4898
+ chunks.push(Buffer.from(value));
4899
+ }
4900
+ try {
4901
+ return JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
4902
+ } catch {
4903
+ throw new ImageRequestError("PackyCode returned an unreadable image response");
4904
+ }
4905
+ } finally {
4906
+ signal.removeEventListener("abort", cancel);
4907
+ await reader.cancel().catch(() => void 0);
4908
+ reader.releaseLock();
4909
+ }
4910
+ }
4911
+ function pngDimensions(data) {
4912
+ const buffer = Buffer.from(data);
4913
+ if (buffer.length < 24 || buffer.subarray(0, 8).toString("hex") !== SIGNATURE || buffer.readUInt32BE(8) !== 13 || buffer.subarray(12, 16).toString("ascii") !== "IHDR") throw new ImageRequestError("PackyCode returned an invalid PNG image");
4914
+ const width = buffer.readUInt32BE(16);
4915
+ const height = buffer.readUInt32BE(20);
4916
+ if (width === 0 || height === 0 || Math.max(width, height) > 3840 || width * height > 8294400) throw new ImageRequestError("PackyCode returned unsupported image dimensions");
4917
+ return {
4918
+ width,
4919
+ height
4920
+ };
4921
+ }
4922
+ function decodeImagePng(encoded, maximum = MAX_IMAGE_BYTES) {
4923
+ if (typeof encoded !== "string" || encoded.length === 0 || encoded.length > Math.ceil(maximum / 3) * 4 || encoded.length % 4 !== 0) throw new ImageRequestError("PackyCode returned invalid or oversized base64 image data");
4924
+ const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0;
4925
+ for (let index = 0; index < encoded.length - padding; index += 1) {
4926
+ const code = encoded.charCodeAt(index);
4927
+ if (!(code >= 65 && code <= 90 || code >= 97 && code <= 122 || code >= 48 && code <= 57 || code === 43 || code === 47)) throw new ImageRequestError("PackyCode returned invalid base64 image data");
4928
+ }
4929
+ const data = Buffer.from(encoded, "base64");
4930
+ if (data.length > maximum || data.toString("base64") !== encoded) throw new ImageRequestError("PackyCode returned invalid base64 image data");
4931
+ pngDimensions(data);
4932
+ return data;
4933
+ }
4934
+ function imageFailure(status, value) {
4935
+ if (value?.error?.code === "moderation_blocked") return "PackyCode image request was blocked by the image safety check; revise the prompt or references";
4936
+ if (status === 401 || status === 403) return "PackyCode image key was rejected; check the separate image key and its model permissions";
4937
+ if (status === 429) return "PackyCode image quota or rate limit is unavailable; check the separate image key";
4938
+ return `PackyCode image request failed (HTTP ${status}); check model, quality, and image key permissions`;
4939
+ }
4940
+ /** One paid attempt only. Invalid references never turn an edit into a generation. */
4941
+ function createPackyCodeImageClient({ resolveKey, getHost, fetchImpl = ambientFetch, timeoutMs = IMAGE_TIMEOUT_MS }) {
4942
+ return { async request(args, { signal, images, mask } = {}) {
4943
+ const options = validateImageOptions(args);
4944
+ const prompt = nonempty(args?.prompt);
4945
+ if (prompt === void 0 || prompt.length > 32e3) throw new ImageRequestError("PackyCode image prompt must contain 1-32000 characters");
4946
+ if (images !== void 0 && (!Array.isArray(images) || images.length === 0 || images.length > MAX_REFERENCE_IMAGES)) throw new ImageRequestError("PackyCode image edits require 1-16 reference images");
4947
+ if (mask !== void 0 && images === void 0) throw new ImageRequestError("PackyCode image mask requires a reference image");
4948
+ const timeout = AbortSignal.timeout(timeoutMs);
4949
+ const requestSignal = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
4950
+ let selected;
4951
+ try {
4952
+ selected = await waitForSharedRequest(Promise.resolve().then(resolveKey), requestSignal);
4953
+ } catch {
4954
+ if (signal?.aborted) throw new ImageRequestError("PackyCode image request was cancelled");
4955
+ if (timeout.aborted) throw new ImageRequestError("PackyCode image request timed out");
4956
+ throw new ImageRequestError("PackyCode image credentials could not be read");
4957
+ }
4958
+ if (typeof selected?.key !== "string" || selected.key === "") throw new ImageRequestError("PackyCode images need a separate image key; add one under Settings → PackyCode → Images");
4959
+ const fields = {
4960
+ ...options,
4961
+ prompt,
4962
+ n: 1,
4963
+ output_format: "png"
4964
+ };
4965
+ let body;
4966
+ const headers = {
4967
+ authorization: `Bearer ${selected.key}`,
4968
+ accept: "application/json"
4969
+ };
4970
+ if (images === void 0) {
4971
+ headers["content-type"] = "application/json";
4972
+ body = JSON.stringify(fields);
4973
+ } else {
4974
+ body = new FormData();
4975
+ for (const [key, value] of Object.entries(fields)) body.append(key, String(value));
4976
+ for (const [index, image] of images.entries()) {
4977
+ if (!EDIT_MEDIA_TYPES.includes(image?.mediaType) || !(image.data instanceof Uint8Array) || image.data.byteLength === 0 || image.data.byteLength >= 50 * 1024 * 1024) throw new ImageRequestError("PackyCode reference images must be PNG, JPEG or WebP smaller than 50 MB");
4978
+ const extension = image.mediaType === "image/jpeg" ? "jpg" : image.mediaType.split("/")[1];
4979
+ body.append("image[]", new Blob([image.data], { type: image.mediaType }), `reference-${index + 1}.${extension}`);
4980
+ }
4981
+ if (mask !== void 0) {
4982
+ if (mask.mediaType !== "image/png" || !(mask.data instanceof Uint8Array) || mask.data.byteLength >= 4 * 1024 * 1024) throw new ImageRequestError("PackyCode image mask must be a PNG smaller than 4 MB");
4983
+ const dimensions = pngDimensions(mask.data);
4984
+ if (dimensions.width !== images[0].width || dimensions.height !== images[0].height) throw new ImageRequestError("PackyCode image mask must match the first reference image dimensions");
4985
+ body.append("mask", new Blob([mask.data], { type: "image/png" }), "mask.png");
4986
+ }
4987
+ }
4988
+ try {
4989
+ requestSignal.throwIfAborted();
4990
+ const response = await fetchImpl(`${normalizeHost(getHost(), DEFAULT_API_HOST).replace(/\/v1$/u, "")}/v1/images/${images === void 0 ? "generations" : "edits"}`, {
4991
+ method: "POST",
4992
+ redirect: "error",
4993
+ headers,
4994
+ body,
4995
+ signal: requestSignal
4996
+ });
4997
+ if (!response.ok) {
4998
+ const value = await readJson(response, 64 * 1024, requestSignal).catch(() => void 0);
4999
+ requestSignal.throwIfAborted();
5000
+ throw new ImageRequestError(imageFailure(response.status, value));
5001
+ }
5002
+ const value = await readJson(response, Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + ENVELOPE_BYTES, requestSignal);
5003
+ const data = decodeImagePng(value?.data?.[0]?.b64_json);
5004
+ return {
5005
+ data,
5006
+ ...pngDimensions(data),
5007
+ requestedModel: options.model,
5008
+ requestedQuality: options.quality,
5009
+ requestedSize: options.size,
5010
+ ...IMAGE_MODEL_IDS.includes(value?.model) ? { reportedModel: value.model } : {},
5011
+ ...IMAGE_QUALITIES.includes(value?.quality) ? { quality: value.quality } : {}
5012
+ };
5013
+ } catch (error) {
5014
+ if (signal?.aborted) throw new ImageRequestError("PackyCode image request was cancelled");
5015
+ if (timeout.aborted) throw new ImageRequestError("PackyCode image request timed out");
5016
+ if (error instanceof ImageRequestError) throw error;
5017
+ throw new ImageRequestError("PackyCode image connection failed");
5018
+ }
5019
+ } };
5020
+ }
5021
+ const referenceSchema = {
5022
+ type: "object",
5023
+ additionalProperties: false,
5024
+ properties: {
5025
+ attachmentId: {
5026
+ type: "string",
5027
+ required: true,
5028
+ description: "Complete sha256:<64 lowercase hex> image reference from the current session or read_image. Never a path or filename."
5029
+ },
5030
+ mediaType: {
5031
+ type: "string",
5032
+ required: true
5033
+ },
5034
+ bytes: {
5035
+ type: "integer",
5036
+ required: true
5037
+ },
5038
+ width: {
5039
+ type: "integer",
5040
+ required: true
5041
+ },
5042
+ height: {
5043
+ type: "integer",
5044
+ required: true
5045
+ },
5046
+ name: { type: "string" },
5047
+ originalDimensions: {
5048
+ type: "object",
5049
+ additionalProperties: false,
5050
+ properties: {
5051
+ width: {
5052
+ type: "integer",
5053
+ required: true
5054
+ },
5055
+ height: {
5056
+ type: "integer",
5057
+ required: true
5058
+ }
5059
+ }
5060
+ }
5061
+ }
5062
+ };
5063
+ /** Select only trusted references already present in this tool call's session. */
5064
+ async function readReferences(values, attachments, messages, signal) {
5065
+ if (!Array.isArray(values) || values.length === 0 || values.length > MAX_REFERENCE_IMAGES) throw new ImageRequestError("PackyCode image edits require 1-16 reference images");
5066
+ const available = /* @__PURE__ */ new Map();
5067
+ const visit = (content) => {
5068
+ for (const block of content ?? []) {
5069
+ if (block?.type === "image" && ATTACHMENT_ID.test(block.attachment?.attachmentId)) available.set(block.attachment.attachmentId, block.attachment);
5070
+ if (block?.type === "tool-result" && Array.isArray(block.content)) visit(block.content);
5071
+ }
5072
+ };
5073
+ for (const message of messages ?? []) if (Array.isArray(message?.content)) visit(message.content);
5074
+ const ids = values.map((value) => value?.attachmentId);
5075
+ if (ids.some((id) => typeof id !== "string" || !ATTACHMENT_ID.test(id) || !available.has(id))) throw new ImageRequestError("PackyCode image reference is missing from this session; use read_image and copy its complete attachment reference");
5076
+ if (new Set(ids).size !== ids.length) throw new ImageRequestError("PackyCode image references must not contain duplicates");
5077
+ const images = [];
5078
+ let total = 0;
5079
+ for (const id of ids) {
5080
+ signal?.throwIfAborted();
5081
+ let stored;
5082
+ try {
5083
+ stored = await attachments.readImage(available.get(id), signal);
5084
+ } catch {
5085
+ throw new ImageRequestError("PackyCode could not read the selected image attachment");
5086
+ }
5087
+ const { data, ref } = stored;
5088
+ if (!(data instanceof Uint8Array) || !EDIT_MEDIA_TYPES.includes(ref?.mediaType)) throw new ImageRequestError("PackyCode image reference must be PNG, JPEG or WebP");
5089
+ total += data.byteLength;
5090
+ if (total > attachments.imageLimits.maxMessageImageBytes) throw new ImageRequestError("PackyCode image references exceed the DSH image limit");
5091
+ images.push({
5092
+ data,
5093
+ mediaType: ref.mediaType,
5094
+ width: ref.width,
5095
+ height: ref.height
5096
+ });
5097
+ }
5098
+ return images;
5099
+ }
5100
+ function attachmentProjection(ref) {
5101
+ return Object.fromEntries([
5102
+ "attachmentId",
5103
+ "mediaType",
5104
+ "bytes",
5105
+ "width",
5106
+ "height",
5107
+ "name",
5108
+ "originalDimensions"
5109
+ ].filter((key) => ref[key] !== void 0).map((key) => [key, ref[key]]));
5110
+ }
5111
+ const renderImage = (value) => [{
5112
+ type: "text",
5113
+ text: `PackyCode · ${value.requestedModel} · ${value.width} × ${value.height} · ${value.requestedQuality}\nOriginal PNG on the DSH host: ${value.localPath}`
5114
+ }, {
5115
+ type: "image",
5116
+ attachment: value.image
5117
+ }];
5118
+ function createPackyCodeImageTool({ client, attachments, getPreference, getSessionMessages, imageDirectory, onGenerated }) {
5119
+ return defineTool({
5120
+ name: IMAGE_TOOL_NAME,
5121
+ description: "Generate or edit images through PackyCode using the separately configured image API key. Use only when the user requests image output. This is independent of the conversation model and Codex subscription. For a new image omit referenceImages; for edits include only the intended images from this session or read_image. Never replace an edit with text-only generation, silently change models or downgrade quality. Return the resulting image to the user. Rendering may take several minutes.",
5122
+ parameters: {
5123
+ prompt: {
5124
+ type: "string",
5125
+ required: true,
5126
+ description: "Complete image description or precise editing instructions."
5127
+ },
5128
+ operation: {
5129
+ type: "string",
5130
+ enum: ["generate", "edit"],
5131
+ description: "Use generate for a new image; edit requires 1-16 real referenceImages. If omitted, nonempty references select editing, while an omitted or empty array selects generation."
5132
+ },
5133
+ model: {
5134
+ type: "string",
5135
+ enum: ["settings", ...IMAGE_MODEL_IDS],
5136
+ description: "Use settings (or omit) to follow the PackyCode image setting. Choose an explicit engine only when the user requests one."
5137
+ },
5138
+ quality: {
5139
+ type: "string",
5140
+ enum: ["settings", ...IMAGE_QUALITIES],
5141
+ description: "Use settings (or omit) to follow the user image setting. auto is an explicit API quality, not a request to follow settings. xhigh and max require a 2.5 model; never downgrade automatically."
5142
+ },
5143
+ size: {
5144
+ type: "string",
5145
+ description: "auto or widthxheight, e.g. 1024x1024; both edges multiples of 16, each <=3840, ratio <=3:1, pixels 655360..8294400."
5146
+ },
5147
+ background: {
5148
+ type: "string",
5149
+ enum: [
5150
+ "auto",
5151
+ "opaque",
5152
+ "transparent"
5153
+ ]
5154
+ },
5155
+ referenceImages: {
5156
+ type: "array",
5157
+ items: referenceSchema,
5158
+ description: "1-16 complete attachment references for editing. Omit or use [] for a new image. Paths and unrelated images are not accepted; never drop real references after an edit fails."
5159
+ },
5160
+ maskImage: {
5161
+ ...referenceSchema,
5162
+ description: "Omit for new images and edits without a mask. Never invent a reference. Optional PNG mask from this session, matching the first reference and under 4 MB; alpha=0 marks the area to edit."
5163
+ }
5164
+ },
5165
+ output: {
5166
+ schema: {
5167
+ type: "object",
5168
+ additionalProperties: false,
5169
+ properties: {
5170
+ image: {
5171
+ ...referenceSchema,
5172
+ required: true
5173
+ },
5174
+ localPath: {
5175
+ type: "string",
5176
+ required: true
5177
+ },
5178
+ width: {
5179
+ type: "integer",
5180
+ required: true
5181
+ },
5182
+ height: {
5183
+ type: "integer",
5184
+ required: true
5185
+ },
5186
+ requestedModel: {
5187
+ type: "string",
5188
+ required: true
5189
+ },
5190
+ requestedQuality: {
5191
+ type: "string",
5192
+ required: true
5193
+ },
5194
+ requestedSize: {
5195
+ type: "string",
5196
+ required: true
5197
+ },
5198
+ reportedModel: { type: "string" },
5199
+ quality: { type: "string" }
5200
+ }
5201
+ },
5202
+ render: (_args, value) => renderImage(value),
5203
+ presentationMeta: (_args, value) => ({
5204
+ kind: "packycode-image",
5205
+ requestedModel: value.requestedModel,
5206
+ requestedQuality: value.requestedQuality,
5207
+ width: value.width,
5208
+ height: value.height
5209
+ })
5210
+ },
5211
+ timeoutMs: IMAGE_TIMEOUT_MS,
5212
+ isConcurrencySafe: () => false,
5213
+ async execute(args, exec) {
5214
+ const preference = getPreference();
5215
+ if (!preference.imageEnabled) throw new ImageRequestError("PackyCode images are disabled in settings");
5216
+ if (exec?.agent?.id === void 0) throw new ImageRequestError("PackyCode images require a session-owned tool call");
5217
+ const signal = exec.signal;
5218
+ const hasReferences = Array.isArray(args.referenceImages) && args.referenceImages.length > 0;
5219
+ if (args.operation === "generate" && hasReferences) throw new ImageRequestError("PackyCode image generation cannot include reference images; use edit");
5220
+ const editing = args.operation === "edit" || hasReferences;
5221
+ const maskImage = emptyMaskReference(args.maskImage) ? void 0 : args.maskImage;
5222
+ if (maskImage !== void 0 && !editing) throw new ImageRequestError("PackyCode image mask requires referenceImages");
5223
+ let messages;
5224
+ try {
5225
+ messages = editing ? await getSessionMessages(exec.agent.id) : void 0;
5226
+ } catch {
5227
+ throw new ImageRequestError("PackyCode could not read the current session image references");
5228
+ }
5229
+ const images = editing ? await readReferences(args.referenceImages, attachments, messages, signal) : void 0;
5230
+ const masks = maskImage === void 0 ? void 0 : await readReferences([maskImage], attachments, messages, signal);
5231
+ if (!getPreference().imageEnabled) throw new ImageRequestError("PackyCode images are disabled in settings");
5232
+ const result = await client.request({
5233
+ ...args,
5234
+ model: args.model === "settings" ? preference.imageModel : args.model ?? preference.imageModel,
5235
+ quality: args.quality === "settings" ? preference.imageQuality : args.quality ?? preference.imageQuality
5236
+ }, {
5237
+ signal,
5238
+ images,
5239
+ mask: masks?.[0]
5240
+ });
5241
+ onGenerated?.();
5242
+ signal?.throwIfAborted();
5243
+ await mkdir(imageDirectory, {
5244
+ recursive: true,
5245
+ mode: 448
5246
+ });
5247
+ const localPath = join(imageDirectory, `${randomUUID()}.png`);
5248
+ let saved = false;
5249
+ try {
5250
+ const file = await open(localPath, "wx", 384);
5251
+ saved = true;
5252
+ try {
5253
+ await file.writeFile(result.data);
5254
+ } finally {
5255
+ await file.close();
5256
+ }
5257
+ const ref = await attachments.saveImage({
5258
+ data: result.data,
5259
+ mediaType: "image/png",
5260
+ name: "packycode-generated.png"
5261
+ });
5262
+ const { data: _data, ...metadata } = result;
5263
+ const value = {
5264
+ ...metadata,
5265
+ image: attachmentProjection(ref),
5266
+ localPath
5267
+ };
5268
+ if (exec.parent !== void 0) exec.deferContext(createUserMessage({
5269
+ content: renderImage(value),
5270
+ source: {
5271
+ kind: "plugin",
5272
+ plugin: "packycode-api"
5273
+ }
5274
+ }));
5275
+ return value;
5276
+ } catch {
5277
+ if (saved) await rm(localPath, { force: true }).catch(() => void 0);
5278
+ throw new ImageRequestError("PackyCode could not save the generated image");
5279
+ }
5280
+ }
5281
+ });
5282
+ }
5283
+ //#endregion
5284
+ //#region src/key-chooser.js
5285
+ /**
5286
+ * Which stored key a request runs with, and what happens when the gateway
5287
+ * rejects one.
5288
+ *
5289
+ * The ring's active key serves every request it can. A request for a model
5290
+ * the active key's groups cannot reach — as the last group reading shows —
5291
+ * runs on the next stored key that can; the active pointer itself does not
5292
+ * move. A key the gateway reports as exhausted or invalid is marked unusable
5293
+ * and the ring moves on, so the retry of the failed step resolves the next
5294
+ * key. Only keys stored in this plugin's ring are ever chosen.
5295
+ *
5296
+ * @module dsh-packycode-api/key-chooser
5297
+ */
5298
+ /** How long a request with a choice of keys waits for the first group reading. */
5299
+ const DEFAULT_GROUP_WAIT_MS = 6e3;
5300
+ /**
5301
+ * @param options - the key ring, the group reader, what to do after a key
5302
+ * moved aside, a logger, and how long to wait for the first group reading.
5303
+ * @returns the chooser.
5304
+ */
5305
+ function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GROUP_WAIT_MS }) {
5306
+ /**
5307
+ * Key material to ring id. A failure is reported from inside a stream, which
5308
+ * carries the request's key but not its id; this index is refreshed on every
5309
+ * resolution so the right key is the one that moves aside.
5310
+ */
5311
+ const idByKey = /* @__PURE__ */ new Map();
5312
+ /** The ring as of the last resolution, for the synchronous per-request choice. */
5313
+ let snapshot = [];
5314
+ const remember = async () => {
5315
+ const entries = await ring.secrets();
5316
+ snapshot = entries;
5317
+ for (const entry of entries) idByKey.set(entry.key, entry.id);
5318
+ };
5319
+ const usable = () => snapshot.filter((entry) => entry.disabled !== true);
5320
+ const covers = (keyId, modelId) => groups.covers(keyId, modelId);
5321
+ /**
5322
+ * Keep the group reading fresh for the choice. It only matters with two
5323
+ * usable keys or more; the first reading is awaited for at most `waitMs`,
5324
+ * later ones refresh in the background.
5325
+ */
5326
+ const warm = async () => {
5327
+ if (usable().length < 2) return;
5328
+ const pending = groups.read().catch(() => void 0);
5329
+ if (groups.hasReading()) return;
5330
+ let timer;
5331
+ await Promise.race([pending, new Promise((resolve) => {
5332
+ timer = setTimeout(resolve, waitMs);
5333
+ })]);
5334
+ clearTimeout(timer);
5335
+ };
5336
+ /** The active key, after refreshing the ring and, when it matters, the groups. */
5337
+ const active = async () => {
5338
+ await remember();
5339
+ const key = await ring.activeKey();
5340
+ if (key !== void 0) await warm();
5341
+ return key;
5342
+ };
5343
+ return Object.freeze({
5344
+ remember,
5345
+ covers,
4607
5346
  active,
4608
5347
  /**
4609
5348
  * The key one HTTP request for `modelId` runs with, given the active key
@@ -4693,7 +5432,6 @@ function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GR
4693
5432
  const LEGAL_KEY = /^[\x21-\x7E]+$/u;
4694
5433
  const RECORD_VERSION = 1;
4695
5434
  const MAX_KEYS = 32;
4696
- const RECORD_KEY = typeof credentialKey === "function" ? credentialKey(PLUGIN_ID, KEY_RECORD_ID) : void 0;
4697
5435
  const text$1 = (value) => typeof value === "string" ? value : "";
4698
5436
  /** Validate a pasted key before it is stored; the gateway never sees a bad one from us. */
4699
5437
  function assertApiKey(value) {
@@ -4757,16 +5495,18 @@ function projection(state) {
4757
5495
  }
4758
5496
  var PackyCodeKeyRing = class {
4759
5497
  #credentials;
4760
- constructor(credentials) {
4761
- if (RECORD_KEY === void 0) throw new Error("dsh-packycode-api requires @deepseek-ai/dsh-credentials with credentialKey()");
5498
+ #recordKey;
5499
+ constructor(credentials, { recordId = KEY_RECORD_ID } = {}) {
5500
+ if (typeof credentialKey !== "function") throw new Error("dsh-packycode-api requires @deepseek-ai/dsh-credentials with credentialKey()");
4762
5501
  if (credentials === null || typeof credentials !== "object" || typeof credentials.readRecord !== "function" || typeof credentials.modifyRecord !== "function") throw new Error("dsh-packycode-api requires the DSH credential record API");
4763
5502
  this.#credentials = credentials;
5503
+ this.#recordKey = credentialKey(PLUGIN_ID, recordId);
4764
5504
  }
4765
5505
  async #read() {
4766
- return normalizeState(await this.#credentials.readRecord(RECORD_KEY));
5506
+ return normalizeState(await this.#credentials.readRecord(this.#recordKey));
4767
5507
  }
4768
5508
  async #write(mutate) {
4769
- return normalizeState(await this.#credentials.modifyRecord(RECORD_KEY, async (current) => {
5509
+ return normalizeState(await this.#credentials.modifyRecord(this.#recordKey, async (current) => {
4770
5510
  const updated = await mutate(normalizeState(current));
4771
5511
  if (updated === void 0) return current;
4772
5512
  return {
@@ -4916,95 +5656,6 @@ var PackyCodeKeyRing = class {
4916
5656
  }
4917
5657
  };
4918
5658
  //#endregion
4919
- //#region src/preferences.js
4920
- /**
4921
- * Where the plugin's preferences are read from and written to, on both
4922
- * settings generations DSH has shipped.
4923
- *
4924
- * - DSH 0.1.5 installs a settings *section*: the section hands back a live
4925
- * source and writes go through `settings.update(namespace, patch)`.
4926
- * - DSH 0.2 has no sections: a preference is a *volatile* Config field of the
4927
- * plugin's own row, read through a live reference, written through
4928
- * `settings.update(rowId, patch)`, and announced by `loader/volatile-update`.
4929
- * Only volatile fields are writable there, so the host overrides stay
4930
- * read-only while web search and model visibility are editable.
4931
- *
4932
- * @module dsh-packycode-api/preferences
4933
- */
4934
- /**
4935
- * Mark a preference editable without restarting the plugin. DSH 0.2 stores
4936
- * plugin preferences as volatile Config fields and writes only those, so the
4937
- * schema version that implements `volatile()` is a peer requirement.
4938
- *
4939
- * Name the cause when it is missing instead of letting a bare
4940
- * `schema.volatile is not a function` reach the host log: the Loader creates
4941
- * no fiber for a plugin whose module throws while it is imported, so DSH shows
4942
- * nothing at all — no settings section, no model route, no RPC channel — and
4943
- * the only explanation is that one log line.
4944
- */
4945
- const live = (schema) => {
4946
- if (typeof schema?.volatile !== "function") throw new Error("PackyCode needs @deepseek-ai/schemastery ^3.18.4, which implements Schema.volatile(); the copy resolved for this plugin is older. A plugin installed from a registry has no node_modules of its own, so this usually means a stale dependency tree inside the installed package directory (for example <profile>/node_modules/dsh-packycode-api/node_modules, left by an earlier local-path install) is shadowing the DSH installation. Remove that directory, or uninstall the plugin and install it again, then restart DSH.");
4947
- return schema.volatile();
4948
- };
4949
- /** Read a Config field that may arrive as a volatile reference. */
4950
- const fieldValue = (value) => value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
4951
- /** A preference target nothing can write to yet. */
4952
- function unwritable(message) {
4953
- return {
4954
- writable: false,
4955
- set: async () => {
4956
- throw new Error(message);
4957
- }
4958
- };
4959
- }
4960
- /**
4961
- * Connect the preferences to the settings service the host provides.
4962
- * @param settingsCtx - the scope the `settings` inject handed over.
4963
- * @param options - the plugin context and Config, the section namespace, the
4964
- * current source and how to replace it, what to do when the section changes,
4965
- * and the preference targets this fills in (host overrides, search, models).
4966
- */
4967
- function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference, modelPreference }) {
4968
- const settings = settingsCtx.settings;
4969
- if (typeof settings?.installSection === "function") {
4970
- try {
4971
- settings.installSection(ctx, namespace, Config, source(), {
4972
- setSource: (current) => setSource(typeof current === "function" ? () => current() ?? {} : () => current ?? {}),
4973
- onChange
4974
- });
4975
- if (typeof settings.update === "function") {
4976
- const write = (patch) => settings.update(namespace, patch);
4977
- const target = {
4978
- writable: settings.writable !== false,
4979
- set: write
4980
- };
4981
- Object.assign(hostPreference, target);
4982
- Object.assign(searchPreference, target);
4983
- Object.assign(modelPreference, target);
4984
- }
4985
- } catch (error) {
4986
- ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
4987
- }
4988
- return;
4989
- }
4990
- if (typeof settings?.update !== "function") return;
4991
- const rowId = ctx.fiber?.entry?.options?.id ?? namespace;
4992
- const target = {
4993
- writable: settings.writable !== false,
4994
- set: (patch) => settings.update(rowId, patch)
4995
- };
4996
- Object.assign(searchPreference, target);
4997
- Object.assign(modelPreference, target);
4998
- if (typeof settings.configure === "function") {
4999
- const scope = typeof settingsCtx.effect === "function" ? settingsCtx : ctx;
5000
- try {
5001
- scope.effect(() => settings.configure({ auto: false }, ctx.fiber), "packycode-api: settings page policy");
5002
- } catch (error) {
5003
- ctx.logger?.warn?.("packycode-api: could not hide the generated settings page: %s", error.message);
5004
- }
5005
- }
5006
- }
5007
- //#endregion
5008
5659
  //#region src/model-selection.js
5009
5660
  /** Profile preferences for the models advertised in PackyCode's picker. */
5010
5661
  /** Store exclusions so new catalog models are selected by default as well. */
@@ -5375,13 +6026,119 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
5375
6026
  });
5376
6027
  }
5377
6028
  //#endregion
6029
+ //#region src/gemini-replay.js
6030
+ /** A bounded resend for the gateway's intermittent Flash signature refusal. */
6031
+ const waitForRetry = (ms, signal) => setTimeout$1(ms, void 0, { signal });
6032
+ /** Only a current-turn result for a signed same-model native call qualifies. */
6033
+ function hasSignedResult(model, context) {
6034
+ if (model?.id !== "gemini-3.8-flash" || model.api !== "google-generative-ai") return false;
6035
+ const messages = context?.messages ?? [];
6036
+ const calls = /* @__PURE__ */ new Set();
6037
+ for (const message of messages.slice(messages.findLastIndex((item) => item.role === "user") + 1)) {
6038
+ if (message.role === "assistant" && message.provider === model.provider && message.model === model.id && message.api === model.api) for (const block of Array.isArray(message.content) ? message.content : []) {
6039
+ const signature = block.thoughtSignature;
6040
+ if (block.type === "toolCall" && typeof block.id === "string" && typeof signature === "string" && signature.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/u.test(signature)) calls.add(block.id);
6041
+ }
6042
+ if (message.role === "toolResult" && calls.has(message.toolCallId)) return true;
6043
+ }
6044
+ return false;
6045
+ }
6046
+ function isSignatureRefusal(event) {
6047
+ if (event.type !== "error" || event.reason !== "error" || event.error.content?.length) return false;
6048
+ const text = event.error.errorMessage ?? "";
6049
+ return /\b400\b/u.test(text) && /\b(?:Corrupted|Invalid) thought signature\b/iu.test(text) && !/\b(?:401|403|408|409|429|5\d\d)\b/u.test(text) && classifyGatewayFailure(text) === void 0;
6050
+ }
6051
+ /**
6052
+ * The caller closes over one resolved model/context/key/session. Reopening it
6053
+ * resends that request once; it never chooses a different key or changes the
6054
+ * signature. SDK retries remain disabled by the host. Its ordinary retry
6055
+ * policy still receives any terminal failure after this one compatibility
6056
+ * resend. Never replay after even a start event has reached the consumer.
6057
+ */
6058
+ function guardGeminiReplay(open, { model, context, signal, wait = waitForRetry } = {}) {
6059
+ const initial = open();
6060
+ if (!hasSignedResult(model, context) || signal?.aborted) return initial;
6061
+ let finalMessage;
6062
+ return lazyStream(model, async () => ({
6063
+ async *[Symbol.asyncIterator]() {
6064
+ for (let attempt = 0; attempt < 2; attempt += 1) {
6065
+ const source = attempt === 0 ? initial : open();
6066
+ let observed = false;
6067
+ let refused;
6068
+ for await (const event of source) if (attempt === 0 && !observed && isSignatureRefusal(event)) refused = event;
6069
+ else {
6070
+ observed = true;
6071
+ yield event;
6072
+ }
6073
+ finalMessage = await source.result();
6074
+ if (refused === void 0) return;
6075
+ try {
6076
+ await wait(1e3, signal);
6077
+ signal?.throwIfAborted();
6078
+ } catch (error) {
6079
+ if (!signal?.aborted) throw error;
6080
+ finalMessage = {
6081
+ ...refused.error,
6082
+ stopReason: "aborted",
6083
+ errorMessage: "Request aborted"
6084
+ };
6085
+ yield {
6086
+ type: "error",
6087
+ reason: "aborted",
6088
+ error: finalMessage
6089
+ };
6090
+ return;
6091
+ }
6092
+ }
6093
+ },
6094
+ result: async () => finalMessage
6095
+ }));
6096
+ }
6097
+ //#endregion
6098
+ //#region src/gemini-api.js
6099
+ /** Gemini's native API, including level-based thinking on older pi-ai hosts. */
6100
+ /**
6101
+ * pi-ai 0.85 treats Argon as a budget-based model, and defaults Flash to
6102
+ * MINIMAL when reasoning is omitted. Both models support LOW/MEDIUM/HIGH,
6103
+ * default to MEDIUM and cannot disable thinking. Keep the native SDK stream,
6104
+ * credentials, tools and replay signatures; correct only that config.
6105
+ */
6106
+ function createGeminiApi() {
6107
+ const api = googleGenerativeAIApi();
6108
+ return {
6109
+ ...api,
6110
+ streamSimple(model, context, options) {
6111
+ if (!["gemini-4-argon", "gemini-3.8-flash"].includes(model.id)) return api.streamSimple(model, context, options);
6112
+ const level = options?.reasoning === void 0 ? "medium" : clampThinkingLevel(model, options.reasoning);
6113
+ const wireLevel = (model.thinkingLevelMap?.[level] ?? level).toUpperCase();
6114
+ return api.streamSimple(model, context, {
6115
+ ...options,
6116
+ onPayload: async (payload, descriptor) => {
6117
+ const { thinkingBudget: _budget, ...thinking } = payload.config?.thinkingConfig ?? {};
6118
+ const normalized = {
6119
+ ...payload,
6120
+ config: {
6121
+ ...payload.config,
6122
+ thinkingConfig: {
6123
+ ...thinking,
6124
+ thinkingLevel: wireLevel
6125
+ }
6126
+ }
6127
+ };
6128
+ return await options?.onPayload?.(normalized, descriptor) ?? normalized;
6129
+ }
6130
+ });
6131
+ }
6132
+ };
6133
+ }
6134
+ //#endregion
5378
6135
  //#region src/provider.js
5379
6136
  /**
5380
6137
  * The PackyCode pi-ai provider, built in code so the user configures no route.
5381
6138
  *
5382
6139
  * `createProvider` dispatches on `model.api`, which is what lets one route
5383
- * carry a chat/completions model, a Responses model, and an Anthropic Messages
5384
- * model — the gateway refuses each of them over the other protocols.
6140
+ * carry Chat Completions, Responses, Anthropic Messages and native Gemini
6141
+ * models — the gateway refuses some of them over other protocols.
5385
6142
  *
5386
6143
  * @module dsh-packycode-api/provider
5387
6144
  */
@@ -5413,7 +6170,8 @@ function createPackyCodeProvider() {
5413
6170
  api: {
5414
6171
  "openai-completions": openAICompletionsApi(),
5415
6172
  "openai-responses": openAIResponsesApi(),
5416
- "anthropic-messages": anthropicMessagesApi()
6173
+ "anthropic-messages": anthropicMessagesApi(),
6174
+ "google-generative-ai": createGeminiApi()
5417
6175
  }
5418
6176
  });
5419
6177
  const requestOptions = (model, options) => model.api === "openai-responses" && !model.id.startsWith("gpt-") ? {
@@ -5505,7 +6263,7 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
5505
6263
  //#region src/route.js
5506
6264
  /**
5507
6265
  * The HTTP model route: the pi-ai provider profile and the adapter that
5508
- * serves the catalog's HTTP models (DeepSeek, GPT and Grok) with the ring's keys.
6266
+ * serves the catalog's HTTP models (DeepSeek, GPT, Grok and Gemini) with the ring's keys.
5509
6267
  *
5510
6268
  * @module dsh-packycode-api/route
5511
6269
  */
@@ -5617,7 +6375,11 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
5617
6375
  };
5618
6376
  const guarded = (method) => (model, context, options) => {
5619
6377
  const routed = requestOptions(model, options);
5620
- return guardKeyRotation(raw[method](model, context, routed), {
6378
+ return guardKeyRotation(guardGeminiReplay(() => raw[method](model, context, routed), {
6379
+ model,
6380
+ context,
6381
+ signal: routed?.signal
6382
+ }), {
5621
6383
  key: routed?.apiKey,
5622
6384
  onKeyFailure: keys.onKeyFailure
5623
6385
  });
@@ -5683,7 +6445,7 @@ const keyId = (payload) => String(payload?.id ?? "");
5683
6445
  * web search controller (without one, its endpoints refuse).
5684
6446
  * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
5685
6447
  */
5686
- function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search, modelSelection }) {
6448
+ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search, modelSelection, images }) {
5687
6449
  const need = (service, message) => {
5688
6450
  if (service === void 0) throw new Error(message);
5689
6451
  return service;
@@ -5693,6 +6455,7 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCl
5693
6455
  const webSearch = () => need(search, "PackyCode web search is unavailable in this runtime");
5694
6456
  const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
5695
6457
  const models = () => need(modelSelection, "PackyCode model selection is unavailable in this runtime");
6458
+ const imageSupport = () => need(images, "PackyCode images are unavailable in this runtime");
5696
6459
  const status = async () => ({
5697
6460
  ...await ring.list(),
5698
6461
  hosts: hosts(),
@@ -5741,6 +6504,7 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCl
5741
6504
  });
5742
6505
  balance.invalidate();
5743
6506
  groups?.invalidate();
6507
+ images?.invalidateBalance?.();
5744
6508
  return status();
5745
6509
  },
5746
6510
  "claude-cli/status": (payload) => cli().status({ force: payload?.force === true }),
@@ -5759,7 +6523,20 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCl
5759
6523
  searchBackend: payload?.searchBackend
5760
6524
  }),
5761
6525
  "models/status": () => models().status(),
5762
- "models/update": (payload) => models().update({ selectedIds: payload?.selectedIds })
6526
+ "models/update": (payload) => models().update({ selectedIds: payload?.selectedIds }),
6527
+ "images/status": () => imageSupport().status(),
6528
+ "images/balance": (payload, signal) => imageSupport().readBalance({
6529
+ force: payload?.force === true,
6530
+ signal
6531
+ }),
6532
+ "images/update": (payload) => imageSupport().update(payload),
6533
+ "images/keys/add": (payload) => imageSupport().mutate("add", {
6534
+ key: payload?.key,
6535
+ label: payload?.label
6536
+ }),
6537
+ "images/keys/remove": (payload) => imageSupport().mutate("remove", { id: payload?.id }),
6538
+ "images/keys/select": (payload) => imageSupport().mutate("select", { id: payload?.id }),
6539
+ "images/keys/enable": (payload) => imageSupport().mutate("enable", { id: payload?.id })
5763
6540
  };
5764
6541
  return async (endpoint, payload, signal) => {
5765
6542
  try {
@@ -6694,16 +7471,23 @@ const Config = z.object({
6694
7471
  /** Which PackyCode model runs a search: `deepseek` or `gpt`. */
6695
7472
  searchBackend: live(z.union([...SEARCH_BACKENDS]).default(DEFAULT_SEARCH_BACKEND)),
6696
7473
  /** Only the picker is filtered; an existing session can keep its model. */
6697
- hiddenModels: live(z.array(z.string()).default([]))
7474
+ hiddenModels: live(z.array(z.string()).default([])),
7475
+ imageEnabled: live(z.boolean().default(true)),
7476
+ imageModel: live(z.union([...IMAGE_MODEL_IDS]).default(DEFAULT_IMAGE_MODEL)),
7477
+ imageQuality: live(z.union([...IMAGE_QUALITIES]).default("auto"))
6698
7478
  });
6699
7479
  function apply(ctx, config) {
6700
7480
  const ring = new PackyCodeKeyRing(ctx.credentials);
7481
+ const imageRing = new PackyCodeKeyRing(ctx.credentials, { recordId: IMAGE_KEY_RECORD_ID });
6701
7482
  let source = () => ({
6702
7483
  apiHost: normalizeHost(config?.apiHost, DEFAULT_API_HOST),
6703
7484
  statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST),
6704
7485
  searchProvider: fieldValue(config?.searchProvider),
6705
7486
  searchBackend: fieldValue(config?.searchBackend),
6706
- hiddenModels: fieldValue(config?.hiddenModels)
7487
+ hiddenModels: fieldValue(config?.hiddenModels),
7488
+ imageEnabled: fieldValue(config?.imageEnabled),
7489
+ imageModel: fieldValue(config?.imageModel),
7490
+ imageQuality: fieldValue(config?.imageQuality)
6707
7491
  });
6708
7492
  const hosts = () => {
6709
7493
  const current = source() ?? {};
@@ -6716,6 +7500,11 @@ function apply(ctx, config) {
6716
7500
  listKeys: () => ring.secrets(),
6717
7501
  getHosts: hosts
6718
7502
  });
7503
+ const imageBalance = createBalanceReader({
7504
+ listKeys: () => imageRing.secrets(),
7505
+ getHosts: hosts,
7506
+ includeAccount: false
7507
+ });
6719
7508
  /**
6720
7509
  * Which token groups each key is in, and so which models it can reach.
6721
7510
  * Nothing reads it at boot: the settings panel asks for it, and a request
@@ -6850,7 +7639,22 @@ function apply(ctx, config) {
6850
7639
  writable: false,
6851
7640
  set: void 0
6852
7641
  };
7642
+ const imagePreference = {
7643
+ writable: false,
7644
+ set: void 0
7645
+ };
6853
7646
  const dshHome = typeof process.env.DSH_HOME === "string" && process.env.DSH_HOME !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
7647
+ const images = createImageController({
7648
+ ring: imageRing,
7649
+ balance: imageBalance,
7650
+ readPreference: () => source() ?? {},
7651
+ writePreference: (patch) => imagePreference.set(patch),
7652
+ isWritable: () => imagePreference.writable === true && typeof imagePreference.set === "function"
7653
+ });
7654
+ const imageClient = createPackyCodeImageClient({
7655
+ resolveKey: () => imageRing.activeKey(),
7656
+ getHost: () => hosts().apiHost
7657
+ });
6854
7658
  const search = createSearchController({
6855
7659
  ring,
6856
7660
  hosts,
@@ -6889,16 +7693,20 @@ function apply(ctx, config) {
6889
7693
  },
6890
7694
  onChange: () => {
6891
7695
  balance.invalidate();
7696
+ imageBalance.invalidate();
6892
7697
  groups.invalidate();
6893
7698
  modelSelection.reconcile();
7699
+ images.reconcile().catch(() => void 0);
6894
7700
  search.reconcile();
6895
7701
  },
6896
7702
  hostPreference,
6897
7703
  searchPreference,
6898
- modelPreference
7704
+ modelPreference,
7705
+ imagePreference
6899
7706
  }));
6900
7707
  ctx.on?.("loader/volatile-update", () => {
6901
7708
  modelSelection.reconcile();
7709
+ images.reconcile().catch(() => void 0);
6902
7710
  search.reconcile();
6903
7711
  });
6904
7712
  /**
@@ -6921,7 +7729,8 @@ function apply(ctx, config) {
6921
7729
  claudeCli,
6922
7730
  pluginManager: createPackyCodePluginManager(),
6923
7731
  search,
6924
- modelSelection
7732
+ modelSelection,
7733
+ images
6925
7734
  });
6926
7735
  /**
6927
7736
  * The shipped Connection getter retains its own service context, whose row
@@ -6942,6 +7751,18 @@ function apply(ctx, config) {
6942
7751
  planActive: (session, agent) => planModeForToolExecution(ctx, session, agent)
6943
7752
  })), "packycode-api: claude_code tool");
6944
7753
  });
7754
+ ctx.inject(["tools", "attachments"], (imageCtx) => {
7755
+ const store = imageCtx.attachments;
7756
+ if (typeof store?.saveImage !== "function" || typeof store?.readImage !== "function") return;
7757
+ (typeof imageCtx.effect === "function" ? imageCtx : ctx).effect(() => images.mount(() => imageCtx.tools.register(createPackyCodeImageTool({
7758
+ client: imageClient,
7759
+ attachments: store,
7760
+ getPreference: images.preference,
7761
+ onGenerated: () => imageBalance.invalidate(),
7762
+ getSessionMessages: (id) => ctx.get?.("sessions")?.get?.(id)?.deriveMessages?.() ?? [],
7763
+ imageDirectory: join(dshHome, "packycode-api", "images")
7764
+ }))), "packycode-api: image tool availability");
7765
+ });
6945
7766
  }
6946
7767
  //#endregion
6947
- export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, cliSearchInstructions, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createClaudeSearchBridge, createGroupReader, createKeyChooser, createModelSelectionController, createPackyCodeAutoSearchProvider, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, planModeForSession, planModeForToolExecution, readClaudeStreamEvents, readHiddenModels, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
7768
+ export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_IMAGE_MODEL, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, IMAGE_MODELS, IMAGE_MODEL_IDS, IMAGE_QUALITIES, IMAGE_TIMEOUT_MS, IMAGE_TOOL_NAME, LEGACY_STATUS_HOSTS, MAX_IMAGE_BYTES, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, cliSearchInstructions, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createClaudeSearchBridge, createGroupReader, createImageController, createKeyChooser, createModelSelectionController, createPackyCodeAutoSearchProvider, createPackyCodeImageClient, createPackyCodeImageTool, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, decodeImagePng, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, planModeForSession, planModeForToolExecution, pngDimensions, readClaudeStreamEvents, readHiddenModels, readImagePreference, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse, validateImageOptions };