@dan-ai-studio/dshopencodego 0.1.51 → 0.1.54

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/README.en.md CHANGED
@@ -247,7 +247,7 @@ npm test # contract tests against a real local HTTP mock gateway
247
247
 
248
248
  **Order matters**: `npm run build` must come before `npm test` — `tests/pack-contents.spec.ts` walks every `exports` target, and whether `lib/` exists decides whether that check runs or is silently skipped (it does not fail, it just stops protecting you). `package.json` no longer carries `prepare`, so `npm ci` will not build for you; all three CI workflows run `npm run build` explicitly.
249
249
 
250
- The suite covers configuration validation, the protocol ladder, gateway and online metadata, the catalog projection, the adapter on the wire (all three protocols), Remote contracts and services, session-header invariants, usage windows and the meter, the client controller and usage pill, both halves' mounting and registration, the error mapping for stream idle timeouts and aborts, the exit-code contract of the verification scripts, and several **structural invariants** (inject declarations match the source's reads, peer coverage holds both ways, the READMEs match the manifest, and the READMEs' claims about the upstream match the upstream actually installed). That is **50 test files** — a number `tests/readme-consistency.spec.ts` checks against the `tests/` directory, so it cannot drift.
250
+ The suite covers configuration validation, the protocol ladder, gateway and online metadata, the catalog projection, the adapter on the wire (all three protocols), Remote contracts and services, session-header invariants, usage windows and the meter, the client controller and usage pill, both halves' mounting and registration, the error mapping for stream idle timeouts and aborts, the exit-code contract of the verification scripts, and several **structural invariants** (inject declarations match the source's reads, peer coverage holds both ways, the READMEs match the manifest, and the READMEs' claims about the upstream match the upstream actually installed). That is **52 test files** — a number `tests/readme-consistency.spec.ts` checks against the `tests/` directory, so it cannot drift.
251
251
 
252
252
  ## Releasing (maintainers)
253
253
 
package/README.md CHANGED
@@ -247,7 +247,7 @@ npm test # 契约测试:真实 HTTP mock 网关,断言请求头/
247
247
 
248
248
  **顺序有讲究**:`npm run build` 要排在 `npm test` 之前——`tests/pack-contents.spec.ts` 会遍历 `exports` 的每个目标,`lib/` 存在与否决定这条检查是执行还是被跳过(不报错,但会少一层保护)。`package.json` 已移除 `prepare`,因此 `npm ci` 不会再替你隐式构建;三个 CI workflow 都显式跑了 `npm run build`。
249
249
 
250
- 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致、README 对上游的断言与实装的上游一致)。共 **50 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
250
+ 测试覆盖配置校验、协议阶梯、网关与在线元数据、目录投影、适配器(三种协议的线上请求)、Remote 契约与服务、会话头不变量、用量窗口与计量、客户端控制器与用量组件、两半的挂载与登记、流空闲超时与中止的错误映射、验证脚本的退出码契约、以及几条**结构性不变量**(inject 声明与源码读取一致、peer 覆盖双向、README 与 manifest 一致、README 对上游的断言与实装的上游一致)。共 **52 个测试文件**——这个数字由 `tests/readme-consistency.spec.ts` 对着 `tests/` 目录校验,不会漂移。
251
251
 
252
252
  ## 发布(维护者)
253
253
 
package/lib/client.js CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  module.exports = __toCommonJS(index_exports);
28
28
 
29
29
  // src/catalog/constants.ts
30
+ var BACKGROUND_RETRY_BACKOFF_MS = 5 * 6e4;
30
31
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
31
32
  var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
32
33
  var UNKNOWN_MODEL_PROBE_COOLDOWN_MS = 5 * 6e4;
@@ -129,6 +130,7 @@ function parseCatalogReading(value) {
129
130
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
130
131
  degradations: parseDegradations(row["degradations"]),
131
132
  ...typeof row["factsCachedAt"] === "number" ? { factsCachedAt: row["factsCachedAt"] } : {},
133
+ ...row["cached"] === true ? { cached: true } : {},
132
134
  fetchedAtMs: row["fetchedAtMs"],
133
135
  quotaSource: quotaSource ?? "seed",
134
136
  counts: {
@@ -337,6 +339,7 @@ var en = {
337
339
  catalogUnconfigured: "not configurable",
338
340
  quotaSeed: "Allowances are the built-in snapshot, transcribed",
339
341
  factsCached: "Model metadata is from the last successful reading,",
342
+ catalogRefreshing: "Showing the last known catalog; a refresh is running in the background.",
340
343
  refreshLabel: "Catalog refresh (minutes)",
341
344
  refreshHint: "How long requests and the model picker reuse one listing.",
342
345
  writeRejected: "The settings write was rejected (the namespace was reloaded) \u2014 try again",
@@ -437,6 +440,7 @@ var zh = {
437
440
  catalogUnconfigured: "\u65E0\u6CD5\u914D\u7F6E",
438
441
  quotaSeed: "\u989D\u5EA6\u6765\u81EA\u5185\u7F6E\u5FEB\u7167\uFF0C\u8F6C\u5199\u4E8E",
439
442
  factsCached: "\u6A21\u578B\u5143\u6570\u636E\u6765\u81EA\u4E0A\u6B21\u6210\u529F\u83B7\u53D6\uFF0C",
443
+ catalogRefreshing: "\u663E\u793A\u4E0A\u6B21\u5DF2\u77E5\u7684\u76EE\u5F55\uFF0C\u540E\u53F0\u6B63\u5728\u5237\u65B0\u3002",
440
444
  refreshLabel: "\u76EE\u5F55\u5237\u65B0\uFF08\u5206\u949F\uFF09",
441
445
  refreshHint: "\u8BF7\u6C42\u4E0E\u6A21\u578B\u9009\u62E9\u5668\u590D\u7528\u540C\u4E00\u4EFD\u5217\u8868\u7684\u65F6\u957F\u3002",
442
446
  writeRejected: "\u8BBE\u7F6E\u5199\u5165\u88AB\u62D2\u7EDD\uFF08\u5DF2\u91CD\u65B0\u8F7D\u5165\uFF09\uFF0C\u8BF7\u91CD\u8BD5",
@@ -1252,6 +1256,7 @@ function Section({ controller, t, getLocale }) {
1252
1256
  " ",
1253
1257
  new Date(reading.factsCachedAt).toISOString().slice(0, 10)
1254
1258
  ] }),
1259
+ reading !== void 0 && reading.cached === true && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: section_default.hint, children: t("catalogRefreshing") }),
1255
1260
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: section_default.row, children: [
1256
1261
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: section_default.hint, htmlFor: "dshopencodego-refresh", children: t("refreshLabel") }),
1257
1262
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
package/lib/index.js CHANGED
@@ -17788,8 +17788,8 @@ function getBunSandboxEnvValue(name2) {
17788
17788
  if (procEnvCache === null) {
17789
17789
  procEnvCache = /* @__PURE__ */ new Map();
17790
17790
  try {
17791
- const { readFileSync: readFileSync3 } = __require("node:fs");
17792
- const data = readFileSync3("/proc/self/environ", "utf-8");
17791
+ const { readFileSync: readFileSync5 } = __require("node:fs");
17792
+ const data = readFileSync5("/proc/self/environ", "utf-8");
17793
17793
  for (const entry of data.split("\0")) {
17794
17794
  const idx = entry.indexOf("=");
17795
17795
  if (idx > 0) {
@@ -42530,6 +42530,7 @@ var MODEL_METADATA_PROVIDER = "opencode-go";
42530
42530
  var METADATA_FETCH_TIMEOUT_MS = 1e4;
42531
42531
  var MODEL_METADATA_TIMEOUT_MS = 3e4;
42532
42532
  var METADATA_RETRY_DELAY_MS = 1500;
42533
+ var BACKGROUND_RETRY_BACKOFF_MS = 5 * 6e4;
42533
42534
  var MODEL_LISTING_MAX_BYTES = 1024 * 1024;
42534
42535
  var MODEL_METADATA_MAX_BYTES = 16 * 1024 * 1024;
42535
42536
  var UNKNOWN_MODEL_PROBE_COOLDOWN_MS = 5 * 6e4;
@@ -42700,6 +42701,45 @@ function rememberPaths(cacheDir, discovered) {
42700
42701
  return merged;
42701
42702
  }
42702
42703
 
42704
+ // src/catalog/go-doc-cache.ts
42705
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
42706
+ import { join as join3 } from "node:path";
42707
+ var CACHE_FILE2 = "go-doc-cache.json";
42708
+ var CACHE_VERSION2 = 1;
42709
+ function readDocCache(cacheDir) {
42710
+ if (cacheDir === void 0) return void 0;
42711
+ try {
42712
+ const parsed = JSON.parse(readFileSync2(join3(cacheDir, CACHE_FILE2), "utf8"));
42713
+ if (parsed.version !== CACHE_VERSION2) return void 0;
42714
+ if (typeof parsed.url !== "string" || typeof parsed.body !== "string") return void 0;
42715
+ if (parsed.body.length === 0) return void 0;
42716
+ return {
42717
+ url: parsed.url,
42718
+ etag: typeof parsed.etag === "string" ? parsed.etag : void 0,
42719
+ body: parsed.body,
42720
+ savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0
42721
+ };
42722
+ } catch {
42723
+ return void 0;
42724
+ }
42725
+ }
42726
+ function writeDocCache(cacheDir, entry) {
42727
+ if (cacheDir === void 0 || entry.body.length === 0) return;
42728
+ try {
42729
+ mkdirSync2(cacheDir, { recursive: true });
42730
+ const file = {
42731
+ version: CACHE_VERSION2,
42732
+ url: entry.url,
42733
+ ...entry.etag === void 0 ? {} : { etag: entry.etag },
42734
+ body: entry.body,
42735
+ savedAt: Date.now()
42736
+ };
42737
+ writeFileSync2(join3(cacheDir, CACHE_FILE2), `${JSON.stringify(file)}
42738
+ `, "utf8");
42739
+ } catch {
42740
+ }
42741
+ }
42742
+
42703
42743
  // src/catalog/go-doc.ts
42704
42744
  var GO_DOC_URLS = [
42705
42745
  "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/go.mdx",
@@ -42778,36 +42818,47 @@ function parseGoDocument(markdown) {
42778
42818
  }
42779
42819
  return { quotas, protocols };
42780
42820
  }
42781
- async function readSource(url, signal) {
42821
+ async function readSource(url, signal, etag) {
42782
42822
  const timeout = AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS);
42783
42823
  try {
42784
42824
  const response = await fetch(url, {
42785
42825
  redirect: "error",
42786
- headers: { ...attributionHeaders2(), accept: "text/plain", "cache-control": "no-cache" },
42826
+ headers: {
42827
+ ...attributionHeaders2(),
42828
+ accept: "text/plain",
42829
+ "cache-control": "no-cache",
42830
+ ...etag === void 0 ? {} : { "if-none-match": etag }
42831
+ },
42787
42832
  signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
42788
42833
  });
42834
+ if (response.status === 304) {
42835
+ await response.body?.cancel().catch(() => {
42836
+ });
42837
+ return { url, notModified: true };
42838
+ }
42789
42839
  if (!response.ok) {
42790
42840
  await response.body?.cancel().catch(() => {
42791
42841
  });
42792
- return { failure: `${url} answered HTTP ${response.status}` };
42842
+ return { url, failure: `${url} answered HTTP ${response.status}` };
42793
42843
  }
42794
- const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
42844
+ const body = await readBoundedText(response, GO_DOC_MAX_BYTES);
42845
+ const document = parseGoDocument(body);
42795
42846
  if (document.quotas.size === 0 && document.protocols.size === 0) {
42796
- return { failure: `${url} carried neither an allowance table nor an endpoint table` };
42847
+ return { url, failure: `${url} carried neither an allowance table nor an endpoint table` };
42797
42848
  }
42798
- return { document };
42849
+ return { url, document, body, etag: response.headers.get("etag") ?? void 0 };
42799
42850
  } catch (error) {
42800
- return { failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
42851
+ return { url, failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
42801
42852
  }
42802
42853
  }
42803
42854
  async function fetchGoDocument(signal, options = {}) {
42804
42855
  const failures = [];
42805
- const known = await readSources(GO_DOC_URLS, signal);
42856
+ const known = await readSources(GO_DOC_URLS, signal, options.cacheDir);
42806
42857
  if (known.document !== void 0) return known.document;
42807
42858
  failures.push(...known.failures);
42808
42859
  const remembered = readCachedPaths(options.cacheDir).filter((path5) => !GO_DOC_URLS.some((url) => url.endsWith(path5)));
42809
42860
  if (remembered.length > 0) {
42810
- const cached = await readSources(remembered.flatMap(mirrorsOf), signal);
42861
+ const cached = await readSources(remembered.flatMap(mirrorsOf), signal, options.cacheDir);
42811
42862
  if (cached.document !== void 0) return cached.document;
42812
42863
  failures.push(...cached.failures);
42813
42864
  }
@@ -42821,7 +42872,7 @@ async function fetchGoDocument(signal, options = {}) {
42821
42872
  if (fresh.length === 0) {
42822
42873
  failures.push("the provider repository named the path that was already tried");
42823
42874
  } else {
42824
- const result = await readSources(fresh, signal);
42875
+ const result = await readSources(fresh, signal, options.cacheDir);
42825
42876
  if (result.document !== void 0) return result.document;
42826
42877
  failures.push(...result.failures);
42827
42878
  }
@@ -42831,12 +42882,32 @@ async function fetchGoDocument(signal, options = {}) {
42831
42882
  "DOCUMENT_UNAVAILABLE"
42832
42883
  );
42833
42884
  }
42834
- async function readSources(urls, signal) {
42835
- const attempts = await Promise.all(urls.map((url) => readSource(url, signal)));
42885
+ async function readSources(urls, signal, cacheDir) {
42836
42886
  const failures = [];
42887
+ const cached = readDocCache(cacheDir);
42888
+ if (cached !== void 0 && urls.includes(cached.url)) {
42889
+ const attempt = await readSource(cached.url, signal, cached.etag);
42890
+ if ("notModified" in attempt) {
42891
+ const document = parseGoDocument(cached.body);
42892
+ if (document.quotas.size > 0 || document.protocols.size > 0) {
42893
+ return { document, failures };
42894
+ }
42895
+ failures.push(`${attempt.url} answered 304, and the remembered copy carries no table this build can read`);
42896
+ } else if ("document" in attempt) {
42897
+ writeDocCache(cacheDir, { url: attempt.url, etag: attempt.etag, body: attempt.body });
42898
+ return { document: attempt.document, failures };
42899
+ } else {
42900
+ failures.push(attempt.failure);
42901
+ }
42902
+ }
42903
+ const rest = cached === void 0 ? urls : urls.filter((url) => url !== cached.url);
42904
+ const attempts = await Promise.all(rest.map((url) => readSource(url, signal)));
42837
42905
  for (const attempt of attempts) {
42838
- if ("document" in attempt) return { document: attempt.document, failures };
42839
- failures.push(attempt.failure);
42906
+ if ("document" in attempt) {
42907
+ writeDocCache(cacheDir, { url: attempt.url, etag: attempt.etag, body: attempt.body });
42908
+ return { document: attempt.document, failures };
42909
+ }
42910
+ if ("failure" in attempt) failures.push(attempt.failure);
42840
42911
  }
42841
42912
  return { failures };
42842
42913
  }
@@ -43055,15 +43126,15 @@ function toPiModel(facts, baseURL) {
43055
43126
  }
43056
43127
 
43057
43128
  // src/catalog/metadata-cache.ts
43058
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
43059
- import { join as join3 } from "node:path";
43060
- var CACHE_FILE2 = "models-dev-facts.json";
43061
- var CACHE_VERSION2 = 1;
43129
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
43130
+ import { join as join4 } from "node:path";
43131
+ var CACHE_FILE3 = "models-dev-facts.json";
43132
+ var CACHE_VERSION3 = 1;
43062
43133
  function readFactsCache(cacheDir) {
43063
43134
  if (cacheDir === void 0) return void 0;
43064
43135
  try {
43065
- const parsed = JSON.parse(readFileSync2(join3(cacheDir, CACHE_FILE2), "utf8"));
43066
- if (parsed.version !== CACHE_VERSION2 || parsed.facts === null || typeof parsed.facts !== "object") return void 0;
43136
+ const parsed = JSON.parse(readFileSync3(join4(cacheDir, CACHE_FILE3), "utf8"));
43137
+ if (parsed.version !== CACHE_VERSION3 || parsed.facts === null || typeof parsed.facts !== "object") return void 0;
43067
43138
  const facts = /* @__PURE__ */ new Map();
43068
43139
  for (const [id, fact] of Object.entries(parsed.facts)) {
43069
43140
  if (fact === null || typeof fact !== "object") continue;
@@ -43083,14 +43154,69 @@ function readFactsCache(cacheDir) {
43083
43154
  function writeFactsCache(cacheDir, facts, etag) {
43084
43155
  if (cacheDir === void 0 || facts.size === 0) return;
43085
43156
  try {
43086
- mkdirSync2(cacheDir, { recursive: true });
43157
+ mkdirSync3(cacheDir, { recursive: true });
43087
43158
  const file = {
43088
- version: CACHE_VERSION2,
43159
+ version: CACHE_VERSION3,
43089
43160
  facts: Object.fromEntries(facts),
43090
43161
  updatedAt: Date.now(),
43091
43162
  ...etag === void 0 ? {} : { etag }
43092
43163
  };
43093
- writeFileSync2(join3(cacheDir, CACHE_FILE2), `${JSON.stringify(file)}
43164
+ writeFileSync3(join4(cacheDir, CACHE_FILE3), `${JSON.stringify(file)}
43165
+ `, "utf8");
43166
+ } catch {
43167
+ }
43168
+ }
43169
+
43170
+ // src/catalog/snapshot-cache.ts
43171
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
43172
+ import { join as join5 } from "node:path";
43173
+ var CACHE_FILE4 = "catalog-snapshot.json";
43174
+ var CACHE_VERSION4 = 1;
43175
+ function readSnapshotCache(cacheDir) {
43176
+ if (cacheDir === void 0) return void 0;
43177
+ try {
43178
+ const parsed = JSON.parse(readFileSync4(join5(cacheDir, CACHE_FILE4), "utf8"));
43179
+ if (parsed.version !== CACHE_VERSION4) return void 0;
43180
+ if (parsed.facts === null || typeof parsed.facts !== "object") return void 0;
43181
+ const facts = /* @__PURE__ */ new Map();
43182
+ for (const [id, fact] of Object.entries(parsed.facts)) {
43183
+ if (fact === null || typeof fact !== "object") continue;
43184
+ if (typeof fact.id !== "string" || typeof fact.api !== "string") continue;
43185
+ facts.set(id, fact);
43186
+ }
43187
+ if (facts.size === 0) return void 0;
43188
+ const quotas = /* @__PURE__ */ new Map();
43189
+ for (const [id, quota] of Object.entries(parsed.quotas ?? {})) {
43190
+ if (quota === null || typeof quota !== "object") continue;
43191
+ quotas.set(id, quota);
43192
+ }
43193
+ const protocols = /* @__PURE__ */ new Map();
43194
+ for (const [id, protocol] of Object.entries(parsed.protocols ?? {})) {
43195
+ if (typeof protocol !== "string") continue;
43196
+ protocols.set(id, protocol);
43197
+ }
43198
+ return {
43199
+ facts,
43200
+ quotas,
43201
+ protocols,
43202
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : 0
43203
+ };
43204
+ } catch {
43205
+ return void 0;
43206
+ }
43207
+ }
43208
+ function writeSnapshotCache(cacheDir, facts, quotas, protocols) {
43209
+ if (cacheDir === void 0 || facts.size === 0) return;
43210
+ try {
43211
+ mkdirSync4(cacheDir, { recursive: true });
43212
+ const file = {
43213
+ version: CACHE_VERSION4,
43214
+ facts: Object.fromEntries(facts),
43215
+ ...quotas.size === 0 ? {} : { quotas: Object.fromEntries(quotas) },
43216
+ ...protocols.size === 0 ? {} : { protocols: Object.fromEntries(protocols) },
43217
+ updatedAt: Date.now()
43218
+ };
43219
+ writeFileSync4(join5(cacheDir, CACHE_FILE4), `${JSON.stringify(file)}
43094
43220
  `, "utf8");
43095
43221
  } catch {
43096
43222
  }
@@ -43158,6 +43284,10 @@ var OpencodeGoCatalog = class {
43158
43284
  document;
43159
43285
  /** Ids that already forced a revalidation, with the time they did. */
43160
43286
  probedAt = /* @__PURE__ */ new Map();
43287
+ /** The background refresh, kept so a caller can wait for it to be over. */
43288
+ backgroundRefresh;
43289
+ /** When the last background refresh was started, for the back-off. */
43290
+ lastRefreshAttemptAt = 0;
43161
43291
  constructor(options) {
43162
43292
  this.options = options;
43163
43293
  const cached = readFactsCache(options.cacheDir);
@@ -43169,7 +43299,19 @@ var OpencodeGoCatalog = class {
43169
43299
  }
43170
43300
  /**
43171
43301
  * The cached snapshot, refreshed when it is older than the configured TTL.
43172
- * @param force - revalidate every source, ignoring the cache.
43302
+ *
43303
+ * A snapshot that is past its TTL is still served — and refreshed behind
43304
+ * it. A caller that asks for a model must not wait for the network to
43305
+ * learn what it already knew, so the answer is the one in hand and the
43306
+ * refresh goes on without anyone watching it. A caller that forces a
43307
+ * refresh — the settings page's button — waits for it, because asking to
43308
+ * refresh and not waiting for the result is asking for nothing.
43309
+ *
43310
+ * A process that has never built a catalog reads the one it wrote down
43311
+ * last time and refreshes that in the background. The first message of a
43312
+ * session therefore names its model at once, instead of after a wait that
43313
+ * belongs to the network rather than to the session.
43314
+ * @param force - revalidate every source, ignoring the cache, and wait.
43173
43315
  * @param signal - caller cancellation, forwarded to the documentation read,
43174
43316
  * the slowest source and the only one a caller can cancel; a build already
43175
43317
  * in flight keeps the signal of the caller that started it, because one
@@ -43179,6 +43321,19 @@ var OpencodeGoCatalog = class {
43179
43321
  if (!force && this.served !== void 0 && Date.now() - this.served.fetchedAtMs < this.options.refreshMs) {
43180
43322
  return Promise.resolve(this.served);
43181
43323
  }
43324
+ if (!force) {
43325
+ if (this.served === void 0) {
43326
+ const remembered = this.readRememberedSnapshot();
43327
+ if (remembered !== void 0) {
43328
+ this.served = remembered;
43329
+ this.refreshInBackground();
43330
+ return Promise.resolve(remembered);
43331
+ }
43332
+ } else {
43333
+ this.refreshInBackground();
43334
+ return Promise.resolve(this.served);
43335
+ }
43336
+ }
43182
43337
  this.pending ??= this.build(signal).then((snapshot2) => {
43183
43338
  this.served = snapshot2;
43184
43339
  return snapshot2;
@@ -43187,6 +43342,64 @@ var OpencodeGoCatalog = class {
43187
43342
  });
43188
43343
  return this.pending;
43189
43344
  }
43345
+ /**
43346
+ * Refresh the catalog behind the snapshot in hand.
43347
+ *
43348
+ * The build is not awaited and its failure is not reported here: the
43349
+ * snapshot in hand stays, and whoever asked for the refresh will see the
43350
+ * next one.
43351
+ */
43352
+ refreshInBackground() {
43353
+ if (this.pending !== void 0) return;
43354
+ if (Date.now() - this.lastRefreshAttemptAt < BACKGROUND_RETRY_BACKOFF_MS) return;
43355
+ this.lastRefreshAttemptAt = Date.now();
43356
+ const refresh = this.build(void 0, false).then((snapshot2) => {
43357
+ this.served = snapshot2;
43358
+ return snapshot2;
43359
+ }).finally(() => {
43360
+ this.pending = void 0;
43361
+ });
43362
+ this.pending = refresh;
43363
+ this.backgroundRefresh = refresh;
43364
+ void refresh.catch(() => void 0);
43365
+ }
43366
+ /**
43367
+ * Wait for the background refresh, if one is running.
43368
+ *
43369
+ * The refresh belongs to no caller, so nothing else can wait for it. A
43370
+ * host that is shutting down — or a test that must not have a request
43371
+ * outlive it — needs to know when the quiet is over.
43372
+ * @returns a promise that settles when the background refresh has.
43373
+ */
43374
+ settled() {
43375
+ const refresh = this.backgroundRefresh;
43376
+ return refresh === void 0 ? Promise.resolve() : refresh.then(() => void 0, () => void 0);
43377
+ }
43378
+ /**
43379
+ * The catalog a previous build produced, read from disk.
43380
+ *
43381
+ * It is presented as what it is: a listing from the past, being brought up
43382
+ * to date in the background. The facts it carries are the ones the last
43383
+ * build produced, so the protocols and the thinking levels are the ones
43384
+ * that were true then.
43385
+ * @returns the remembered catalog, or undefined when there is none.
43386
+ */
43387
+ readRememberedSnapshot() {
43388
+ const remembered = readSnapshotCache(this.options.cacheDir);
43389
+ if (remembered === void 0) return void 0;
43390
+ const facts = new Map(remembered.facts);
43391
+ return {
43392
+ facts,
43393
+ unavailable: /* @__PURE__ */ new Map(),
43394
+ provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43395
+ // The listing is from the past, and the page says so.
43396
+ live: false,
43397
+ // And a refresh of it is already under way.
43398
+ cached: true,
43399
+ fetchedAtMs: remembered.updatedAt,
43400
+ ...remembered.quotas.size === 0 ? {} : { documentQuotas: remembered.quotas }
43401
+ };
43402
+ }
43190
43403
  /**
43191
43404
  * The snapshot that can serve one exact model.
43192
43405
  *
@@ -43232,13 +43445,17 @@ var OpencodeGoCatalog = class {
43232
43445
  /**
43233
43446
  * Revalidate every source, reusing the metadata document when it is unchanged.
43234
43447
  * @param signal - caller cancellation, forwarded to the documentation read.
43448
+ * @param retry - whether a failed metadata read is tried again. A caller
43449
+ * that is waiting for the answer wants it tried again; a refresh nobody
43450
+ * is waiting for does not, because the answer it wants is the one it
43451
+ * already has.
43235
43452
  */
43236
- async build(signal) {
43453
+ async build(signal, retry = true) {
43237
43454
  const builtin = builtinModels(this.options.baseURL);
43238
43455
  const readDocument = this.options.readDocument ?? ((signal2) => fetchGoDocument(signal2, { cacheDir: this.options.cacheDir }));
43239
43456
  const [listing, metadata, documentation] = await Promise.allSettled([
43240
43457
  fetchModelIds(this.options.baseURL),
43241
- this.refreshMetadata(builtin),
43458
+ this.refreshMetadata(builtin, retry),
43242
43459
  readDocument(signal)
43243
43460
  ]);
43244
43461
  const degradations = [];
@@ -43281,13 +43498,20 @@ var OpencodeGoCatalog = class {
43281
43498
  }
43282
43499
  const fromCache = metadata.status !== "fulfilled" && this.lastMetadata === void 0;
43283
43500
  const online = metadata.status === "fulfilled" ? metadata.value : this.lastMetadata ?? this.cachedMetadata;
43284
- return this.assemble(
43501
+ const snapshot2 = this.assemble(
43285
43502
  listing.value,
43286
43503
  online,
43287
43504
  builtin,
43288
43505
  degradations,
43289
43506
  fromCache ? this.cachedAt : void 0
43290
43507
  );
43508
+ writeSnapshotCache(
43509
+ this.options.cacheDir,
43510
+ snapshot2.facts,
43511
+ this.document?.quotas ?? /* @__PURE__ */ new Map(),
43512
+ this.document?.protocols ?? /* @__PURE__ */ new Map()
43513
+ );
43514
+ return snapshot2;
43291
43515
  }
43292
43516
  /** Merge the listing with the metadata document and the installed entries. */
43293
43517
  assemble(ids, online, builtin, degradations = [], factsCachedAt) {
@@ -43453,10 +43677,11 @@ var OpencodeGoCatalog = class {
43453
43677
  * @param builtin - the installed catalog, for the parse.
43454
43678
  * @returns the parsed metadata.
43455
43679
  */
43456
- async refreshMetadata(builtin) {
43680
+ async refreshMetadata(builtin, retry) {
43457
43681
  try {
43458
43682
  return await this.readMetadataDocument(builtin);
43459
43683
  } catch (first) {
43684
+ if (!retry) throw first;
43460
43685
  await new Promise((resolve3) => {
43461
43686
  setTimeout(resolve3, METADATA_RETRY_DELAY_MS);
43462
43687
  });
@@ -45571,6 +45796,7 @@ function parseCatalogReading(value) {
45571
45796
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
45572
45797
  degradations: parseDegradations(row["degradations"]),
45573
45798
  ...typeof row["factsCachedAt"] === "number" ? { factsCachedAt: row["factsCachedAt"] } : {},
45799
+ ...row["cached"] === true ? { cached: true } : {},
45574
45800
  fetchedAtMs: row["fetchedAtMs"],
45575
45801
  quotaSource: quotaSource ?? "seed",
45576
45802
  counts: {
@@ -45813,6 +46039,7 @@ function catalogReading(snapshot2, visibility, listingFailure, now) {
45813
46039
  ...!snapshot2.live && listingFailure !== void 0 ? { error: listingFailure } : {},
45814
46040
  degradations: snapshot2.degradations ?? [],
45815
46041
  ...snapshot2.factsCachedAt === void 0 ? {} : { factsCachedAt: snapshot2.factsCachedAt },
46042
+ ...snapshot2.cached === void 0 ? {} : { cached: snapshot2.cached },
45816
46043
  fetchedAtMs: snapshot2.fetchedAtMs,
45817
46044
  quotaSource: documentQuotas === void 0 ? "seed" : "document",
45818
46045
  counts: {
@@ -45981,7 +46208,7 @@ function createUpdateChecker(deps) {
45981
46208
  }
45982
46209
 
45983
46210
  // src/update/version.ts
45984
- var PLUGIN_VERSION = true ? "0.1.51" : "0.0.0-dev";
46211
+ var PLUGIN_VERSION = true ? "0.1.54" : "0.0.0-dev";
45985
46212
 
45986
46213
  // src/update/service.ts
45987
46214
  import { TypertRemoteService as TypertRemoteService2 } from "@deepseek-ai/dsh-typert-protocol";
@@ -33,6 +33,18 @@ export declare const MODEL_METADATA_TIMEOUT_MS = 30000;
33
33
  * document still fails inside the catalog build.
34
34
  */
35
35
  export declare const METADATA_RETRY_DELAY_MS = 1500;
36
+ /**
37
+ * How long a failed background refresh waits before it is tried again.
38
+ *
39
+ * A refresh that fails leaves the catalog's age where it was, so every call
40
+ * sees it as past its TTL and starts another. Without a back-off, a service
41
+ * that is down turns every model request into a full download attempt.
42
+ *
43
+ * Five minutes is short enough that a service that comes back is picked up
44
+ * soon, and long enough that a service that stays down is asked once, not
45
+ * once per request.
46
+ */
47
+ export declare const BACKGROUND_RETRY_BACKOFF_MS: number;
36
48
  /** Response caps: the listing is tiny, the metadata document is not. */
37
49
  export declare const MODEL_LISTING_MAX_BYTES: number;
38
50
  export declare const MODEL_METADATA_MAX_BYTES: number;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Remembering the provider's document.
3
+ *
4
+ * The document is small — tens of kilobytes — but it is fetched by every
5
+ * process that builds a catalog, and a desktop build fetches over the
6
+ * browser's network stack, where that fetch is measured in hundreds of
7
+ * milliseconds. The registry serves an ETag for it, and a conditional
8
+ * request that comes back `304 Not Modified` carries no body at all, so a
9
+ * process that remembers the ETag can have the document for the price of a
10
+ * round trip.
11
+ *
12
+ * What is remembered is the raw text, not the parsed tables. The parsed
13
+ * form is a reading of a document, and a reading can be taken again; the
14
+ * text is the document.
15
+ *
16
+ * @module @dan-ai-studio/dshopencodego/catalog/go-doc-cache
17
+ */
18
+ /**
19
+ * The document a previous read produced.
20
+ *
21
+ * A cache that cannot be read is no cache: the caller then reads the
22
+ * document the ordinary way, which is slower and no worse.
23
+ * @param cacheDir - the plugin's cache directory.
24
+ * @returns the remembered document, or undefined.
25
+ */
26
+ export declare function readDocCache(cacheDir: string | undefined): {
27
+ readonly url: string;
28
+ readonly etag: string | undefined;
29
+ readonly body: string;
30
+ readonly savedAt: number;
31
+ } | undefined;
32
+ /**
33
+ * Remember the document a read produced.
34
+ *
35
+ * A cache directory that cannot be written costs a fetch, and nothing else.
36
+ * @param cacheDir - the plugin's cache directory.
37
+ * @param entry - the document, where it came from, and its ETag.
38
+ */
39
+ export declare function writeDocCache(cacheDir: string | undefined, entry: {
40
+ readonly url: string;
41
+ readonly etag: string | undefined;
42
+ readonly body: string;
43
+ }): void;
@@ -73,6 +73,15 @@ export interface CatalogSnapshot {
73
73
  * a user cannot act on.
74
74
  */
75
75
  readonly degradations?: readonly CatalogDegradation[];
76
+ /**
77
+ * True when this snapshot was read from disk and is being brought up to
78
+ * date in the background.
79
+ *
80
+ * It is the difference between "here is the last known catalog" and "here
81
+ * is the catalog, and it is current". A page that shows the first must
82
+ * also say that a refresh is under way.
83
+ */
84
+ readonly cached?: boolean;
76
85
  /**
77
86
  * When the facts in this snapshot were written, when they came from disk
78
87
  * rather than from a live read.
@@ -149,16 +158,59 @@ export declare class OpencodeGoCatalog {
149
158
  private document;
150
159
  /** Ids that already forced a revalidation, with the time they did. */
151
160
  private readonly probedAt;
161
+ /** The background refresh, kept so a caller can wait for it to be over. */
162
+ private backgroundRefresh;
163
+ /** When the last background refresh was started, for the back-off. */
164
+ private lastRefreshAttemptAt;
152
165
  constructor(options: CatalogOptions);
153
166
  /**
154
167
  * The cached snapshot, refreshed when it is older than the configured TTL.
155
- * @param force - revalidate every source, ignoring the cache.
168
+ *
169
+ * A snapshot that is past its TTL is still served — and refreshed behind
170
+ * it. A caller that asks for a model must not wait for the network to
171
+ * learn what it already knew, so the answer is the one in hand and the
172
+ * refresh goes on without anyone watching it. A caller that forces a
173
+ * refresh — the settings page's button — waits for it, because asking to
174
+ * refresh and not waiting for the result is asking for nothing.
175
+ *
176
+ * A process that has never built a catalog reads the one it wrote down
177
+ * last time and refreshes that in the background. The first message of a
178
+ * session therefore names its model at once, instead of after a wait that
179
+ * belongs to the network rather than to the session.
180
+ * @param force - revalidate every source, ignoring the cache, and wait.
156
181
  * @param signal - caller cancellation, forwarded to the documentation read,
157
182
  * the slowest source and the only one a caller can cancel; a build already
158
183
  * in flight keeps the signal of the caller that started it, because one
159
184
  * shared snapshot cannot carry two different cancellations.
160
185
  */
161
186
  snapshot(force?: boolean, signal?: AbortSignal): Promise<CatalogSnapshot>;
187
+ /**
188
+ * Refresh the catalog behind the snapshot in hand.
189
+ *
190
+ * The build is not awaited and its failure is not reported here: the
191
+ * snapshot in hand stays, and whoever asked for the refresh will see the
192
+ * next one.
193
+ */
194
+ private refreshInBackground;
195
+ /**
196
+ * Wait for the background refresh, if one is running.
197
+ *
198
+ * The refresh belongs to no caller, so nothing else can wait for it. A
199
+ * host that is shutting down — or a test that must not have a request
200
+ * outlive it — needs to know when the quiet is over.
201
+ * @returns a promise that settles when the background refresh has.
202
+ */
203
+ settled(): Promise<void>;
204
+ /**
205
+ * The catalog a previous build produced, read from disk.
206
+ *
207
+ * It is presented as what it is: a listing from the past, being brought up
208
+ * to date in the background. The facts it carries are the ones the last
209
+ * build produced, so the protocols and the thinking levels are the ones
210
+ * that were true then.
211
+ * @returns the remembered catalog, or undefined when there is none.
212
+ */
213
+ private readRememberedSnapshot;
162
214
  /**
163
215
  * The snapshot that can serve one exact model.
164
216
  *
@@ -187,6 +239,10 @@ export declare class OpencodeGoCatalog {
187
239
  /**
188
240
  * Revalidate every source, reusing the metadata document when it is unchanged.
189
241
  * @param signal - caller cancellation, forwarded to the documentation read.
242
+ * @param retry - whether a failed metadata read is tried again. A caller
243
+ * that is waiting for the answer wants it tried again; a refresh nobody
244
+ * is waiting for does not, because the answer it wants is the one it
245
+ * already has.
190
246
  */
191
247
  private build;
192
248
  /** Merge the listing with the metadata document and the installed entries. */
@@ -38,6 +38,16 @@ export interface CatalogReading {
38
38
  * like a current one.
39
39
  */
40
40
  readonly factsCachedAt?: number;
41
+ /**
42
+ * True when the catalog behind this reading was read from disk and is
43
+ * being brought up to date in the background.
44
+ *
45
+ * The reading is usable — the models, the protocols, the levels are all
46
+ * there — but it was true when it was written, and a refresh is under
47
+ * way. The page says so, because a page that looks current and is not is
48
+ * worse than one that admits it.
49
+ */
50
+ readonly cached?: boolean;
41
51
  /** When the underlying snapshot was built. */
42
52
  readonly fetchedAtMs: number;
43
53
  /**
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Remembering the whole catalog.
3
+ *
4
+ * The parsed metadata facts and the provider's document are remembered
5
+ * already, but they are remembered as *evidence* — what to ask about, and
6
+ * what to fall back to when an answer cannot be had. This file remembers
7
+ * the *answer*: the catalog as it stood, so that a process can serve it
8
+ * without waiting for the network at all.
9
+ *
10
+ * That is the difference between a wait and a stale page. A process that
11
+ * starts with nothing must ask the gateway, the metadata document, and the
12
+ * provider's page before it can name a model — and a new session's first
13
+ * message waits for that. A process that starts with a remembered catalog
14
+ * can name a model immediately, and refresh what it remembered afterwards.
15
+ *
16
+ * The listing is the authority on which models exist, so a remembered
17
+ * catalog can only describe the models the gateway advertised when it was
18
+ * written. A model the gateway has since added is missing from it — and a
19
+ * model the gateway has since retired is still in it, which is why the
20
+ * remembered catalog is always presented as being *from* the past.
21
+ *
22
+ * @module @dan-ai-studio/dshopencodego/catalog/snapshot-cache
23
+ */
24
+ import type { GoQuota } from '../go-limits.ts';
25
+ import type { ModelFacts } from './metadata.ts';
26
+ import type { WireProtocol } from './protocol.ts';
27
+ /**
28
+ * The catalog a previous build produced.
29
+ *
30
+ * A cache that cannot be read is no cache: the caller then builds the
31
+ * catalog the ordinary way, which waits, and no worse.
32
+ * @param cacheDir - the plugin's cache directory.
33
+ * @returns the remembered catalog, or undefined.
34
+ */
35
+ export declare function readSnapshotCache(cacheDir: string | undefined): {
36
+ readonly facts: ReadonlyMap<string, ModelFacts>;
37
+ readonly quotas: ReadonlyMap<string, GoQuota>;
38
+ readonly protocols: ReadonlyMap<string, WireProtocol>;
39
+ readonly updatedAt: number;
40
+ } | undefined;
41
+ /**
42
+ * Remember the catalog a build produced.
43
+ *
44
+ * A cache directory that cannot be written costs a wait, and nothing else.
45
+ * @param cacheDir - the plugin's cache directory.
46
+ * @param facts - the facts the build produced.
47
+ * @param quotas - the provider's allowances.
48
+ * @param protocols - the provider's protocols.
49
+ */
50
+ export declare function writeSnapshotCache(cacheDir: string | undefined, facts: ReadonlyMap<string, ModelFacts>, quotas: ReadonlyMap<string, GoQuota>, protocols: ReadonlyMap<string, WireProtocol>): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dan-ai-studio/dshopencodego",
3
- "version": "0.1.51",
3
+ "version": "0.1.54",
4
4
  "description": "OpenCode Go provider for DeepSeek Harness: live gateway catalog, per-conversation session header, usage",
5
5
  "type": "module",
6
6
  "license": "MIT",