@demigodmode/pi-web-agent 0.5.1 → 0.6.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.
package/README.md CHANGED
@@ -106,6 +106,34 @@ Example:
106
106
  }
107
107
  ```
108
108
 
109
+ Backend config is also supported. Defaults are DuckDuckGo search, plain HTTP fetch, and local browser headless fallback:
110
+
111
+ ```json
112
+ {
113
+ "backends": {
114
+ "search": { "provider": "duckduckgo" },
115
+ "fetch": { "provider": "http" },
116
+ "headless": { "provider": "local-browser" }
117
+ }
118
+ }
119
+ ```
120
+
121
+ Self-hosted example for existing SearXNG/Firecrawl services:
122
+
123
+ ```json
124
+ {
125
+ "backends": {
126
+ "search": { "provider": "searxng", "baseUrl": "http://localhost:8080" },
127
+ "fetch": { "provider": "firecrawl", "baseUrl": "http://localhost:3002" },
128
+ "headless": { "provider": "local-browser" }
129
+ }
130
+ }
131
+ ```
132
+
133
+ Prefer `PI_WEB_AGENT_FIRECRAWL_API_KEY` for Firecrawl auth instead of putting secrets in project config. `/web-agent doctor` validates required backend fields and checks configured self-hosted endpoints.
134
+
135
+ Full guide: https://demigodmode.github.io/pi-web-agent/self-hosted-backends
136
+
109
137
  ## Local development
110
138
 
111
139
  ```bash
@@ -0,0 +1,42 @@
1
+ export type SearchBackendConfig = {
2
+ provider: 'duckduckgo' | 'searxng';
3
+ baseUrl?: string;
4
+ };
5
+ export type FetchBackendConfig = {
6
+ provider: 'http' | 'firecrawl';
7
+ baseUrl?: string;
8
+ apiKey?: string;
9
+ };
10
+ export type HeadlessBackendConfig = {
11
+ provider: 'local-browser';
12
+ };
13
+ export type BackendConfig = {
14
+ search: SearchBackendConfig;
15
+ fetch: FetchBackendConfig;
16
+ headless: HeadlessBackendConfig;
17
+ };
18
+ export type BackendConfigOverride = {
19
+ search?: Partial<SearchBackendConfig>;
20
+ fetch?: Partial<FetchBackendConfig>;
21
+ headless?: Partial<HeadlessBackendConfig>;
22
+ };
23
+ export type BackendConfigFile = {
24
+ backends?: {
25
+ search?: {
26
+ provider?: unknown;
27
+ baseUrl?: unknown;
28
+ };
29
+ fetch?: {
30
+ provider?: unknown;
31
+ baseUrl?: unknown;
32
+ apiKey?: unknown;
33
+ };
34
+ headless?: {
35
+ provider?: unknown;
36
+ };
37
+ };
38
+ };
39
+ export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
40
+ export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
41
+ export declare function validateBackendConfig(config: BackendConfig): string[];
42
+ export declare function mergeBackendConfigLayers(...layers: Array<BackendConfig | BackendConfigOverride | undefined>): BackendConfig;
@@ -0,0 +1,61 @@
1
+ export const DEFAULT_BACKEND_CONFIG = {
2
+ search: { provider: 'duckduckgo' },
3
+ fetch: { provider: 'http' },
4
+ headless: { provider: 'local-browser' }
5
+ };
6
+ export function extractBackendConfigOverride(file) {
7
+ const backends = file?.backends;
8
+ const override = {};
9
+ if (backends?.search?.provider === 'duckduckgo' || backends?.search?.provider === 'searxng') {
10
+ override.search = { provider: backends.search.provider };
11
+ if (typeof backends.search.baseUrl === 'string') {
12
+ override.search.baseUrl = backends.search.baseUrl;
13
+ }
14
+ }
15
+ if (backends?.fetch?.provider === 'http' || backends?.fetch?.provider === 'firecrawl') {
16
+ override.fetch = { provider: backends.fetch.provider };
17
+ if (typeof backends.fetch.baseUrl === 'string') {
18
+ override.fetch.baseUrl = backends.fetch.baseUrl;
19
+ }
20
+ if (typeof backends.fetch.apiKey === 'string') {
21
+ override.fetch.apiKey = backends.fetch.apiKey;
22
+ }
23
+ }
24
+ if (backends?.headless?.provider === 'local-browser') {
25
+ override.headless = { provider: 'local-browser' };
26
+ }
27
+ return override;
28
+ }
29
+ export function validateBackendConfig(config) {
30
+ const issues = [];
31
+ if (config.search.provider === 'searxng' && !config.search.baseUrl) {
32
+ issues.push('search provider searxng requires backends.search.baseUrl');
33
+ }
34
+ if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
35
+ issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
36
+ }
37
+ return issues;
38
+ }
39
+ function mergeSearchConfig(current, override) {
40
+ if (!override)
41
+ return current;
42
+ if (override.provider && override.provider !== current.provider) {
43
+ return { ...override, provider: override.provider };
44
+ }
45
+ return { ...current, ...override };
46
+ }
47
+ function mergeFetchConfig(current, override) {
48
+ if (!override)
49
+ return current;
50
+ if (override.provider && override.provider !== current.provider) {
51
+ return { ...override, provider: override.provider };
52
+ }
53
+ return { ...current, ...override };
54
+ }
55
+ export function mergeBackendConfigLayers(...layers) {
56
+ return layers.reduce((merged, layer) => ({
57
+ search: mergeSearchConfig(merged.search, layer?.search),
58
+ fetch: mergeFetchConfig(merged.fetch, layer?.fetch),
59
+ headless: { ...merged.headless, ...layer?.headless }
60
+ }), DEFAULT_BACKEND_CONFIG);
61
+ }
@@ -0,0 +1,5 @@
1
+ import type { BackendConfig } from './config.js';
2
+ export declare function checkBackendHealth(config: BackendConfig, { fetchImpl, timeoutMs }?: {
3
+ fetchImpl?: typeof fetch;
4
+ timeoutMs?: number;
5
+ }): Promise<string[]>;
@@ -0,0 +1,68 @@
1
+ function withTimeout(timeoutMs) {
2
+ const controller = new AbortController();
3
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
4
+ return { signal: controller.signal, done: () => clearTimeout(timeout) };
5
+ }
6
+ function message(error) {
7
+ return error instanceof Error ? error.message : String(error);
8
+ }
9
+ function searxngDoctorUrl(baseUrl) {
10
+ const url = new URL('/search', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
11
+ url.searchParams.set('q', 'pi-web-agent-doctor');
12
+ url.searchParams.set('format', 'json');
13
+ return url.toString();
14
+ }
15
+ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
16
+ const lines = [];
17
+ if (config.search.provider === 'duckduckgo') {
18
+ lines.push('search backend: duckduckgo');
19
+ }
20
+ else if (!config.search.baseUrl) {
21
+ lines.push('search backend: searxng warning (missing baseUrl)');
22
+ }
23
+ else {
24
+ const timeout = withTimeout(timeoutMs);
25
+ try {
26
+ const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl), { signal: timeout.signal });
27
+ const json = (await response.json());
28
+ lines.push(response.ok && Array.isArray(json.results)
29
+ ? 'search backend: searxng ok'
30
+ : 'search backend: searxng warning (unexpected response)');
31
+ }
32
+ catch (error) {
33
+ lines.push(`search backend: searxng warning (${message(error)})`);
34
+ }
35
+ finally {
36
+ timeout.done();
37
+ }
38
+ }
39
+ if (config.fetch.provider === 'http') {
40
+ lines.push('fetch backend: http');
41
+ }
42
+ else if (!config.fetch.baseUrl) {
43
+ lines.push('fetch backend: firecrawl warning (missing baseUrl)');
44
+ }
45
+ else {
46
+ const timeout = withTimeout(timeoutMs);
47
+ try {
48
+ const headers = { 'content-type': 'application/json' };
49
+ if (config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY) {
50
+ headers.Authorization = `Bearer ${config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY}`;
51
+ }
52
+ const response = await fetchImpl(new URL('/v1/scrape', config.fetch.baseUrl).toString(), {
53
+ method: 'POST',
54
+ headers,
55
+ body: JSON.stringify({ url: 'https://example.com', formats: ['markdown'] }),
56
+ signal: timeout.signal
57
+ });
58
+ lines.push(response.ok ? 'fetch backend: firecrawl ok' : `fetch backend: firecrawl warning (HTTP ${response.status})`);
59
+ }
60
+ catch (error) {
61
+ lines.push(`fetch backend: firecrawl warning (${message(error)})`);
62
+ }
63
+ finally {
64
+ timeout.done();
65
+ }
66
+ }
67
+ return lines;
68
+ }
@@ -0,0 +1,14 @@
1
+ import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
2
+ import { type BackendConfig } from './config.js';
3
+ export type BackendSet = {
4
+ search: (input: {
5
+ query: string;
6
+ }) => Promise<WebSearchResponse>;
7
+ fetchPage: (input: {
8
+ url: string;
9
+ }) => Promise<WebFetchResponse>;
10
+ headlessFetch: (input: {
11
+ url: string;
12
+ }) => Promise<WebFetchHeadlessResponse>;
13
+ };
14
+ export declare function createBackendSet(config?: BackendConfig): BackendSet;
@@ -0,0 +1,58 @@
1
+ import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
2
+ import { createSearxngSearchTool } from '../search/searxng.js';
3
+ import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
4
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
5
+ import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
6
+ import { createWebFetchTool } from '../tools/web-fetch.js';
7
+ import { createWebSearchTool } from '../tools/web-search.js';
8
+ import { DEFAULT_BACKEND_CONFIG } from './config.js';
9
+ function invalidSearxngSearch() {
10
+ return async function search() {
11
+ const result = {
12
+ status: 'error',
13
+ results: [],
14
+ metadata: { backend: 'searxng', cacheHit: false },
15
+ error: {
16
+ code: 'BACKEND_CONFIG_INVALID',
17
+ message: 'SearXNG search requires backends.search.baseUrl.'
18
+ }
19
+ };
20
+ return { ...result, presentation: buildSearchPresentation(result) };
21
+ };
22
+ }
23
+ function invalidFirecrawlFetch() {
24
+ return async function fetchPage(url) {
25
+ const result = {
26
+ status: 'error',
27
+ url,
28
+ metadata: { method: 'firecrawl', cacheHit: false },
29
+ error: {
30
+ code: 'BACKEND_CONFIG_INVALID',
31
+ message: 'Firecrawl fetch requires backends.fetch.baseUrl.'
32
+ }
33
+ };
34
+ return { ...result, presentation: buildFetchPresentation(result) };
35
+ };
36
+ }
37
+ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG) {
38
+ const search = config.search.provider === 'searxng'
39
+ ? config.search.baseUrl
40
+ ? createSearxngSearchTool({ baseUrl: config.search.baseUrl })
41
+ : invalidSearxngSearch()
42
+ : createWebSearchTool();
43
+ const fetchPage = config.fetch.provider === 'firecrawl'
44
+ ? config.fetch.baseUrl
45
+ ? createWebFetchTool({
46
+ fetchPage: createFirecrawlFetcher({
47
+ baseUrl: config.fetch.baseUrl,
48
+ apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY
49
+ })
50
+ })
51
+ : createWebFetchTool({ fetchPage: invalidFirecrawlFetch() })
52
+ : createWebFetchTool();
53
+ return {
54
+ search,
55
+ fetchPage,
56
+ headlessFetch: createWebFetchHeadlessTool()
57
+ };
58
+ }
@@ -1,3 +1,4 @@
1
+ import { type BackendConfig } from '../backends/config.js';
1
2
  import { type ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
3
  import { loadPresentationConfigLayers, type LoadedPresentationConfig } from '../presentation/config-store.js';
3
4
  import { type BrowserResolutionResult } from '../fetch/browser-resolution.js';
@@ -13,6 +14,7 @@ type CommandDeps = {
13
14
  arch: string;
14
15
  };
15
16
  checkTypebox?: () => Promise<boolean>;
17
+ checkBackends?: (config: BackendConfig) => Promise<string[]>;
16
18
  };
17
19
  export type SettingsDraftState = {
18
20
  scope: PresentationScope;
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_BACKEND_CONFIG, validateBackendConfig } from '../backends/config.js';
2
+ import { checkBackendHealth } from '../backends/doctor.js';
1
3
  import { DynamicBorder, getSettingsListTheme } from '@mariozechner/pi-coding-agent';
2
4
  import { Container, SelectList, SettingsList, Text } from '@mariozechner/pi-tui';
3
5
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
@@ -22,6 +24,19 @@ async function defaultCheckTypebox() {
22
24
  return false;
23
25
  }
24
26
  }
27
+ function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
28
+ const search = config.search.baseUrl
29
+ ? `search: ${config.search.provider} (${config.search.baseUrl})`
30
+ : `search: ${config.search.provider}`;
31
+ const fetch = config.fetch.baseUrl
32
+ ? `fetch: ${config.fetch.provider} (${config.fetch.baseUrl})`
33
+ : `fetch: ${config.fetch.provider}`;
34
+ return [
35
+ search,
36
+ fetch,
37
+ `headless: ${config.headless.provider}`
38
+ ].join('\n');
39
+ }
25
40
  function formatConfigSummary(config) {
26
41
  const lines = [`defaultMode: ${config.defaultMode}`];
27
42
  for (const toolName of PRESENTATION_TOOL_NAMES) {
@@ -233,6 +248,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
233
248
  arch: process.arch
234
249
  };
235
250
  const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
251
+ const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config));
236
252
  pi.registerCommand('web-agent', {
237
253
  description: 'Open settings or manage pi-web-agent presentation config',
238
254
  handler: async (args, ctx) => {
@@ -254,11 +270,17 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
254
270
  }
255
271
  }
256
272
  if (action === 'doctor') {
257
- const [typeboxOk, browser] = await Promise.all([checkTypebox(), resolveBrowser()]);
273
+ const [typeboxOk, browser, loaded] = await Promise.all([checkTypebox(), resolveBrowser(), load()]);
274
+ const backendConfig = loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG;
275
+ const backendIssues = validateBackendConfig(backendConfig);
276
+ const backendHealth = await checkBackends(backendConfig);
258
277
  const lines = [
259
278
  'pi-web-agent: loaded',
260
279
  `runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
261
- `typebox: ${typeboxOk ? 'ok' : 'missing'}`
280
+ `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
281
+ formatBackendSummary(backendConfig),
282
+ backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
283
+ ...backendHealth
262
284
  ];
263
285
  if (browser.ok) {
264
286
  lines.push(`browser: ${browser.browser} ${browser.executablePath}`);
@@ -275,6 +297,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
275
297
  const loaded = await load();
276
298
  ctx.ui.notify([
277
299
  formatConfigSummary(loaded.effectiveConfig),
300
+ formatBackendSummary(loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG),
278
301
  `global: ${loaded.global.path}${loaded.global.exists ? '' : ' (missing)'}`,
279
302
  `project: ${loaded.project.path}${loaded.project.exists ? '' : ' (missing)'}`
280
303
  ].join('\n'), 'info');
package/dist/extension.js CHANGED
@@ -1,19 +1,33 @@
1
+ import { DEFAULT_BACKEND_CONFIG } from './backends/config.js';
1
2
  import { Type } from 'typebox';
2
3
  import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
3
4
  import { DEFAULT_PRESENTATION_CONFIG, resolvePresentationMode } from './presentation/config.js';
5
+ import { createResearchWorkflow } from './orchestration/index.js';
4
6
  import { loadPresentationConfigLayers } from './presentation/config-store.js';
5
7
  import { selectPresentationView } from './presentation/select-view.js';
6
8
  import { createWebExploreTool } from './tools/web-explore.js';
7
- async function getEffectivePresentationConfig(pi) {
9
+ async function loadWebAgentConfig(pi) {
8
10
  const store = pi.__presentationConfigStore;
11
+ return store?.load?.() ?? loadPresentationConfigLayers();
12
+ }
13
+ async function getEffectivePresentationConfig(pi) {
9
14
  try {
10
- const loaded = await (store?.load?.() ?? loadPresentationConfigLayers());
15
+ const loaded = await loadWebAgentConfig(pi);
11
16
  return loaded.effectiveConfig;
12
17
  }
13
18
  catch {
14
19
  return DEFAULT_PRESENTATION_CONFIG;
15
20
  }
16
21
  }
22
+ async function getEffectiveBackendConfig(pi) {
23
+ try {
24
+ const loaded = await loadWebAgentConfig(pi);
25
+ return loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG;
26
+ }
27
+ catch {
28
+ return DEFAULT_BACKEND_CONFIG;
29
+ }
30
+ }
17
31
  async function renderToolText(pi, toolName, details) {
18
32
  const config = await getEffectivePresentationConfig(pi);
19
33
  const mode = resolvePresentationMode(toolName, config);
@@ -21,8 +35,22 @@ async function renderToolText(pi, toolName, details) {
21
35
  }
22
36
  export default function extension(pi) {
23
37
  registerWebAgentConfigCommands(pi);
24
- const webExplore = pi.__webExploreTool ??
25
- createWebExploreTool();
38
+ const injectedWebExplore = pi.__webExploreTool;
39
+ let cachedBackendKey;
40
+ let cachedWebExplore;
41
+ async function getConfiguredWebExplore() {
42
+ if (injectedWebExplore)
43
+ return injectedWebExplore;
44
+ const backendConfig = await getEffectiveBackendConfig(pi);
45
+ const backendKey = JSON.stringify(backendConfig);
46
+ if (!cachedWebExplore || cachedBackendKey !== backendKey) {
47
+ cachedBackendKey = backendKey;
48
+ cachedWebExplore = createWebExploreTool({
49
+ explore: createResearchWorkflow({ backendConfig })
50
+ });
51
+ }
52
+ return cachedWebExplore;
53
+ }
26
54
  pi.on('before_agent_start', async (event) => ({
27
55
  systemPrompt: `${event.systemPrompt}\n\n` +
28
56
  'For web research questions that require finding and comparing sources, use web_explore. ' +
@@ -37,6 +65,7 @@ export default function extension(pi) {
37
65
  query: Type.String({ description: 'Web research question to explore.' })
38
66
  }),
39
67
  async execute(_toolCallId, params) {
68
+ const webExplore = await getConfiguredWebExplore();
40
69
  const result = await webExplore({ query: params.query });
41
70
  return {
42
71
  content: [
@@ -0,0 +1,6 @@
1
+ import type { WebFetchResponse } from '../types.js';
2
+ export declare function createFirecrawlFetcher({ baseUrl, apiKey, fetchImpl }: {
3
+ baseUrl: string;
4
+ apiKey?: string;
5
+ fetchImpl?: typeof fetch;
6
+ }): (url: string) => Promise<WebFetchResponse>;
@@ -0,0 +1,61 @@
1
+ function buildScrapeUrl(baseUrl) {
2
+ return new URL('/v1/scrape', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
3
+ }
4
+ function errorMessage(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ export function createFirecrawlFetcher({ baseUrl, apiKey, fetchImpl = fetch }) {
8
+ return async function firecrawlFetch(url) {
9
+ try {
10
+ const headers = { 'content-type': 'application/json' };
11
+ if (apiKey) {
12
+ headers.Authorization = `Bearer ${apiKey}`;
13
+ }
14
+ const response = await fetchImpl(buildScrapeUrl(baseUrl), {
15
+ method: 'POST',
16
+ headers,
17
+ body: JSON.stringify({ url, formats: ['markdown'] })
18
+ });
19
+ if (!response.ok) {
20
+ throw new Error(`HTTP ${response.status}`);
21
+ }
22
+ const parsed = (await response.json());
23
+ if (parsed.success === false) {
24
+ throw new Error(typeof parsed.error === 'string' ? parsed.error : 'Firecrawl scrape failed.');
25
+ }
26
+ const text = typeof parsed.data?.markdown === 'string'
27
+ ? parsed.data.markdown
28
+ : typeof parsed.data?.html === 'string'
29
+ ? parsed.data.html
30
+ : '';
31
+ const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
32
+ ? parsed.data.metadata.sourceURL
33
+ : url;
34
+ const title = typeof parsed.data?.metadata?.title === 'string'
35
+ ? parsed.data.metadata.title
36
+ : undefined;
37
+ if (!text.trim()) {
38
+ return {
39
+ status: 'needs_headless',
40
+ url: resolvedUrl,
41
+ metadata: { method: 'firecrawl', cacheHit: false },
42
+ error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
43
+ };
44
+ }
45
+ return {
46
+ status: 'ok',
47
+ url: resolvedUrl,
48
+ content: { title, text },
49
+ metadata: { method: 'firecrawl', cacheHit: false, truncated: text.length >= 4000 }
50
+ };
51
+ }
52
+ catch (error) {
53
+ return {
54
+ status: 'error',
55
+ url,
56
+ metadata: { method: 'firecrawl', cacheHit: false },
57
+ error: { code: 'FETCH_FAILED', message: `Firecrawl scrape failed: ${errorMessage(error)}` }
58
+ };
59
+ }
60
+ };
61
+ }
@@ -1,5 +1,7 @@
1
+ import type { BackendConfig } from '../backends/config.js';
1
2
  import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
2
- export declare function createResearchWorkflow({ search, fetchPage, headlessFetch }?: {
3
+ export declare function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch }?: {
4
+ backendConfig?: BackendConfig;
3
5
  search?: (input: {
4
6
  query: string;
5
7
  }) => Promise<WebSearchResponse>;
@@ -1,9 +1,11 @@
1
- import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
2
- import { createWebFetchTool } from '../tools/web-fetch.js';
3
- import { createWebSearchTool } from '../tools/web-search.js';
1
+ import { createBackendSet } from '../backends/factory.js';
4
2
  import { createResearchOrchestrator } from './research-orchestrator.js';
5
3
  import { createResearchWorker } from './research-worker.js';
6
- export function createResearchWorkflow({ search = createWebSearchTool(), fetchPage = createWebFetchTool(), headlessFetch = createWebFetchHeadlessTool() } = {}) {
7
- const worker = createResearchWorker({ search, fetchPage });
8
- return createResearchOrchestrator({ worker, headlessFetch });
4
+ export function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch } = {}) {
5
+ const backends = createBackendSet(backendConfig);
6
+ const resolvedSearch = search ?? backends.search;
7
+ const resolvedFetchPage = fetchPage ?? backends.fetchPage;
8
+ const resolvedHeadlessFetch = headlessFetch ?? backends.headlessFetch;
9
+ const worker = createResearchWorker({ search: resolvedSearch, fetchPage: resolvedFetchPage });
10
+ return createResearchOrchestrator({ worker, headlessFetch: resolvedHeadlessFetch });
9
11
  }
@@ -1,5 +1,5 @@
1
1
  export type ResearchSourceKind = 'official-docs' | 'official-api' | 'official-discussion' | 'community' | 'issue-thread' | 'package-page' | 'other';
2
- export type ResearchMethod = 'search' | 'http' | 'headless';
2
+ export type ResearchMethod = 'search' | 'http' | 'headless' | 'firecrawl';
3
3
  export type ResearchEvidence = {
4
4
  title: string;
5
5
  url: string;
@@ -1,3 +1,4 @@
1
+ import { type BackendConfig, type BackendConfigOverride } from '../backends/config.js';
1
2
  import type { PresentationConfig, PresentationConfigOverride, PresentationScope } from './types.js';
2
3
  export type PresentationConfigStoreOptions = {
3
4
  homeDir?: string;
@@ -7,12 +8,14 @@ export type PresentationConfigLayer = {
7
8
  path: string;
8
9
  exists: boolean;
9
10
  rawConfig?: PresentationConfigOverride;
11
+ rawBackends?: BackendConfigOverride;
10
12
  error?: string;
11
13
  };
12
14
  export type LoadedPresentationConfig = {
13
15
  global: PresentationConfigLayer;
14
16
  project: PresentationConfigLayer;
15
17
  effectiveConfig: PresentationConfig;
18
+ effectiveBackends: BackendConfig;
16
19
  };
17
20
  export declare function getPresentationConfigPaths(options?: PresentationConfigStoreOptions): {
18
21
  globalPath: string;
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { DEFAULT_BACKEND_CONFIG, extractBackendConfigOverride, mergeBackendConfigLayers } from '../backends/config.js';
3
4
  import { DEFAULT_PRESENTATION_CONFIG, extractPresentationConfigOverride, mergePresentationConfigLayers } from './config.js';
4
5
  export function getPresentationConfigPaths(options = {}) {
5
6
  const homeDir = options.homeDir ?? process.env.USERPROFILE ?? process.env.HOME ?? '';
@@ -9,14 +10,21 @@ export function getPresentationConfigPaths(options = {}) {
9
10
  projectPath: path.join(projectDir, '.pi', 'extensions', 'pi-web-agent', 'config.json')
10
11
  };
11
12
  }
13
+ function hasPresentationRoot(parsed) {
14
+ return parsed.presentation !== undefined;
15
+ }
12
16
  async function readPresentationConfigFile(filePath) {
13
17
  try {
14
18
  const rawText = await readFile(filePath, 'utf8');
15
19
  const parsed = JSON.parse(rawText);
20
+ const presentationFile = hasPresentationRoot(parsed)
21
+ ? parsed
22
+ : { presentation: parsed };
16
23
  return {
17
24
  path: filePath,
18
25
  exists: true,
19
- rawConfig: extractPresentationConfigOverride(parsed)
26
+ rawConfig: extractPresentationConfigOverride(presentationFile),
27
+ rawBackends: extractBackendConfigOverride(parsed)
20
28
  };
21
29
  }
22
30
  catch (error) {
@@ -48,7 +56,8 @@ export async function loadPresentationConfigLayers(options = {}) {
48
56
  return {
49
57
  global,
50
58
  project,
51
- effectiveConfig: mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, global.rawConfig, project.rawConfig)
59
+ effectiveConfig: mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, global.rawConfig, project.rawConfig),
60
+ effectiveBackends: mergeBackendConfigLayers(DEFAULT_BACKEND_CONFIG, global.rawBackends, project.rawBackends)
52
61
  };
53
62
  }
54
63
  export async function savePresentationConfigScope(options, scope, config) {
@@ -1,6 +1,8 @@
1
1
  function internalReaderLabel(method) {
2
2
  if (method === 'headless')
3
3
  return 'web_fetch_headless';
4
+ if (method === 'firecrawl')
5
+ return 'firecrawl';
4
6
  if (method === 'http')
5
7
  return 'web_fetch';
6
8
  return 'web_explore';
@@ -0,0 +1,7 @@
1
+ import type { WebSearchResponse } from '../types.js';
2
+ export declare function createSearxngSearchTool({ baseUrl, fetchImpl }: {
3
+ baseUrl: string;
4
+ fetchImpl?: typeof fetch;
5
+ }): ({ query }: {
6
+ query: string;
7
+ }) => Promise<WebSearchResponse>;
@@ -0,0 +1,66 @@
1
+ import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
+ function buildSearchUrl(baseUrl, query) {
3
+ const url = new URL('/search', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
4
+ url.searchParams.set('q', query);
5
+ url.searchParams.set('format', 'json');
6
+ return url.toString();
7
+ }
8
+ function normalizeResults(response) {
9
+ return (response.results ?? []).flatMap((result) => {
10
+ if (typeof result.title !== 'string' || typeof result.url !== 'string') {
11
+ return [];
12
+ }
13
+ return [
14
+ {
15
+ title: result.title,
16
+ url: result.url,
17
+ snippet: typeof result.content === 'string' ? result.content : ''
18
+ }
19
+ ];
20
+ });
21
+ }
22
+ export function createSearxngSearchTool({ baseUrl, fetchImpl = fetch }) {
23
+ return async function searxngSearch({ query }) {
24
+ const normalizedQuery = query.trim();
25
+ if (!normalizedQuery) {
26
+ const result = {
27
+ status: 'error',
28
+ results: [],
29
+ metadata: { backend: 'searxng', cacheHit: false },
30
+ error: { code: 'INVALID_QUERY', message: 'Query must not be empty.' }
31
+ };
32
+ return { ...result, presentation: buildSearchPresentation(result) };
33
+ }
34
+ try {
35
+ const response = await fetchImpl(buildSearchUrl(baseUrl, normalizedQuery));
36
+ if (!response.ok) {
37
+ throw new Error(`HTTP ${response.status}`);
38
+ }
39
+ const parsed = (await response.json());
40
+ const results = normalizeResults(parsed);
41
+ const result = results.length > 0
42
+ ? {
43
+ status: 'ok',
44
+ results,
45
+ metadata: { backend: 'searxng', cacheHit: false }
46
+ }
47
+ : {
48
+ status: 'error',
49
+ results: [],
50
+ metadata: { backend: 'searxng', cacheHit: false },
51
+ error: { code: 'NO_RESULTS', message: 'SearXNG returned no usable results for this query.' }
52
+ };
53
+ return { ...result, presentation: buildSearchPresentation(result) };
54
+ }
55
+ catch (error) {
56
+ const rawMessage = error instanceof Error ? error.message : String(error);
57
+ const result = {
58
+ status: 'error',
59
+ results: [],
60
+ metadata: { backend: 'searxng', cacheHit: false },
61
+ error: { code: 'FETCH_FAILED', message: `SearXNG search request failed: ${rawMessage}` }
62
+ };
63
+ return { ...result, presentation: buildSearchPresentation(result) };
64
+ }
65
+ };
66
+ }
@@ -31,7 +31,7 @@ export declare function createWebExploreTool({ explore }?: {
31
31
  sources: Array<{
32
32
  title: string;
33
33
  url: string;
34
- method?: "http" | "headless";
34
+ method?: "http" | "headless" | "firecrawl";
35
35
  }>;
36
36
  caveat?: string;
37
37
  metadata?: {
package/dist/types.d.ts CHANGED
@@ -11,11 +11,11 @@ export type ToolError = {
11
11
  message: string;
12
12
  };
13
13
  export type SearchMetadata = {
14
- backend: 'duckduckgo';
14
+ backend: 'duckduckgo' | 'searxng';
15
15
  cacheHit: boolean;
16
16
  };
17
17
  export type FetchMetadata = {
18
- method: 'http' | 'headless';
18
+ method: 'http' | 'headless' | 'firecrawl';
19
19
  cacheHit: boolean;
20
20
  contentType?: string;
21
21
  truncated?: boolean;
@@ -56,7 +56,7 @@ export type WebExploreResponse = {
56
56
  sources: Array<{
57
57
  title: string;
58
58
  url: string;
59
- method?: 'http' | 'headless';
59
+ method?: 'http' | 'headless' | 'firecrawl';
60
60
  }>;
61
61
  caveat?: string;
62
62
  metadata?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",