@oyasmi/pipiclaw 0.5.8 → 0.5.9

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.
@@ -11,6 +11,8 @@ import { clipText, extractAssistantText, extractLabelFromArgs, HAN_REGEX } from
11
11
  import { createBashTool } from "../tools/bash.js";
12
12
  import { createEditTool } from "../tools/edit.js";
13
13
  import { createReadTool } from "../tools/read.js";
14
+ import { createWebFetchTool } from "../tools/web-fetch.js";
15
+ import { createWebSearchTool } from "../tools/web-search.js";
14
16
  import { createWriteTool } from "../tools/write.js";
15
17
  import { formatSubAgentList, resolveSubAgentConfig, validateSubAgentTask, } from "./discovery.js";
16
18
  const subagentSchema = Type.Object({
@@ -118,6 +120,25 @@ function createToolSet(executor, bashTimeoutSec, options) {
118
120
  }),
119
121
  ];
120
122
  }
123
+ function createNamedToolSet(executor, bashTimeoutSec, options) {
124
+ const tools = createToolSet(executor, bashTimeoutSec, options);
125
+ const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
126
+ if (options.webConfig && options.webConfig.enable !== false) {
127
+ byName.web_search = createWebSearchTool({
128
+ webConfig: options.webConfig,
129
+ securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
130
+ workspaceDir: options.workspaceDir,
131
+ channelId: options.runtimeContext.channelId,
132
+ });
133
+ byName.web_fetch = createWebFetchTool({
134
+ webConfig: options.webConfig,
135
+ securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
136
+ workspaceDir: options.workspaceDir,
137
+ channelId: options.runtimeContext.channelId,
138
+ });
139
+ }
140
+ return byName;
141
+ }
121
142
  function buildSubAgentTask(task, config, runtimeContext, contextBlocks) {
122
143
  const taskText = task.trim();
123
144
  const lines = [
@@ -288,17 +309,18 @@ export function createSubAgentTool(options) {
288
309
  details: createDetails(config, usage, assistantTurns, toolCalls, Date.now() - startedAt, Boolean(failureReason), failureReason),
289
310
  });
290
311
  };
312
+ const availableTools = Object.values(createNamedToolSet(options.executor, config.bashTimeoutSec, options));
291
313
  const worker = options.createWorker?.({
292
314
  subAgent: config,
293
315
  apiKey,
294
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
316
+ tools: filterToolsByName(availableTools, config.tools),
295
317
  }) ??
296
318
  new Agent({
297
319
  initialState: {
298
320
  systemPrompt: config.systemPrompt,
299
321
  model: config.model,
300
322
  thinkingLevel: "off",
301
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
323
+ tools: filterToolsByName(availableTools, config.tools),
302
324
  },
303
325
  convertToLlm,
304
326
  getApiKey: async () => apiKey,
@@ -0,0 +1,30 @@
1
+ export type WebSearchProvider = "brave" | "tavily" | "jina" | "searxng" | "duckduckgo";
2
+ export interface PipiclawWebSearchConfig {
3
+ provider: WebSearchProvider;
4
+ apiKey: string;
5
+ baseUrl: string;
6
+ maxResults: number;
7
+ timeoutMs: number;
8
+ }
9
+ export interface PipiclawWebFetchConfig {
10
+ maxChars: number;
11
+ timeoutMs: number;
12
+ maxImageBytes: number;
13
+ preferJina: boolean;
14
+ enableJinaFallback: boolean;
15
+ defaultExtractMode: "markdown" | "text";
16
+ }
17
+ export interface PipiclawWebToolsConfig {
18
+ enable: boolean;
19
+ proxy: string | null;
20
+ search: PipiclawWebSearchConfig;
21
+ fetch: PipiclawWebFetchConfig;
22
+ }
23
+ export interface PipiclawToolsConfig {
24
+ tools: {
25
+ web: PipiclawWebToolsConfig;
26
+ };
27
+ }
28
+ export declare const DEFAULT_TOOLS_CONFIG: PipiclawToolsConfig;
29
+ export declare function getToolsConfigPath(appHomeDir?: string): string;
30
+ export declare function loadToolsConfig(appHomeDir?: string): PipiclawToolsConfig;
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { APP_HOME_DIR, TOOLS_CONFIG_PATH } from "../paths.js";
4
+ import { isRecord } from "../shared/type-guards.js";
5
+ const WEB_SEARCH_PROVIDERS = ["brave", "tavily", "jina", "searxng", "duckduckgo"];
6
+ export const DEFAULT_TOOLS_CONFIG = {
7
+ tools: {
8
+ web: {
9
+ enable: false,
10
+ proxy: null,
11
+ search: {
12
+ provider: "brave",
13
+ apiKey: "",
14
+ baseUrl: "",
15
+ maxResults: 5,
16
+ timeoutMs: 30_000,
17
+ },
18
+ fetch: {
19
+ maxChars: 50_000,
20
+ timeoutMs: 30_000,
21
+ maxImageBytes: 10 * 1024 * 1024,
22
+ preferJina: false,
23
+ enableJinaFallback: false,
24
+ defaultExtractMode: "markdown",
25
+ },
26
+ },
27
+ },
28
+ };
29
+ function clampInteger(value, fallback, minimum, maximum) {
30
+ if (typeof value !== "number" || !Number.isFinite(value)) {
31
+ return fallback;
32
+ }
33
+ const normalized = Math.floor(value);
34
+ if (normalized < minimum) {
35
+ return fallback;
36
+ }
37
+ if (maximum !== undefined && normalized > maximum) {
38
+ return fallback;
39
+ }
40
+ return normalized;
41
+ }
42
+ function asTrimmedString(value, fallback = "") {
43
+ return typeof value === "string" ? value.trim() : fallback;
44
+ }
45
+ function asOptionalProxy(value) {
46
+ if (value === null || value === undefined) {
47
+ return null;
48
+ }
49
+ if (typeof value !== "string") {
50
+ return null;
51
+ }
52
+ const trimmed = value.trim();
53
+ return trimmed.length > 0 ? trimmed : null;
54
+ }
55
+ function mergeToolsConfig(source) {
56
+ if (!isRecord(source)) {
57
+ return DEFAULT_TOOLS_CONFIG;
58
+ }
59
+ const tools = isRecord(source.tools) ? source.tools : {};
60
+ const web = isRecord(tools.web) ? tools.web : {};
61
+ const search = isRecord(web.search) ? web.search : {};
62
+ const fetch = isRecord(web.fetch) ? web.fetch : {};
63
+ const providerValue = asTrimmedString(search.provider, DEFAULT_TOOLS_CONFIG.tools.web.search.provider).toLowerCase();
64
+ const provider = WEB_SEARCH_PROVIDERS.includes(providerValue)
65
+ ? providerValue
66
+ : DEFAULT_TOOLS_CONFIG.tools.web.search.provider;
67
+ const defaultExtractMode = asTrimmedString(fetch.defaultExtractMode, DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode);
68
+ return {
69
+ tools: {
70
+ web: {
71
+ enable: typeof web.enable === "boolean" ? web.enable : DEFAULT_TOOLS_CONFIG.tools.web.enable,
72
+ proxy: asOptionalProxy(web.proxy),
73
+ search: {
74
+ provider,
75
+ apiKey: asTrimmedString(search.apiKey),
76
+ baseUrl: asTrimmedString(search.baseUrl),
77
+ maxResults: clampInteger(search.maxResults, DEFAULT_TOOLS_CONFIG.tools.web.search.maxResults, 1, 10),
78
+ timeoutMs: clampInteger(search.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.search.timeoutMs, 1),
79
+ },
80
+ fetch: {
81
+ maxChars: clampInteger(fetch.maxChars, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxChars, 100),
82
+ timeoutMs: clampInteger(fetch.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.fetch.timeoutMs, 1),
83
+ maxImageBytes: clampInteger(fetch.maxImageBytes, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxImageBytes, 1),
84
+ preferJina: typeof fetch.preferJina === "boolean"
85
+ ? fetch.preferJina
86
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.preferJina,
87
+ enableJinaFallback: typeof fetch.enableJinaFallback === "boolean"
88
+ ? fetch.enableJinaFallback
89
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.enableJinaFallback,
90
+ defaultExtractMode: defaultExtractMode === "text" || defaultExtractMode === "markdown"
91
+ ? defaultExtractMode
92
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode,
93
+ },
94
+ },
95
+ },
96
+ };
97
+ }
98
+ export function getToolsConfigPath(appHomeDir = APP_HOME_DIR) {
99
+ return appHomeDir === APP_HOME_DIR ? TOOLS_CONFIG_PATH : join(appHomeDir, "tools.json");
100
+ }
101
+ export function loadToolsConfig(appHomeDir = APP_HOME_DIR) {
102
+ const configPath = getToolsConfigPath(appHomeDir);
103
+ if (!existsSync(configPath)) {
104
+ return DEFAULT_TOOLS_CONFIG;
105
+ }
106
+ try {
107
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
108
+ return mergeToolsConfig(raw);
109
+ }
110
+ catch (error) {
111
+ console.warn(`Failed to load tools config from ${configPath}: ${error}`);
112
+ return DEFAULT_TOOLS_CONFIG;
113
+ }
114
+ }
@@ -2,8 +2,11 @@ import { APP_HOME_DIR } from "../paths.js";
2
2
  import { loadSecurityConfig } from "../security/config.js";
3
3
  import { createSubAgentTool } from "../subagents/tool.js";
4
4
  import { createBashTool } from "./bash.js";
5
+ import { loadToolsConfig } from "./config.js";
5
6
  import { createEditTool } from "./edit.js";
6
7
  import { createReadTool } from "./read.js";
8
+ import { createWebFetchTool } from "./web-fetch.js";
9
+ import { createWebSearchTool } from "./web-search.js";
7
10
  import { createWriteTool } from "./write.js";
8
11
  export function createPipiclawBaseTools(executor, options = {}) {
9
12
  const hasSecurityOptions = options.securityConfig || options.securityContext || options.channelId;
@@ -23,6 +26,7 @@ export function createPipiclawBaseTools(executor, options = {}) {
23
26
  }
24
27
  export function createPipiclawTools(options) {
25
28
  const securityConfig = loadSecurityConfig(APP_HOME_DIR);
29
+ const toolsConfig = loadToolsConfig(APP_HOME_DIR);
26
30
  const securityContext = {
27
31
  workspaceDir: options.workspaceDir,
28
32
  workspacePath: options.workspacePath,
@@ -33,8 +37,25 @@ export function createPipiclawTools(options) {
33
37
  securityContext,
34
38
  channelId: options.channelId,
35
39
  });
40
+ const webTools = toolsConfig.tools.web.enable === false
41
+ ? []
42
+ : [
43
+ createWebSearchTool({
44
+ webConfig: toolsConfig.tools.web,
45
+ securityConfig,
46
+ workspaceDir: options.workspaceDir,
47
+ channelId: options.channelId,
48
+ }),
49
+ createWebFetchTool({
50
+ webConfig: toolsConfig.tools.web,
51
+ securityConfig,
52
+ workspaceDir: options.workspaceDir,
53
+ channelId: options.channelId,
54
+ }),
55
+ ];
36
56
  return [
37
57
  ...baseTools,
58
+ ...webTools,
38
59
  createSubAgentTool({
39
60
  executor: options.executor,
40
61
  getCurrentModel: options.getCurrentModel,
@@ -45,6 +66,7 @@ export function createPipiclawTools(options) {
45
66
  getSubAgentDiscovery: options.getSubAgentDiscovery,
46
67
  getMemoryRecallSettings: options.getMemoryRecallSettings,
47
68
  securityConfig,
69
+ webConfig: toolsConfig.tools.web,
48
70
  runtimeContext: {
49
71
  workspacePath: options.workspacePath,
50
72
  channelId: options.channelId,
@@ -0,0 +1,17 @@
1
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type { SecurityConfig } from "../security/types.js";
3
+ import type { PipiclawWebToolsConfig } from "./config.js";
4
+ declare const webFetchSchema: import("@sinclair/typebox").TObject<{
5
+ label: import("@sinclair/typebox").TString;
6
+ url: import("@sinclair/typebox").TString;
7
+ extractMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"markdown">, import("@sinclair/typebox").TLiteral<"text">]>>;
8
+ maxChars: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
9
+ }>;
10
+ export interface WebFetchToolOptions {
11
+ webConfig: PipiclawWebToolsConfig;
12
+ securityConfig: SecurityConfig;
13
+ workspaceDir: string;
14
+ channelId?: string;
15
+ }
16
+ export declare function createWebFetchTool(options: WebFetchToolOptions): AgentTool<typeof webFetchSchema>;
17
+ export {};
@@ -0,0 +1,29 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { resolveWebFetchRequest } from "../web/config.js";
3
+ import { runWebFetch } from "../web/fetch.js";
4
+ const webFetchSchema = Type.Object({
5
+ label: Type.String({ description: "Brief description of what you're fetching and why (shown to user)" }),
6
+ url: Type.String({ description: "HTTP or HTTPS URL to fetch" }),
7
+ extractMode: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("text")], {
8
+ description: "Preferred text extraction format for HTML pages",
9
+ })),
10
+ maxChars: Type.Optional(Type.Number({ description: "Maximum extracted text characters to return" })),
11
+ });
12
+ export function createWebFetchTool(options) {
13
+ return {
14
+ name: "web_fetch",
15
+ label: "web_fetch",
16
+ description: "Fetch a public URL and extract readable content. Returns text for HTML/JSON/text pages and image content blocks for images.",
17
+ parameters: webFetchSchema,
18
+ execute: async (_toolCallId, { url, extractMode, maxChars, }, signal) => {
19
+ const request = resolveWebFetchRequest(options.webConfig.fetch, url, extractMode, maxChars);
20
+ const result = await runWebFetch({
21
+ webConfig: options.webConfig,
22
+ securityConfig: options.securityConfig,
23
+ workspaceDir: options.workspaceDir,
24
+ channelId: options.channelId,
25
+ }, request, signal);
26
+ return result;
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type { SecurityConfig } from "../security/types.js";
3
+ import type { PipiclawWebToolsConfig } from "./config.js";
4
+ declare const webSearchSchema: import("@sinclair/typebox").TObject<{
5
+ label: import("@sinclair/typebox").TString;
6
+ query: import("@sinclair/typebox").TString;
7
+ count: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
8
+ }>;
9
+ export interface WebSearchToolOptions {
10
+ webConfig: PipiclawWebToolsConfig;
11
+ securityConfig: SecurityConfig;
12
+ workspaceDir: string;
13
+ channelId?: string;
14
+ }
15
+ export declare function createWebSearchTool(options: WebSearchToolOptions): AgentTool<typeof webSearchSchema>;
16
+ export {};
@@ -0,0 +1,29 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { resolveWebSearchRequest } from "../web/config.js";
3
+ import { runWebSearch } from "../web/search.js";
4
+ const webSearchSchema = Type.Object({
5
+ label: Type.String({ description: "Brief description of what you're searching for and why (shown to user)" }),
6
+ query: Type.String({ description: "Search query" }),
7
+ count: Type.Optional(Type.Number({ description: "Maximum number of results to return (1-10)" })),
8
+ });
9
+ export function createWebSearchTool(options) {
10
+ return {
11
+ name: "web_search",
12
+ label: "web_search",
13
+ description: "Search the public web and return titles, URLs, and snippets from the configured provider.",
14
+ parameters: webSearchSchema,
15
+ execute: async (_toolCallId, { query, count }, signal) => {
16
+ const request = resolveWebSearchRequest(options.webConfig.search, query, count);
17
+ const result = await runWebSearch({
18
+ webConfig: options.webConfig,
19
+ securityConfig: options.securityConfig,
20
+ workspaceDir: options.workspaceDir,
21
+ channelId: options.channelId,
22
+ }, request.query, request.count, signal);
23
+ return {
24
+ content: [{ type: "text", text: result.content }],
25
+ details: result.details,
26
+ };
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,40 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { SecurityConfig } from "../security/types.js";
3
+ import type { PipiclawWebToolsConfig } from "../tools/config.js";
4
+ export declare const WEB_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Pipiclaw/0.5";
5
+ export interface WebRuntimeContext {
6
+ webConfig: PipiclawWebToolsConfig;
7
+ securityConfig: SecurityConfig;
8
+ workspaceDir: string;
9
+ channelId?: string;
10
+ }
11
+ export interface WebHttpResponse {
12
+ status: number;
13
+ finalUrl: string;
14
+ headers: Record<string, string>;
15
+ body: Buffer;
16
+ }
17
+ export interface WebHttpRequestOptions {
18
+ method?: "GET" | "POST";
19
+ url: string;
20
+ headers?: Record<string, string>;
21
+ params?: Record<string, string | number | boolean | undefined>;
22
+ data?: unknown;
23
+ timeoutMs: number;
24
+ signal?: AbortSignal;
25
+ maxRedirects?: number;
26
+ }
27
+ export declare class WebHttpClient {
28
+ private readonly context;
29
+ constructor(context: WebRuntimeContext);
30
+ request(options: WebHttpRequestOptions): Promise<WebHttpResponse>;
31
+ requestJson<T>(options: WebHttpRequestOptions): Promise<{
32
+ response: WebHttpResponse;
33
+ data: T;
34
+ }>;
35
+ requestText(options: WebHttpRequestOptions): Promise<{
36
+ response: WebHttpResponse;
37
+ text: string;
38
+ }>;
39
+ }
40
+ export declare function createWebHttpClient(context: WebRuntimeContext): WebHttpClient;
@@ -0,0 +1,181 @@
1
+ import { Buffer } from "node:buffer";
2
+ import axios from "axios";
3
+ import { HttpProxyAgent } from "http-proxy-agent";
4
+ import { HttpsProxyAgent } from "https-proxy-agent";
5
+ import { getProxyForUrl } from "proxy-from-env";
6
+ import { SocksProxyAgent } from "socks-proxy-agent";
7
+ import { logSecurityEvent } from "../security/logger.js";
8
+ import { NetworkGuardError, validateNetworkTarget, validateRedirectTarget } from "../security/network.js";
9
+ export const WEB_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Pipiclaw/0.5";
10
+ const agentCache = new Map();
11
+ function normalizeHeaders(headers) {
12
+ if (!headers || typeof headers !== "object") {
13
+ return {};
14
+ }
15
+ const result = {};
16
+ for (const [key, value] of Object.entries(headers)) {
17
+ if (typeof value === "string") {
18
+ result[key.toLowerCase()] = value;
19
+ }
20
+ else if (Array.isArray(value)) {
21
+ result[key.toLowerCase()] = value.join(", ");
22
+ }
23
+ else if (value !== undefined && value !== null) {
24
+ result[key.toLowerCase()] = String(value);
25
+ }
26
+ }
27
+ return result;
28
+ }
29
+ function buildUrlWithParams(url, params) {
30
+ if (!params) {
31
+ return url;
32
+ }
33
+ const resolved = new URL(url);
34
+ for (const [key, value] of Object.entries(params)) {
35
+ if (value === undefined) {
36
+ continue;
37
+ }
38
+ resolved.searchParams.set(key, String(value));
39
+ }
40
+ return resolved.toString();
41
+ }
42
+ function getProxyAgent(requestUrl, explicitProxy) {
43
+ const proxyUrl = explicitProxy?.trim() || getProxyForUrl(requestUrl);
44
+ if (!proxyUrl) {
45
+ return undefined;
46
+ }
47
+ const requestProtocol = new URL(requestUrl).protocol;
48
+ const proxyProtocol = new URL(proxyUrl).protocol;
49
+ const cacheKey = `${requestProtocol}|${proxyUrl}`;
50
+ const cached = agentCache.get(cacheKey);
51
+ if (cached) {
52
+ return cached;
53
+ }
54
+ let agent;
55
+ if (proxyProtocol.startsWith("socks")) {
56
+ agent = new SocksProxyAgent(proxyUrl);
57
+ }
58
+ else if (requestProtocol === "https:") {
59
+ agent = new HttpsProxyAgent(proxyUrl);
60
+ }
61
+ else {
62
+ agent = new HttpProxyAgent(proxyUrl);
63
+ }
64
+ agentCache.set(cacheKey, agent);
65
+ return agent;
66
+ }
67
+ function logBlockedRequest(context, error) {
68
+ logSecurityEvent(context.workspaceDir, context.securityConfig, {
69
+ type: "network",
70
+ tool: "web",
71
+ channelId: context.channelId,
72
+ url: error.url,
73
+ stage: error.stage,
74
+ resolvedHost: error.resolvedHost,
75
+ resolvedAddress: error.resolvedAddress,
76
+ category: error.category,
77
+ reason: error.message,
78
+ });
79
+ }
80
+ function decodeBody(body) {
81
+ return new TextDecoder("utf-8", { fatal: false }).decode(body);
82
+ }
83
+ function isRedirectStatus(status) {
84
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
85
+ }
86
+ export class WebHttpClient {
87
+ constructor(context) {
88
+ this.context = context;
89
+ }
90
+ async request(options) {
91
+ const maxRedirects = options.maxRedirects ?? this.context.securityConfig.networkGuard.maxRedirects;
92
+ let currentUrl = buildUrlWithParams(options.url, options.params);
93
+ let method = options.method ?? "GET";
94
+ let data = options.data;
95
+ for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) {
96
+ try {
97
+ if (redirectCount === 0) {
98
+ await validateNetworkTarget(currentUrl, { config: this.context.securityConfig });
99
+ }
100
+ else {
101
+ await validateRedirectTarget(currentUrl, { config: this.context.securityConfig });
102
+ }
103
+ }
104
+ catch (error) {
105
+ if (error instanceof NetworkGuardError) {
106
+ logBlockedRequest(this.context, error);
107
+ }
108
+ throw error;
109
+ }
110
+ const agent = getProxyAgent(currentUrl, this.context.webConfig.proxy);
111
+ const response = await axios.request({
112
+ method,
113
+ url: currentUrl,
114
+ data,
115
+ headers: {
116
+ "User-Agent": WEB_USER_AGENT,
117
+ Accept: "*/*",
118
+ ...options.headers,
119
+ },
120
+ responseType: "arraybuffer",
121
+ validateStatus: () => true,
122
+ timeout: options.timeoutMs,
123
+ signal: options.signal,
124
+ maxRedirects: 0,
125
+ proxy: false,
126
+ httpAgent: agent,
127
+ httpsAgent: agent,
128
+ });
129
+ const headers = normalizeHeaders(response.headers);
130
+ const body = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data);
131
+ if (isRedirectStatus(response.status) && headers.location) {
132
+ if (redirectCount === maxRedirects) {
133
+ throw new Error(`Too many redirects while fetching ${options.url}`);
134
+ }
135
+ currentUrl = new URL(headers.location, currentUrl).toString();
136
+ if (response.status === 303 ||
137
+ ((response.status === 301 || response.status === 302) && method === "POST")) {
138
+ method = "GET";
139
+ data = undefined;
140
+ }
141
+ continue;
142
+ }
143
+ return {
144
+ status: response.status,
145
+ finalUrl: currentUrl,
146
+ headers,
147
+ body,
148
+ };
149
+ }
150
+ throw new Error(`Too many redirects while fetching ${options.url}`);
151
+ }
152
+ async requestJson(options) {
153
+ const response = await this.request({
154
+ ...options,
155
+ headers: {
156
+ Accept: "application/json",
157
+ ...options.headers,
158
+ },
159
+ });
160
+ const text = decodeBody(response.body);
161
+ try {
162
+ return {
163
+ response,
164
+ data: JSON.parse(text),
165
+ };
166
+ }
167
+ catch (error) {
168
+ throw new Error(`Expected JSON response from ${options.url}, got invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
169
+ }
170
+ }
171
+ async requestText(options) {
172
+ const response = await this.request(options);
173
+ return {
174
+ response,
175
+ text: decodeBody(response.body),
176
+ };
177
+ }
178
+ }
179
+ export function createWebHttpClient(context) {
180
+ return new WebHttpClient(context);
181
+ }
@@ -0,0 +1,18 @@
1
+ import type { PipiclawWebFetchConfig, PipiclawWebSearchConfig, PipiclawWebToolsConfig } from "../tools/config.js";
2
+ export interface ResolvedWebSearchRequest {
3
+ query: string;
4
+ count: number;
5
+ timeoutMs: number;
6
+ }
7
+ export interface ResolvedWebFetchRequest {
8
+ url: string;
9
+ extractMode: "markdown" | "text";
10
+ maxChars: number;
11
+ timeoutMs: number;
12
+ maxImageBytes: number;
13
+ preferJina: boolean;
14
+ enableJinaFallback: boolean;
15
+ }
16
+ export declare function resolveWebSearchRequest(config: PipiclawWebSearchConfig, query: string, count?: number): ResolvedWebSearchRequest;
17
+ export declare function resolveWebFetchRequest(config: PipiclawWebFetchConfig, url: string, extractMode?: "markdown" | "text", maxChars?: number): ResolvedWebFetchRequest;
18
+ export declare function isWebToolsEnabled(config: PipiclawWebToolsConfig): boolean;
@@ -0,0 +1,34 @@
1
+ export function resolveWebSearchRequest(config, query, count) {
2
+ return {
3
+ query: query.trim(),
4
+ count: clamp(count, config.maxResults, 1, 10),
5
+ timeoutMs: config.timeoutMs,
6
+ };
7
+ }
8
+ export function resolveWebFetchRequest(config, url, extractMode, maxChars) {
9
+ return {
10
+ url: url.trim(),
11
+ extractMode: extractMode === "text" ? "text" : extractMode === "markdown" ? "markdown" : config.defaultExtractMode,
12
+ maxChars: clamp(maxChars, config.maxChars, 100),
13
+ timeoutMs: config.timeoutMs,
14
+ maxImageBytes: config.maxImageBytes,
15
+ preferJina: config.preferJina,
16
+ enableJinaFallback: config.enableJinaFallback,
17
+ };
18
+ }
19
+ export function isWebToolsEnabled(config) {
20
+ return config.enable !== false;
21
+ }
22
+ function clamp(value, fallback, minimum, maximum) {
23
+ if (typeof value !== "number" || !Number.isFinite(value)) {
24
+ return fallback;
25
+ }
26
+ const normalized = Math.floor(value);
27
+ if (normalized < minimum) {
28
+ return fallback;
29
+ }
30
+ if (maximum !== undefined && normalized > maximum) {
31
+ return fallback;
32
+ }
33
+ return normalized;
34
+ }
@@ -0,0 +1,7 @@
1
+ export declare function htmlToText(html: string): string;
2
+ export declare function htmlToMarkdown(html: string): string;
3
+ export declare function extractReadableContent(html: string, url: string, extractMode: "markdown" | "text"): {
4
+ title: string;
5
+ content: string;
6
+ extractor: string;
7
+ };