@qearlyao/familiar 0.2.5 → 0.4.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 (123) hide show
  1. package/HEARTBEAT.md +1 -1
  2. package/README.md +33 -0
  3. package/config.example.toml +4 -2
  4. package/dist/{agent.js → agent/factory.js} +97 -328
  5. package/dist/agent/payload-normalizers.js +52 -0
  6. package/dist/agent/session-helpers.js +86 -0
  7. package/dist/agent/tool-descriptions.js +4 -0
  8. package/dist/agent/tools.js +30 -0
  9. package/dist/agent/transcript-log.js +93 -0
  10. package/dist/cli.js +45 -15
  11. package/dist/config/enums.js +35 -0
  12. package/dist/{config.js → config/index.js} +9 -272
  13. package/dist/config/interpolate.js +15 -0
  14. package/dist/config/model-refs.js +11 -0
  15. package/dist/{config-overrides.js → config/overrides.js} +1 -1
  16. package/dist/config/readers.js +116 -0
  17. package/dist/{config-registry.js → config/registry.js} +27 -8
  18. package/dist/config/sections.js +113 -0
  19. package/dist/{settings.js → config/settings.js} +5 -2
  20. package/dist/config/types.js +1 -0
  21. package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
  22. package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
  23. package/dist/conversation/ids.js +11 -0
  24. package/dist/conversation/owner-identity.js +29 -0
  25. package/dist/discord/channel.js +32 -0
  26. package/dist/discord/chunking.js +163 -0
  27. package/dist/discord/client.js +44 -0
  28. package/dist/discord/commands.js +181 -0
  29. package/dist/discord/daemon.js +379 -0
  30. package/dist/discord/inbound.js +44 -0
  31. package/dist/discord/send.js +115 -0
  32. package/dist/discord/turn.js +55 -0
  33. package/dist/index.js +12 -11
  34. package/dist/lifecycle/control.js +1 -0
  35. package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
  36. package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
  37. package/dist/{service.js → lifecycle/service.js} +1 -0
  38. package/dist/media/attachment-limits.js +3 -0
  39. package/dist/{generated-media.js → media/generated-media.js} +1 -1
  40. package/dist/{image-gen.js → media/image-gen.js} +2 -2
  41. package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
  42. package/dist/media/media-understanding.js +215 -0
  43. package/dist/memory/index/store.js +21 -17
  44. package/dist/memory/index/vector-codec.js +2 -2
  45. package/dist/memory/lcm/context-transformer.js +6 -2
  46. package/dist/memory/lcm/segment-manager.js +6 -2
  47. package/dist/memory/lcm/store/index-ids.js +6 -0
  48. package/dist/memory/lcm/store/inserts.js +31 -0
  49. package/dist/memory/lcm/store/normalizers.js +91 -0
  50. package/dist/memory/lcm/store/row-mappers.js +114 -0
  51. package/dist/memory/lcm/store/row-types.js +1 -0
  52. package/dist/memory/lcm/store/serialization.js +37 -0
  53. package/dist/memory/lcm/store/snapshots.js +73 -0
  54. package/dist/memory/lcm/store.js +20 -360
  55. package/dist/memory/lcm/summarizer.js +1 -1
  56. package/dist/{added-models.js → models/added-models.js} +1 -1
  57. package/dist/{persona.js → prompting/persona.js} +1 -1
  58. package/dist/runtime/agent-core.js +82 -0
  59. package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
  60. package/dist/runtime/agent-work-queue.js +55 -0
  61. package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
  62. package/dist/runtime/runtime-manager.js +51 -0
  63. package/dist/runtime/scheduler-runner.js +243 -0
  64. package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
  65. package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
  66. package/dist/util/fs.js +2 -1
  67. package/dist/web/agent-routes.js +104 -0
  68. package/dist/web/auth-routes.js +39 -0
  69. package/dist/web/auth.js +205 -0
  70. package/dist/web/config-routes.js +55 -0
  71. package/dist/web/conversation-routes.js +122 -0
  72. package/dist/web/daemon.js +108 -0
  73. package/dist/web/diary-routes.js +88 -0
  74. package/dist/web/errors.js +3 -0
  75. package/dist/web/event-hub.js +246 -0
  76. package/dist/{web-http.js → web/http.js} +19 -5
  77. package/dist/web/memes.js +25 -0
  78. package/dist/web/messages.js +348 -0
  79. package/dist/web/multipart.js +86 -0
  80. package/dist/web/payloads.js +34 -0
  81. package/dist/web/request-context.js +25 -0
  82. package/dist/web/route-helpers.js +9 -0
  83. package/dist/web/routes.js +37 -0
  84. package/dist/web/runtime-actions.js +231 -0
  85. package/dist/web/session-store.js +161 -0
  86. package/dist/{web-static.js → web/static.js} +19 -14
  87. package/dist/web/stream.js +78 -0
  88. package/dist/web-tools/cache.js +42 -0
  89. package/dist/web-tools/config.js +16 -0
  90. package/dist/web-tools/fetch-providers.js +119 -0
  91. package/dist/web-tools/format.js +88 -0
  92. package/dist/web-tools/http.js +81 -0
  93. package/dist/web-tools/index.js +152 -0
  94. package/dist/web-tools/routing.js +29 -0
  95. package/dist/web-tools/safety.js +73 -0
  96. package/dist/web-tools/search-providers.js +277 -0
  97. package/dist/web-tools/types.js +54 -0
  98. package/dist/web-tools/util.js +23 -0
  99. package/npm-shrinkwrap.json +319 -201
  100. package/package.json +6 -4
  101. package/web/dist/assets/index-C-k4O5Dz.js +6 -0
  102. package/web/dist/assets/index-Dj-L9nX4.css +2 -0
  103. package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
  104. package/web/dist/assets/react-Bi_azaFt.js +9 -0
  105. package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  106. package/web/dist/assets/ui-C12-nN_X.js +51 -0
  107. package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
  108. package/web/dist/index.html +11 -3
  109. package/dist/discord.js +0 -1299
  110. package/dist/media-understanding.js +0 -120
  111. package/dist/web-auth.js +0 -111
  112. package/dist/web-tools.js +0 -941
  113. package/dist/web.js +0 -1209
  114. package/web/dist/assets/index-B23WT77N.js +0 -63
  115. package/web/dist/assets/index-D3MotFzN.css +0 -2
  116. /package/dist/{control.js → agent/types.js} +0 -0
  117. /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
  118. /package/dist/{tts.js → media/tts.js} +0 -0
  119. /package/dist/{models.js → models/index.js} +0 -0
  120. /package/dist/{skills.js → prompting/skills.js} +0 -0
  121. /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
  122. /package/dist/{web-events.js → web/events.js} +0 -0
  123. /package/dist/{web-types.js → web/types.js} +0 -0
@@ -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,152 @@
1
+ import { PageCache } from "./cache.js";
2
+ import { loadWebConfig } from "./config.js";
3
+ import { createJinaProvider, createTinyfishProvider } from "./fetch-providers.js";
4
+ import { formatFetchContent, formatSearchResults, paginateContent } from "./format.js";
5
+ import { resolveSearchProviders } from "./routing.js";
6
+ import { isTransientProviderError, validateFetchUrl } from "./safety.js";
7
+ import { createBraveProvider, createExaProvider, createTavilyProvider, normalizeDomains } from "./search-providers.js";
8
+ import { FETCH_DEFAULT_MAX_CHARS, WEB_UNTRUSTED_PREFIX, webFetchSchema, webSearchSchema, } from "./types.js";
9
+ const pageCache = new PageCache();
10
+ function makeSearchTool(config) {
11
+ const providers = {};
12
+ if (config.apiKeys.BRAVE_API_KEY)
13
+ providers.brave = createBraveProvider(config.apiKeys.BRAVE_API_KEY);
14
+ if (config.apiKeys.TAVILY_API_KEY)
15
+ providers.tavily = createTavilyProvider(config.apiKeys.TAVILY_API_KEY);
16
+ if (config.apiKeys.EXA_API_KEY)
17
+ providers.exa = createExaProvider(config.apiKeys.EXA_API_KEY);
18
+ return {
19
+ name: "search_web",
20
+ label: "Web Search",
21
+ description: "look something up on the open web. returns titles, urls, snippets, and dates when present. depth=thorough swaps brevity for inline excerpts.",
22
+ parameters: webSearchSchema,
23
+ async execute(_toolCallId, params, signal, onUpdate) {
24
+ const activeSignal = signal ?? new AbortController().signal;
25
+ if (Object.keys(providers).length === 0) {
26
+ throw new Error("No search provider configured. Set BRAVE_API_KEY, TAVILY_API_KEY, or EXA_API_KEY.");
27
+ }
28
+ const domains = normalizeDomains(params.domains);
29
+ const depth = params.depth ?? "basic";
30
+ const providersInOrder = resolveSearchProviders({ depth, freshness: params.freshness, domains }, providers);
31
+ if (providersInOrder.length === 0) {
32
+ throw new Error("No search provider available for this request.");
33
+ }
34
+ let lastError;
35
+ for (const provider of providersInOrder) {
36
+ if (activeSignal.aborted)
37
+ throw new Error("Search aborted.");
38
+ onUpdate?.({ content: [{ type: "text", text: `Searching via ${provider.name}...` }], details: undefined });
39
+ try {
40
+ const response = await provider.search({
41
+ query: params.query,
42
+ maxResults: params.maxResults ?? 5,
43
+ includeContent: depth === "thorough",
44
+ freshness: params.freshness,
45
+ domains,
46
+ signal: activeSignal,
47
+ });
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: formatSearchResults({
53
+ results: response.results,
54
+ provider: provider.name,
55
+ requestedDepth: depth,
56
+ servedDepth: depth,
57
+ freshness: params.freshness,
58
+ domains,
59
+ appliedFilters: response.appliedFilters,
60
+ notes: response.notes,
61
+ }),
62
+ },
63
+ ],
64
+ details: {
65
+ provider: provider.name,
66
+ requestedDepth: depth,
67
+ servedDepth: depth,
68
+ degraded: false,
69
+ freshness: params.freshness ?? null,
70
+ domains: domains ?? [],
71
+ resultCount: response.results.length,
72
+ },
73
+ };
74
+ }
75
+ catch (error) {
76
+ lastError = error instanceof Error ? error : new Error(String(error));
77
+ if (!isTransientProviderError(lastError))
78
+ throw lastError;
79
+ }
80
+ }
81
+ throw new Error(`All search providers failed for this request. ${lastError?.message ?? ""}`.trim());
82
+ },
83
+ };
84
+ }
85
+ function makeFetchTool(config) {
86
+ const providers = createFetchProviders(config);
87
+ return {
88
+ name: "fetch_web",
89
+ label: "Web Fetch",
90
+ description: "pull a webpage down as clean markdown.",
91
+ parameters: webFetchSchema,
92
+ async execute(_toolCallId, params, signal) {
93
+ const activeSignal = signal ?? new AbortController().signal;
94
+ const url = validateFetchUrl(params.url);
95
+ const offset = params.offset ?? 0;
96
+ const maxChars = params.maxChars ?? FETCH_DEFAULT_MAX_CHARS;
97
+ const cached = pageCache.get(url);
98
+ let providerName = cached?.provider ?? providers[0]?.name ?? "jina";
99
+ let content = cached?.content;
100
+ if (!content) {
101
+ let lastError;
102
+ for (const provider of providers) {
103
+ try {
104
+ content = await provider.fetch(url, activeSignal);
105
+ providerName = provider.name;
106
+ pageCache.set(url, content, provider.name);
107
+ break;
108
+ }
109
+ catch (error) {
110
+ lastError = error instanceof Error ? error : new Error(String(error));
111
+ if (!isTransientProviderError(lastError))
112
+ throw lastError;
113
+ }
114
+ }
115
+ if (!content)
116
+ throw new Error(`All fetch providers failed for this request. ${lastError?.message ?? ""}`.trim());
117
+ }
118
+ const chunk = paginateContent(content, offset, maxChars);
119
+ return {
120
+ content: [
121
+ {
122
+ type: "text",
123
+ text: formatFetchContent(url, providerName, chunk),
124
+ },
125
+ ],
126
+ details: {
127
+ provider: providerName,
128
+ url,
129
+ totalChars: content.length,
130
+ offset: chunk.offset,
131
+ returnedChars: chunk.returnedChars,
132
+ nextOffset: chunk.nextOffset,
133
+ hasMore: chunk.hasMore,
134
+ },
135
+ };
136
+ },
137
+ };
138
+ }
139
+ export function createFetchProviders(config) {
140
+ const providers = [];
141
+ if (config.apiKeys.TINYFISH_API_KEY)
142
+ providers.push(createTinyfishProvider(config.apiKeys.TINYFISH_API_KEY));
143
+ providers.push(createJinaProvider(config.apiKeys.JINA_API_KEY));
144
+ return providers;
145
+ }
146
+ export function webContentWarning() {
147
+ return WEB_UNTRUSTED_PREFIX;
148
+ }
149
+ export function createWebTools(_config) {
150
+ const loaded = loadWebConfig();
151
+ return [makeSearchTool(loaded), makeFetchTool(loaded)];
152
+ }
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ import net from "node:net";
2
+ import { ProviderError } from "./types.js";
3
+ export function isBlockedHostname(hostname) {
4
+ if (hostname === "localhost" ||
5
+ hostname === "metadata.google.internal" ||
6
+ hostname === "metadata" ||
7
+ hostname === "169.254.169.254" ||
8
+ hostname === "169.254.169.250" ||
9
+ hostname === "100.100.100.200" ||
10
+ hostname.endsWith(".local") ||
11
+ hostname.endsWith(".localhost") ||
12
+ hostname.endsWith(".internal") ||
13
+ hostname.endsWith(".home")) {
14
+ return true;
15
+ }
16
+ const ipVersion = net.isIP(hostname);
17
+ if (ipVersion === 4) {
18
+ const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
19
+ const [a, b] = octets;
20
+ if (a === 0 || a === 10 || a === 127)
21
+ return true;
22
+ if (a === 169 && b === 254)
23
+ return true;
24
+ if (a === 172 && b >= 16 && b <= 31)
25
+ return true;
26
+ if (a === 192 && b === 168)
27
+ return true;
28
+ if (a === 100 && b >= 64 && b <= 127)
29
+ return true;
30
+ if (a >= 224)
31
+ return true;
32
+ return false;
33
+ }
34
+ if (ipVersion === 6) {
35
+ const normalized = hostname.toLowerCase();
36
+ return (normalized === "::1" ||
37
+ normalized.startsWith("fc") ||
38
+ normalized.startsWith("fd") ||
39
+ normalized.startsWith("fe8") ||
40
+ normalized.startsWith("fe9") ||
41
+ normalized.startsWith("fea") ||
42
+ normalized.startsWith("feb") ||
43
+ normalized.startsWith("ff"));
44
+ }
45
+ return false;
46
+ }
47
+ export function validateFetchUrl(input) {
48
+ const trimmed = input.trim();
49
+ if (!trimmed)
50
+ throw new Error("Invalid URL: URL is required.");
51
+ let parsed;
52
+ try {
53
+ parsed = new URL(trimmed);
54
+ }
55
+ catch {
56
+ throw new Error("Invalid URL: malformed URL.");
57
+ }
58
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
59
+ throw new Error("Invalid URL: only http and https URLs are allowed.");
60
+ }
61
+ if (parsed.username || parsed.password) {
62
+ throw new Error("Invalid URL: embedded credentials are not allowed.");
63
+ }
64
+ const hostname = parsed.hostname.replaceAll(/^\[|\]$/g, "").toLowerCase();
65
+ if (!hostname)
66
+ throw new Error("Invalid URL: hostname is required.");
67
+ if (isBlockedHostname(hostname))
68
+ throw new Error("Blocked URL: target host is not allowed.");
69
+ return parsed.toString();
70
+ }
71
+ export function isTransientProviderError(error) {
72
+ return error instanceof ProviderError ? error.transient : false;
73
+ }