dsh-qoder-connect 0.2.0 → 0.2.2

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.d.ts CHANGED
@@ -2,10 +2,10 @@ import z from "@deepseek-ai/schemastery";
2
2
  import "@earendil-works/pi-ai";
3
3
  import { GenerateOptions, StreamChunk } from "@deepseek-ai/dsh-llm";
4
4
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
5
+ import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
5
6
  import { IncomingMessage, ServerResponse } from "node:http";
6
7
  import { Context } from "@deepseek-ai/cordis";
7
8
  import { SettingsNamespace } from "@deepseek-ai/dsh-settings";
8
- import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
9
9
  //#region src/paths.d.ts
10
10
  /**
11
11
  * The plugin's data directory — the ONE place every file the plugin owns
@@ -1448,16 +1448,14 @@ declare const QUOTA_POLL_DEFAULT_MS = 300000;
1448
1448
  declare const QUOTA_POLL_MIN_MS = 60000;
1449
1449
  declare const Config: z<Config>;
1450
1450
  /**
1451
- * Every field each settings section owns, and therefore every field the live
1452
- * configuration has to carry through.
1451
+ * The field groups the configuration is merged from, one list per card.
1453
1452
  *
1454
- * One list per section, shared by the merge and by the test that pins it to
1455
- * the schema. Writing the merge out by hand is what broke auto check-in: the
1456
- * `qoder-quota` section grew four fields (`autoCheckInCN`, `autoCheckInGlobal`,
1457
- * `checkInMinuteCN`, `checkInMinuteGlobal`) while the merge kept copying only
1458
- * the three that predated them, so `current().autoCheckInCN` read `undefined`
1459
- * forever and the scheduler saw the toggle as permanently off. The card saved
1460
- * it, the file held it, and nothing ever acted on it.
1453
+ * Writing the merge out by hand is what broke auto check-in: the quota group
1454
+ * grew four fields (`autoCheckInCN`, `autoCheckInGlobal`, `checkInMinuteCN`,
1455
+ * `checkInMinuteGlobal`) while the merge kept copying only the three that
1456
+ * predated them, so `current().autoCheckInCN` read `undefined` forever and the
1457
+ * scheduler saw the toggle as permanently off. The card saved it, the file
1458
+ * held it, and nothing ever acted on it.
1461
1459
  */
1462
1460
  declare const CN_SECTION_KEYS: readonly ["probeConsent", "useMaximumContextWindowCN", "modelContextWindowsCN", "disabledModelsCN"];
1463
1461
  declare const GLOBAL_SECTION_KEYS: readonly ["useMaximumContextWindow", "modelContextWindows", "disabledModels"];
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-DuEgaUad.js";
1
+ import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, q as qoderStateDir, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as QoderCredentialStore } from "./variants-CFI6-cGn.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -7,10 +7,8 @@ import { createProvider } from "@earendil-works/pi-ai";
7
7
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
8
  import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
9
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
- import { homedir } from "node:os";
11
10
  import { createServer } from "node:http";
12
11
  import { Readable } from "node:stream";
13
- import yamlModule from "js-yaml";
14
12
  //#region src/loopback.ts
15
13
  /**
16
14
  * Shared loopback gates for the plugin's local HTTP surfaces: the loopback
@@ -1660,8 +1658,6 @@ function appendHintRow(agent, kind, text) {
1660
1658
  *
1661
1659
  * @module dsh-qoder-connect/settings-store
1662
1660
  */
1663
- const yaml = yamlModule;
1664
- const loadYaml = typeof yaml.load === "function" ? yaml.load : yaml.default?.load;
1665
1661
  /** Settings file name inside the plugin data directory. */
1666
1662
  const SETTINGS_FILE_NAME = "settings.json";
1667
1663
  /** This plugin's data directory (shared with the credential and caches). */
@@ -1691,61 +1687,11 @@ function writeSettings(values) {
1691
1687
  writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, "utf8");
1692
1688
  renameSync(tmp, path);
1693
1689
  }
1694
- /** Resolve `$DSH_HOME` the way the Host does, without importing a Host package. */
1695
- function dshHome() {
1696
- const fromEnv = process.env.DSH_HOME?.trim() || void 0;
1697
- if (fromEnv !== void 0) return fromEnv;
1698
- return join(homedir(), ".dsh");
1699
- }
1700
- /**
1701
- * Read this plugin's sections out of the legacy settings document.
1702
- *
1703
- * The 0.1.5 host kept every plugin's settings in `$DSH_HOME/settings.yaml`;
1704
- * 0.1.7 renames it to `settings.yaml.imported` once, on the first boot, and
1705
- * imports only the sections whose names match a profile entry id. This
1706
- * plugin's 0.1.5 sections were keyed by its SERVED NAMESPACES (`qoder`,
1707
- * `qoder-global`, `qoder-quota`), which name no entry in the profile — the row
1708
- * is `llm-qoder` — so the platform refuses them and their data survives only
1709
- * in the renamed document. Reading it here recovers that data independently of
1710
- * the platform's import, its section-name mapping, and its timing.
1711
- *
1712
- * @param namespaces - the section names the 0.1.5 host keyed this plugin's
1713
- * settings under (one per served section).
1714
- * @returns the union of those sections' fields, or undefined when the document
1715
- * is absent, unreadable, or holds none of them.
1716
- */
1717
- function readLegacySections(namespaces) {
1718
- let path;
1719
- for (const name of ["settings.yaml", "settings.yaml.imported"]) {
1720
- const candidate = join(dshHome(), name);
1721
- if (existsSync(candidate)) {
1722
- path = candidate;
1723
- break;
1724
- }
1725
- }
1726
- if (path === void 0 || loadYaml === void 0) return void 0;
1727
- let document;
1728
- try {
1729
- document = loadYaml(readFileSync(path, "utf8"));
1730
- } catch {
1731
- return;
1732
- }
1733
- if (document === null || typeof document !== "object") return void 0;
1734
- const merged = {};
1735
- let found = false;
1736
- for (const ns of namespaces) {
1737
- const section = document[ns];
1738
- if (section === null || typeof section !== "object" || Array.isArray(section)) continue;
1739
- Object.assign(merged, section);
1740
- found = true;
1741
- }
1742
- return found ? merged : void 0;
1743
- }
1744
1690
  /**
1745
1691
  * Reserved bookkeeping key: how many writes the settings card has made through
1746
- * this store. Its presence separates "the file holds migration seed" from "the
1747
- * file holds the user's live edits" — see the seed rule in ./index.ts. Field
1748
- * readers address their fields by name and never collide with it.
1692
+ * this store. Its presence separates "the file holds the startup seed" from
1693
+ * "the file holds the user's live edits" — see the seed rule in ./index.ts.
1694
+ * Field readers address their fields by name and never collide with it.
1749
1695
  */
1750
1696
  const WRITE_MARK = "__writes";
1751
1697
  /**
@@ -1765,12 +1711,10 @@ var SettingsStore = class {
1765
1711
  /**
1766
1712
  * Whether the settings card has ever written through this store.
1767
1713
  *
1768
- * False during the migration window — the window where a 0.1.5 settings.yaml
1769
- * import (which lands only after the Loader settles, i.e. AFTER this plugin's
1770
- * first apply) can still deliver the user's real values, and where a first
1771
- * seed may have been taken before that import. While false the entry config
1772
- * stays authoritative; once the card writes, the file is — a runtime edit
1773
- * must never be regressed by a stale profile row.
1714
+ * Until it has, the entry config stays the fallback layer (a hand-edited
1715
+ * profile row can still seed a fresh install); once the card writes, the
1716
+ * file is authoritative — a runtime edit must never be regressed by a stale
1717
+ * profile row.
1774
1718
  */
1775
1719
  get edited() {
1776
1720
  return typeof this.user[WRITE_MARK] === "number" && this.user[WRITE_MARK] > 0;
@@ -1996,37 +1940,15 @@ const Config = z.object({
1996
1940
  checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
1997
1941
  quotaPollMs: QUOTA_POLL_FIELD
1998
1942
  });
1999
- z.object({
2000
- probeConsent: PROBE_CONSENT_FIELD,
2001
- useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
2002
- modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
2003
- disabledModelsCN: DISABLED_MODELS_FIELD
2004
- });
2005
- z.object({
2006
- useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
2007
- modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
2008
- disabledModels: DISABLED_MODELS_FIELD
2009
- });
2010
- z.object({
2011
- sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
2012
- sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
2013
- autoCheckInCN: AUTO_CHECK_IN_FIELD,
2014
- autoCheckInGlobal: AUTO_CHECK_IN_FIELD,
2015
- checkInMinuteCN: CHECK_IN_MINUTE_FIELD,
2016
- checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
2017
- quotaPollMs: QUOTA_POLL_FIELD
2018
- });
2019
1943
  /**
2020
- * Every field each settings section owns, and therefore every field the live
2021
- * configuration has to carry through.
2022
- *
2023
- * One list per section, shared by the merge and by the test that pins it to
2024
- * the schema. Writing the merge out by hand is what broke auto check-in: the
2025
- * `qoder-quota` section grew four fields (`autoCheckInCN`, `autoCheckInGlobal`,
2026
- * `checkInMinuteCN`, `checkInMinuteGlobal`) while the merge kept copying only
2027
- * the three that predated them, so `current().autoCheckInCN` read `undefined`
2028
- * forever and the scheduler saw the toggle as permanently off. The card saved
2029
- * it, the file held it, and nothing ever acted on it.
1944
+ * The field groups the configuration is merged from, one list per card.
1945
+ *
1946
+ * Writing the merge out by hand is what broke auto check-in: the quota group
1947
+ * grew four fields (`autoCheckInCN`, `autoCheckInGlobal`, `checkInMinuteCN`,
1948
+ * `checkInMinuteGlobal`) while the merge kept copying only the three that
1949
+ * predated them, so `current().autoCheckInCN` read `undefined` forever and the
1950
+ * scheduler saw the toggle as permanently off. The card saved it, the file
1951
+ * held it, and nothing ever acted on it.
2030
1952
  */
2031
1953
  const CN_SECTION_KEYS = [
2032
1954
  "probeConsent",
@@ -2081,10 +2003,6 @@ function readConfig(config) {
2081
2003
  }
2082
2004
  /** Stable identity key used by credentials, probe records, and catalog entries. */
2083
2005
  const credentialIdentity = qoderCredentialIdentity;
2084
- /** The settings namespace a variant's card and provider directory entry use. */
2085
- function settingsNamespaceFor(variant) {
2086
- return variant.id === CHINA_VARIANT.id ? QODER_SETTINGS_NS : QODER_GLOBAL_SETTINGS_NS;
2087
- }
2088
2006
  /**
2089
2007
  * The static catalog a variant serves before its first successful fetch.
2090
2008
  *
@@ -2314,15 +2232,7 @@ async function startVariant(ctx, runtime, seedCatalog) {
2314
2232
  let releaseDirectory;
2315
2233
  try {
2316
2234
  releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
2317
- const host017 = (() => {
2318
- try {
2319
- return ctx.get?.("configEditor") !== void 0;
2320
- } catch {
2321
- return false;
2322
- }
2323
- })();
2324
- const entryId = ctx.fiber?.entry?.options?.id;
2325
- const settingsNs = host017 && entryId !== void 0 ? entryId : settingsNamespaceFor(variant);
2235
+ const settingsNs = ctx.fiber?.entry?.options?.id ?? variant.id;
2326
2236
  releaseDirectory = ctx.llm.registerConfigurableProviders([{
2327
2237
  provider: variant.id,
2328
2238
  displayName: variant.displayName,
@@ -2558,43 +2468,22 @@ function validateSettingsPatch(patch) {
2558
2468
  }
2559
2469
  }
2560
2470
  /**
2561
- * When the profile tree last recomposed, module-level so every apply sees it.
2562
- *
2563
- * The legacy settings.yaml import writes the profile patch one section at a
2564
- * time for seconds after the Loader settles, emitting this event on every
2565
- * write. Cleaning up our row in that window is futile — the import's next
2566
- * section simply writes it back — so the cleanup waits for the tree to fall
2567
- * quiet first.
2568
- */
2569
- let lastConfigReloadAt = Date.now();
2570
- /** Wait until the profile tree has been quiet (the legacy import finished). */
2571
- async function waitForProfileQuiet() {
2572
- const deadline = Date.now() + 12e4;
2573
- for (;;) {
2574
- if (Date.now() - lastConfigReloadAt >= 5e3) return;
2575
- if (Date.now() >= deadline) return;
2576
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2577
- }
2578
- }
2579
- /**
2580
2471
  * Delete this plugin's own fields from the profile entry config, leaving every
2581
2472
  * other key of the row untouched.
2582
2473
  *
2583
2474
  * One-time, right after the settings file has been seeded: the entry returns to
2584
- * its shipped state, so no second source of truth remains. Both write APIs are
2585
- * probed — `configEditor` (0.1.7) first, then the settings service's namespace
2586
- * `replace` (0.1.5), which rebuilds each of this plugin's three sections from
2587
- * the foreign keys alone.
2475
+ * its shipped state, so no second source of truth remains. The write rides
2476
+ * `configEditor` — the 0.1.7 profile-patch editor — probed on the plugin
2477
+ * context first and on the settings service's owner context second, because
2478
+ * that is where the host root mounts it.
2588
2479
  *
2589
2480
  * @param ctx - plugin context.
2590
2481
  * @param ownKeys - this plugin's declared config fields.
2591
- * @param namespaces - the 0.1.5 section namespaces this plugin owns.
2592
2482
  */
2593
- async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
2483
+ async function cleanupEntryConfig(ctx, ownKeys) {
2594
2484
  const attempts = 10;
2595
2485
  for (let attempt = 0; attempt < attempts; attempt++) try {
2596
- if (attempt === 0) await waitForProfileQuiet();
2597
- else await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
2486
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
2598
2487
  const probe = ctx;
2599
2488
  const editor = (typeof probe.get === "function" ? (() => {
2600
2489
  try {
@@ -2626,16 +2515,6 @@ async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
2626
2515
  });
2627
2516
  return;
2628
2517
  }
2629
- await new Promise((resolve) => {
2630
- ctx.inject(["settings"], (settingsCtx) => {
2631
- const settings = settingsCtx.settings;
2632
- if (typeof settings?.replace !== "function" || typeof settings.installSection !== "function") {
2633
- resolve(void 0);
2634
- return;
2635
- }
2636
- Promise.all(namespaces.map((ns) => Promise.resolve(settings.replace(ns, {})).catch(() => void 0))).then(() => resolve(void 0));
2637
- });
2638
- });
2639
2518
  return;
2640
2519
  } catch (error) {
2641
2520
  if (attempt === 9) {
@@ -2672,63 +2551,41 @@ function apply(ctx, config) {
2672
2551
  ...store.values()
2673
2552
  });
2674
2553
  /**
2675
- * One-time migration: per field — the file never held it → take the entry;
2676
- * the card HAS written this file → keep the file; the entry carries a
2677
- * NON-DEFAULT value → take the entry (the legacy settings.yaml import lands
2678
- * only after the Loader settles, i.e. after this plugin's first apply, and a
2679
- * seed taken inside that window can hold a stale or mis-encoded value); the
2680
- * entry only carries the schema default → keep the file.
2554
+ * One-time seed: per field — the file never held it → take the entry; the
2555
+ * card HAS written this file → keep the file; the entry carries a
2556
+ * NON-DEFAULT value → take the entry; the entry only carries the schema
2557
+ * default → keep the file.
2681
2558
  *
2682
2559
  * That last clause is load-bearing: a bundle layer's insert config does NOT
2683
2560
  * reach the composition (verified with `dsh --dump-config`), so once the
2684
2561
  * one-time cleanup has emptied the entry row the entry answers pure defaults —
2685
2562
  * treating those as authoritative would erase the user's values on the next
2686
- * boot. Re-checked on every apply, which is also what closes the legacy
2687
- * import's timing window.
2563
+ * boot. Re-checked on every apply, which is also what closes the volatile
2564
+ * commit timing window.
2688
2565
  *
2689
2566
  * When the entry is authoritative, its own fields are then deleted from the
2690
2567
  * profile row, so no second source of truth remains.
2691
2568
  */
2692
2569
  const migrateOwnSettings = () => {
2693
- const legacy = readLegacySections([
2694
- QODER_SETTINGS_NS,
2695
- QODER_GLOBAL_SETTINGS_NS,
2696
- QODER_QUOTA_SETTINGS_NS
2697
- ]);
2698
2570
  const seeded = {};
2699
2571
  for (const key of CONFIG_KEYS) {
2700
2572
  const entryValue = readField(config, key);
2701
- const legacyValue = legacy === void 0 ? void 0 : legacy[key];
2702
2573
  if (!Object.hasOwn(store.user, key)) {
2703
2574
  if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
2704
- else if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
2705
2575
  else if (entryValue !== void 0) seeded[key] = entryValue;
2706
- else if (legacyValue !== void 0) seeded[key] = legacyValue;
2707
2576
  continue;
2708
2577
  }
2709
2578
  if (store.edited) continue;
2710
- if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) {
2711
- seeded[key] = entryValue;
2712
- continue;
2713
- }
2714
- if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
2579
+ if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
2715
2580
  }
2716
2581
  if (Object.keys(seeded).length === 0) return;
2717
2582
  store.patch(seeded);
2718
- cleanupEntryConfig(ctx, CONFIG_KEYS, [
2719
- QODER_SETTINGS_NS,
2720
- QODER_GLOBAL_SETTINGS_NS,
2721
- QODER_QUOTA_SETTINGS_NS
2722
- ]);
2583
+ cleanupEntryConfig(ctx, CONFIG_KEYS);
2723
2584
  };
2724
2585
  migrateOwnSettings();
2725
- const eventSink = ctx;
2726
- eventSink.on("loader/volatile-update", () => {
2586
+ ctx.on("loader/volatile-update", () => {
2727
2587
  migrateOwnSettings();
2728
2588
  });
2729
- eventSink.on("app-boot/config-reload", () => {
2730
- lastConfigReloadAt = Date.now();
2731
- });
2732
2589
  /** Timers and in-flight work belonging to this plugin instance. */
2733
2590
  let stopped = false;
2734
2591
  const timers = [];
@@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
8
8
  import { EMPTY_RESPONSE_CODE, LlmError, ProviderRequestId, ReasoningEffortId, ToolCallId, attributionHeaders, contentHasImage, createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
9
9
  import { homedir } from "node:os";
10
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
11
+ import { requestImageDimensions } from "@deepseek-ai/dsh-attachment";
10
12
  import { execFileSync } from "node:child_process";
11
13
  //#region src/paths.ts
12
14
  /**
@@ -1035,10 +1037,40 @@ function getQoderClaimCampaignUrl(region = "global", campaignId) {
1035
1037
  }
1036
1038
  //#endregion
1037
1039
  //#region src/qoder/transport/machine-id.ts
1040
+ /**
1041
+ * The machine id Qoder's own desktop CLI keeps at
1042
+ * `$HOME/.qoder/.auth/machine_id`.
1043
+ *
1044
+ * Strictly a read source for this plugin: when the official client generated
1045
+ * one, the transport reuses it so the same device presents one stable
1046
+ * identity upstream. The plugin never writes there — that tree belongs to
1047
+ * the official CLI.
1048
+ */
1049
+ function officialMachineIdPath() {
1050
+ return join(homedir(), ".qoder", ".auth", "machine_id");
1051
+ }
1052
+ /**
1053
+ * The trusted locations consulted when a caller nominates no chain: the
1054
+ * official Qoder CLI's id first, this plugin's own seed second.
1055
+ *
1056
+ * A freshly created id is saved ONLY at the end of the chain, which is now
1057
+ * always inside the plugin's data directory. An earlier default ended the
1058
+ * chain at `~/.dsh/qoder/machine_id`, derived from `os.homedir()` rather
1059
+ * than the resolved harness home, so any host with `DSH_HOME` pointed at
1060
+ * another location got a device credential scattered into the wrong `$HOME`
1061
+ * — outside every profile, with no owner deleting it. That fallback is
1062
+ * gone.
1063
+ */
1038
1064
  function defaultMachineIdPaths() {
1039
- return [join(homedir(), ".qoder", ".auth", "machine_id"), join(homedir(), ".dsh", "qoder", "machine_id")];
1065
+ return [officialMachineIdPath(), qoderMachineIdPath()];
1040
1066
  }
1041
- /** Read Qoder's machine id or create the DSH-owned fallback. */
1067
+ /**
1068
+ * Read Qoder's machine id, or create this plugin's seed at the chain's end.
1069
+ *
1070
+ * `paths` is the read chain; its last entry doubles as the create point.
1071
+ * Passing no chain defaults to {@link defaultMachineIdPaths}; passing an
1072
+ * empty array nominates no save location, which keeps the id process-local.
1073
+ */
1042
1074
  function getMachineId(paths = defaultMachineIdPaths()) {
1043
1075
  for (const path of paths) {
1044
1076
  if (!existsSync(path)) continue;
@@ -1049,7 +1081,8 @@ function getMachineId(paths = defaultMachineIdPaths()) {
1049
1081
  }
1050
1082
  const machineId = crypto.randomUUID();
1051
1083
  const savePath = paths.at(-1);
1052
- if (savePath !== void 0) try {
1084
+ if (savePath === void 0) return machineId;
1085
+ try {
1053
1086
  mkdirSync(dirname(savePath), { recursive: true });
1054
1087
  writeFileSync(savePath, machineId, {
1055
1088
  encoding: "utf8",
@@ -1262,22 +1295,34 @@ async function readLimitedText(response, maxBytes, label) {
1262
1295
  }
1263
1296
  if (!response.body) return "";
1264
1297
  const reader = response.body.getReader();
1265
- const decoder = new TextDecoder();
1298
+ const chunks = [];
1266
1299
  let bytes = 0;
1267
- let text = "";
1268
1300
  try {
1269
1301
  while (true) {
1270
1302
  const { done, value } = await reader.read();
1271
1303
  if (done) break;
1272
1304
  bytes += value.byteLength;
1273
1305
  if (bytes > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1274
- text += decoder.decode(value, { stream: true });
1306
+ chunks.push(value);
1275
1307
  }
1276
- return text + decoder.decode();
1277
1308
  } finally {
1278
1309
  await reader.cancel().catch(() => void 0);
1279
1310
  reader.releaseLock();
1280
1311
  }
1312
+ let payload = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
1313
+ const encoding = (response.headers.get("content-encoding") ?? "").toLowerCase();
1314
+ const looksGzipped = payload.length >= 2 && payload[0] === 31 && payload[1] === 139;
1315
+ if (encoding !== "" || looksGzipped) {
1316
+ try {
1317
+ if (encoding.includes("gzip") || encoding === "" && looksGzipped) payload = gunzipSync(payload);
1318
+ else if (encoding.includes("br")) payload = brotliDecompressSync(payload);
1319
+ else if (encoding.includes("deflate")) payload = inflateSync(payload);
1320
+ } catch {
1321
+ throw new QoderLlmError(`${label} arrived compressed but could not be decompressed.`, "MALFORMED_RESPONSE");
1322
+ }
1323
+ if (payload.byteLength > maxBytes) throw new QoderLlmError(`${label} exceeded its response size limit.`, "MALFORMED_RESPONSE");
1324
+ }
1325
+ return new TextDecoder().decode(payload);
1281
1326
  }
1282
1327
  var SingleFlight = class {
1283
1328
  flights = /* @__PURE__ */ new Map();
@@ -1330,6 +1375,7 @@ async function openApiJsonRequest(fetchImpl, options) {
1330
1375
  try {
1331
1376
  const headers = {
1332
1377
  accept: "application/json",
1378
+ "accept-encoding": "identity",
1333
1379
  "user-agent": options.userAgent ?? defaultUserAgent,
1334
1380
  "cosy-version": "1.0.1",
1335
1381
  "cosy-clienttype": "5",
@@ -1647,6 +1693,7 @@ async function fetchQoderModels(credentials, options = {}) {
1647
1693
  method: "GET",
1648
1694
  headers: {
1649
1695
  accept: "application/json",
1696
+ "accept-encoding": "identity",
1650
1697
  ...buildAuthHeaders(null, url, credentials)
1651
1698
  },
1652
1699
  signal: deadline.signal
@@ -1686,9 +1733,16 @@ async function fetchQoderModels(credentials, options = {}) {
1686
1733
  function unsupported(message) {
1687
1734
  return new QoderLlmError(message, "UNSUPPORTED_CONTENT");
1688
1735
  }
1689
- function toolResultText(block) {
1736
+ /**
1737
+ * Flatten one tool-result message's content to wire text.
1738
+ *
1739
+ * DSH 0.1.7 models a tool result as a first-class `role: 'tool'` message
1740
+ * (before 0.1.6 it was a `tool-result` block inside a user message); either
1741
+ * way the payload is a content array whose text/图像 blocks this flattens.
1742
+ */
1743
+ function toolResultText(content) {
1690
1744
  let text = "";
1691
- for (const nested of block.content) {
1745
+ for (const nested of content) {
1692
1746
  if (nested.type === "image") continue;
1693
1747
  if (nested.type !== "text") throw unsupported(`Qoder tool results support text only; received nested ${String(nested.type)} content.`);
1694
1748
  text += nested.text;
@@ -1699,16 +1753,19 @@ function toolResultText(block) {
1699
1753
  * Check message shapes without performing any provider I/O.
1700
1754
  *
1701
1755
  * Callers run this before resolving credentials so an invalid request never
1702
- * consumes a Qoder subscription.
1756
+ * consumes a Qoder subscription. The input is DSH's request shape — durable
1757
+ * messages and one-shot user inputs alike; only `role` and `content` are read.
1703
1758
  */
1704
1759
  function validateMessageShapes(messages) {
1705
1760
  for (const message of messages) {
1706
- const toolResults = message.content.filter((block) => block.type === "tool-result");
1707
- if (toolResults.length > 0) {
1708
- if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
1709
- for (const result of toolResults) toolResultText(result);
1761
+ if (message.role === "tool") {
1762
+ for (const block of message.content) {
1763
+ if (block.type === "text" || block.type === "image") continue;
1764
+ throw unsupported(`Qoder tool results support text and image content only; received ${String(block.type)}.`);
1765
+ }
1710
1766
  continue;
1711
1767
  }
1768
+ if (message.role === "developer") throw unsupported("Qoder transport does not support developer messages (dynamic tool addition/removal).");
1712
1769
  for (const block of message.content) {
1713
1770
  if (block.type === "text") continue;
1714
1771
  if (block.type === "image") {
@@ -1737,10 +1794,12 @@ async function resolveImagePart(block, context) {
1737
1794
  if (attachments === void 0) throw new QoderLlmError("Qoder image input requires the DSH attachment service.", "ATTACHMENT");
1738
1795
  let image;
1739
1796
  try {
1740
- const limits = attachments.imageLimits;
1797
+ const { width: sourceWidth, height: sourceHeight } = block.attachment;
1798
+ const projected = requestImageDimensions(sourceWidth, sourceHeight, attachments.imageLimits.maxImagePixels);
1741
1799
  image = await attachments.readImageRequest(block.attachment, {
1742
- maxPixels: limits.maxImagePixels,
1743
- maxBytes: limits.maxImageBytes
1800
+ width: projected.width,
1801
+ height: projected.height,
1802
+ maxBytes: attachments.imageLimits.maxImageBytes
1744
1803
  }, signal);
1745
1804
  } catch (error) {
1746
1805
  if (signal?.aborted) throw new QoderLlmError("Qoder image preparation was aborted.", "ABORTED", { cause: error });
@@ -1781,26 +1840,22 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1781
1840
  content: systemPrompt
1782
1841
  });
1783
1842
  for (const message of messages) {
1784
- const toolResults = message.content.filter((block) => block.type === "tool-result");
1785
- if (toolResults.length > 0) {
1786
- if (message.role !== "user" || toolResults.length !== message.content.length) throw unsupported("Qoder tool-result messages cannot contain sibling content or use a non-user role.");
1787
- for (const result of toolResults) {
1843
+ if (message.role === "tool") {
1844
+ output.push({
1845
+ role: "tool",
1846
+ tool_call_id: String(message.toolCallId),
1847
+ content: toolResultText(message.content)
1848
+ });
1849
+ const images = message.content.filter((block) => block.type === "image");
1850
+ if (images.length > 0) {
1851
+ if (attachments !== void 0) enforceImageLimits(images, attachments);
1788
1852
  output.push({
1789
- role: "tool",
1790
- tool_call_id: String(result.toolCallId),
1791
- content: toolResultText(result)
1853
+ role: "user",
1854
+ content: [{
1855
+ type: "text",
1856
+ text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1857
+ }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1792
1858
  });
1793
- const images = result.content.filter((block) => block.type === "image");
1794
- if (images.length > 0) {
1795
- if (attachments !== void 0) enforceImageLimits(images, attachments);
1796
- output.push({
1797
- role: "user",
1798
- content: [{
1799
- type: "text",
1800
- text: `[${images.length} image${images.length === 1 ? "" : "s"} returned by the previous tool call]`
1801
- }, ...await Promise.all(images.map((image) => resolveImagePart(image, context)))]
1802
- });
1803
- }
1804
1859
  }
1805
1860
  continue;
1806
1861
  }
@@ -1843,6 +1898,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1843
1898
  if (message.role === "assistant") reasoningText += block.text;
1844
1899
  continue;
1845
1900
  }
1901
+ throw unsupported(`Qoder transport encountered unsupported block type: ${String(block.type)}`);
1846
1902
  }
1847
1903
  if (message.role === "assistant") {
1848
1904
  const hasReasoning = preserveThinking && reasoningText.length > 0;
@@ -1861,6 +1917,7 @@ async function validateAndTranslateMessages(messages, systemPrompt, attachments,
1861
1917
  userContent[pending.slot] = await resolveImagePart(pending.block, context);
1862
1918
  }));
1863
1919
  }
1920
+ if (message.role === "developer") continue;
1864
1921
  output.push({
1865
1922
  role: message.role,
1866
1923
  content: hasImage ? userContent.filter((part) => part !== void 0) : text
@@ -4388,7 +4445,7 @@ const DEFAULT_MAX_TOKENS = 32768;
4388
4445
  const DEFAULT_CONTEXT_WINDOW = 18e4;
4389
4446
  //#endregion
4390
4447
  //#region src/version.ts
4391
- const QODER_CONNECT_VERSION = "0.2.0";
4448
+ const QODER_CONNECT_VERSION = "0.2.2";
4392
4449
  //#endregion
4393
4450
  //#region src/host-heartbeat.ts
4394
4451
  /**