@dan-ai-studio/dshopencodego 0.1.51 → 0.1.53

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
@@ -129,6 +129,7 @@ function parseCatalogReading(value) {
129
129
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
130
130
  degradations: parseDegradations(row["degradations"]),
131
131
  ...typeof row["factsCachedAt"] === "number" ? { factsCachedAt: row["factsCachedAt"] } : {},
132
+ ...row["cached"] === true ? { cached: true } : {},
132
133
  fetchedAtMs: row["fetchedAtMs"],
133
134
  quotaSource: quotaSource ?? "seed",
134
135
  counts: {
@@ -337,6 +338,7 @@ var en = {
337
338
  catalogUnconfigured: "not configurable",
338
339
  quotaSeed: "Allowances are the built-in snapshot, transcribed",
339
340
  factsCached: "Model metadata is from the last successful reading,",
341
+ catalogRefreshing: "Showing the last known catalog; a refresh is running in the background.",
340
342
  refreshLabel: "Catalog refresh (minutes)",
341
343
  refreshHint: "How long requests and the model picker reuse one listing.",
342
344
  writeRejected: "The settings write was rejected (the namespace was reloaded) \u2014 try again",
@@ -437,6 +439,7 @@ var zh = {
437
439
  catalogUnconfigured: "\u65E0\u6CD5\u914D\u7F6E",
438
440
  quotaSeed: "\u989D\u5EA6\u6765\u81EA\u5185\u7F6E\u5FEB\u7167\uFF0C\u8F6C\u5199\u4E8E",
439
441
  factsCached: "\u6A21\u578B\u5143\u6570\u636E\u6765\u81EA\u4E0A\u6B21\u6210\u529F\u83B7\u53D6\uFF0C",
442
+ catalogRefreshing: "\u663E\u793A\u4E0A\u6B21\u5DF2\u77E5\u7684\u76EE\u5F55\uFF0C\u540E\u53F0\u6B63\u5728\u5237\u65B0\u3002",
440
443
  refreshLabel: "\u76EE\u5F55\u5237\u65B0\uFF08\u5206\u949F\uFF09",
441
444
  refreshHint: "\u8BF7\u6C42\u4E0E\u6A21\u578B\u9009\u62E9\u5668\u590D\u7528\u540C\u4E00\u4EFD\u5217\u8868\u7684\u65F6\u957F\u3002",
442
445
  writeRejected: "\u8BBE\u7F6E\u5199\u5165\u88AB\u62D2\u7EDD\uFF08\u5DF2\u91CD\u65B0\u8F7D\u5165\uFF09\uFF0C\u8BF7\u91CD\u8BD5",
@@ -1252,6 +1255,7 @@ function Section({ controller, t, getLocale }) {
1252
1255
  " ",
1253
1256
  new Date(reading.factsCachedAt).toISOString().slice(0, 10)
1254
1257
  ] }),
1258
+ reading !== void 0 && reading.cached === true && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: section_default.hint, children: t("catalogRefreshing") }),
1255
1259
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: section_default.row, children: [
1256
1260
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: section_default.hint, htmlFor: "dshopencodego-refresh", children: t("refreshLabel") }),
1257
1261
  /* @__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) {
@@ -42700,6 +42700,45 @@ function rememberPaths(cacheDir, discovered) {
42700
42700
  return merged;
42701
42701
  }
42702
42702
 
42703
+ // src/catalog/go-doc-cache.ts
42704
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
42705
+ import { join as join3 } from "node:path";
42706
+ var CACHE_FILE2 = "go-doc-cache.json";
42707
+ var CACHE_VERSION2 = 1;
42708
+ function readDocCache(cacheDir) {
42709
+ if (cacheDir === void 0) return void 0;
42710
+ try {
42711
+ const parsed = JSON.parse(readFileSync2(join3(cacheDir, CACHE_FILE2), "utf8"));
42712
+ if (parsed.version !== CACHE_VERSION2) return void 0;
42713
+ if (typeof parsed.url !== "string" || typeof parsed.body !== "string") return void 0;
42714
+ if (parsed.body.length === 0) return void 0;
42715
+ return {
42716
+ url: parsed.url,
42717
+ etag: typeof parsed.etag === "string" ? parsed.etag : void 0,
42718
+ body: parsed.body,
42719
+ savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0
42720
+ };
42721
+ } catch {
42722
+ return void 0;
42723
+ }
42724
+ }
42725
+ function writeDocCache(cacheDir, entry) {
42726
+ if (cacheDir === void 0 || entry.body.length === 0) return;
42727
+ try {
42728
+ mkdirSync2(cacheDir, { recursive: true });
42729
+ const file = {
42730
+ version: CACHE_VERSION2,
42731
+ url: entry.url,
42732
+ ...entry.etag === void 0 ? {} : { etag: entry.etag },
42733
+ body: entry.body,
42734
+ savedAt: Date.now()
42735
+ };
42736
+ writeFileSync2(join3(cacheDir, CACHE_FILE2), `${JSON.stringify(file)}
42737
+ `, "utf8");
42738
+ } catch {
42739
+ }
42740
+ }
42741
+
42703
42742
  // src/catalog/go-doc.ts
42704
42743
  var GO_DOC_URLS = [
42705
42744
  "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/go.mdx",
@@ -42778,36 +42817,47 @@ function parseGoDocument(markdown) {
42778
42817
  }
42779
42818
  return { quotas, protocols };
42780
42819
  }
42781
- async function readSource(url, signal) {
42820
+ async function readSource(url, signal, etag) {
42782
42821
  const timeout = AbortSignal.timeout(METADATA_FETCH_TIMEOUT_MS);
42783
42822
  try {
42784
42823
  const response = await fetch(url, {
42785
42824
  redirect: "error",
42786
- headers: { ...attributionHeaders2(), accept: "text/plain", "cache-control": "no-cache" },
42825
+ headers: {
42826
+ ...attributionHeaders2(),
42827
+ accept: "text/plain",
42828
+ "cache-control": "no-cache",
42829
+ ...etag === void 0 ? {} : { "if-none-match": etag }
42830
+ },
42787
42831
  signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout])
42788
42832
  });
42833
+ if (response.status === 304) {
42834
+ await response.body?.cancel().catch(() => {
42835
+ });
42836
+ return { url, notModified: true };
42837
+ }
42789
42838
  if (!response.ok) {
42790
42839
  await response.body?.cancel().catch(() => {
42791
42840
  });
42792
- return { failure: `${url} answered HTTP ${response.status}` };
42841
+ return { url, failure: `${url} answered HTTP ${response.status}` };
42793
42842
  }
42794
- const document = parseGoDocument(await readBoundedText(response, GO_DOC_MAX_BYTES));
42843
+ const body = await readBoundedText(response, GO_DOC_MAX_BYTES);
42844
+ const document = parseGoDocument(body);
42795
42845
  if (document.quotas.size === 0 && document.protocols.size === 0) {
42796
- return { failure: `${url} carried neither an allowance table nor an endpoint table` };
42846
+ return { url, failure: `${url} carried neither an allowance table nor an endpoint table` };
42797
42847
  }
42798
- return { document };
42848
+ return { url, document, body, etag: response.headers.get("etag") ?? void 0 };
42799
42849
  } catch (error) {
42800
- return { failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
42850
+ return { url, failure: `${url}: ${error instanceof Error ? error.message : String(error)}` };
42801
42851
  }
42802
42852
  }
42803
42853
  async function fetchGoDocument(signal, options = {}) {
42804
42854
  const failures = [];
42805
- const known = await readSources(GO_DOC_URLS, signal);
42855
+ const known = await readSources(GO_DOC_URLS, signal, options.cacheDir);
42806
42856
  if (known.document !== void 0) return known.document;
42807
42857
  failures.push(...known.failures);
42808
42858
  const remembered = readCachedPaths(options.cacheDir).filter((path5) => !GO_DOC_URLS.some((url) => url.endsWith(path5)));
42809
42859
  if (remembered.length > 0) {
42810
- const cached = await readSources(remembered.flatMap(mirrorsOf), signal);
42860
+ const cached = await readSources(remembered.flatMap(mirrorsOf), signal, options.cacheDir);
42811
42861
  if (cached.document !== void 0) return cached.document;
42812
42862
  failures.push(...cached.failures);
42813
42863
  }
@@ -42821,7 +42871,7 @@ async function fetchGoDocument(signal, options = {}) {
42821
42871
  if (fresh.length === 0) {
42822
42872
  failures.push("the provider repository named the path that was already tried");
42823
42873
  } else {
42824
- const result = await readSources(fresh, signal);
42874
+ const result = await readSources(fresh, signal, options.cacheDir);
42825
42875
  if (result.document !== void 0) return result.document;
42826
42876
  failures.push(...result.failures);
42827
42877
  }
@@ -42831,12 +42881,32 @@ async function fetchGoDocument(signal, options = {}) {
42831
42881
  "DOCUMENT_UNAVAILABLE"
42832
42882
  );
42833
42883
  }
42834
- async function readSources(urls, signal) {
42835
- const attempts = await Promise.all(urls.map((url) => readSource(url, signal)));
42884
+ async function readSources(urls, signal, cacheDir) {
42836
42885
  const failures = [];
42886
+ const cached = readDocCache(cacheDir);
42887
+ if (cached !== void 0 && urls.includes(cached.url)) {
42888
+ const attempt = await readSource(cached.url, signal, cached.etag);
42889
+ if ("notModified" in attempt) {
42890
+ const document = parseGoDocument(cached.body);
42891
+ if (document.quotas.size > 0 || document.protocols.size > 0) {
42892
+ return { document, failures };
42893
+ }
42894
+ failures.push(`${attempt.url} answered 304, and the remembered copy carries no table this build can read`);
42895
+ } else if ("document" in attempt) {
42896
+ writeDocCache(cacheDir, { url: attempt.url, etag: attempt.etag, body: attempt.body });
42897
+ return { document: attempt.document, failures };
42898
+ } else {
42899
+ failures.push(attempt.failure);
42900
+ }
42901
+ }
42902
+ const rest = cached === void 0 ? urls : urls.filter((url) => url !== cached.url);
42903
+ const attempts = await Promise.all(rest.map((url) => readSource(url, signal)));
42837
42904
  for (const attempt of attempts) {
42838
- if ("document" in attempt) return { document: attempt.document, failures };
42839
- failures.push(attempt.failure);
42905
+ if ("document" in attempt) {
42906
+ writeDocCache(cacheDir, { url: attempt.url, etag: attempt.etag, body: attempt.body });
42907
+ return { document: attempt.document, failures };
42908
+ }
42909
+ if ("failure" in attempt) failures.push(attempt.failure);
42840
42910
  }
42841
42911
  return { failures };
42842
42912
  }
@@ -43055,15 +43125,15 @@ function toPiModel(facts, baseURL) {
43055
43125
  }
43056
43126
 
43057
43127
  // 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;
43128
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
43129
+ import { join as join4 } from "node:path";
43130
+ var CACHE_FILE3 = "models-dev-facts.json";
43131
+ var CACHE_VERSION3 = 1;
43062
43132
  function readFactsCache(cacheDir) {
43063
43133
  if (cacheDir === void 0) return void 0;
43064
43134
  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;
43135
+ const parsed = JSON.parse(readFileSync3(join4(cacheDir, CACHE_FILE3), "utf8"));
43136
+ if (parsed.version !== CACHE_VERSION3 || parsed.facts === null || typeof parsed.facts !== "object") return void 0;
43067
43137
  const facts = /* @__PURE__ */ new Map();
43068
43138
  for (const [id, fact] of Object.entries(parsed.facts)) {
43069
43139
  if (fact === null || typeof fact !== "object") continue;
@@ -43083,14 +43153,69 @@ function readFactsCache(cacheDir) {
43083
43153
  function writeFactsCache(cacheDir, facts, etag) {
43084
43154
  if (cacheDir === void 0 || facts.size === 0) return;
43085
43155
  try {
43086
- mkdirSync2(cacheDir, { recursive: true });
43156
+ mkdirSync3(cacheDir, { recursive: true });
43087
43157
  const file = {
43088
- version: CACHE_VERSION2,
43158
+ version: CACHE_VERSION3,
43089
43159
  facts: Object.fromEntries(facts),
43090
43160
  updatedAt: Date.now(),
43091
43161
  ...etag === void 0 ? {} : { etag }
43092
43162
  };
43093
- writeFileSync2(join3(cacheDir, CACHE_FILE2), `${JSON.stringify(file)}
43163
+ writeFileSync3(join4(cacheDir, CACHE_FILE3), `${JSON.stringify(file)}
43164
+ `, "utf8");
43165
+ } catch {
43166
+ }
43167
+ }
43168
+
43169
+ // src/catalog/snapshot-cache.ts
43170
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
43171
+ import { join as join5 } from "node:path";
43172
+ var CACHE_FILE4 = "catalog-snapshot.json";
43173
+ var CACHE_VERSION4 = 1;
43174
+ function readSnapshotCache(cacheDir) {
43175
+ if (cacheDir === void 0) return void 0;
43176
+ try {
43177
+ const parsed = JSON.parse(readFileSync4(join5(cacheDir, CACHE_FILE4), "utf8"));
43178
+ if (parsed.version !== CACHE_VERSION4) return void 0;
43179
+ if (parsed.facts === null || typeof parsed.facts !== "object") return void 0;
43180
+ const facts = /* @__PURE__ */ new Map();
43181
+ for (const [id, fact] of Object.entries(parsed.facts)) {
43182
+ if (fact === null || typeof fact !== "object") continue;
43183
+ if (typeof fact.id !== "string" || typeof fact.api !== "string") continue;
43184
+ facts.set(id, fact);
43185
+ }
43186
+ if (facts.size === 0) return void 0;
43187
+ const quotas = /* @__PURE__ */ new Map();
43188
+ for (const [id, quota] of Object.entries(parsed.quotas ?? {})) {
43189
+ if (quota === null || typeof quota !== "object") continue;
43190
+ quotas.set(id, quota);
43191
+ }
43192
+ const protocols = /* @__PURE__ */ new Map();
43193
+ for (const [id, protocol] of Object.entries(parsed.protocols ?? {})) {
43194
+ if (typeof protocol !== "string") continue;
43195
+ protocols.set(id, protocol);
43196
+ }
43197
+ return {
43198
+ facts,
43199
+ quotas,
43200
+ protocols,
43201
+ updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : 0
43202
+ };
43203
+ } catch {
43204
+ return void 0;
43205
+ }
43206
+ }
43207
+ function writeSnapshotCache(cacheDir, facts, quotas, protocols) {
43208
+ if (cacheDir === void 0 || facts.size === 0) return;
43209
+ try {
43210
+ mkdirSync4(cacheDir, { recursive: true });
43211
+ const file = {
43212
+ version: CACHE_VERSION4,
43213
+ facts: Object.fromEntries(facts),
43214
+ ...quotas.size === 0 ? {} : { quotas: Object.fromEntries(quotas) },
43215
+ ...protocols.size === 0 ? {} : { protocols: Object.fromEntries(protocols) },
43216
+ updatedAt: Date.now()
43217
+ };
43218
+ writeFileSync4(join5(cacheDir, CACHE_FILE4), `${JSON.stringify(file)}
43094
43219
  `, "utf8");
43095
43220
  } catch {
43096
43221
  }
@@ -43158,6 +43283,8 @@ var OpencodeGoCatalog = class {
43158
43283
  document;
43159
43284
  /** Ids that already forced a revalidation, with the time they did. */
43160
43285
  probedAt = /* @__PURE__ */ new Map();
43286
+ /** The background refresh, kept so a caller can wait for it to be over. */
43287
+ backgroundRefresh;
43161
43288
  constructor(options) {
43162
43289
  this.options = options;
43163
43290
  const cached = readFactsCache(options.cacheDir);
@@ -43169,7 +43296,19 @@ var OpencodeGoCatalog = class {
43169
43296
  }
43170
43297
  /**
43171
43298
  * The cached snapshot, refreshed when it is older than the configured TTL.
43172
- * @param force - revalidate every source, ignoring the cache.
43299
+ *
43300
+ * A snapshot that is past its TTL is still served — and refreshed behind
43301
+ * it. A caller that asks for a model must not wait for the network to
43302
+ * learn what it already knew, so the answer is the one in hand and the
43303
+ * refresh goes on without anyone watching it. A caller that forces a
43304
+ * refresh — the settings page's button — waits for it, because asking to
43305
+ * refresh and not waiting for the result is asking for nothing.
43306
+ *
43307
+ * A process that has never built a catalog reads the one it wrote down
43308
+ * last time and refreshes that in the background. The first message of a
43309
+ * session therefore names its model at once, instead of after a wait that
43310
+ * belongs to the network rather than to the session.
43311
+ * @param force - revalidate every source, ignoring the cache, and wait.
43173
43312
  * @param signal - caller cancellation, forwarded to the documentation read,
43174
43313
  * the slowest source and the only one a caller can cancel; a build already
43175
43314
  * in flight keeps the signal of the caller that started it, because one
@@ -43179,6 +43318,19 @@ var OpencodeGoCatalog = class {
43179
43318
  if (!force && this.served !== void 0 && Date.now() - this.served.fetchedAtMs < this.options.refreshMs) {
43180
43319
  return Promise.resolve(this.served);
43181
43320
  }
43321
+ if (!force) {
43322
+ if (this.served === void 0) {
43323
+ const remembered = this.readRememberedSnapshot();
43324
+ if (remembered !== void 0) {
43325
+ this.served = remembered;
43326
+ this.refreshInBackground();
43327
+ return Promise.resolve(remembered);
43328
+ }
43329
+ } else {
43330
+ this.refreshInBackground();
43331
+ return Promise.resolve(this.served);
43332
+ }
43333
+ }
43182
43334
  this.pending ??= this.build(signal).then((snapshot2) => {
43183
43335
  this.served = snapshot2;
43184
43336
  return snapshot2;
@@ -43187,6 +43339,62 @@ var OpencodeGoCatalog = class {
43187
43339
  });
43188
43340
  return this.pending;
43189
43341
  }
43342
+ /**
43343
+ * Refresh the catalog behind the snapshot in hand.
43344
+ *
43345
+ * The build is not awaited and its failure is not reported here: the
43346
+ * snapshot in hand stays, and whoever asked for the refresh will see the
43347
+ * next one.
43348
+ */
43349
+ refreshInBackground() {
43350
+ if (this.pending !== void 0) return;
43351
+ const refresh = this.build().then((snapshot2) => {
43352
+ this.served = snapshot2;
43353
+ return snapshot2;
43354
+ }).finally(() => {
43355
+ this.pending = void 0;
43356
+ });
43357
+ this.pending = refresh;
43358
+ this.backgroundRefresh = refresh;
43359
+ void refresh.catch(() => void 0);
43360
+ }
43361
+ /**
43362
+ * Wait for the background refresh, if one is running.
43363
+ *
43364
+ * The refresh belongs to no caller, so nothing else can wait for it. A
43365
+ * host that is shutting down — or a test that must not have a request
43366
+ * outlive it — needs to know when the quiet is over.
43367
+ * @returns a promise that settles when the background refresh has.
43368
+ */
43369
+ settled() {
43370
+ const refresh = this.backgroundRefresh;
43371
+ return refresh === void 0 ? Promise.resolve() : refresh.then(() => void 0, () => void 0);
43372
+ }
43373
+ /**
43374
+ * The catalog a previous build produced, read from disk.
43375
+ *
43376
+ * It is presented as what it is: a listing from the past, being brought up
43377
+ * to date in the background. The facts it carries are the ones the last
43378
+ * build produced, so the protocols and the thinking levels are the ones
43379
+ * that were true then.
43380
+ * @returns the remembered catalog, or undefined when there is none.
43381
+ */
43382
+ readRememberedSnapshot() {
43383
+ const remembered = readSnapshotCache(this.options.cacheDir);
43384
+ if (remembered === void 0) return void 0;
43385
+ const facts = new Map(remembered.facts);
43386
+ return {
43387
+ facts,
43388
+ unavailable: /* @__PURE__ */ new Map(),
43389
+ provider: buildProvider2(this.options.baseURL, [...facts.values()].map((fact) => toPiModel(fact, this.options.baseURL))),
43390
+ // The listing is from the past, and the page says so.
43391
+ live: false,
43392
+ // And a refresh of it is already under way.
43393
+ cached: true,
43394
+ fetchedAtMs: remembered.updatedAt,
43395
+ ...remembered.quotas.size === 0 ? {} : { documentQuotas: remembered.quotas }
43396
+ };
43397
+ }
43190
43398
  /**
43191
43399
  * The snapshot that can serve one exact model.
43192
43400
  *
@@ -43281,13 +43489,20 @@ var OpencodeGoCatalog = class {
43281
43489
  }
43282
43490
  const fromCache = metadata.status !== "fulfilled" && this.lastMetadata === void 0;
43283
43491
  const online = metadata.status === "fulfilled" ? metadata.value : this.lastMetadata ?? this.cachedMetadata;
43284
- return this.assemble(
43492
+ const snapshot2 = this.assemble(
43285
43493
  listing.value,
43286
43494
  online,
43287
43495
  builtin,
43288
43496
  degradations,
43289
43497
  fromCache ? this.cachedAt : void 0
43290
43498
  );
43499
+ writeSnapshotCache(
43500
+ this.options.cacheDir,
43501
+ snapshot2.facts,
43502
+ this.document?.quotas ?? /* @__PURE__ */ new Map(),
43503
+ this.document?.protocols ?? /* @__PURE__ */ new Map()
43504
+ );
43505
+ return snapshot2;
43291
43506
  }
43292
43507
  /** Merge the listing with the metadata document and the installed entries. */
43293
43508
  assemble(ids, online, builtin, degradations = [], factsCachedAt) {
@@ -45571,6 +45786,7 @@ function parseCatalogReading(value) {
45571
45786
  ...typeof row["error"] === "string" ? { error: row["error"] } : {},
45572
45787
  degradations: parseDegradations(row["degradations"]),
45573
45788
  ...typeof row["factsCachedAt"] === "number" ? { factsCachedAt: row["factsCachedAt"] } : {},
45789
+ ...row["cached"] === true ? { cached: true } : {},
45574
45790
  fetchedAtMs: row["fetchedAtMs"],
45575
45791
  quotaSource: quotaSource ?? "seed",
45576
45792
  counts: {
@@ -45813,6 +46029,7 @@ function catalogReading(snapshot2, visibility, listingFailure, now) {
45813
46029
  ...!snapshot2.live && listingFailure !== void 0 ? { error: listingFailure } : {},
45814
46030
  degradations: snapshot2.degradations ?? [],
45815
46031
  ...snapshot2.factsCachedAt === void 0 ? {} : { factsCachedAt: snapshot2.factsCachedAt },
46032
+ ...snapshot2.cached === void 0 ? {} : { cached: snapshot2.cached },
45816
46033
  fetchedAtMs: snapshot2.fetchedAtMs,
45817
46034
  quotaSource: documentQuotas === void 0 ? "seed" : "document",
45818
46035
  counts: {
@@ -45981,7 +46198,7 @@ function createUpdateChecker(deps) {
45981
46198
  }
45982
46199
 
45983
46200
  // src/update/version.ts
45984
- var PLUGIN_VERSION = true ? "0.1.51" : "0.0.0-dev";
46201
+ var PLUGIN_VERSION = true ? "0.1.53" : "0.0.0-dev";
45985
46202
 
45986
46203
  // src/update/service.ts
45987
46204
  import { TypertRemoteService as TypertRemoteService2 } from "@deepseek-ai/dsh-typert-protocol";
@@ -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,57 @@ 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;
152
163
  constructor(options: CatalogOptions);
153
164
  /**
154
165
  * The cached snapshot, refreshed when it is older than the configured TTL.
155
- * @param force - revalidate every source, ignoring the cache.
166
+ *
167
+ * A snapshot that is past its TTL is still served — and refreshed behind
168
+ * it. A caller that asks for a model must not wait for the network to
169
+ * learn what it already knew, so the answer is the one in hand and the
170
+ * refresh goes on without anyone watching it. A caller that forces a
171
+ * refresh — the settings page's button — waits for it, because asking to
172
+ * refresh and not waiting for the result is asking for nothing.
173
+ *
174
+ * A process that has never built a catalog reads the one it wrote down
175
+ * last time and refreshes that in the background. The first message of a
176
+ * session therefore names its model at once, instead of after a wait that
177
+ * belongs to the network rather than to the session.
178
+ * @param force - revalidate every source, ignoring the cache, and wait.
156
179
  * @param signal - caller cancellation, forwarded to the documentation read,
157
180
  * the slowest source and the only one a caller can cancel; a build already
158
181
  * in flight keeps the signal of the caller that started it, because one
159
182
  * shared snapshot cannot carry two different cancellations.
160
183
  */
161
184
  snapshot(force?: boolean, signal?: AbortSignal): Promise<CatalogSnapshot>;
185
+ /**
186
+ * Refresh the catalog behind the snapshot in hand.
187
+ *
188
+ * The build is not awaited and its failure is not reported here: the
189
+ * snapshot in hand stays, and whoever asked for the refresh will see the
190
+ * next one.
191
+ */
192
+ private refreshInBackground;
193
+ /**
194
+ * Wait for the background refresh, if one is running.
195
+ *
196
+ * The refresh belongs to no caller, so nothing else can wait for it. A
197
+ * host that is shutting down — or a test that must not have a request
198
+ * outlive it — needs to know when the quiet is over.
199
+ * @returns a promise that settles when the background refresh has.
200
+ */
201
+ settled(): Promise<void>;
202
+ /**
203
+ * The catalog a previous build produced, read from disk.
204
+ *
205
+ * It is presented as what it is: a listing from the past, being brought up
206
+ * to date in the background. The facts it carries are the ones the last
207
+ * build produced, so the protocols and the thinking levels are the ones
208
+ * that were true then.
209
+ * @returns the remembered catalog, or undefined when there is none.
210
+ */
211
+ private readRememberedSnapshot;
162
212
  /**
163
213
  * The snapshot that can serve one exact model.
164
214
  *
@@ -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.53",
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",