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