@viztor/dsh-tinyfish 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @viztor/dsh-tinyfish might be problematic. Click here for more details.

package/lib/index.mjs ADDED
@@ -0,0 +1,825 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { WebError } from "@deepseek-ai/dsh-web";
9
+ //#region src/client.ts
10
+ /** Monid REST base. */
11
+ const DEFAULT_MONID_BASE = "https://api.monid.ai";
12
+ /** TinyFish's own search and fetch bases. */
13
+ const DEFAULT_SEARCH_BASE = "https://api.search.tinyfish.ai";
14
+ const DEFAULT_FETCH_BASE = "https://api.fetch.tinyfish.ai";
15
+ /** Where the `monid` CLI keeps the platform key. */
16
+ const DEFAULT_CREDENTIALS = "~/.config/monid/credentials.yaml";
17
+ /** Where the official `tinyfish` CLI keeps its key (the same file it reads). */
18
+ const DEFAULT_TINYFISH_CONFIG = "~/.tinyfish/config.json";
19
+ /** Base backoff between attempts; doubles per attempt. */
20
+ const DEFAULT_RETRY_DELAY_MS = 1200;
21
+ /** Poll cadence and ceiling for an async Monid run. */
22
+ const DEFAULT_POLL_MS = 1500;
23
+ const DEFAULT_MAX_POLLS = 40;
24
+ /**
25
+ * Monid sits behind Cloudflare, which rejects the default fetch/undici
26
+ * user-agent with a 403 `browser_signature_banned`. The direct API is fine
27
+ * either way, so one browser UA is sent on every request.
28
+ */
29
+ const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
30
+ const WEB_PROVIDER_ERROR = "WEB_PROVIDER_ERROR";
31
+ const WEB_PROVIDER_CREDENTIAL_MISSING = "WEB_PROVIDER_CREDENTIAL_MISSING";
32
+ const WEB_ABORTED = "WEB_ABORTED";
33
+ /**
34
+ * A `WebError` the retry loop may try again.
35
+ *
36
+ * A subclass rather than a flag on the error, because `WebError` is the
37
+ * seam's own type and adding a field to it would put a property on something
38
+ * the harness renders. `instanceof WebError` still holds, so cancellation and
39
+ * error routing downstream are unaffected.
40
+ *
41
+ * Transient covers rate limits and upstream 5xx — the shapes that are worth
42
+ * another attempt. It is deliberately not a code: the retry loop asks "would
43
+ * this probably work next time", and one code cannot answer that for both a
44
+ * 429 and a 503.
45
+ */
46
+ var TransientWebError = class extends WebError {
47
+ constructor(message, code, options) {
48
+ super(message, code, options);
49
+ this.name = "TransientWebError";
50
+ }
51
+ };
52
+ /** True for an error the retry loop is allowed to try again. */
53
+ const isTransient = (error) => error instanceof TransientWebError;
54
+ /**
55
+ * Build the provider's stable cancellation error.
56
+ *
57
+ * The caller's `reason` is kept as the `cause` when the signal has already
58
+ * fired, so the harness's own abort reason survives instead of being replaced
59
+ * by a generic string.
60
+ */
61
+ const aborted = (signal, fallback) => new WebError("TinyFish request aborted", WEB_ABORTED, { cause: signal?.aborted ? signal.reason : fallback });
62
+ /**
63
+ * True for a fetch/`AbortSignal` abort.
64
+ *
65
+ * `signal.aborted` alone is not enough: a request can be cancelled by a
66
+ * timeout racing an in-flight `fetch`, in which case the signal never fired
67
+ * and the rejection arrives as a `DOMException` named `AbortError`. Treating
68
+ * that as a transport failure would surface a user-initiated stop as an
69
+ * upstream error.
70
+ */
71
+ const isAbortError = (error) => error instanceof Error && error.name === "AbortError";
72
+ /** Throw the stable cancellation error when the caller has already aborted. */
73
+ const throwIfAborted = (signal) => {
74
+ if (signal?.aborted) throw aborted(signal);
75
+ };
76
+ /**
77
+ * Race an operation against the caller's cancellation.
78
+ *
79
+ * The settlement handlers stay attached after an abort, so a late rejection
80
+ * from the abandoned operation cannot become an unhandled rejection — the
81
+ * usual failure mode of naively wrapping a promise in a race.
82
+ */
83
+ async function abortable(operation, signal) {
84
+ if (!signal) return operation;
85
+ throwIfAborted(signal);
86
+ const cancelled = new Promise((_resolve, reject) => {
87
+ const onAbort = () => {
88
+ reject(aborted(signal));
89
+ };
90
+ signal.addEventListener("abort", onAbort, { once: true });
91
+ });
92
+ return Promise.race([operation, cancelled]);
93
+ }
94
+ /** Sleep that rejects promptly when the caller's signal aborts. */
95
+ async function sleep(ms, signal) {
96
+ return new Promise((resolve, reject) => {
97
+ if (signal?.aborted) {
98
+ reject(aborted(signal));
99
+ return;
100
+ }
101
+ const timer = setTimeout(resolve, ms);
102
+ signal?.addEventListener("abort", () => {
103
+ clearTimeout(timer);
104
+ reject(aborted(signal));
105
+ }, { once: true });
106
+ });
107
+ }
108
+ /** Expand a leading `~`. */
109
+ const home = (path) => path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
110
+ /**
111
+ * Read the active key out of the monid CLI's credentials file.
112
+ *
113
+ * The file is YAML, but its shape is fixed and tiny — `active_key:` plus one
114
+ * indented `key:` per entry — so a targeted read beats pulling a YAML parser
115
+ * into a plugin with no other use for one.
116
+ */
117
+ function readMonidCredentials(path) {
118
+ let text;
119
+ try {
120
+ text = readFileSync(home(path), "utf8");
121
+ } catch {
122
+ return "";
123
+ }
124
+ const active = /^active_key:\s*(\S+)\s*$/m.exec(text)?.[1];
125
+ const parts = text.split(/^(\s{2,})(\S+):\s*$/m);
126
+ const entries = /* @__PURE__ */ new Map();
127
+ for (let i = 1; i + 2 < parts.length; i += 3) {
128
+ const name = parts[i + 1];
129
+ const body = parts[i + 2];
130
+ if (name !== void 0 && body !== void 0) entries.set(name, body);
131
+ }
132
+ const keyIn = (body) => /^\s+key:\s*(\S+)\s*$/m.exec(body ?? "")?.[1];
133
+ if (active) {
134
+ const chosen = keyIn(entries.get(active));
135
+ if (chosen) return chosen;
136
+ }
137
+ for (const body of entries.values()) {
138
+ const key = keyIn(body);
139
+ if (key) return key;
140
+ }
141
+ return keyIn(text) ?? "";
142
+ }
143
+ /**
144
+ * Read the key the official `tinyfish` CLI already stored.
145
+ *
146
+ * Reusing that file is deliberate: the user has already authenticated the CLI
147
+ * (`tinyfish auth login`), and reading the same store means the plugin has no
148
+ * separate credential to provision, rotate, or leak.
149
+ */
150
+ function readTinyfishConfig(path) {
151
+ try {
152
+ const config = JSON.parse(readFileSync(home(path), "utf8"));
153
+ if (config && typeof config === "object" && "api_key" in config) {
154
+ const value = config.api_key;
155
+ return typeof value === "string" ? value.trim() : "";
156
+ }
157
+ return "";
158
+ } catch {
159
+ return "";
160
+ }
161
+ }
162
+ /**
163
+ * Resolve the credential for one channel.
164
+ *
165
+ * Explicit config wins, then the environment, then the channel's own
166
+ * credential store. Stores are re-read per call rather than cached at module
167
+ * load: they are a few hundred bytes, and caching would pin a rotated key
168
+ * inside a long-lived host process.
169
+ */
170
+ function resolveApiKey(channel, options = {}) {
171
+ const explicit = options.apiKey?.trim();
172
+ if (explicit) return explicit;
173
+ const env = options.env ?? process.env;
174
+ if (channel === "monid") {
175
+ const fromEnv = env.MONID_API_KEY ?? env.MONID_MCP_TOKEN;
176
+ if (fromEnv?.trim()) return fromEnv.trim();
177
+ return readMonidCredentials(options.credentialsPath ?? DEFAULT_CREDENTIALS);
178
+ }
179
+ const direct = env.TINYFISH_API_KEY;
180
+ if (direct?.trim()) return direct.trim();
181
+ return readTinyfishConfig(options.tinyfishConfigPath ?? DEFAULT_TINYFISH_CONFIG);
182
+ }
183
+ /**
184
+ * The same resolution, with the harness credentials service consulted first.
185
+ *
186
+ * The split is deliberate rather than an oversight: `available()` must be a
187
+ * cheap synchronous check that never awaits, and it uses the synchronous form.
188
+ * Only the credential lookup is async, and only when a service is present.
189
+ */
190
+ async function resolveApiKeyAsync(channel, options = {}) {
191
+ const explicit = options.apiKey?.trim();
192
+ if (explicit) return explicit;
193
+ const { apiKeyEnv, monidKeyEnv, resolveCredential } = options;
194
+ const ref = channel === "monid" ? monidKeyEnv : apiKeyEnv;
195
+ throwIfAborted(options.signal);
196
+ if (ref && resolveCredential) try {
197
+ const trimmed = (await abortable(Promise.resolve(resolveCredential(ref)), options.signal))?.trim();
198
+ if (trimmed) return trimmed;
199
+ } catch (error) {
200
+ if (options.signal?.aborted) throw aborted(options.signal, error);
201
+ }
202
+ return resolveApiKey(channel, options);
203
+ }
204
+ /**
205
+ * Assert that a channel is configured, naming the fix in the message.
206
+ *
207
+ * Deliberately a statement rather than an accessor: the caller resolves the
208
+ * key itself and must use that same value, so this only ever throws.
209
+ */
210
+ function requireKey(channel, key) {
211
+ if (key) return;
212
+ if (channel === "monid") throw new WebError("The tinyfish provider has no Monid API key. Run `monid keys add` to store one in the local credential file, or export MONID_API_KEY, or set the web-tinyfish `apiKeyEnv` row in the profile's cordis.patch.yml.", WEB_PROVIDER_CREDENTIAL_MISSING);
213
+ throw new WebError("The tinyfish provider has no TinyFish API key. Run `tinyfish auth login` to save one, or export TINYFISH_API_KEY, or set the web-tinyfish `apiKeyEnv` row in the profile's cordis.patch.yml — or switch the provider's channel to 'monid' to use a Monid key instead.", WEB_PROVIDER_CREDENTIAL_MISSING);
214
+ }
215
+ /** `fetch` with auth applied and failures mapped to `WebError`. */
216
+ async function call(url, options) {
217
+ const { channel, key, init, signal } = options;
218
+ const headers = {
219
+ "User-Agent": USER_AGENT,
220
+ ...channel === "monid" ? {
221
+ Authorization: `Bearer ${key}`,
222
+ "Content-Type": "application/json"
223
+ } : { "X-API-Key": key },
224
+ ...init.headers
225
+ };
226
+ let response;
227
+ try {
228
+ response = await fetch(url, {
229
+ ...init,
230
+ headers,
231
+ redirect: "error",
232
+ ...signal ? { signal } : {}
233
+ });
234
+ } catch (error) {
235
+ if (signal?.aborted) throw aborted(signal);
236
+ if (isAbortError(error)) throw aborted(signal, error);
237
+ throw new WebError(`TinyFish request to ${url} failed: ${String(error)}`, WEB_PROVIDER_ERROR, { cause: error });
238
+ }
239
+ if (response.status === 401 || response.status === 403) {
240
+ const fingerprint = createHash("sha256").update(key, "utf8").digest("hex").slice(0, 12);
241
+ throw new WebError(`TinyFish rejected the ${channel} API key (HTTP ${response.status}, key sha256:${fingerprint}). Refresh it, or switch the provider's channel.`, WEB_PROVIDER_ERROR);
242
+ }
243
+ if (response.status === 429) throw new TransientWebError("TinyFish rate limit reached (HTTP 429).", WEB_PROVIDER_ERROR);
244
+ if (!response.ok) throw new WebError(`TinyFish returned HTTP ${response.status} for ${url}`, WEB_PROVIDER_ERROR);
245
+ try {
246
+ return await response.json();
247
+ } catch (error) {
248
+ throw new WebError(`TinyFish returned a non-JSON body for ${url}`, WEB_PROVIDER_ERROR, { cause: error });
249
+ }
250
+ }
251
+ /** Query string for the direct channel, skipping empty filters. */
252
+ function searchQueryString(params) {
253
+ const search = new URLSearchParams();
254
+ for (const [key, value] of Object.entries(params)) {
255
+ if (value === void 0 || value === null || value === "") continue;
256
+ search.set(key, String(value));
257
+ }
258
+ return search.toString();
259
+ }
260
+ /** One search through the monid channel, polling if the run is async. */
261
+ async function searchMonid(options) {
262
+ const { key, base, params, signal, pollMs, maxPolls } = options;
263
+ let envelope = await call(`${base}/v1/run`, {
264
+ channel: "monid",
265
+ key,
266
+ signal,
267
+ init: {
268
+ method: "POST",
269
+ body: JSON.stringify({
270
+ provider: "tinyfish",
271
+ endpoint: "/search",
272
+ input: { queryParams: params }
273
+ })
274
+ }
275
+ });
276
+ let polls = 0;
277
+ while (envelope.status === "RUNNING" && polls < maxPolls) {
278
+ throwIfAborted(signal);
279
+ polls += 1;
280
+ await sleep(pollMs, signal);
281
+ envelope = await call(`${base}/v1/run`, {
282
+ channel: "monid",
283
+ key,
284
+ signal,
285
+ init: {
286
+ method: "POST",
287
+ body: JSON.stringify({ runId: envelope.runId })
288
+ }
289
+ });
290
+ }
291
+ if (envelope.status === "RUNNING") throw new WebError(`TinyFish run ${envelope.runId ?? "?"} did not settle within ${polls} polls`, WEB_PROVIDER_ERROR);
292
+ assertUsableRun(envelope);
293
+ return envelope.output ?? {};
294
+ }
295
+ /** Raise for a BLOCKED / FAILED run, which retrying will not fix. */
296
+ function assertUsableRun(envelope) {
297
+ if (envelope.status === "BLOCKED") {
298
+ const { reason } = envelope;
299
+ const detail = reason && typeof reason === "object" ? [reason.reason, ...reason.hints ?? []].filter(Boolean).join(" ") : String(reason ?? "");
300
+ throw new WebError(`The Monid workspace blocked this run${detail ? `: ${detail}` : "."} Top up at https://app.monid.ai/wallet.`, WEB_PROVIDER_ERROR);
301
+ }
302
+ if (envelope.status === "FAILED" || envelope.status === "TIMED_OUT") throw new WebError(`TinyFish run ${envelope.runId ?? "?"} ended ${envelope.status}`, WEB_PROVIDER_ERROR);
303
+ const provider = envelope.providerResponse;
304
+ if (!envelope.output && (provider?.error || (provider?.httpStatus ?? 0) >= 500)) {
305
+ const message = extractProviderMessage(provider?.error);
306
+ throw new TransientWebError(`TinyFish is temporarily unavailable${message ? `: ${message}` : "."}`, WEB_PROVIDER_ERROR);
307
+ }
308
+ }
309
+ /** Dig a human message out of Monid's nested provider error, if there is one. */
310
+ function extractProviderMessage(error) {
311
+ if (!error || typeof error !== "object") return "";
312
+ const message = error.error?.message;
313
+ return typeof message === "string" ? message : "";
314
+ }
315
+ /** One fetch through the monid channel. */
316
+ async function fetchMonid(options) {
317
+ const { key, base, body, signal } = options;
318
+ const envelope = await call(`${base}/v1/run`, {
319
+ channel: "monid",
320
+ key,
321
+ signal,
322
+ init: {
323
+ method: "POST",
324
+ body: JSON.stringify({
325
+ provider: "tinyfish",
326
+ endpoint: "/fetch",
327
+ input: { body }
328
+ })
329
+ }
330
+ });
331
+ assertUsableRun(envelope);
332
+ return envelope.output ?? {};
333
+ }
334
+ /**
335
+ * Run one operation with a bounded retry.
336
+ *
337
+ * Two transient shapes are worth retrying, and both are observed in practice:
338
+ * a `SERVICE_BUSY`/5xx envelope from the monid channel, and a `/search` that
339
+ * answers a perfectly valid query with zero results (roughly one run in three).
340
+ * The endpoints are $0, so an empty result is worth a couple more attempts
341
+ * before it is believed. A BLOCKED run never retries.
342
+ */
343
+ async function withRetry(operation, policy) {
344
+ const { attempts, signal, delayMs = DEFAULT_RETRY_DELAY_MS, retryWhen, onRetry } = policy;
345
+ let lastError;
346
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
347
+ try {
348
+ const value = await operation();
349
+ if (attempt > 1) onRetry?.(attempt, attempts, lastError);
350
+ if (!retryWhen?.(value) || attempt === attempts) return value;
351
+ onRetry?.(attempt, attempts);
352
+ } catch (error) {
353
+ if (signal?.aborted) throw aborted(signal, error);
354
+ if (isAbortError(error)) throw aborted(signal, error);
355
+ if (!isTransient(error)) throw error;
356
+ if (attempt === attempts) throw error;
357
+ lastError = error;
358
+ onRetry?.(attempt, attempts, error);
359
+ }
360
+ await sleep(delayMs * attempt, signal);
361
+ }
362
+ throw lastError ?? new WebError("TinyFish gave up", "WEB_PROVIDER_ERROR");
363
+ }
364
+ /**
365
+ * Search TinyFish. Returns the upstream payload for both channels:
366
+ * `{ query, results[], total_results, page }`.
367
+ */
368
+ async function tinyfishSearch(options) {
369
+ const { channel, query, apiKey, apiKeyEnv, monidKeyEnv, resolveCredential, credentialsPath, tinyfishConfigPath, filters = {}, monidBase = DEFAULT_MONID_BASE, searchBase = DEFAULT_SEARCH_BASE, signal, attempts = 3, delayMs, onRetry, pollMs = DEFAULT_POLL_MS, maxPolls = DEFAULT_MAX_POLLS } = options;
370
+ const key = await resolveApiKeyAsync(channel, {
371
+ apiKey,
372
+ apiKeyEnv,
373
+ monidKeyEnv,
374
+ resolveCredential,
375
+ credentialsPath,
376
+ env: options.env,
377
+ tinyfishConfigPath,
378
+ signal
379
+ });
380
+ requireKey(channel, key);
381
+ const params = {
382
+ query,
383
+ ...filters
384
+ };
385
+ return withRetry(async () => channel === "monid" ? searchMonid({
386
+ key,
387
+ base: monidBase,
388
+ params,
389
+ signal,
390
+ pollMs,
391
+ maxPolls
392
+ }) : call(`${searchBase}?${searchQueryString(params)}`, {
393
+ channel: "direct",
394
+ key,
395
+ signal,
396
+ init: { method: "GET" }
397
+ }), {
398
+ attempts,
399
+ signal,
400
+ delayMs,
401
+ retryWhen: (payload) => {
402
+ const value = payload;
403
+ return !Array.isArray(value?.results) || value.results.length === 0;
404
+ },
405
+ onRetry: (attempt, total) => onRetry?.(attempt, total)
406
+ });
407
+ }
408
+ /**
409
+ * Fetch up to 10 URLs as clean Markdown. Returns the upstream payload for both
410
+ * channels: `{ results[], errors[] }`.
411
+ */
412
+ async function tinyfishFetch(options) {
413
+ const { channel, urls, apiKey, apiKeyEnv, monidKeyEnv, resolveCredential, credentialsPath, tinyfishConfigPath, purpose, monidBase = DEFAULT_MONID_BASE, fetchBase = DEFAULT_FETCH_BASE, signal, attempts = 3, delayMs, onRetry } = options;
414
+ const key = await resolveApiKeyAsync(channel, {
415
+ apiKey,
416
+ apiKeyEnv,
417
+ monidKeyEnv,
418
+ resolveCredential,
419
+ credentialsPath,
420
+ env: options.env,
421
+ tinyfishConfigPath,
422
+ signal
423
+ });
424
+ requireKey(channel, key);
425
+ const body = {
426
+ urls,
427
+ format: "markdown"
428
+ };
429
+ if (purpose) body.purpose = purpose;
430
+ return withRetry(async () => channel === "monid" ? fetchMonid({
431
+ key,
432
+ base: monidBase,
433
+ body,
434
+ signal
435
+ }) : call(fetchBase, {
436
+ channel: "direct",
437
+ key,
438
+ signal,
439
+ init: {
440
+ method: "POST",
441
+ body: JSON.stringify(body)
442
+ }
443
+ }), {
444
+ attempts,
445
+ signal,
446
+ delayMs,
447
+ onRetry: (attempt, total) => onRetry?.(attempt, total)
448
+ });
449
+ }
450
+ //#endregion
451
+ //#region src/provider.ts
452
+ /**
453
+ * The two `ctx.web` providers.
454
+ *
455
+ * Both are thin: dispatch through the transport, then normalise TinyFish's
456
+ * payload into the seam's vocabulary. The seam owns `maxResults` truncation,
457
+ * cancellation, error codes and the tool card, so nothing here re-implements
458
+ * any of that.
459
+ *
460
+ * @module dsh-tinyfish/provider
461
+ */
462
+ /** Stable id these providers register under. */
463
+ const TINYFISH_PROVIDER_ID = "tinyfish";
464
+ /**
465
+ * TinyFish reports dates as human strings ("Apr 30, 2026", "1 year ago"), but
466
+ * `WebSearchSource.publishedAt` is contractually an ISO-8601 string. Rather
467
+ * than pass a value the type does not promise, coerce what parses and drop the
468
+ * rest — a missing date is honest, a malformed one is not.
469
+ *
470
+ * A date carrying no timezone is read as UTC, not local. `Date.parse("Apr 30,
471
+ * 2026")` means local midnight, so `.toISOString()` would shift the day for any
472
+ * host east or west of Greenwich — the same page would report a different
473
+ * `publishedAt` depending on where the Worker ran. These strings are date-only,
474
+ * so UTC midnight is both the stable reading and the one that keeps the day the
475
+ * publisher actually meant.
476
+ */
477
+ function toIsoDate(value) {
478
+ if (typeof value !== "string" || !value.trim()) return void 0;
479
+ const text = value.trim();
480
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text) || /\d{1,2}:\d{2}/.test(text);
481
+ let candidate = text;
482
+ if (!zoned) {
483
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text)) candidate = `${text}T00:00:00Z`;
484
+ else if (/^[A-Za-z]{3,9}\s+\d{1,2},\s*\d{4}$/.test(text)) candidate = `${text} UTC`;
485
+ }
486
+ const parsed = Date.parse(candidate);
487
+ if (Number.isNaN(parsed)) return void 0;
488
+ return new Date(parsed).toISOString();
489
+ }
490
+ /**
491
+ * TinyFish search through the `ctx.web` search seam.
492
+ *
493
+ * The seam's request is only `{query, maxResults}`; everything else
494
+ * (`domainType`, `location`, `includeDomains`, …) comes from plugin config and
495
+ * applies to every query, which is the shape these filters actually want.
496
+ */
497
+ var TinyfishSearchProvider = class {
498
+ id = TINYFISH_PROVIDER_ID;
499
+ resolveOptions;
500
+ /**
501
+ * @param resolveOptions - a thunk, not a value: the plugin's settings section
502
+ * can change between searches, and re-registering the provider to carry a
503
+ * new config would make the seam's selection flicker for the user.
504
+ */
505
+ constructor(resolveOptions) {
506
+ this.resolveOptions = resolveOptions;
507
+ }
508
+ /**
509
+ * Cheap local usability check. Must not make network calls — the seam calls
510
+ * this to decide between providers, and a network call here would turn
511
+ * selection into a latency spike on every search.
512
+ *
513
+ * Checks the same things the shipped providers do: a credential is
514
+ * resolvable *and* both endpoints parse as URLs. A misconfigured base is a
515
+ * setup mistake worth surfacing at selection time rather than as a 404 later.
516
+ */
517
+ available() {
518
+ const options = this.resolveOptions();
519
+ return Boolean(options.search && hasCredential(options) && URL.canParse(options.searchBase) && (options.channel === "direct" || URL.canParse(options.monidBase)));
520
+ }
521
+ async search(request, signal) {
522
+ const options = this.resolveOptions();
523
+ const payload = await tinyfishSearch({
524
+ channel: options.channel,
525
+ apiKey: options.apiKey,
526
+ apiKeyEnv: options.apiKeyEnv,
527
+ monidKeyEnv: options.monidKeyEnv,
528
+ resolveCredential: options.resolveCredential,
529
+ query: request.query,
530
+ filters: { ...options.filters },
531
+ monidBase: options.monidBase,
532
+ searchBase: options.searchBase,
533
+ signal,
534
+ attempts: options.attempts
535
+ });
536
+ return {
537
+ sources: (Array.isArray(payload?.results) ? payload.results : []).filter((row) => typeof row?.url === "string" && row.url !== "").map((row) => {
538
+ const source = { url: row.url };
539
+ if (row.title) source.title = String(row.title);
540
+ const snippet = row.snippet ?? row.description;
541
+ if (snippet) source.snippet = String(snippet);
542
+ const publishedAt = toIsoDate(row.date);
543
+ if (publishedAt) source.publishedAt = publishedAt;
544
+ return source;
545
+ }),
546
+ truncated: false
547
+ };
548
+ }
549
+ };
550
+ /**
551
+ * TinyFish fetch through the `ctx.web` fetch seam.
552
+ *
553
+ * Returns `kind: "text"` on purpose: TinyFish already extracts clean Markdown,
554
+ * so `dsh-tool-web` passes it straight through. The `http` provider instead
555
+ * returns `kind: "html"` and pays for a turndown conversion this path skips.
556
+ */
557
+ var TinyfishFetchProvider = class {
558
+ id = TINYFISH_PROVIDER_ID;
559
+ /** A thunk, not a value; see {@link TinyfishSearchProvider} for why. */
560
+ resolveOptions;
561
+ constructor(resolveOptions) {
562
+ this.resolveOptions = resolveOptions;
563
+ }
564
+ /**
565
+ * Cheap local usability check. Must not make network calls — the seam calls
566
+ * this to decide between providers, and a network call here would turn
567
+ * selection into a latency spike on every search.
568
+ *
569
+ * Checks the same things the shipped providers do: a credential is
570
+ * resolvable *and* both endpoints parse as URLs. A misconfigured base is a
571
+ * setup mistake worth surfacing at selection time rather than as a 404 later.
572
+ */
573
+ available() {
574
+ const options = this.resolveOptions();
575
+ return Boolean(options.fetch && hasCredential(options) && URL.canParse(options.fetchBase) && (options.channel === "direct" || URL.canParse(options.monidBase)));
576
+ }
577
+ async fetch(request, signal) {
578
+ const options = this.resolveOptions();
579
+ const payload = await tinyfishFetch({
580
+ channel: options.channel,
581
+ apiKey: options.apiKey,
582
+ apiKeyEnv: options.apiKeyEnv,
583
+ monidKeyEnv: options.monidKeyEnv,
584
+ resolveCredential: options.resolveCredential,
585
+ urls: [request.url],
586
+ purpose: options.purpose,
587
+ monidBase: options.monidBase,
588
+ fetchBase: options.fetchBase,
589
+ signal,
590
+ attempts: options.attempts
591
+ });
592
+ const results = Array.isArray(payload?.results) ? payload.results : [];
593
+ const failure = (Array.isArray(payload?.errors) ? payload.errors : []).find((row) => sameUrl(row?.url, request.url));
594
+ if (!results.length && failure) {
595
+ const status = Number(failure.status);
596
+ return {
597
+ url: request.url,
598
+ statusCode: Number.isFinite(status) && status > 0 ? status : 502,
599
+ body: {
600
+ kind: "text",
601
+ content: `Could not retrieve this page: ${failure.error ?? "fetch failed"} (HTTP ${failure.status ?? "?"}).`
602
+ },
603
+ truncated: false
604
+ };
605
+ }
606
+ const page = results[0];
607
+ if (!page) throw new WebError(`TinyFish returned no content for ${request.url}`, WEB_PROVIDER_ERROR);
608
+ return {
609
+ url: page.final_url ?? page.url ?? request.url,
610
+ statusCode: 200,
611
+ body: {
612
+ kind: "text",
613
+ content: String(page.text ?? "")
614
+ },
615
+ truncated: false
616
+ };
617
+ }
618
+ };
619
+ /**
620
+ * True when this configuration can produce a credential without a network
621
+ * call: a literal key, the environment, or a credential the CLIs already
622
+ * stored. A `credential-ref` resolved by the harness service is not visible
623
+ * from here, so a plugin that relies on one stays available through the
624
+ * service-backed path in the client.
625
+ */
626
+ function hasCredential(options) {
627
+ const ref = options.channel === "monid" ? options.monidKeyEnv : options.apiKeyEnv;
628
+ return Boolean(resolveApiKey(options.channel, {
629
+ apiKey: options.apiKey,
630
+ monidKeyEnv: options.monidKeyEnv,
631
+ env: {
632
+ TINYFISH_API_KEY: process.env[ref],
633
+ MONID_API_KEY: process.env[ref],
634
+ MONID_MCP_TOKEN: process.env[ref]
635
+ }
636
+ }));
637
+ }
638
+ /** Compare URLs ignoring a trailing slash, so `/a` and `/a/` match. */
639
+ function sameUrl(a, b) {
640
+ if (typeof a !== "string") return false;
641
+ const strip = (value) => value.replace(/\/+$/, "");
642
+ return strip(a) === strip(b);
643
+ }
644
+ //#endregion
645
+ //#region src/index.ts
646
+ /**
647
+ * `dsh-tinyfish` — TinyFish-backed search and fetch for the DSH web
648
+ * capability seam.
649
+ *
650
+ * Registers two providers under one id, `tinyfish`, and lets the profile pick
651
+ * the channel:
652
+ *
653
+ * monid — reach TinyFish through the Monid REST API. Costs nothing on the
654
+ * Monid wallet, and reuses the credential the MCP mount already
655
+ * holds, so it keeps working when no TinyFish account is configured.
656
+ * direct — call TinyFish's own API, using the key the `tinyfish` CLI already
657
+ * stored in ~/.tinyfish/config.json.
658
+ *
659
+ * Both channels return the same upstream payload, so nothing above this file
660
+ * branches on which one is active.
661
+ *
662
+ * @module dsh-tinyfish
663
+ */
664
+ /** Settings namespace, matching the `<kind>-<provider>` convention. */
665
+ const WEB_TINYFISH_SETTINGS_NAMESPACE = "web-tinyfish";
666
+ /**
667
+ * The plugin's settings schema.
668
+ *
669
+ * This is the harness's own `@deepseek-ai/schemastery` fork rather than the
670
+ * public package, because the fork is what implements the `.role()`,
671
+ * `.volatile()` and `.get()` surface the loader and the settings UI both rely
672
+ * on, and the public 3.18.x line does not have it. Cordis resolves a plugin's
673
+ * `Config` through `resolveConfig` and falls back to the raw row when a plugin
674
+ * exports none — so exporting this is what makes the row render as a real
675
+ * settings section rather than free-form YAML.
676
+ *
677
+ * - `role("secret")` keeps a literal key out of any redacted dump.
678
+ * - `role("credential-ref")` makes a field a *reference* to a stored
679
+ * credential, resolved through `ctx.get("credentials")` — the way the shipped
680
+ * search provider does it, so the value is manageable from Settings instead
681
+ * of only from a patch file.
682
+ * - `volatile()` marks a field that must be re-read at the start of every
683
+ * operation. Everything here is: a key can be rotated while the harness is
684
+ * running, and a provider that captured one at load time would keep sending
685
+ * a dead credential.
686
+ */
687
+ const Config = z.object({
688
+ channel: z.union([z.const("monid"), z.const("direct")]).default("direct").description("Which upstream route to use. `monid` reuses the MCP credential."),
689
+ apiKey: z.string().role("secret").volatile().description("Literal credential, overriding both refs. Prefer a credential ref or the environment."),
690
+ apiKeyEnv: z.string().role("credential-ref").default("TINYFISH_API_KEY").volatile().description("Stored credential or environment variable for the direct channel."),
691
+ monidKeyEnv: z.string().role("credential-ref").default("MONID_API_KEY").volatile().description("Stored credential or environment variable for the monid channel. Kept separate so both keys can be saved at once."),
692
+ purpose: z.string().volatile().description("Goal statement; TinyFish ranks on it."),
693
+ attempts: z.number().step(1).min(1).max(5).default(3).volatile().description("Attempts for a transient failure or an empty search."),
694
+ filters: z.object({
695
+ domainType: z.union([
696
+ z.const("web"),
697
+ z.const("news"),
698
+ z.const("research_paper")
699
+ ]).description("Restrict the result corpus."),
700
+ language: z.string().description("Language code for geo-targeted results."),
701
+ location: z.string().description("Location code for geo-targeted results."),
702
+ includeDomains: z.string().description("Comma-separated domains to allow."),
703
+ excludeDomains: z.string().description("Comma-separated domains to drop.")
704
+ }).description("Search filters applied to every query.").volatile(),
705
+ monidBase: z.string().default(DEFAULT_MONID_BASE).description("Monid REST base."),
706
+ searchBase: z.string().default(DEFAULT_SEARCH_BASE).description("TinyFish search base."),
707
+ fetchBase: z.string().default(DEFAULT_FETCH_BASE).description("TinyFish fetch base."),
708
+ search: z.boolean().default(true).description("Offer TinyFish as the search provider."),
709
+ fetch: z.boolean().default(true).description("Offer TinyFish as the fetch provider.")
710
+ });
711
+ /** Cordis service dependencies. */
712
+ const inject = ["web"];
713
+ /** The bundle name. Must equal the manifest `name`: the loader matches on it. */
714
+ const name = "dsh-tinyfish";
715
+ /**
716
+ * Clamp `attempts` to a range the retry loop can honour.
717
+ *
718
+ * Blank means unset, not zero. The value arrives via `readField`, which turns
719
+ * an absent field into `""`, and `Number("")` is `0` — finite, and therefore
720
+ * clamped up to the minimum of 1 rather than falling back to the default. A
721
+ * field the user never set would have silently become "try once".
722
+ */
723
+ function normalizeAttempts(value) {
724
+ if (value === "" || value === null || value === void 0) return 3;
725
+ const n = Number(value);
726
+ if (!Number.isFinite(n)) return 3;
727
+ return Math.min(5, Math.max(1, Math.floor(n)));
728
+ }
729
+ /**
730
+ * Coerce one config value to a string, refusing anything that is not scalar.
731
+ *
732
+ * A validated section hands out boxed schema nodes; a raw patch row does not.
733
+ * Both have to be readable, because a plugin can be called either way.
734
+ */
735
+ function asScalar(value) {
736
+ if (value === null || value === void 0) return "";
737
+ if (typeof value === "string") return value;
738
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
739
+ return "";
740
+ }
741
+ /** Read one field from a section, whether it is boxed (`.get()`) or raw. */
742
+ function readField(section, key) {
743
+ const value = section[key];
744
+ return asScalar(typeof value?.get === "function" ? value.get() : value);
745
+ }
746
+ /**
747
+ * Build the credential lookup for one operation.
748
+ *
749
+ * Two sources, in the harness's own order of trust: the credentials service
750
+ * first, then the launch environment — the snapshot the harness froze at
751
+ * boot, which is what makes a value stable across a `chdir` or a workspace
752
+ * switch mid-session.
753
+ *
754
+ * Both are optional at runtime. A host that has neither still works, because
755
+ * the client falls through to the environment and then the CLI stores.
756
+ */
757
+ function credentialLookup(ctx) {
758
+ let credentials;
759
+ try {
760
+ credentials = ctx.get("credentials");
761
+ } catch {
762
+ credentials = void 0;
763
+ }
764
+ let ambient;
765
+ try {
766
+ ambient = launchEnvironmentOf(ctx);
767
+ } catch {
768
+ ambient = void 0;
769
+ }
770
+ if (!credentials && !ambient) return void 0;
771
+ return async (ref) => {
772
+ if (credentials) {
773
+ const resolved = await credentials.resolve(credentialRef(ref));
774
+ if (resolved?.value) return resolved.value;
775
+ }
776
+ const value = ambient?.get(ref)?.value;
777
+ return value && value.length > 0 ? value : void 0;
778
+ };
779
+ }
780
+ /** Project one resolved section into the options the next operation serves. */
781
+ function resolveOptions(config, ctx, env = process.env) {
782
+ const section = config ?? {};
783
+ const rawFilters = section.filters ?? {};
784
+ const filters = {};
785
+ const pick = (key, upstream) => {
786
+ const value = asScalar(rawFilters[key]);
787
+ if (value) filters[upstream] = value;
788
+ };
789
+ pick("domainType", "domain_type");
790
+ pick("language", "language");
791
+ pick("location", "location");
792
+ pick("includeDomains", "include_domains");
793
+ pick("excludeDomains", "exclude_domains");
794
+ const apiKey = readField(section, "apiKey").trim();
795
+ const purpose = readField(section, "purpose").trim();
796
+ return {
797
+ channel: readField(section, "channel") === "monid" ? "monid" : "direct",
798
+ apiKey: apiKey || void 0,
799
+ apiKeyEnv: readField(section, "apiKeyEnv") || "TINYFISH_API_KEY",
800
+ monidKeyEnv: readField(section, "monidKeyEnv") || "MONID_API_KEY",
801
+ resolveCredential: ctx ? credentialLookup(ctx) : void 0,
802
+ purpose: purpose || void 0,
803
+ filters,
804
+ attempts: normalizeAttempts(readField(section, "attempts")),
805
+ monidBase: readField(section, "monidBase") || env.TINYFISH_MONID_BASE_URL || "https://api.monid.ai",
806
+ searchBase: readField(section, "searchBase") || env.TINYFISH_SEARCH_BASE_URL || "https://api.search.tinyfish.ai",
807
+ fetchBase: readField(section, "fetchBase") || env.TINYFISH_FETCH_BASE_URL || "https://api.fetch.tinyfish.ai",
808
+ search: readField(section, "search") !== "false",
809
+ fetch: readField(section, "fetch") !== "false"
810
+ };
811
+ }
812
+ /**
813
+ * Register both providers with `ctx.web`.
814
+ *
815
+ * Selection stays the profile's call: this plugin only offers `tinyfish`, and
816
+ * `dsh-web`'s `searchProvider` / `fetchProvider` decide whether it is used.
817
+ * Reverting is two words in the patch, with this plugin still mounted.
818
+ */
819
+ function apply(ctx, config) {
820
+ const options = () => resolveOptions(config, ctx);
821
+ ctx.web.registerSearchProvider(new TinyfishSearchProvider(options));
822
+ ctx.web.registerFetchProvider(new TinyfishFetchProvider(options));
823
+ }
824
+ //#endregion
825
+ export { Config, DEFAULT_FETCH_BASE, DEFAULT_MONID_BASE, DEFAULT_SEARCH_BASE, TINYFISH_PROVIDER_ID, TinyfishFetchProvider, TinyfishSearchProvider, WEB_ABORTED, WEB_PROVIDER_CREDENTIAL_MISSING, WEB_PROVIDER_ERROR, WEB_TINYFISH_SETTINGS_NAMESPACE, WebError, apply, inject, name, resolveApiKey, resolveOptions, tinyfishFetch, tinyfishSearch, toIsoDate };