pi-provider-kimi-code 0.6.13 → 0.6.14

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/src/oauth.ts CHANGED
@@ -218,6 +218,24 @@ export async function requestDeviceAuthorization(): Promise<DeviceAuthorization>
218
218
  throw new Error("Invalid device authorization response");
219
219
  }
220
220
 
221
+ // Guard against a malicious or buggy server handing us a non-web URL
222
+ // (file:, custom scheme, ...) that the OS URL handler would happily open.
223
+ // Mirrors pi 1.5.0's check for the built-in OAuth providers.
224
+ for (const url of [data.verification_uri, verificationUrl]) {
225
+ if (!url) continue;
226
+ let parsed: URL;
227
+ try {
228
+ parsed = new URL(url);
229
+ } catch {
230
+ throw new Error("Device authorization returned an invalid verification URL");
231
+ }
232
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
233
+ throw new Error(
234
+ `Device authorization returned a non-web verification URL (${parsed.protocol})`,
235
+ );
236
+ }
237
+ }
238
+
221
239
  return {
222
240
  user_code: data.user_code,
223
241
  device_code: data.device_code,
@@ -1,5 +1,13 @@
1
1
  // Tool schema deduplication: extract repeated sub-schemas into $defs/$ref
2
2
  // to stay under Moonshot's ~15 KB per-tool function.parameters limit.
3
+ //
4
+ // Known risk (upstream kimi-code #4489-era): the official client does the
5
+ // OPPOSITE for its Anthropic-protocol path — it fully dereferences $ref
6
+ // (normalizeKimiToolSchema) because Anthropic-style serving stacks do not
7
+ // resolve $defs. No managed Kimi model declares `protocol: "anthropic"` in
8
+ // /models yet (verified 2026-11), so dedup is safe on the OpenAI-compatible
9
+ // path this extension uses today. If a managed model ever ships with the
10
+ // anthropic protocol, gate this dedup off for that protocol.
3
11
 
4
12
  import { createHash } from "node:crypto";
5
13
 
@@ -69,6 +69,13 @@ export function setUploadThreshold(config: KimiCodeConfig, thresholdBytes: numbe
69
69
  return { ...config, uploads: { ...config.uploads, thresholdBytes } };
70
70
  }
71
71
 
72
+ export function setThinkingKeep(
73
+ config: KimiCodeConfig,
74
+ thinkingKeep: KimiCodeConfig["model"]["thinkingKeep"],
75
+ ): KimiCodeConfig {
76
+ return { ...config, model: { ...config.model, thinkingKeep } };
77
+ }
78
+
72
79
  export function buildKimiMainTitle(
73
80
  config: KimiCodeConfig,
74
81
  extras: KimiOAuthExtras,
@@ -82,6 +89,7 @@ export function buildKimiMainTitle(
82
89
  `Protocol: ${config.protocol} (${sources.protocol})`,
83
90
  `Region: ${formatRegionValue(config.region)} (${sources.region})`,
84
91
  `Upload threshold: ${formatByteSize(config.uploads.thresholdBytes)} (${sources.uploads.thresholdBytes})`,
92
+ `Thinking keep: ${config.model.thinkingKeep ?? "default"} (${sources.model.thinkingKeep})`,
85
93
  "",
86
94
  "Effective tools:",
87
95
  ...KIMI_TOOL_NAMES.map((toolName) => {
package/src/stream.ts CHANGED
@@ -83,6 +83,16 @@ import {
83
83
  getBaseUrl,
84
84
  } from "./constants.ts";
85
85
  import { getKimiProviderHeaders } from "./device.ts";
86
+ import { improveKimiErrorMessage } from "./errors.ts";
87
+
88
+ function improveErrorEvent(event: AssistantMessageEvent): AssistantMessageEvent {
89
+ if (event.type !== "error") return event;
90
+ const message = event.error?.errorMessage;
91
+ if (!message) return event;
92
+ const improved = improveKimiErrorMessage(message);
93
+ if (improved === message || !event.error) return event;
94
+ return { ...event, error: { ...event.error, errorMessage: improved } };
95
+ }
86
96
  import {
87
97
  isKimiAuthErrorMessage,
88
98
  readStoredOAuthCredential,
@@ -471,10 +481,11 @@ export function streamSimpleKimi(
471
481
  }
472
482
 
473
483
  // First non-start, non-retry event: flush buffered prefix, then
474
- // stream normally.
484
+ // stream normally. Quota-exhaustion errors get an actionable
485
+ // rewrite (hard limit, not a transient 429).
475
486
  for (const e of prefixBuffer) filtered.push(e);
476
487
  prefixBuffer = [];
477
- filtered.push(event);
488
+ filtered.push(improveErrorEvent(event));
478
489
  }
479
490
 
480
491
  // Stream ended normally: flush any remaining buffered starts.
@@ -509,7 +520,9 @@ export function streamSimpleKimi(
509
520
  model: model.id,
510
521
  content: [],
511
522
  stopReason: "error",
512
- errorMessage: err instanceof Error ? err.message : String(err),
523
+ errorMessage: improveKimiErrorMessage(
524
+ err instanceof Error ? err.message : String(err),
525
+ ),
513
526
  usage: {
514
527
  input: 0,
515
528
  output: 0,
@@ -0,0 +1,420 @@
1
+ // Lazy, cached sync of the kimi-datasource catalog.
2
+ //
3
+ // The upstream kimi-datasource plugin moved out of the kimi-code repo into the
4
+ // server-side plugin marketplace (#4525). There is no "list sources" RPC on the
5
+ // /tools backend: the authoritative source list is the `enum` inside the
6
+ // plugin's `bin/kimi-datasource.mjs` tool schema. So the sync pipeline is:
7
+ //
8
+ // GET {base}/coding/v1/plugins/marketplace (no auth required)
9
+ // -> entry "kimi-datasource": { version, source (zip URL), sha256sum }
10
+ // -> zip -> bin/kimi-datasource.mjs -> parse enum + capability text
11
+ //
12
+ // The result is cached on disk for CATALOG_TTL_MS (24h). Callers fall back to
13
+ // stale cache, then to the bundled snapshot, so tool calls never fail just
14
+ // because the marketplace is unreachable.
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { inflateRawSync } from "node:zlib";
20
+
21
+ import { getKimiCodeHome } from "../constants.ts";
22
+ import { getKimiBaseV1 } from "./common.ts";
23
+
24
+ export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
25
+ // After a failed refresh, suppress retries for an hour instead of a full day.
26
+ const CATALOG_RETRY_BACKOFF_MS = 60 * 60 * 1000;
27
+ const CATALOG_FETCH_TIMEOUT_MS = 15_000;
28
+ const MARKETPLACE_PLUGIN_ID = "kimi-datasource";
29
+ const BIN_ENTRY_SUFFIX = "bin/kimi-datasource.mjs";
30
+
31
+ // Bundled snapshot of marketplace kimi-datasource 3.5.1 (2026-11-03). Used when
32
+ // no cache exists and the marketplace cannot be reached.
33
+ export const BUNDLED_DATASOURCE_VERSION = "3.5.1";
34
+ export const BUNDLED_DATASOURCE_SOURCES: readonly string[] = [
35
+ "stock_finance_data",
36
+ "yahoo_finance",
37
+ "world_bank_open_data",
38
+ "tianyancha",
39
+ "arxiv",
40
+ "scholar",
41
+ "yuandian_law",
42
+ "wind",
43
+ "imf",
44
+ "gildata",
45
+ "sec_edgar",
46
+ "sp_data",
47
+ "china_nda",
48
+ "china_nbs",
49
+ "china_standards",
50
+ "who",
51
+ "fao",
52
+ "unsd",
53
+ "ecb",
54
+ "eurostat",
55
+ "unicef",
56
+ "oecd",
57
+ "fred",
58
+ "xhcj",
59
+ "caixin",
60
+ "chembl",
61
+ "materials_project",
62
+ "ncbi_blast",
63
+ "oqmd",
64
+ "pdb",
65
+ "pubchem",
66
+ "uniprot",
67
+ "dongcai",
68
+ "cls",
69
+ "finance_research",
70
+ ];
71
+
72
+ export const BUNDLED_DATASOURCE_CAPABILITIES =
73
+ "stock_finance_data / yahoo_finance = general quotes and financials " +
74
+ "(yahoo_finance FX history is limited to about 2 years); world_bank_open_data = historical macro; " +
75
+ "imf = FX rates, CPI, GDP forecasts, balance of payments; tianyancha = CN company registry; " +
76
+ "arxiv / scholar = papers; yuandian_law = CN laws and cases; " +
77
+ "wind = A-share intraday minute series, funds, bonds (map PE/PB/ROE-style field names via wind_search_fields first); " +
78
+ "gildata = natural-language stock/fund screening; " +
79
+ "sec_edgar = US filings (10-K/10-Q, S-1, Form 4, 13F, 8-K); " +
80
+ "sp_data = S&P fundamentals (consensus estimates, valuation ratios, transcripts); " +
81
+ "china_nda = CN government open data catalogs; china_nbs = CN NBS macro indicators and time series; " +
82
+ "china_standards = CN standards (GB national, HB industry, DB local, TT association); " +
83
+ "who / fao / unsd / ecb / eurostat / unicef / oecd / fred = international organization open data " +
84
+ "(global health, food & agriculture, UN statistics, ECB & EU statistics, child indicators, OECD datasets, US & global macro series); " +
85
+ "xhcj = Xinhua Finance (CNFIC) news flashes, announcements, and policies; " +
86
+ "caixin = Caixin database (600+ data APIs, discover via caixin_api_search first); " +
87
+ "chembl = curated ChEMBL compounds, targets, assays, bioactivities, and mechanisms of action; " +
88
+ "materials_project = Materials Project DFT properties and MP ID lookup (requires MP_API_KEY); " +
89
+ "ncbi_blast = asynchronous nucleotide/protein sequence similarity searches; " +
90
+ "oqmd = OQMD DFT material properties and OPTIMADE crystal structures; " +
91
+ "pdb = RCSB PDB experimental 3D structures, polymer entities, and ligands; " +
92
+ "pubchem = PubChem chemical identities, synonyms, computed properties, and 2D similarity; " +
93
+ "uniprot = UniProt protein sequences, functions, and annotations; " +
94
+ "dongcai = East Money Miaoxiang market intelligence; cls = Cailian Press financial news; " +
95
+ "finance_research = sell-side research reports";
96
+
97
+ export interface DatasourceCatalog {
98
+ readonly version: string;
99
+ readonly sources: readonly string[];
100
+ readonly capabilities: string;
101
+ readonly fetchedAt: number;
102
+ readonly origin: "network" | "cache" | "bundled";
103
+ }
104
+
105
+ interface CatalogCacheFile {
106
+ readonly version?: unknown;
107
+ readonly sources?: unknown;
108
+ readonly capabilities?: unknown;
109
+ readonly fetchedAt?: unknown;
110
+ }
111
+
112
+ export function catalogCachePath(): string {
113
+ const explicit = process.env.KIMI_DATASOURCE_CATALOG_PATH?.trim();
114
+ if (explicit) return explicit;
115
+ return join(getKimiCodeHome(), "kimi-datasource-catalog.json");
116
+ }
117
+
118
+ function parseCacheFile(raw: string): DatasourceCatalog | undefined {
119
+ let parsed: CatalogCacheFile;
120
+ try {
121
+ parsed = JSON.parse(raw) as CatalogCacheFile;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ if (typeof parsed.version !== "string" || !Array.isArray(parsed.sources)) return undefined;
126
+ const sources = parsed.sources.filter((s): s is string => typeof s === "string" && s.length > 0);
127
+ if (sources.length === 0) return undefined;
128
+ const fetchedAt = typeof parsed.fetchedAt === "number" ? parsed.fetchedAt : 0;
129
+ return {
130
+ version: parsed.version,
131
+ sources,
132
+ capabilities:
133
+ typeof parsed.capabilities === "string" && parsed.capabilities.length > 0
134
+ ? parsed.capabilities
135
+ : BUNDLED_DATASOURCE_CAPABILITIES,
136
+ fetchedAt,
137
+ origin: "cache",
138
+ };
139
+ }
140
+
141
+ /** Synchronous cache read for tool-registration time (description embedding). */
142
+ export function readCatalogCacheSync(): DatasourceCatalog | undefined {
143
+ try {
144
+ const path = catalogCachePath();
145
+ if (!existsSync(path)) return undefined;
146
+ return parseCacheFile(readFileSync(path, "utf8"));
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ function writeCatalogCache(catalog: DatasourceCatalog): void {
153
+ try {
154
+ const path = catalogCachePath();
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ writeFileSync(
157
+ path,
158
+ `${JSON.stringify(
159
+ {
160
+ version: catalog.version,
161
+ sources: catalog.sources,
162
+ capabilities: catalog.capabilities,
163
+ fetchedAt: catalog.fetchedAt,
164
+ },
165
+ null,
166
+ 2,
167
+ )}\n`,
168
+ "utf8",
169
+ );
170
+ } catch {
171
+ // Cache is best-effort; a read-only home dir must not break the tool.
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Minimal zip reader (stored + deflate), enough to pull one text entry.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function findEndOfCentralDirectory(buf: Buffer): number {
180
+ const min = Math.max(0, buf.length - 66_000);
181
+ for (let i = buf.length - 22; i >= min; i--) {
182
+ if (buf.readUInt32LE(i) === 0x06054b50) return i;
183
+ }
184
+ throw new Error("invalid zip: end of central directory not found");
185
+ }
186
+
187
+ export function extractZipEntry(buf: Buffer, entrySuffix: string): Buffer | undefined {
188
+ const eocd = findEndOfCentralDirectory(buf);
189
+ const entryCount = buf.readUInt16LE(eocd + 10);
190
+ let offset = buf.readUInt32LE(eocd + 16);
191
+ for (let i = 0; i < entryCount; i++) {
192
+ if (buf.readUInt32LE(offset) !== 0x02014b50) throw new Error("invalid zip: bad central entry");
193
+ const method = buf.readUInt16LE(offset + 10);
194
+ const compressedSize = buf.readUInt32LE(offset + 20);
195
+ const nameLen = buf.readUInt16LE(offset + 28);
196
+ const extraLen = buf.readUInt16LE(offset + 30);
197
+ const commentLen = buf.readUInt16LE(offset + 32);
198
+ const localOffset = buf.readUInt32LE(offset + 42);
199
+ const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
200
+ offset += 46 + nameLen + extraLen + commentLen;
201
+ if (name !== entrySuffix && !name.endsWith(`/${entrySuffix}`)) continue;
202
+
203
+ if (buf.readUInt32LE(localOffset) !== 0x04034b50)
204
+ throw new Error("invalid zip: bad local header");
205
+ const localNameLen = buf.readUInt16LE(localOffset + 26);
206
+ const localExtraLen = buf.readUInt16LE(localOffset + 28);
207
+ const dataStart = localOffset + 30 + localNameLen + localExtraLen;
208
+ const data = buf.subarray(dataStart, dataStart + compressedSize);
209
+ if (method === 0) return Buffer.from(data);
210
+ if (method === 8) return inflateRawSync(data);
211
+ throw new Error(`unsupported zip compression method: ${method}`);
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Parse the datasource bin script: source enum + capability description.
218
+ // ---------------------------------------------------------------------------
219
+
220
+ export function parseDatasourceBin(script: string): {
221
+ sources: string[];
222
+ capabilities?: string;
223
+ } {
224
+ const enumMatch = /enum:\s*\[((?:\s*'[^']*',?\s*)+)\]/.exec(script);
225
+ if (!enumMatch) throw new Error("datasource bin: enum block not found");
226
+ const sources = [...enumMatch[1].matchAll(/'([^']+)'/g)]
227
+ .map((m) => m[1])
228
+ .filter((s) => s.length > 0);
229
+ if (sources.length === 0) throw new Error("datasource bin: enum block is empty");
230
+
231
+ let capabilities: string | undefined;
232
+ const descMatch = /description:\s*((?:\s*'[^']*'\s*\+?\s*)+)/.exec(script.slice(enumMatch.index));
233
+ if (descMatch) {
234
+ const text = [...descMatch[1].matchAll(/'([^']*)'/g)].map((m) => m[1]).join("");
235
+ if (text.length > 0) capabilities = text;
236
+ }
237
+ return { sources, capabilities };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Marketplace fetch
242
+ // ---------------------------------------------------------------------------
243
+
244
+ interface MarketplaceEntry {
245
+ readonly version?: unknown;
246
+ readonly source?: unknown;
247
+ readonly sha256sum?: unknown;
248
+ }
249
+
250
+ async function fetchMarketplaceEntry(
251
+ fetchImpl: typeof fetch,
252
+ signal: AbortSignal,
253
+ ): Promise<{ version: string; source: string; sha256sum?: string } | undefined> {
254
+ const url = `${getKimiBaseV1()}/plugins/marketplace`;
255
+ const response = await fetchImpl(url, { signal });
256
+ if (!response.ok) throw new Error(`marketplace fetch failed: HTTP ${response.status}`);
257
+ const body = (await response.json()) as { plugins?: MarketplaceEntry[] };
258
+ const entry = (body.plugins ?? []).find((p) => {
259
+ const id = (p as Record<string, unknown>).id;
260
+ return id === MARKETPLACE_PLUGIN_ID;
261
+ });
262
+ if (!entry || typeof entry.source !== "string" || entry.source.length === 0) return undefined;
263
+ return {
264
+ version: typeof entry.version === "string" ? entry.version : "unknown",
265
+ source: entry.source,
266
+ sha256sum: typeof entry.sha256sum === "string" ? entry.sha256sum : undefined,
267
+ };
268
+ }
269
+
270
+ async function fetchCatalogFromNetwork(
271
+ fetchImpl: typeof fetch,
272
+ cached: DatasourceCatalog | undefined,
273
+ now: number,
274
+ signal?: AbortSignal,
275
+ ): Promise<DatasourceCatalog | undefined> {
276
+ const timeout = AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS);
277
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
278
+ const entry = await fetchMarketplaceEntry(fetchImpl, combined);
279
+ if (!entry) return undefined;
280
+
281
+ // Same marketplace version as the cache: the catalog cannot have changed,
282
+ // so just renew the TTL without downloading the archive again.
283
+ if (cached && entry.version === cached.version) {
284
+ const renewed: DatasourceCatalog = { ...cached, fetchedAt: now, origin: "cache" };
285
+ writeCatalogCache(renewed);
286
+ return renewed;
287
+ }
288
+
289
+ const zipResponse = await fetchImpl(entry.source, { signal: combined });
290
+ if (!zipResponse.ok) throw new Error(`archive fetch failed: HTTP ${zipResponse.status}`);
291
+ const zipBuf = Buffer.from(await zipResponse.arrayBuffer());
292
+ if (entry.sha256sum) {
293
+ const digest = createHash("sha256").update(zipBuf).digest("hex");
294
+ if (digest !== entry.sha256sum.toLowerCase()) {
295
+ throw new Error("archive sha256 mismatch");
296
+ }
297
+ }
298
+ const bin = extractZipEntry(zipBuf, BIN_ENTRY_SUFFIX);
299
+ if (!bin) throw new Error("archive is missing the datasource bin entry");
300
+ const parsed = parseDatasourceBin(bin.toString("utf8"));
301
+ const catalog: DatasourceCatalog = {
302
+ version: entry.version,
303
+ sources: parsed.sources,
304
+ capabilities: parsed.capabilities ?? BUNDLED_DATASOURCE_CAPABILITIES,
305
+ fetchedAt: now,
306
+ origin: "network",
307
+ };
308
+ writeCatalogCache(catalog);
309
+ return catalog;
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Public lazy accessor with 24h caching. Never throws.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ let memoryCatalog: DatasourceCatalog | undefined;
317
+ let inFlight: Promise<DatasourceCatalog> | undefined;
318
+
319
+ export interface EnsureCatalogOptions {
320
+ readonly fetchImpl?: typeof fetch;
321
+ /** Skip the TTL check and hit the network (used to re-validate unknown names). */
322
+ readonly forceRefresh?: boolean;
323
+ readonly signal?: AbortSignal;
324
+ readonly now?: () => number;
325
+ }
326
+
327
+ function bundledCatalog(): DatasourceCatalog {
328
+ return {
329
+ version: BUNDLED_DATASOURCE_VERSION,
330
+ sources: BUNDLED_DATASOURCE_SOURCES,
331
+ capabilities: BUNDLED_DATASOURCE_CAPABILITIES,
332
+ fetchedAt: 0,
333
+ origin: "bundled",
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Synchronous best-known catalog for validating a call without blocking on
339
+ * the network: memory, then disk (any age), then the bundled snapshot.
340
+ */
341
+ export function peekDatasourceCatalog(): DatasourceCatalog {
342
+ return memoryCatalog ?? readCatalogCacheSync() ?? bundledCatalog();
343
+ }
344
+
345
+ /** Fire-and-forget refresh when the best-known catalog is past its TTL. */
346
+ export function refreshDatasourceCatalogInBackground(fetchImpl?: typeof fetch): void {
347
+ const now = Date.now();
348
+ if (memoryCatalog && now - memoryCatalog.fetchedAt < CATALOG_TTL_MS) return;
349
+ const disk = readCatalogCacheSync();
350
+ if (disk && now - disk.fetchedAt < CATALOG_TTL_MS) {
351
+ memoryCatalog = disk;
352
+ return;
353
+ }
354
+ void refreshCatalog(fetchImpl ?? fetch, disk, now);
355
+ }
356
+
357
+ export async function ensureDatasourceCatalog(
358
+ options: EnsureCatalogOptions = {},
359
+ ): Promise<DatasourceCatalog> {
360
+ const now = options.now?.() ?? Date.now();
361
+ const fetchImpl = options.fetchImpl ?? fetch;
362
+
363
+ if (!options.forceRefresh) {
364
+ if (memoryCatalog && now - memoryCatalog.fetchedAt < CATALOG_TTL_MS) return memoryCatalog;
365
+ const disk = readCatalogCacheSync();
366
+ if (disk && now - disk.fetchedAt < CATALOG_TTL_MS) {
367
+ memoryCatalog = disk;
368
+ return disk;
369
+ }
370
+ return refreshCatalog(fetchImpl, disk, now, options.signal);
371
+ }
372
+
373
+ // Forced re-validation: network only, keep the previous catalog on failure.
374
+ const previous = memoryCatalog ?? readCatalogCacheSync();
375
+ try {
376
+ const fresh = await fetchCatalogFromNetwork(fetchImpl, previous, now, options.signal);
377
+ if (fresh) {
378
+ memoryCatalog = fresh;
379
+ return fresh;
380
+ }
381
+ } catch {
382
+ // fall through to whatever we had
383
+ }
384
+ return previous ?? bundledCatalog();
385
+ }
386
+
387
+ function refreshCatalog(
388
+ fetchImpl: typeof fetch,
389
+ disk: DatasourceCatalog | undefined,
390
+ now: number,
391
+ signal?: AbortSignal,
392
+ ): Promise<DatasourceCatalog> {
393
+ if (inFlight) return inFlight;
394
+ inFlight = (async () => {
395
+ try {
396
+ const fresh = await fetchCatalogFromNetwork(fetchImpl, disk, now, signal);
397
+ if (fresh) {
398
+ memoryCatalog = fresh;
399
+ return fresh;
400
+ }
401
+ } catch {
402
+ // fall through to stale/bundled
403
+ } finally {
404
+ inFlight = undefined;
405
+ }
406
+ const fallback = disk ?? bundledCatalog();
407
+ // Mark the fallback with the attempt time (minus most of the TTL) so a
408
+ // network outage does not trigger a fetch on every tool call, but a
409
+ // transient failure still retries after the backoff window.
410
+ memoryCatalog = { ...fallback, fetchedAt: now - (CATALOG_TTL_MS - CATALOG_RETRY_BACKOFF_MS) };
411
+ return memoryCatalog;
412
+ })();
413
+ return inFlight;
414
+ }
415
+
416
+ /** Test hook: drop the in-memory catalog and in-flight refresh. */
417
+ export function resetDatasourceCatalogForTests(): void {
418
+ memoryCatalog = undefined;
419
+ inFlight = undefined;
420
+ }