@qearlyao/familiar 0.2.5 → 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.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/config.example.toml +2 -2
  3. package/dist/agent/payload-normalizers.js +52 -0
  4. package/dist/agent/session-helpers.js +86 -0
  5. package/dist/agent/tool-descriptions.js +4 -0
  6. package/dist/agent/tools.js +30 -0
  7. package/dist/agent/transcript-log.js +93 -0
  8. package/dist/agent/types.js +1 -0
  9. package/dist/agent-core.js +82 -0
  10. package/dist/agent-work-queue.js +55 -0
  11. package/dist/agent.js +91 -322
  12. package/dist/browser-tools.js +7 -8
  13. package/dist/chat-log.js +15 -3
  14. package/dist/cli.js +36 -6
  15. package/dist/config/enums.js +35 -0
  16. package/dist/config/interpolate.js +15 -0
  17. package/dist/config/model-refs.js +11 -0
  18. package/dist/config/readers.js +116 -0
  19. package/dist/config/sections.js +113 -0
  20. package/dist/config/types.js +1 -0
  21. package/dist/config-registry.js +26 -7
  22. package/dist/config.js +8 -271
  23. package/dist/discord/channel.js +32 -0
  24. package/dist/discord/chunking.js +163 -0
  25. package/dist/discord/client.js +44 -0
  26. package/dist/discord/commands.js +181 -0
  27. package/dist/discord/inbound.js +44 -0
  28. package/dist/discord/send.js +106 -0
  29. package/dist/discord/turn.js +55 -0
  30. package/dist/discord.js +266 -1186
  31. package/dist/ids.js +11 -0
  32. package/dist/index.js +1 -0
  33. package/dist/memory/index/store.js +21 -17
  34. package/dist/memory/index/vector-codec.js +2 -2
  35. package/dist/memory/lcm/context-transformer.js +6 -2
  36. package/dist/memory/lcm/segment-manager.js +6 -2
  37. package/dist/memory/lcm/store/index-ids.js +6 -0
  38. package/dist/memory/lcm/store/inserts.js +31 -0
  39. package/dist/memory/lcm/store/normalizers.js +91 -0
  40. package/dist/memory/lcm/store/row-mappers.js +114 -0
  41. package/dist/memory/lcm/store/row-types.js +1 -0
  42. package/dist/memory/lcm/store/serialization.js +37 -0
  43. package/dist/memory/lcm/store/snapshots.js +73 -0
  44. package/dist/memory/lcm/store.js +20 -360
  45. package/dist/owner-identity.js +29 -0
  46. package/dist/runtime-manager.js +51 -0
  47. package/dist/runtime.js +89 -41
  48. package/dist/scheduler-runner.js +243 -0
  49. package/dist/scheduler.js +1 -1
  50. package/dist/service.js +1 -0
  51. package/dist/settings.js +3 -0
  52. package/dist/web/event-hub.js +246 -0
  53. package/dist/{web-http.js → web/http.js} +19 -5
  54. package/dist/web/memes.js +25 -0
  55. package/dist/web/messages.js +345 -0
  56. package/dist/web/multipart.js +80 -0
  57. package/dist/web/payloads.js +34 -0
  58. package/dist/{web-static.js → web/static.js} +19 -14
  59. package/dist/web/stream.js +69 -0
  60. package/dist/web-tools/cache.js +42 -0
  61. package/dist/web-tools/config.js +16 -0
  62. package/dist/web-tools/fetch-providers.js +119 -0
  63. package/dist/web-tools/format.js +88 -0
  64. package/dist/web-tools/http.js +81 -0
  65. package/dist/web-tools/routing.js +29 -0
  66. package/dist/web-tools/safety.js +73 -0
  67. package/dist/web-tools/search-providers.js +277 -0
  68. package/dist/web-tools/types.js +54 -0
  69. package/dist/web-tools/util.js +23 -0
  70. package/dist/web-tools.js +9 -798
  71. package/dist/web.js +416 -984
  72. package/npm-shrinkwrap.json +242 -201
  73. package/package.json +4 -4
  74. package/web/dist/assets/index-CSkxUQCr.js +63 -0
  75. package/web/dist/assets/index-DllM6RqL.css +2 -0
  76. package/web/dist/index.html +6 -3
  77. package/web/dist/assets/index-B23WT77N.js +0 -63
  78. package/web/dist/assets/index-D3MotFzN.css +0 -2
  79. /package/dist/{web-auth.js → web/auth.js} +0 -0
  80. /package/dist/{web-events.js → web/events.js} +0 -0
  81. /package/dist/{web-types.js → web/types.js} +0 -0
@@ -0,0 +1,80 @@
1
+ export function isWebUploadAttachment(value) {
2
+ return !!value && typeof value === "object" && Buffer.isBuffer(value.buffer);
3
+ }
4
+ export async function readRawBody(request, maxBytes) {
5
+ const chunks = [];
6
+ let total = 0;
7
+ for await (const chunk of request) {
8
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
9
+ total += buffer.length;
10
+ if (total > maxBytes)
11
+ throw new Error("Request body too large");
12
+ chunks.push(buffer);
13
+ }
14
+ return Buffer.concat(chunks);
15
+ }
16
+ export function multipartBoundary(contentType) {
17
+ const header = Array.isArray(contentType) ? contentType.find((value) => value.includes("boundary=")) : contentType;
18
+ const match = header?.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
19
+ if (!match?.[1] && !match?.[2])
20
+ throw new Error("Missing multipart boundary");
21
+ return match[1] ?? match[2] ?? "";
22
+ }
23
+ export function parseContentDisposition(header) {
24
+ const parts = header.split(";").map((part) => part.trim());
25
+ const values = {};
26
+ for (const part of parts.slice(1)) {
27
+ const [key, rawValue] = part.split("=");
28
+ if (!key || rawValue === undefined)
29
+ continue;
30
+ values[key.toLowerCase()] = rawValue.replace(/^"|"$/g, "");
31
+ }
32
+ return values;
33
+ }
34
+ export async function readMultipartBody(request, contentType) {
35
+ const boundary = multipartBoundary(contentType);
36
+ const raw = await readRawBody(request, 32 * 1024 * 1024);
37
+ const binary = raw.toString("binary");
38
+ const marker = `--${boundary}`;
39
+ const attachments = [];
40
+ const body = { text: "" };
41
+ for (const section of binary.split(marker).slice(1)) {
42
+ if (!section || section === "--\r\n" || section === "--")
43
+ continue;
44
+ const trimmed = section.replace(/^\r\n/, "").replace(/\r\n--$/, "");
45
+ const headerEnd = trimmed.indexOf("\r\n\r\n");
46
+ if (headerEnd < 0)
47
+ continue;
48
+ const headerText = trimmed.slice(0, headerEnd);
49
+ let contentBinary = trimmed.slice(headerEnd + 4);
50
+ if (contentBinary.endsWith("\r\n"))
51
+ contentBinary = contentBinary.slice(0, -2);
52
+ const headers = Object.fromEntries(headerText.split("\r\n").map((line) => {
53
+ const colon = line.indexOf(":");
54
+ return colon >= 0
55
+ ? [line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()]
56
+ : [line.toLowerCase(), ""];
57
+ }));
58
+ const disposition = parseContentDisposition(headers["content-disposition"] ?? "");
59
+ const name = disposition.name;
60
+ if (!name)
61
+ continue;
62
+ if (name === "text" || name === "channelKey" || name === "clientId") {
63
+ body[name] = Buffer.from(contentBinary, "binary").toString("utf8");
64
+ continue;
65
+ }
66
+ if (name !== "attachments")
67
+ continue;
68
+ const buffer = Buffer.from(contentBinary, "binary");
69
+ if (buffer.length === 0)
70
+ continue;
71
+ attachments.push({
72
+ name: disposition.filename,
73
+ mimeType: headers["content-type"],
74
+ size: buffer.length,
75
+ buffer,
76
+ });
77
+ }
78
+ body.attachments = attachments;
79
+ return body;
80
+ }
@@ -0,0 +1,34 @@
1
+ import { supportedThinkingLevels } from "../models.js";
2
+ import { isRecord } from "../util/guards.js";
3
+ export function commandArgs(command, args) {
4
+ if (!isRecord(args))
5
+ return "";
6
+ if (command === "model")
7
+ return typeof args.model === "string" ? args.model : "";
8
+ if (command === "thinking")
9
+ return typeof args.level === "string" ? args.level : "";
10
+ if (command === "channel-trigger")
11
+ return typeof args.trigger === "string" ? args.trigger : "";
12
+ return "";
13
+ }
14
+ export function agentSettingsPayload(familiarAgent, channelKey, personaName) {
15
+ const { model } = familiarAgent.resolveChannelModel(channelKey);
16
+ return {
17
+ model: familiarAgent.getModel(channelKey),
18
+ thinking: familiarAgent.getThinkingLevel(channelKey),
19
+ supportedThinking: supportedThinkingLevels(model),
20
+ persona: { name: personaName },
21
+ };
22
+ }
23
+ export function sessionDto(session) {
24
+ return {
25
+ key: session.key,
26
+ label: session.label,
27
+ service: session.channel.service,
28
+ scope: session.channel.scope,
29
+ channelId: session.channel.channelId,
30
+ channelName: session.channel.channelName,
31
+ threadId: session.channel.threadId,
32
+ isDefault: session.isDefault,
33
+ };
34
+ }
@@ -1,12 +1,14 @@
1
1
  import { createReadStream, existsSync } from "node:fs";
2
2
  import { lstat, realpath, stat } from "node:fs/promises";
3
- import { extname, join, relative, resolve } from "node:path";
3
+ import { dirname, extname, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "./generated-media.js";
6
- import { sendText } from "./web-http.js";
7
- function getProjectRoot() {
8
- return resolve(fileURLToPath(import.meta.url), "../..");
9
- }
5
+ import { attachmentsDir, browserScreenshotsDir, generatedAttachmentsDir } from "../generated-media.js";
6
+ import { sendText } from "./http.js";
7
+ const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
8
+ const DIST_DIR = resolve(PROJECT_ROOT, "web/dist");
9
+ // Only successful realpaths are cached: the attachment root dirs are created
10
+ // lazily on first write, so a missing root must stay retryable.
11
+ const rootRealPathCache = new Map();
10
12
  function mimeType(path) {
11
13
  const extension = extname(path).toLowerCase();
12
14
  if (extension === ".html")
@@ -36,19 +38,18 @@ function mimeType(path) {
36
38
  return "application/octet-stream";
37
39
  }
38
40
  export async function serveStatic(response, requestPath) {
39
- const distDir = resolve(getProjectRoot(), "web/dist");
40
- if (!existsSync(distDir))
41
+ if (!existsSync(DIST_DIR))
41
42
  return false;
42
43
  const pathname = decodeURIComponent(requestPath.split("?")[0] || "/");
43
- const candidate = resolve(distDir, pathname === "/" ? "index.html" : pathname.slice(1));
44
- if (!candidate.startsWith(distDir)) {
44
+ const candidate = resolve(DIST_DIR, pathname === "/" ? "index.html" : pathname.slice(1));
45
+ if (!candidate.startsWith(DIST_DIR)) {
45
46
  sendText(response, 403, "Forbidden");
46
47
  return true;
47
48
  }
48
49
  let filePath = candidate;
49
50
  const fileStat = await stat(filePath).catch(() => undefined);
50
51
  if (!fileStat?.isFile())
51
- filePath = join(distDir, "index.html");
52
+ filePath = join(DIST_DIR, "index.html");
52
53
  const stream = createReadStream(filePath);
53
54
  response.writeHead(200, { "content-type": mimeType(filePath) });
54
55
  stream.pipe(response);
@@ -88,9 +89,13 @@ export async function serveAttachment(config, response, requestPath, rangeHeader
88
89
  { root: browserScreenshotsDir(config), relativePath: relativePath.replace(/^screenshot[\\/]/, "") },
89
90
  ];
90
91
  for (const { root, relativePath: candidateRelativePath } of candidates) {
91
- const rootRealPath = await realpath(root).catch(() => undefined);
92
- if (!rootRealPath)
93
- continue;
92
+ let rootRealPath = rootRealPathCache.get(root);
93
+ if (rootRealPath === undefined) {
94
+ rootRealPath = await realpath(root).catch(() => undefined);
95
+ if (!rootRealPath)
96
+ continue;
97
+ rootRealPathCache.set(root, rootRealPath);
98
+ }
94
99
  const filePath = resolve(root, candidateRelativePath);
95
100
  const rel = relative(root, filePath);
96
101
  if (rel.startsWith("..") || rel.startsWith("/") || rel.startsWith("\\") || rel === "") {
@@ -0,0 +1,69 @@
1
+ import { isRecord } from "../util/guards.js";
2
+ import { acceptWebSocket, decodeFrames } from "./events.js";
3
+ export function attachWebSocketStream(server, options) {
4
+ const { authorize, eventHub, getRuntime, abort, retry, deleteLatest } = options;
5
+ const runtimeActions = { abort, retry, delete: deleteLatest };
6
+ const handleStreamMessage = (raw, client) => {
7
+ const message = JSON.parse(raw);
8
+ if (!isRecord(message) || typeof message.type !== "string")
9
+ return;
10
+ if (message.type === "hello") {
11
+ if (!client.channelKey)
12
+ return;
13
+ eventHub.replay(client, client.channelKey, typeof message.lastEventId === "string" ? message.lastEventId : null);
14
+ return;
15
+ }
16
+ const action = runtimeActions[message.type];
17
+ if (action) {
18
+ void getRuntime(client.channelKey)
19
+ .then(action)
20
+ .catch((error) => console.error(`WebSocket ${message.type} runtime lookup failed`, error));
21
+ }
22
+ };
23
+ server.on("upgrade", (request, socket) => {
24
+ const netSocket = socket;
25
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
26
+ if (url.pathname !== "/api/web/stream" || !authorize(request, url.pathname)) {
27
+ netSocket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
28
+ netSocket.destroy();
29
+ return;
30
+ }
31
+ const requestedChannelKey = url.searchParams.get("channelKey") || undefined;
32
+ void getRuntime(requestedChannelKey)
33
+ .then((runtime) => {
34
+ if (netSocket.destroyed)
35
+ return;
36
+ if (!acceptWebSocket(request, netSocket))
37
+ return;
38
+ netSocket.setNoDelay(true);
39
+ const client = { socket: netSocket, channelKey: runtime.channelKey, authed: false };
40
+ const dispose = eventHub.registerClient(client);
41
+ let frameBuffer = Buffer.alloc(0);
42
+ netSocket.on("data", (chunk) => {
43
+ try {
44
+ frameBuffer = Buffer.concat([frameBuffer, chunk]);
45
+ const decoded = decodeFrames(frameBuffer);
46
+ frameBuffer = decoded.remaining;
47
+ if (decoded.close)
48
+ netSocket.destroy();
49
+ for (const raw of decoded.messages) {
50
+ handleStreamMessage(raw, client);
51
+ }
52
+ }
53
+ catch (error) {
54
+ console.error("WebSocket frame handling failed", error);
55
+ netSocket.destroy();
56
+ }
57
+ });
58
+ netSocket.on("close", dispose);
59
+ netSocket.on("error", dispose);
60
+ })
61
+ .catch((error) => {
62
+ console.error("WebSocket runtime lookup failed", error);
63
+ if (!netSocket.destroyed) {
64
+ netSocket.write("HTTP/1.1 503 Service Unavailable\r\n\r\n");
65
+ netSocket.destroy();
66
+ }
67
+ });
68
+ });
69
+ }
@@ -0,0 +1,42 @@
1
+ import { MAX_CACHE_CHARS_PER_PAGE } from "./types.js";
2
+ export class PageCache {
3
+ ttlMs;
4
+ capacity;
5
+ entries = new Map();
6
+ constructor(options = {}) {
7
+ this.ttlMs = options.ttlMs ?? 5 * 60 * 1000;
8
+ this.capacity = options.capacity ?? 20;
9
+ }
10
+ get(url) {
11
+ const entry = this.entries.get(url);
12
+ if (!entry)
13
+ return undefined;
14
+ if (Date.now() - entry.fetchedAt > this.ttlMs) {
15
+ this.entries.delete(url);
16
+ return undefined;
17
+ }
18
+ entry.lastAccessed = Date.now();
19
+ this.entries.delete(url);
20
+ this.entries.set(url, entry);
21
+ return entry;
22
+ }
23
+ set(url, content, provider) {
24
+ if (content.length > MAX_CACHE_CHARS_PER_PAGE)
25
+ return;
26
+ if (this.entries.has(url))
27
+ this.entries.delete(url);
28
+ const now = Date.now();
29
+ this.entries.set(url, {
30
+ content,
31
+ provider,
32
+ fetchedAt: now,
33
+ lastAccessed: now,
34
+ });
35
+ while (this.entries.size > this.capacity) {
36
+ const oldest = this.entries.keys().next().value;
37
+ if (!oldest)
38
+ break;
39
+ this.entries.delete(oldest);
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,16 @@
1
+ export function readEnvKey(name) {
2
+ const value = process.env[name];
3
+ return value?.trim() ? value.trim() : undefined;
4
+ }
5
+ export function loadWebConfig() {
6
+ return {
7
+ apiKeys: {
8
+ BRAVE_API_KEY: readEnvKey("BRAVE_API_KEY"),
9
+ TAVILY_API_KEY: readEnvKey("TAVILY_API_KEY"),
10
+ EXA_API_KEY: readEnvKey("EXA_API_KEY"),
11
+ JINA_API_KEY: readEnvKey("JINA_API_KEY"),
12
+ TINYFISH_API_KEY: readEnvKey("TINYFISH_API_KEY"),
13
+ },
14
+ warnings: [],
15
+ };
16
+ }
@@ -0,0 +1,119 @@
1
+ import { isRecord } from "../util/guards.js";
2
+ import { fetchJson, fetchText } from "./http.js";
3
+ import { FETCH_TIMEOUT_MS, MAX_RESPONSE_BYTES, ProviderError } from "./types.js";
4
+ export function createJinaProvider(apiKey) {
5
+ return {
6
+ name: "jina",
7
+ async fetch(url, signal) {
8
+ const target = `https://r.jina.ai/${url}`;
9
+ const headers = buildJinaHeaders(apiKey, "application/json");
10
+ try {
11
+ const jsonContent = await fetchJinaContent(target, headers, signal, true);
12
+ if (jsonContent)
13
+ return jsonContent;
14
+ }
15
+ catch (error) {
16
+ if (!shouldFallbackToText(error)) {
17
+ throw error;
18
+ }
19
+ }
20
+ const textContent = await fetchJinaContent(target, buildJinaHeaders(apiKey, "text/plain"), signal, false);
21
+ if (textContent)
22
+ return textContent;
23
+ throw new ProviderError("jina", "jina returned an empty response.", false);
24
+ },
25
+ };
26
+ }
27
+ export function createTinyfishProvider(apiKey) {
28
+ const trimmed = apiKey.trim();
29
+ return {
30
+ name: "tinyfish",
31
+ async fetch(url, signal) {
32
+ const response = await fetchJson("tinyfish", "https://api.fetch.tinyfish.ai", {
33
+ method: "POST",
34
+ headers: {
35
+ "Content-Type": "application/json",
36
+ "X-API-Key": trimmed,
37
+ },
38
+ body: JSON.stringify({
39
+ urls: [url],
40
+ format: "markdown",
41
+ }),
42
+ signal,
43
+ timeoutMs: FETCH_TIMEOUT_MS,
44
+ maxBytes: MAX_RESPONSE_BYTES.fetch,
45
+ validate: parseTinyfishResponse,
46
+ });
47
+ return response.content;
48
+ },
49
+ };
50
+ }
51
+ export function parseTinyfishResponse(value) {
52
+ if (!isRecord(value)) {
53
+ throw new ProviderError("tinyfish", "TinyFish returned unexpected response shape.", false);
54
+ }
55
+ const results = value.results;
56
+ if (Array.isArray(results)) {
57
+ const first = results[0];
58
+ if (isRecord(first)) {
59
+ const content = typeof first.content === "string"
60
+ ? first.content
61
+ : typeof first.markdown === "string"
62
+ ? first.markdown
63
+ : typeof first.text === "string"
64
+ ? first.text
65
+ : "";
66
+ if (content.trim())
67
+ return { content: content.replaceAll(/\r\n/g, "\n").trim() };
68
+ }
69
+ }
70
+ const errors = Array.isArray(value.errors) ? value.errors : undefined;
71
+ const firstError = errors?.find((entry) => isRecord(entry));
72
+ if (isRecord(firstError)) {
73
+ const message = typeof firstError.message === "string"
74
+ ? firstError.message
75
+ : typeof firstError.error === "string"
76
+ ? firstError.error
77
+ : "TinyFish failed to fetch the page.";
78
+ throw new ProviderError("tinyfish", message, false);
79
+ }
80
+ throw new ProviderError("tinyfish", "TinyFish returned no page content.", false);
81
+ }
82
+ export function buildJinaHeaders(apiKey, accept) {
83
+ const headers = {
84
+ Accept: accept,
85
+ "X-Retain-Images": "none",
86
+ };
87
+ if (apiKey?.trim())
88
+ headers.Authorization = `Bearer ${apiKey.trim()}`;
89
+ return headers;
90
+ }
91
+ export async function fetchJinaContent(targetUrl, headers, signal, preferJson) {
92
+ const responseText = await fetchText("jina", targetUrl, {
93
+ headers,
94
+ signal,
95
+ timeoutMs: FETCH_TIMEOUT_MS,
96
+ maxBytes: MAX_RESPONSE_BYTES.fetch,
97
+ });
98
+ if (preferJson) {
99
+ try {
100
+ const parsed = JSON.parse(responseText);
101
+ if (isRecord(parsed) && isRecord(parsed.data)) {
102
+ if (typeof parsed.data.content === "string" && parsed.data.content.trim()) {
103
+ return parsed.data.content.replaceAll(/\r\n/g, "\n").trim();
104
+ }
105
+ if (typeof parsed.data.markdown === "string" && parsed.data.markdown.trim()) {
106
+ return parsed.data.markdown.replaceAll(/\r\n/g, "\n").trim();
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ return undefined;
114
+ }
115
+ return responseText.replaceAll(/\r\n/g, "\n").trim() || undefined;
116
+ }
117
+ export function shouldFallbackToText(error) {
118
+ return error instanceof ProviderError && (error.status === 406 || error.status === 415);
119
+ }
@@ -0,0 +1,88 @@
1
+ import { FETCH_DEFAULT_MAX_CHARS, SEARCH_OUTPUT_BUDGET, WEB_UNTRUSTED_PREFIX, } from "./types.js";
2
+ import { normalizeIsoDate } from "./util.js";
3
+ export function collectSearchNotes(requested, served, notes = []) {
4
+ if (requested !== served) {
5
+ notes.push(`Depth: requested ${requested}, served ${served}`);
6
+ }
7
+ return [...new Set(notes)];
8
+ }
9
+ export function buildSearchDocument(args) {
10
+ const lines = [`## Search Results (via ${args.provider}, ${args.depth})`];
11
+ if (args.notes?.length) {
12
+ lines.push("", ...args.notes);
13
+ }
14
+ if (args.freshness || args.domains?.length || args.appliedFilters) {
15
+ const notes = [];
16
+ if (args.freshness)
17
+ notes.push(`Freshness: ${args.freshness}`);
18
+ if (args.domains?.length)
19
+ notes.push(`Domains: ${args.domains.join(", ")}`);
20
+ lines.push("", ...notes);
21
+ }
22
+ for (const [index, result] of args.results.entries()) {
23
+ lines.push("", `### ${index + 1}. ${result.title}`, `URL: ${result.url}`);
24
+ const published = normalizeIsoDate(result.publishedAt);
25
+ if (published)
26
+ lines.push(`Published: ${published.slice(0, 10)}`);
27
+ lines.push(`Snippet: ${result.snippet || "[No snippet available]"}`);
28
+ if (result.content) {
29
+ lines.push("", "Content:", result.content);
30
+ }
31
+ }
32
+ return lines.join("\n");
33
+ }
34
+ export function formatFetchContent(url, provider, chunk) {
35
+ const header = `## Content from ${url} (via ${provider})`;
36
+ if (chunk.offset >= chunk.totalChars) {
37
+ return prefixUntrustedWebContent([
38
+ header,
39
+ "",
40
+ `[Offset ${chunk.offset} is beyond the end of the document. Total content length: ${chunk.totalChars} characters.]`,
41
+ ].join("\n"));
42
+ }
43
+ const lines = [
44
+ header,
45
+ "",
46
+ `[Showing chars ${chunk.offset}-${chunk.offset + chunk.returnedChars - 1} of ${chunk.totalChars}]`,
47
+ "",
48
+ chunk.text,
49
+ ];
50
+ if (chunk.hasMore && chunk.nextOffset !== undefined) {
51
+ lines.push("", `[More content available. Next chunk: fetch_web(url="${url}", offset=${chunk.nextOffset})]`);
52
+ }
53
+ return prefixUntrustedWebContent(lines.join("\n"));
54
+ }
55
+ export function prefixUntrustedWebContent(text) {
56
+ return `${WEB_UNTRUSTED_PREFIX}\n\n${text}`;
57
+ }
58
+ export function paginateContent(content, offset, maxChars = FETCH_DEFAULT_MAX_CHARS) {
59
+ const totalChars = content.length;
60
+ if (offset >= totalChars) {
61
+ return { text: "", offset, returnedChars: 0, totalChars, hasMore: false };
62
+ }
63
+ const safeMaxChars = Math.max(1, Math.min(maxChars, 20_000));
64
+ const end = Math.min(offset + safeMaxChars, totalChars);
65
+ const text = content.slice(offset, end).trim();
66
+ return {
67
+ text,
68
+ offset,
69
+ returnedChars: text.length,
70
+ totalChars,
71
+ nextOffset: end < totalChars ? end : undefined,
72
+ hasMore: end < totalChars,
73
+ };
74
+ }
75
+ export function formatSearchResults(args) {
76
+ const notes = collectSearchNotes(args.requestedDepth, args.servedDepth, [...(args.notes ?? [])]);
77
+ const document = buildSearchDocument({
78
+ provider: args.provider,
79
+ depth: args.servedDepth,
80
+ freshness: args.freshness,
81
+ domains: args.domains,
82
+ results: args.results,
83
+ appliedFilters: args.appliedFilters,
84
+ notes,
85
+ });
86
+ const output = prefixUntrustedWebContent(document);
87
+ return output.length > SEARCH_OUTPUT_BUDGET ? `${output.slice(0, SEARCH_OUTPUT_BUDGET - 3).trimEnd()}...` : output;
88
+ }
@@ -0,0 +1,81 @@
1
+ import { ProviderError } from "./types.js";
2
+ export function buildRequestSignal(signal, timeoutMs) {
3
+ return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
4
+ }
5
+ export async function readBoundedBody(response, maxBytes) {
6
+ if (!response.body)
7
+ return "";
8
+ const reader = response.body.getReader();
9
+ const decoder = new TextDecoder();
10
+ const chunks = [];
11
+ let totalBytes = 0;
12
+ while (true) {
13
+ const { done, value } = await reader.read();
14
+ if (done)
15
+ break;
16
+ if (!value)
17
+ continue;
18
+ totalBytes += value.byteLength;
19
+ if (totalBytes > maxBytes) {
20
+ throw new Error(`Response exceeded size limit of ${maxBytes} bytes.`);
21
+ }
22
+ chunks.push(decoder.decode(value, { stream: true }));
23
+ }
24
+ chunks.push(decoder.decode());
25
+ return chunks.join("");
26
+ }
27
+ export function createHttpError(provider, response) {
28
+ return new ProviderError(provider, `${provider} request failed: ${response.status} ${response.statusText}`.trim(), response.status >= 500 || response.status === 408 || response.status === 429, response.status);
29
+ }
30
+ // Pass through aborts and already-typed ProviderErrors raw; wrap everything else
31
+ // (network failures, JSON.parse/validate throws) as a retryable ProviderError.
32
+ function rethrowAsProviderError(provider, error, signal) {
33
+ if (error instanceof ProviderError)
34
+ throw error;
35
+ if (signal.aborted)
36
+ throw error;
37
+ throw new ProviderError(provider, error instanceof Error ? `${provider} request failed: ${error.message}` : `${provider} request failed.`, true, undefined, error);
38
+ }
39
+ async function performFetch(provider, url, options) {
40
+ try {
41
+ const response = await fetch(url, {
42
+ method: options.method ?? "GET",
43
+ headers: options.headers,
44
+ body: options.body,
45
+ redirect: "error",
46
+ signal: buildRequestSignal(options.signal, options.timeoutMs),
47
+ });
48
+ if (!response.ok)
49
+ throw createHttpError(provider, response);
50
+ return await readBoundedBody(response, options.maxBytes);
51
+ }
52
+ catch (error) {
53
+ rethrowAsProviderError(provider, error, options.signal);
54
+ }
55
+ }
56
+ export async function fetchJson(provider, url, options) {
57
+ try {
58
+ const body = await performFetch(provider, url, {
59
+ method: options.method,
60
+ headers: options.headers,
61
+ body: options.body,
62
+ signal: options.signal,
63
+ timeoutMs: options.timeoutMs,
64
+ maxBytes: options.maxBytes,
65
+ });
66
+ const parsed = body ? JSON.parse(body) : null;
67
+ return options.validate(parsed);
68
+ }
69
+ catch (error) {
70
+ rethrowAsProviderError(provider, error, options.signal);
71
+ }
72
+ }
73
+ export async function fetchText(provider, url, options) {
74
+ return await performFetch(provider, url, {
75
+ method: "GET",
76
+ headers: options.headers,
77
+ signal: options.signal,
78
+ timeoutMs: options.timeoutMs,
79
+ maxBytes: options.maxBytes,
80
+ });
81
+ }
@@ -0,0 +1,29 @@
1
+ export function searchProviderOrder(depth, args) {
2
+ if (depth === "thorough")
3
+ return ["tavily", "exa", "brave"];
4
+ if (args.domains?.length)
5
+ return ["tavily", "exa", "brave"];
6
+ return ["brave", "tavily", "exa"];
7
+ }
8
+ export function canServe(provider, depth) {
9
+ if (depth === "thorough")
10
+ return provider.capabilities.has("search") && provider.capabilities.has("content");
11
+ return provider.capabilities.has("search");
12
+ }
13
+ export function canServeSearchArgs(provider, args) {
14
+ if (!canServe(provider, args.depth))
15
+ return false;
16
+ if ((args.domains?.length ?? 0) > 1 && !provider.capabilities.has("domainFilter"))
17
+ return false;
18
+ return true;
19
+ }
20
+ export function resolveSearchProviders(args, searchProviders) {
21
+ const providers = [];
22
+ for (const name of searchProviderOrder(args.depth, args)) {
23
+ const candidate = searchProviders[name];
24
+ if (candidate && canServeSearchArgs(candidate, args) && !providers.includes(candidate)) {
25
+ providers.push(candidate);
26
+ }
27
+ }
28
+ return providers;
29
+ }