@perxel/spotlight 0.0.0-stage → 0.1.1

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/dist/build/index.d.ts +32 -0
  4. package/dist/build/index.js +68 -0
  5. package/dist/chat/index.d.ts +3 -0
  6. package/dist/chat/index.js +5 -0
  7. package/dist/chat/parts.d.ts +57 -0
  8. package/dist/chat/parts.js +69 -0
  9. package/dist/chat/useAskChat.d.ts +67 -0
  10. package/dist/chat/useAskChat.js +200 -0
  11. package/dist/core/copy.d.ts +4 -0
  12. package/dist/core/copy.js +40 -0
  13. package/dist/core/fold.d.ts +10 -0
  14. package/dist/core/fold.js +23 -0
  15. package/dist/core/messages.d.ts +24 -0
  16. package/dist/core/messages.js +72 -0
  17. package/dist/core/search.d.ts +73 -0
  18. package/dist/core/search.js +210 -0
  19. package/dist/core/storage.d.ts +17 -0
  20. package/dist/core/storage.js +48 -0
  21. package/dist/gate/index.d.ts +2 -0
  22. package/dist/gate/index.js +5 -0
  23. package/dist/gate/parts.d.ts +179 -0
  24. package/dist/gate/parts.js +282 -0
  25. package/dist/gate/useSearchAsk.d.ts +90 -0
  26. package/dist/gate/useSearchAsk.js +122 -0
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.js +8 -0
  29. package/dist/liquid/context.d.ts +26 -0
  30. package/dist/liquid/context.js +6 -0
  31. package/dist/liquid/gate.d.ts +8 -0
  32. package/dist/liquid/gate.js +52 -0
  33. package/dist/liquid/icons.d.ts +12 -0
  34. package/dist/liquid/icons.js +15 -0
  35. package/dist/liquid/index.d.ts +9 -0
  36. package/dist/liquid/index.js +19 -0
  37. package/dist/liquid/liquid.css +1007 -0
  38. package/dist/liquid/panel.d.ts +8 -0
  39. package/dist/liquid/panel.js +186 -0
  40. package/dist/liquid/util.d.ts +6 -0
  41. package/dist/liquid/util.js +16 -0
  42. package/dist/react/index.d.ts +6 -0
  43. package/dist/react/index.js +10 -0
  44. package/dist/react/provider.d.ts +52 -0
  45. package/dist/react/provider.js +218 -0
  46. package/dist/react/turnstile.d.ts +16 -0
  47. package/dist/react/turnstile.js +100 -0
  48. package/dist/server/handler.d.ts +102 -0
  49. package/dist/server/handler.js +274 -0
  50. package/dist/server/index.d.ts +4 -0
  51. package/dist/server/index.js +5 -0
  52. package/dist/server/sources.d.ts +79 -0
  53. package/dist/server/sources.js +315 -0
  54. package/dist/server/turnstile.d.ts +15 -0
  55. package/dist/server/turnstile.js +29 -0
  56. package/dist/server/types.d.ts +34 -0
  57. package/dist/server/types.js +1 -0
  58. package/dist/types.d.ts +207 -0
  59. package/dist/types.js +7 -0
  60. package/package.json +102 -4
@@ -0,0 +1,102 @@
1
+ import { type LanguageModel } from "ai";
2
+ import type { AskContext, AskSource, Cited, ToolRun } from "./types.js";
3
+ /**
4
+ * `createAskHandler(options)`: a Web-standard `(Request) => Promise<Response>` that answers a chat
5
+ * from the AI SDK's `useChat` (the whole conversation in each request) with a streamed UI message
6
+ * response. Mount it as a Next route handler (`export const POST = handler`), a Worker's `fetch`,
7
+ * Hono, Node. Stateless: the history comes from the client and only its text is used (tool
8
+ * parts and sources from the client are dropped, so a client cannot hand the model tool results).
9
+ *
10
+ * Checks, in order: CORS origin (when `origins` is set) → method and size → body shape and limits
11
+ * → `verify` (bot check) → `rateLimit` → the model call. A refusal is `{code}` JSON with an HTTP
12
+ * status; the panel maps the code to its copy.
13
+ */
14
+ export type AskErrorCode = "origin" | "bad_request" | "verify_failed" | "rate_limited" | "conversation_limit" | "budget" | "config" | "upstream";
15
+ /**
16
+ * A model error's code: `config` when the route is set up wrong (no API key, or the provider
17
+ * refused it: 401/403), so the visitor is not told to try again and the log says what to fix;
18
+ * `upstream` for the rest (provider down, timeout).
19
+ */
20
+ export declare function modelErrorCode(err: unknown): "config" | "upstream";
21
+ /** One answered turn, for `onFinish` (log it, store it, count its cost). */
22
+ export interface AskTurn {
23
+ /** The chat id the client sent (`useChat`'s id), if any. */
24
+ id: string | null;
25
+ question: string;
26
+ answer: string;
27
+ /** Every tool the model ran this turn, in order. */
28
+ tools: ToolRun[];
29
+ /** The sources sent to the client. */
30
+ sources: Cited[];
31
+ /** Number of messages in the conversation, this question included. */
32
+ messages: number;
33
+ model: string;
34
+ usage: {
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ };
38
+ timing: {
39
+ totalMs: number;
40
+ /** Until the first answer text, -1 when none. */
41
+ firstTextMs: number;
42
+ /** Model time (step time minus tool time). */
43
+ modelMs: number;
44
+ toolMs: number;
45
+ steps: number;
46
+ };
47
+ /** The AI SDK's end event, for what is not lifted out above (provider metadata, cost). */
48
+ raw: unknown;
49
+ }
50
+ export interface AskHandlerOptions {
51
+ /** Any AI SDK language model, or one per request. */
52
+ model: LanguageModel | ((ctx: AskContext) => LanguageModel);
53
+ /** Instructions for the model; a function gets the request context (locale in the body, say). */
54
+ system?: string | ((ctx: AskContext) => string | Promise<string>);
55
+ /** Where the model's tools come from: `contentSource`, `toolSource`, `mcpSource`, mixed freely. */
56
+ sources?: AskSource[];
57
+ /** Allowlist of tool names the model may call (after prefixing); all when omitted. */
58
+ tools?: string[];
59
+ /**
60
+ * Tools that only drive the interface (an action, a handoff): not counted as lookups, and their
61
+ * results are never searched for sources.
62
+ */
63
+ uiTools?: string[];
64
+ /** Bot check: true to go on, false (or an error code) to refuse. `turnstile({secret})` is one. */
65
+ verify?: (ctx: AskContext) => boolean | string | Promise<boolean | string>;
66
+ /** Rate limit or budget: true to go on, false (`rate_limited`) or an error code to refuse. */
67
+ rateLimit?: (ctx: AskContext) => boolean | string | Promise<boolean | string>;
68
+ /** After each answered turn. Runs after the response has finished streaming. */
69
+ onFinish?: (turn: AskTurn, ctx: AskContext) => void | Promise<void>;
70
+ /** Keeps `onFinish` alive past the response on platforms that need it (a Worker's `ctx.waitUntil`). */
71
+ waitUntil?: (promise: Promise<unknown>) => void;
72
+ /** Allowed `Origin`s for cross-origin calls (CORS); same-origin only when omitted. */
73
+ origins?: string[];
74
+ /** Headers on every answer (a session, a version). */
75
+ headers?: (ctx: AskContext) => HeadersInit | undefined | Promise<HeadersInit | undefined>;
76
+ /** Cite sources: `{url, title}` pairs found in tool results become `source-url` parts. Default on, 4 at most, 2 per tool result. */
77
+ cite?: false | {
78
+ max?: number;
79
+ perResult?: number;
80
+ };
81
+ /** Characters in the visitor's question (default 1000). */
82
+ maxInputChars?: number;
83
+ /** Questions in one conversation (default 20); more is `conversation_limit`. */
84
+ maxQuestions?: number;
85
+ /** Earlier messages kept for the model (default 20, the newest). */
86
+ maxHistory?: number;
87
+ /** Characters kept per earlier message (default 4000). */
88
+ maxHistoryChars?: number;
89
+ /** Model steps per answer, tool calls included (default 4). */
90
+ maxSteps?: number;
91
+ /** Output tokens per answer (default 700). */
92
+ maxOutputTokens?: number;
93
+ /** Request body bytes (default 256 KB). */
94
+ maxBodyBytes?: number;
95
+ /** Called with errors (model, tool, `onFinish`); default `console.error`, with a hint when the model has no key or it was refused. */
96
+ onError?: (error: unknown, where: "model" | "tool" | "finish") => void;
97
+ /** Extra `streamText` settings (temperature, providerOptions…). */
98
+ settings?: Record<string, unknown>;
99
+ }
100
+ /** Every `{url, title}` pair in a tool result, in order (urls: http(s) or a path). Bounded walk. */
101
+ export declare function sourcesIn(data: unknown, limit?: number): Cited[];
102
+ export declare function createAskHandler(options: AskHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,274 @@
1
+ import { createUIMessageStream, createUIMessageStreamResponse, isStepCount, streamText, toUIMessageStream, } from "ai";
2
+ const STATUS = {
3
+ origin: 403,
4
+ bad_request: 400,
5
+ verify_failed: 403,
6
+ rate_limited: 429,
7
+ conversation_limit: 429,
8
+ budget: 503,
9
+ config: 503,
10
+ upstream: 502,
11
+ };
12
+ /**
13
+ * A model error's code: `config` when the route is set up wrong (no API key, or the provider
14
+ * refused it: 401/403), so the visitor is not told to try again and the log says what to fix;
15
+ * `upstream` for the rest (provider down, timeout).
16
+ */
17
+ export function modelErrorCode(err) {
18
+ let e = err;
19
+ // A retried call fails with the last attempt's error inside.
20
+ if (e?.name === "AI_RetryError" && e.lastError)
21
+ e = e.lastError;
22
+ if (e?.name === "AI_LoadAPIKeyError")
23
+ return "config";
24
+ if (e?.name === "AI_APICallError" && (e.statusCode === 401 || e.statusCode === 403))
25
+ return "config";
26
+ return "upstream";
27
+ }
28
+ function logError(err, where) {
29
+ if (where === "model" && modelErrorCode(err) === "config")
30
+ console.error(`[spotlight] model: the AI provider has no API key or refused it. Check the key the route's model is created with (an empty Worker secret or env var is a common cause).`, err);
31
+ else
32
+ console.error(`[spotlight] ${where}:`, err);
33
+ }
34
+ function isRecord(v) {
35
+ return typeof v === "object" && v !== null && !Array.isArray(v);
36
+ }
37
+ /** The text of a client message: its text parts joined. */
38
+ function messageText(m) {
39
+ if (!Array.isArray(m.parts))
40
+ return typeof m.content === "string" ? m.content : "";
41
+ return m.parts
42
+ .filter((p) => isRecord(p) && p.type === "text" && typeof p.text === "string")
43
+ .map((p) => p.text)
44
+ .join("\n")
45
+ .trim();
46
+ }
47
+ function cors(res, origin) {
48
+ if (!origin)
49
+ return res;
50
+ const headers = new Headers(res.headers);
51
+ headers.set("access-control-allow-origin", origin);
52
+ headers.set("access-control-expose-headers", "*");
53
+ headers.append("vary", "Origin");
54
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
55
+ }
56
+ /** Every `{url, title}` pair in a tool result, in order (urls: http(s) or a path). Bounded walk. */
57
+ export function sourcesIn(data, limit = 50) {
58
+ const out = [];
59
+ const queue = [data];
60
+ let seen = 0;
61
+ while (queue.length && seen < 500 && out.length < limit) {
62
+ const node = queue.shift();
63
+ seen++;
64
+ if (Array.isArray(node))
65
+ queue.push(...node);
66
+ else if (isRecord(node)) {
67
+ const { url, title } = node;
68
+ if (typeof url === "string" && /^(https?:\/\/|\/)/.test(url) && typeof title === "string" && title.trim() && !out.some((o) => o.url === url)) {
69
+ out.push({ url, title: title.trim() });
70
+ }
71
+ for (const v of Object.values(node))
72
+ if (typeof v === "object" && v !== null)
73
+ queue.push(v);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ export function createAskHandler(options) {
79
+ const { sources = [], uiTools = [], maxInputChars = 1000, maxQuestions = 20, maxHistory = 20, maxHistoryChars = 4000, maxSteps = 4, maxOutputTokens = 700, maxBodyBytes = 256 * 1024, onError = logError, } = options;
80
+ const cite = options.cite === false ? null : { max: options.cite?.max ?? 4, perResult: options.cite?.perResult ?? 2 };
81
+ const ui = new Set(uiTools);
82
+ return async function handle(request) {
83
+ const t0 = Date.now();
84
+ const requestOrigin = request.headers.get("origin");
85
+ const sameOrigin = !requestOrigin || requestOrigin === new URL(request.url).origin;
86
+ const allowed = !sameOrigin && requestOrigin && options.origins?.includes(requestOrigin) ? requestOrigin : null;
87
+ const refuse = (code, headers) => cors(Response.json({ code }, { status: STATUS[code], headers }), allowed);
88
+ if (request.method === "OPTIONS") {
89
+ if (!allowed)
90
+ return new Response(null, { status: 403 });
91
+ return new Response(null, {
92
+ status: 204,
93
+ headers: {
94
+ "access-control-allow-origin": allowed,
95
+ "access-control-allow-methods": "POST, OPTIONS",
96
+ "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "content-type",
97
+ "access-control-max-age": "86400",
98
+ vary: "Origin",
99
+ },
100
+ });
101
+ }
102
+ if (!sameOrigin && !allowed)
103
+ return refuse("origin");
104
+ if (request.method !== "POST")
105
+ return refuse("bad_request");
106
+ // Body: size, JSON, messages.
107
+ if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes)
108
+ return refuse("bad_request");
109
+ const raw = await request.text().catch(() => "");
110
+ if (!raw || raw.length > maxBodyBytes)
111
+ return refuse("bad_request");
112
+ let body;
113
+ try {
114
+ const parsed = JSON.parse(raw);
115
+ if (!isRecord(parsed))
116
+ return refuse("bad_request");
117
+ body = parsed;
118
+ }
119
+ catch {
120
+ return refuse("bad_request");
121
+ }
122
+ const list = Array.isArray(body.messages) ? body.messages.filter(isRecord) : [];
123
+ const turns = list
124
+ .filter((m) => m.role === "user" || m.role === "assistant")
125
+ .map((m) => ({ role: m.role, text: messageText(m) }))
126
+ .filter((m) => m.text);
127
+ const last = turns.at(-1);
128
+ if (!last || last.role !== "user" || last.text.length > maxInputChars)
129
+ return refuse("bad_request");
130
+ if (turns.filter((m) => m.role === "user").length > maxQuestions)
131
+ return refuse("conversation_limit");
132
+ const { messages: _messages, ...fields } = body;
133
+ const ctx = {
134
+ request,
135
+ body: fields,
136
+ id: typeof body.id === "string" ? body.id.slice(0, 100) : null,
137
+ question: last.text,
138
+ history: turns.slice(0, -1),
139
+ };
140
+ const verified = options.verify ? await options.verify(ctx) : true;
141
+ if (verified !== true)
142
+ return refuse(typeof verified === "string" ? verified : "verify_failed");
143
+ const limited = options.rateLimit ? await options.rateLimit(ctx) : true;
144
+ if (limited !== true)
145
+ return refuse(typeof limited === "string" ? limited : "rate_limited", { "retry-after": "60" });
146
+ // A model factory that throws (a missing key checked by the site) refuses with `config`.
147
+ let model;
148
+ try {
149
+ model = typeof options.model === "function" ? options.model(ctx) : options.model;
150
+ }
151
+ catch (err) {
152
+ onError(err, "model");
153
+ return refuse("config");
154
+ }
155
+ const system = typeof options.system === "function" ? await options.system(ctx) : options.system;
156
+ const history = turns
157
+ .slice(0, -1)
158
+ .slice(-maxHistory)
159
+ .map((m) => ({ role: m.role, content: m.text.slice(0, maxHistoryChars) }));
160
+ const runs = [];
161
+ const cited = [];
162
+ let firstTextMs = -1;
163
+ const stream = createUIMessageStream({
164
+ execute: async ({ writer }) => {
165
+ // Tools from every source; a name taken by an earlier source gets the source's prefix.
166
+ const tools = {};
167
+ const taken = new Set();
168
+ for (const source of sources) {
169
+ let set;
170
+ try {
171
+ set = await source.tools(ctx);
172
+ }
173
+ catch (err) {
174
+ onError(err, "tool");
175
+ continue;
176
+ }
177
+ for (const [name, t] of Object.entries(set)) {
178
+ const key = taken.has(name) ? `${source.name}_${name}` : name;
179
+ taken.add(key);
180
+ if (options.tools && !options.tools.includes(key))
181
+ continue;
182
+ tools[key] = wrapTool(key, t, (run, result) => {
183
+ runs.push(run);
184
+ if (!cite || ui.has(key) || !run.ok)
185
+ return;
186
+ for (const s of sourcesIn(result, cite.perResult)) {
187
+ if (cited.length >= cite.max || cited.some((c) => c.url === s.url))
188
+ continue;
189
+ cited.push(s);
190
+ writer.write({ type: "source-url", sourceId: s.url, url: s.url, title: s.title });
191
+ }
192
+ }, (err) => onError(err, "tool"));
193
+ }
194
+ }
195
+ const result = streamText({
196
+ ...options.settings,
197
+ model,
198
+ ...(system ? { instructions: system } : {}),
199
+ messages: [...history, { role: "user", content: ctx.question }],
200
+ tools,
201
+ stopWhen: isStepCount(maxSteps),
202
+ maxOutputTokens,
203
+ maxRetries: 1,
204
+ onChunk: ({ chunk }) => {
205
+ if (firstTextMs < 0 && chunk.type === "text-delta")
206
+ firstTextMs = Date.now() - t0;
207
+ },
208
+ onEnd: (event) => {
209
+ if (!options.onFinish)
210
+ return;
211
+ let modelMs = 0;
212
+ let toolMs = 0;
213
+ for (const s of event.steps) {
214
+ const tools = Object.values(s.performance?.toolExecutionMs ?? {}).reduce((a, b) => a + b, 0);
215
+ modelMs += Math.max(0, (s.performance?.stepTimeMs ?? 0) - tools);
216
+ toolMs += tools;
217
+ }
218
+ const turn = {
219
+ id: ctx.id,
220
+ question: ctx.question,
221
+ answer: event.text,
222
+ tools: runs,
223
+ sources: cited,
224
+ messages: turns.length,
225
+ model: event.response?.modelId ?? "",
226
+ usage: { inputTokens: event.totalUsage.inputTokens ?? 0, outputTokens: event.totalUsage.outputTokens ?? 0 },
227
+ timing: { totalMs: Date.now() - t0, firstTextMs, modelMs: Math.round(modelMs), toolMs: Math.round(toolMs), steps: event.steps.length },
228
+ raw: event,
229
+ };
230
+ const done = Promise.resolve()
231
+ .then(() => options.onFinish(turn, ctx))
232
+ .catch((err) => onError(err, "finish"));
233
+ options.waitUntil?.(done);
234
+ },
235
+ });
236
+ writer.merge(toUIMessageStream({
237
+ stream: result.stream,
238
+ onError: (err) => {
239
+ onError(err, "model");
240
+ return modelErrorCode(err);
241
+ },
242
+ }));
243
+ },
244
+ onError: (err) => {
245
+ onError(err, "model");
246
+ return modelErrorCode(err);
247
+ },
248
+ });
249
+ const headers = options.headers ? await options.headers(ctx) : undefined;
250
+ return cors(createUIMessageStreamResponse({ stream, ...(headers ? { headers } : {}) }), allowed);
251
+ };
252
+ }
253
+ /** The tool with its runs recorded; a thrown error becomes `{error}` for the model, not a failed turn. */
254
+ function wrapTool(name, t, done, onError) {
255
+ if (!t.execute)
256
+ return t;
257
+ const execute = t.execute;
258
+ return {
259
+ ...t,
260
+ execute: async (input, opts) => {
261
+ const start = Date.now();
262
+ try {
263
+ const result = await execute(input, opts);
264
+ done({ name, ms: Date.now() - start, ok: true, input }, result);
265
+ return result;
266
+ }
267
+ catch (err) {
268
+ onError(err);
269
+ done({ name, ms: Date.now() - start, ok: false, input }, null);
270
+ return { error: err instanceof Error ? err.message : "The tool failed." };
271
+ }
272
+ },
273
+ };
274
+ }
@@ -0,0 +1,4 @@
1
+ export { createAskHandler, modelErrorCode, sourcesIn, type AskErrorCode, type AskHandlerOptions, type AskTurn } from "./handler.js";
2
+ export { contentSource, mcpSource, toolSource, passagesOf, sectionOf, resetMcpCache, type ContentDoc, type ContentSourceOptions, type McpSourceOptions } from "./sources.js";
3
+ export { turnstile, type TurnstileOptions } from "./turnstile.js";
4
+ export type { AskContext, AskSource, Cited, ToolRun } from "./types.js";
@@ -0,0 +1,5 @@
1
+ // `@perxel/spotlight/server`: an optional chat endpoint for Ask. Web-standard (`Request` →
2
+ // `Response`), any AI SDK model, tools from the site's pages, its own functions or a remote MCP.
3
+ export { createAskHandler, modelErrorCode, sourcesIn } from "./handler.js";
4
+ export { contentSource, mcpSource, toolSource, passagesOf, sectionOf, resetMcpCache } from "./sources.js";
5
+ export { turnstile } from "./turnstile.js";
@@ -0,0 +1,79 @@
1
+ import { type ToolSet } from "ai";
2
+ import { type SearchDoc } from "../core/search.js";
3
+ import type { AskContext, AskSource } from "./types.js";
4
+ /**
5
+ * The site's own AI SDK tools (live numbers, filters over structured data, an outside API), run
6
+ * in the same route. A function gets the request context (locale, say) and returns the tools.
7
+ */
8
+ export declare function toolSource(tools: ToolSet | ((ctx: AskContext) => ToolSet | Promise<ToolSet>), { name }?: {
9
+ name?: string;
10
+ }): AskSource;
11
+ /** A page for `contentSource`: a search document, with its full text if there is one. */
12
+ export type ContentDoc = SearchDoc & {
13
+ body?: string;
14
+ };
15
+ export interface ContentSourceOptions {
16
+ /** The pages: the same documents as search (with a `url`), or a function of the request (locale). */
17
+ docs: ContentDoc[] | ((ctx: AskContext) => ContentDoc[] | Promise<ContentDoc[]>);
18
+ /** Full text by document id, when it is not on the documents (`body`). Markdown headings (`#`) mark sections. */
19
+ bodies?: Record<string, string> | ((ctx: AskContext) => Record<string, string> | Promise<Record<string, string>>);
20
+ /** Makes relative urls absolute in what the model sees (and in the cited sources). */
21
+ baseUrl?: string;
22
+ /** Hits per search (default 5). */
23
+ maxHits?: number;
24
+ /** Passages per hit, from its full text (default 2), and their length in characters (default 320). */
25
+ passages?: number;
26
+ passageLength?: number;
27
+ /** Characters of a page `get_page` returns (default 6000); longer pages are cut and list their sections. */
28
+ pageLength?: number;
29
+ /** Tool names (default `search_site`, `get_page`). */
30
+ names?: {
31
+ search?: string;
32
+ page?: string;
33
+ };
34
+ /** What the tools say they search ("this site's pages"). */
35
+ about?: string;
36
+ name?: string;
37
+ }
38
+ /** Up to `count` paragraphs of `body` holding the most terms, each cut around its first match. */
39
+ export declare function passagesOf(body: string, words: string[], count: number, length: number): string[];
40
+ /**
41
+ * The section of a body under the heading that matches `section`: a markdown heading (until the
42
+ * next one of its level or higher), else, for plain text where headings became their own lines,
43
+ * a short heading-like line (until the next one).
44
+ */
45
+ export declare function sectionOf(body: string, section: string): string | null;
46
+ /**
47
+ * Search and read the site's own pages, with no MCP: two tools built from the same documents as
48
+ * the gate's search plus their full text. Answers stay cheap: a search returns a few short
49
+ * passages per hit (most answers need nothing more), a page is capped or read one section at a
50
+ * time.
51
+ */
52
+ export declare function contentSource(options: ContentSourceOptions): AskSource;
53
+ export interface McpSourceOptions {
54
+ /** The server's MCP endpoint (streamable HTTP), e.g. `https://mcp.example.com/mcp`. */
55
+ url: string;
56
+ /** Headers on every call (an API key). A function runs per request. */
57
+ headers?: Record<string, string> | ((ctx: AskContext) => Record<string, string> | Promise<Record<string, string>>);
58
+ /** Allowlist of the server's tool names to offer. Recommended: a tool added to the server later is not offered by accident. */
59
+ tools?: string[];
60
+ /**
61
+ * Arguments set by the site, hidden from the model: e.g. `{locale: ctx.body.locale}` for a tool
62
+ * whose input has a `locale`. Only applied to tools whose schema has that property.
63
+ */
64
+ fixed?: (ctx: AskContext) => Record<string, unknown>;
65
+ /** How long the tool list is kept (per server instance, default 5 minutes). */
66
+ listTtlMs?: number;
67
+ /** Per call (default 15 s). */
68
+ timeoutMs?: number;
69
+ name?: string;
70
+ fetcher?: typeof fetch;
71
+ }
72
+ /**
73
+ * Tools on a remote MCP server, called over streamable HTTP with no SDK (works on any runtime
74
+ * with `fetch`). The tool list is fetched once and kept for `listTtlMs`. Use `tools` to allowlist:
75
+ * an anonymous chat must only reach tools meant for the public.
76
+ */
77
+ export declare function mcpSource(options: McpSourceOptions): AskSource;
78
+ /** Test hook: forget the cached tool lists and sessions. */
79
+ export declare function resetMcpCache(): void;