@demigodmode/pi-web-agent 0.6.0 → 1.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,207 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is intentionally simple and release-oriented.
6
+
7
+ ## Unreleased
8
+
9
+ ### Added
10
+ - Nothing yet.
11
+
12
+ ### Changed
13
+ - Nothing yet.
14
+
15
+ ### Fixed
16
+ - Nothing yet.
17
+
18
+ ### Breaking
19
+ - None.
20
+
21
+ ## [1.1.0] - 2026-05-25
22
+ ### Added
23
+ - Added explicit opt-in fallback from SearXNG to DuckDuckGo and Firecrawl to HTTP.
24
+ - Added supported SearXNG and Firecrawl backend options in config.
25
+ - Added env-gated live tests for local SearXNG and Firecrawl instances.
26
+
27
+ ### Changed
28
+ - `/web-agent show` and `/web-agent doctor` now report configured backend fallback/options.
29
+
30
+ ### Fixed
31
+ - Nothing yet.
32
+
33
+ ### Breaking
34
+ - None.
35
+
36
+ ## [1.0.0] - 2026-05-08
37
+ ### Added
38
+ - Added one-time `pi-web-agent` changelog notices after package updates and `/web-agent changelog` for manual viewing.
39
+
40
+ ### Changed
41
+ - Migrated Pi package imports to `@earendil-works/*` after the upstream Pi scope move.
42
+
43
+ ### Fixed
44
+ - Nothing yet.
45
+
46
+ ### Breaking
47
+ - This release requires Pi 0.74+. Users on older Pi versions should stay on `@demigodmode/pi-web-agent@0.6.x` until they update Pi.
48
+
49
+ ## [0.6.0] - 2026-05-04
50
+ ### Added
51
+ - Added configurable web backends for `web_explore`, including SearXNG search and Firecrawl fetch support.
52
+ - Added backend diagnostics to `/web-agent doctor`, including config validation and self-hosted endpoint checks.
53
+ - Added dedicated self-hosted backend docs for connecting existing SearXNG and Firecrawl services.
54
+
55
+ ### Changed
56
+ - `/web-agent show` now includes the effective backend configuration.
57
+ - `web_explore` now loads the effective backend config while preserving the default DuckDuckGo, HTTP, and local-browser behavior.
58
+
59
+ ### Fixed
60
+ - Fixed backend config merging so provider-specific fields do not leak when a higher-precedence config changes providers.
61
+ - Kept the configured `web_explore` workflow reusable while backend config is unchanged, avoiding unnecessary backend/cache recreation.
62
+
63
+ ### Breaking
64
+ - None.
65
+
66
+ ## [0.5.1] - 2026-05-04
67
+ ### Added
68
+ - Nothing yet.
69
+
70
+ ### Changed
71
+ - Nothing yet.
72
+
73
+ ### Fixed
74
+ - Fixed the Windows browser-resolution test so it is deterministic on Linux CI.
75
+
76
+ ### Breaking
77
+ - None.
78
+
79
+ ## [0.5.0] - 2026-05-04
80
+ ### Added
81
+ - Added `/web-agent doctor` to report extension, runtime dependency, and browser detection status.
82
+ - Added a `/web-agent` action menu for settings, config display, doctor, and reset actions.
83
+
84
+ ### Changed
85
+ - Migrated runtime schema imports from `@sinclair/typebox` to `typebox` for Pi 0.69 compatibility.
86
+ - Documented the current headless rendering browser requirement and doctor command.
87
+
88
+ ### Fixed
89
+ - Fixed headless browser detection so Chrome, Chromium, Edge, and Brave can be found across Windows, macOS, and Linux instead of only checking Windows Chrome/Edge paths.
90
+
91
+ ### Breaking
92
+ - None.
93
+
94
+ ## [0.4.0] - 2026-04-29
95
+ ### Added
96
+ - Made `web_explore` the single public web research tool, with search, fetch, source ranking, and headless escalation handled internally.
97
+ - Added adaptive research helpers for query planning, candidate selection, evidence ranking, stop decisions, and answer synthesis.
98
+ - Added preview/verbose provenance for `web_explore` showing which internal reader produced each finding.
99
+
100
+ ### Changed
101
+ - Simplified `/web-agent` presentation settings to `defaultMode` and `web_explore` only.
102
+ - Updated live web evals to treat shell/network fallbacks after `web_explore` as a quality issue.
103
+ - Updated the release script to use `npm version --no-git-tag-version` before tagging so package metadata is changed through npm instead of regex replacement.
104
+
105
+ ### Fixed
106
+ - Turned successful headless reads into usable `web_explore` evidence instead of returning empty results for dynamic docs pages.
107
+ - Filtered headless bot-check/security-verification pages out of research evidence.
108
+ - Made empty research results display as “No usable evidence found” instead of looking like a successful synthesis.
109
+ - Added the Linux Rollup optional package to the lockfile so GitHub Actions can build from `npm ci` without patch-installing Rollup.
110
+
111
+ ### Breaking
112
+ - None.
113
+
114
+ ## [0.3.1] - 2026-04-22
115
+ ### Added
116
+ - Nothing yet.
117
+
118
+ ### Changed
119
+ - Stopped self-upgrading npm inside the publish workflow before install and publish steps.
120
+ - Added GitHub release creation to the tag publish workflow.
121
+
122
+ ### Fixed
123
+ - Fixed the tag publish workflow so npm publishing no longer fails before `npm ci`.
124
+
125
+ ### Breaking
126
+ - None.
127
+
128
+ ## [0.3.0] - 2026-04-22
129
+ ### Added
130
+ - Added compact, preview, and verbose presentation modes for web tool output.
131
+ - Added a user-facing `/web-agent` settings UI plus helper commands for showing, resetting, and changing presentation config.
132
+ - Added global and project-local presentation config files with project-overrides-global precedence.
133
+ - Added docs for presentation settings, config paths, and command usage.
134
+
135
+ ### Changed
136
+ - Made compact output the default presentation mode for all web tools.
137
+ - Made bare `/web-agent` open the settings UI directly.
138
+
139
+ ### Fixed
140
+ - Fixed settings scope switching so global and project drafts do not leak into each other.
141
+ - Fixed config persistence so inherited values are not unnecessarily written into lower-precedence config files.
142
+ - Fixed command notifications to use supported Pi UI notify levels.
143
+
144
+ ### Breaking
145
+ - None.
146
+
147
+ ## [0.2.2] - 2026-04-21
148
+ ### Added
149
+ - Expanded the live web eval so it covers deterministic search failure cases and reports when follow-up web calls were blocked after `web_explore`.
150
+
151
+ ### Changed
152
+ - Tightened post-`web_explore` discipline by blocking same-flow low-level web tool churn instead of relying on prompt wording alone.
153
+
154
+ ### Fixed
155
+ - Split `web_search` failures into more useful states like no results, parse failures, blocked pages, and fetch failures.
156
+ - Catch DuckDuckGo challenge pages that still return HTTP 200 so blocked searches stop looking like vague parser bugs.
157
+ - Stopped the model from spiraling into extra `web_search` / `web_fetch` calls after a successful `web_explore` in the live-eval cases.
158
+
159
+ ### Breaking
160
+ - None.
161
+
162
+ ## [0.2.1] - 2026-04-20
163
+ ### Added
164
+ - Nothing yet.
165
+
166
+ ### Changed
167
+ - Nothing yet.
168
+
169
+ ### Fixed
170
+ - Added the missing npm `--provenance` flag to the publish workflow so Trusted Publishing can exchange the GitHub OIDC token correctly.
171
+
172
+ ### Breaking
173
+ - None.
174
+
175
+ ## [0.2.0] - 2026-04-20
176
+ ### Added
177
+ - Added AGPL licensing, a release foundation test, and changelog-driven release tooling.
178
+ - Added GitHub Actions workflows for CI and tag-based npm publishing.
179
+ - Added maintainer docs for releases, self-hosted runners, and main branch protection.
180
+
181
+ ### Changed
182
+ - Documented the release process in the README.
183
+ - Switched npm publishing guidance from `NPM_TOKEN` secrets to npm Trusted Publishing.
184
+
185
+ ### Fixed
186
+ - Stopped injecting post-`web_explore` reminder text through a context hook so it no longer leaks into normal sessions.
187
+ - Worked around Rollup's missing Linux native package in GitHub Actions so CI and publish jobs run reliably on Ubuntu.
188
+
189
+ ### Breaking
190
+ - None.
191
+
192
+ ## [0.1.0] - 2026-04-20
193
+
194
+ ### Added
195
+ - Published `@demigodmode/pi-web-agent` as a Pi package.
196
+ - Added explicit web research tools for search, HTTP fetch, headless fetch, and bounded exploration.
197
+ - Added headless fetch implementation and package install validation.
198
+
199
+ ### Changed
200
+ - Tightened follow-up tool discipline after `web_explore`.
201
+ - Split package build output from repo-local development tooling.
202
+
203
+ ### Fixed
204
+ - Fixed post-`web_explore` reminder handling so it is derived from context instead of shared mutable state.
205
+
206
+ ### Breaking
207
+ - None.
package/README.md CHANGED
@@ -14,6 +14,8 @@ That sounds obvious, but a lot of agent tooling gets fuzzy right there. This pac
14
14
 
15
15
  ## Install
16
16
 
17
+ Compatibility notice: current `pi-web-agent` requires Pi 0.74+ because Pi packages moved to the `@earendil-works/*` scope. Update Pi before updating this package. If you are on an older Pi version, stay on `@demigodmode/pi-web-agent@0.6.x` until Pi is updated.
18
+
17
19
  ```bash
18
20
  pi install npm:@demigodmode/pi-web-agent
19
21
  ```
@@ -71,6 +73,7 @@ Helper commands:
71
73
  ```text
72
74
  /web-agent doctor
73
75
  /web-agent show
76
+ /web-agent changelog
74
77
  /web-agent reset project
75
78
  /web-agent reset global
76
79
  /web-agent mode preview
@@ -106,33 +109,9 @@ Example:
106
109
  }
107
110
  ```
108
111
 
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.
112
+ Backend config is also supported. Defaults remain DuckDuckGo search, plain HTTP fetch, and local browser headless fallback. If you already run SearXNG or Firecrawl, see the self-hosted backend guide:
134
113
 
135
- Full guide: https://demigodmode.github.io/pi-web-agent/self-hosted-backends
114
+ - https://demigodmode.github.io/pi-web-agent/self-hosted-backends
136
115
 
137
116
  ## Local development
138
117
 
@@ -1,11 +1,24 @@
1
+ export type SearxngOptions = {
2
+ categories?: string[];
3
+ language?: string;
4
+ safesearch?: 0 | 1 | 2;
5
+ };
6
+ export type FirecrawlOptions = {
7
+ formats?: string[];
8
+ onlyMainContent?: boolean;
9
+ };
1
10
  export type SearchBackendConfig = {
2
11
  provider: 'duckduckgo' | 'searxng';
3
12
  baseUrl?: string;
13
+ fallback?: 'duckduckgo';
14
+ options?: SearxngOptions;
4
15
  };
5
16
  export type FetchBackendConfig = {
6
17
  provider: 'http' | 'firecrawl';
7
18
  baseUrl?: string;
8
19
  apiKey?: string;
20
+ fallback?: 'http';
21
+ options?: FirecrawlOptions;
9
22
  };
10
23
  export type HeadlessBackendConfig = {
11
24
  provider: 'local-browser';
@@ -25,11 +38,15 @@ export type BackendConfigFile = {
25
38
  search?: {
26
39
  provider?: unknown;
27
40
  baseUrl?: unknown;
41
+ fallback?: unknown;
42
+ options?: unknown;
28
43
  };
29
44
  fetch?: {
30
45
  provider?: unknown;
31
46
  baseUrl?: unknown;
32
47
  apiKey?: unknown;
48
+ fallback?: unknown;
49
+ options?: unknown;
33
50
  };
34
51
  headless?: {
35
52
  provider?: unknown;
@@ -3,6 +3,38 @@ export const DEFAULT_BACKEND_CONFIG = {
3
3
  fetch: { provider: 'http' },
4
4
  headless: { provider: 'local-browser' }
5
5
  };
6
+ function extractStringArray(value) {
7
+ if (!Array.isArray(value))
8
+ return undefined;
9
+ const strings = value.filter((item) => typeof item === 'string');
10
+ return strings.length === value.length ? strings : undefined;
11
+ }
12
+ function extractSearxngOptions(value) {
13
+ if (!value || typeof value !== 'object')
14
+ return undefined;
15
+ const raw = value;
16
+ const options = {};
17
+ const categories = extractStringArray(raw.categories);
18
+ if (categories)
19
+ options.categories = categories;
20
+ if (typeof raw.language === 'string')
21
+ options.language = raw.language;
22
+ if (raw.safesearch === 0 || raw.safesearch === 1 || raw.safesearch === 2)
23
+ options.safesearch = raw.safesearch;
24
+ return Object.keys(options).length > 0 ? options : undefined;
25
+ }
26
+ function extractFirecrawlOptions(value) {
27
+ if (!value || typeof value !== 'object')
28
+ return undefined;
29
+ const raw = value;
30
+ const options = {};
31
+ const formats = extractStringArray(raw.formats);
32
+ if (formats)
33
+ options.formats = formats;
34
+ if (typeof raw.onlyMainContent === 'boolean')
35
+ options.onlyMainContent = raw.onlyMainContent;
36
+ return Object.keys(options).length > 0 ? options : undefined;
37
+ }
6
38
  export function extractBackendConfigOverride(file) {
7
39
  const backends = file?.backends;
8
40
  const override = {};
@@ -11,6 +43,13 @@ export function extractBackendConfigOverride(file) {
11
43
  if (typeof backends.search.baseUrl === 'string') {
12
44
  override.search.baseUrl = backends.search.baseUrl;
13
45
  }
46
+ if (backends.search.fallback === 'duckduckgo') {
47
+ override.search.fallback = 'duckduckgo';
48
+ }
49
+ const options = extractSearxngOptions(backends.search.options);
50
+ if (options) {
51
+ override.search.options = options;
52
+ }
14
53
  }
15
54
  if (backends?.fetch?.provider === 'http' || backends?.fetch?.provider === 'firecrawl') {
16
55
  override.fetch = { provider: backends.fetch.provider };
@@ -20,6 +59,13 @@ export function extractBackendConfigOverride(file) {
20
59
  if (typeof backends.fetch.apiKey === 'string') {
21
60
  override.fetch.apiKey = backends.fetch.apiKey;
22
61
  }
62
+ if (backends.fetch.fallback === 'http') {
63
+ override.fetch.fallback = 'http';
64
+ }
65
+ const options = extractFirecrawlOptions(backends.fetch.options);
66
+ if (options) {
67
+ override.fetch.options = options;
68
+ }
23
69
  }
24
70
  if (backends?.headless?.provider === 'local-browser') {
25
71
  override.headless = { provider: 'local-browser' };
@@ -34,6 +80,25 @@ export function validateBackendConfig(config) {
34
80
  if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
35
81
  issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
36
82
  }
83
+ if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng') {
84
+ issues.push('search fallback duckduckgo is only supported when search provider is searxng');
85
+ }
86
+ if (config.fetch.fallback === 'http' && config.fetch.provider !== 'firecrawl') {
87
+ issues.push('fetch fallback http is only supported when fetch provider is firecrawl');
88
+ }
89
+ if (config.search.options?.categories && config.search.options.categories.length === 0) {
90
+ issues.push('search options.categories must contain at least one category when provided');
91
+ }
92
+ if (config.search.options?.language !== undefined && !config.search.options.language.trim()) {
93
+ issues.push('search options.language must not be empty when provided');
94
+ }
95
+ if (config.search.options?.safesearch !== undefined &&
96
+ ![0, 1, 2].includes(config.search.options.safesearch)) {
97
+ issues.push('search options.safesearch must be 0, 1, or 2 when provided');
98
+ }
99
+ if (config.fetch.options?.formats && config.fetch.options.formats.length === 0) {
100
+ issues.push('fetch options.formats must contain at least one format when provided');
101
+ }
37
102
  return issues;
38
103
  }
39
104
  function mergeSearchConfig(current, override) {
@@ -6,12 +6,25 @@ function withTimeout(timeoutMs) {
6
6
  function message(error) {
7
7
  return error instanceof Error ? error.message : String(error);
8
8
  }
9
- function searxngDoctorUrl(baseUrl) {
9
+ function searxngDoctorUrl(baseUrl, options = {}) {
10
10
  const url = new URL('/search', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
11
11
  url.searchParams.set('q', 'pi-web-agent-doctor');
12
12
  url.searchParams.set('format', 'json');
13
+ if (options.categories?.length)
14
+ url.searchParams.set('categories', options.categories.join(','));
15
+ if (options.language)
16
+ url.searchParams.set('language', options.language);
17
+ if (options.safesearch !== undefined)
18
+ url.searchParams.set('safesearch', String(options.safesearch));
13
19
  return url.toString();
14
20
  }
21
+ function firecrawlDoctorBody(options = {}) {
22
+ return {
23
+ url: 'https://example.com',
24
+ formats: options.formats ?? ['markdown'],
25
+ ...(options.onlyMainContent !== undefined ? { onlyMainContent: options.onlyMainContent } : {})
26
+ };
27
+ }
15
28
  export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
16
29
  const lines = [];
17
30
  if (config.search.provider === 'duckduckgo') {
@@ -23,7 +36,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
23
36
  else {
24
37
  const timeout = withTimeout(timeoutMs);
25
38
  try {
26
- const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl), { signal: timeout.signal });
39
+ const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl, config.search.options), { signal: timeout.signal });
27
40
  const json = (await response.json());
28
41
  lines.push(response.ok && Array.isArray(json.results)
29
42
  ? 'search backend: searxng ok'
@@ -36,6 +49,9 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
36
49
  timeout.done();
37
50
  }
38
51
  }
52
+ if (config.search.fallback) {
53
+ lines.push(`search fallback: ${config.search.fallback}`);
54
+ }
39
55
  if (config.fetch.provider === 'http') {
40
56
  lines.push('fetch backend: http');
41
57
  }
@@ -52,7 +68,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
52
68
  const response = await fetchImpl(new URL('/v1/scrape', config.fetch.baseUrl).toString(), {
53
69
  method: 'POST',
54
70
  headers,
55
- body: JSON.stringify({ url: 'https://example.com', formats: ['markdown'] }),
71
+ body: JSON.stringify(firecrawlDoctorBody(config.fetch.options)),
56
72
  signal: timeout.signal
57
73
  });
58
74
  lines.push(response.ok ? 'fetch backend: firecrawl ok' : `fetch backend: firecrawl warning (HTTP ${response.status})`);
@@ -64,5 +80,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
64
80
  timeout.done();
65
81
  }
66
82
  }
83
+ if (config.fetch.fallback) {
84
+ lines.push(`fetch fallback: ${config.fetch.fallback}`);
85
+ }
67
86
  return lines;
68
87
  }
@@ -1,3 +1,8 @@
1
+ import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
2
+ import { createSearxngSearchTool } from '../search/searxng.js';
3
+ import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
4
+ import { createWebFetchTool } from '../tools/web-fetch.js';
5
+ import { createWebSearchTool } from '../tools/web-search.js';
1
6
  import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
2
7
  import { type BackendConfig } from './config.js';
3
8
  export type BackendSet = {
@@ -11,4 +16,11 @@ export type BackendSet = {
11
16
  url: string;
12
17
  }) => Promise<WebFetchHeadlessResponse>;
13
18
  };
14
- export declare function createBackendSet(config?: BackendConfig): BackendSet;
19
+ export type BackendFactoryDeps = {
20
+ createDuckDuckGoSearch?: typeof createWebSearchTool;
21
+ createSearxngSearch?: typeof createSearxngSearchTool;
22
+ createHttpFetch?: typeof createWebFetchTool;
23
+ createFirecrawlFetch?: typeof createFirecrawlFetcher;
24
+ createHeadlessFetch?: typeof createWebFetchHeadlessTool;
25
+ };
26
+ export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
@@ -34,25 +34,72 @@ function invalidFirecrawlFetch() {
34
34
  return { ...result, presentation: buildFetchPresentation(result) };
35
35
  };
36
36
  }
37
- export function createBackendSet(config = DEFAULT_BACKEND_CONFIG) {
38
- const search = config.search.provider === 'searxng'
37
+ function withSearchFallback(primary, fallback) {
38
+ return async (input) => {
39
+ const first = await primary(input);
40
+ if (first.status !== 'error')
41
+ return first;
42
+ const second = await fallback(input);
43
+ const result = {
44
+ ...second,
45
+ metadata: {
46
+ ...second.metadata,
47
+ fallbackFrom: 'searxng',
48
+ fallbackReason: first.error?.message ?? 'SearXNG search failed.'
49
+ }
50
+ };
51
+ return { ...result, presentation: buildSearchPresentation(result) };
52
+ };
53
+ }
54
+ function withFetchFallback(primary, fallback) {
55
+ return async (input) => {
56
+ const first = await primary(input);
57
+ if (first.status !== 'error' && first.status !== 'needs_headless')
58
+ return first;
59
+ const second = await fallback(input);
60
+ const result = {
61
+ ...second,
62
+ metadata: {
63
+ ...second.metadata,
64
+ fallbackFrom: 'firecrawl',
65
+ fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
66
+ }
67
+ };
68
+ return { ...result, presentation: buildFetchPresentation(result) };
69
+ };
70
+ }
71
+ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
72
+ const createDuckDuckGoSearch = deps.createDuckDuckGoSearch ?? createWebSearchTool;
73
+ const createSearxngSearch = deps.createSearxngSearch ?? createSearxngSearchTool;
74
+ const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
75
+ const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
76
+ const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
77
+ let search = config.search.provider === 'searxng'
39
78
  ? config.search.baseUrl
40
- ? createSearxngSearchTool({ baseUrl: config.search.baseUrl })
79
+ ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
41
80
  : invalidSearxngSearch()
42
- : createWebSearchTool();
43
- const fetchPage = config.fetch.provider === 'firecrawl'
81
+ : createDuckDuckGoSearch();
82
+ if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
83
+ search = withSearchFallback(search, createDuckDuckGoSearch());
84
+ }
85
+ const httpFetch = createHttpFetch();
86
+ let fetchPage = config.fetch.provider === 'firecrawl'
44
87
  ? config.fetch.baseUrl
45
- ? createWebFetchTool({
46
- fetchPage: createFirecrawlFetcher({
88
+ ? createHttpFetch({
89
+ fetchPage: createFirecrawlFetch({
47
90
  baseUrl: config.fetch.baseUrl,
48
- apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY
91
+ apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
92
+ options: config.fetch.options
49
93
  })
50
94
  })
51
- : createWebFetchTool({ fetchPage: invalidFirecrawlFetch() })
52
- : createWebFetchTool();
95
+ : createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
96
+ : httpFetch;
97
+ if (config.fetch.provider === 'firecrawl' && config.fetch.fallback === 'http') {
98
+ fetchPage = withFetchFallback(fetchPage, httpFetch);
99
+ }
53
100
  return {
54
101
  search,
55
102
  fetchPage,
56
- headlessFetch: createWebFetchHeadlessTool()
103
+ headlessFetch: createHeadlessFetch()
57
104
  };
58
105
  }
@@ -0,0 +1,16 @@
1
+ export type ChangelogEntry = {
2
+ version: string;
3
+ content: string;
4
+ };
5
+ type ChangelogOptions = {
6
+ packageRoot?: string;
7
+ statePath?: string;
8
+ };
9
+ export declare function parseChangelogEntries(changelog: string): ChangelogEntry[];
10
+ export declare function markChangelogSeen({ statePath, version }: {
11
+ statePath?: string;
12
+ version: string;
13
+ }): Promise<void>;
14
+ export declare function getUpdateChangelogNotice(options?: ChangelogOptions): Promise<string | undefined>;
15
+ export declare function getLatestChangelogEntry(options?: ChangelogOptions): Promise<string | undefined>;
16
+ export {};
@@ -0,0 +1,105 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export function parseChangelogEntries(changelog) {
5
+ const lines = changelog.split(/\r?\n/);
6
+ const entries = [];
7
+ let currentVersion;
8
+ let currentLines = [];
9
+ function flush() {
10
+ if (currentVersion && currentLines.length > 0) {
11
+ entries.push({ version: currentVersion, content: currentLines.join('\n').trim() });
12
+ }
13
+ }
14
+ for (const line of lines) {
15
+ if (line.startsWith('## ')) {
16
+ flush();
17
+ const match = line.match(/^##\s+\[?(\d+\.\d+\.\d+)\]?/);
18
+ currentVersion = match?.[1];
19
+ currentLines = currentVersion ? [line] : [];
20
+ continue;
21
+ }
22
+ if (currentVersion) {
23
+ currentLines.push(line);
24
+ }
25
+ }
26
+ flush();
27
+ return entries;
28
+ }
29
+ function compareVersions(left, right) {
30
+ const leftParts = left.split('.').map(Number);
31
+ const rightParts = right.split('.').map(Number);
32
+ for (let i = 0; i < 3; i += 1) {
33
+ const diff = (leftParts[i] || 0) - (rightParts[i] || 0);
34
+ if (diff !== 0)
35
+ return diff;
36
+ }
37
+ return 0;
38
+ }
39
+ function defaultPackageRoot() {
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ return path.basename(here) === 'dist' ? path.dirname(here) : path.dirname(here);
42
+ }
43
+ function defaultStatePath() {
44
+ const homeDir = process.env.USERPROFILE ?? process.env.HOME ?? '';
45
+ return path.join(homeDir, '.pi', 'agent', 'extensions', 'pi-web-agent', 'state.json');
46
+ }
47
+ async function readPackageVersion(packageRoot) {
48
+ try {
49
+ const raw = await readFile(path.join(packageRoot, 'package.json'), 'utf8');
50
+ const parsed = JSON.parse(raw);
51
+ return typeof parsed.version === 'string' ? parsed.version : undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ async function readChangelogEntries(packageRoot) {
58
+ try {
59
+ return parseChangelogEntries(await readFile(path.join(packageRoot, 'CHANGELOG.md'), 'utf8'));
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ async function readState(statePath) {
66
+ try {
67
+ return JSON.parse(await readFile(statePath, 'utf8'));
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ }
73
+ export async function markChangelogSeen({ statePath = defaultStatePath(), version }) {
74
+ try {
75
+ await mkdir(path.dirname(statePath), { recursive: true });
76
+ await writeFile(statePath, `${JSON.stringify({ lastChangelogVersion: version }, null, 2)}\n`, 'utf8');
77
+ }
78
+ catch {
79
+ // Changelog display is best effort. Never block extension startup on state persistence.
80
+ }
81
+ }
82
+ export async function getUpdateChangelogNotice(options = {}) {
83
+ const packageRoot = options.packageRoot ?? defaultPackageRoot();
84
+ const statePath = options.statePath ?? defaultStatePath();
85
+ const version = await readPackageVersion(packageRoot);
86
+ if (!version)
87
+ return undefined;
88
+ const state = await readState(statePath);
89
+ if (!state.lastChangelogVersion) {
90
+ await markChangelogSeen({ statePath, version });
91
+ return undefined;
92
+ }
93
+ if (compareVersions(version, state.lastChangelogVersion) <= 0) {
94
+ return undefined;
95
+ }
96
+ const entries = (await readChangelogEntries(packageRoot))
97
+ .filter((entry) => compareVersions(entry.version, state.lastChangelogVersion || '0.0.0') > 0)
98
+ .map((entry) => entry.content);
99
+ await markChangelogSeen({ statePath, version });
100
+ return entries.length > 0 ? entries.join('\n\n') : undefined;
101
+ }
102
+ export async function getLatestChangelogEntry(options = {}) {
103
+ const entries = await readChangelogEntries(options.packageRoot ?? defaultPackageRoot());
104
+ return entries[0]?.content;
105
+ }
@@ -1,5 +1,5 @@
1
1
  import { type BackendConfig } from '../backends/config.js';
2
- import { type ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ import { type ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
3
  import { loadPresentationConfigLayers, type LoadedPresentationConfig } from '../presentation/config-store.js';
4
4
  import { type BrowserResolutionResult } from '../fetch/browser-resolution.js';
5
5
  import type { PresentationConfig, PresentationConfigOverride, PresentationScope } from '../presentation/types.js';
@@ -15,6 +15,7 @@ type CommandDeps = {
15
15
  };
16
16
  checkTypebox?: () => Promise<boolean>;
17
17
  checkBackends?: (config: BackendConfig) => Promise<string[]>;
18
+ getChangelog?: () => Promise<string | undefined>;
18
19
  };
19
20
  export type SettingsDraftState = {
20
21
  scope: PresentationScope;
@@ -1,10 +1,11 @@
1
1
  import { DEFAULT_BACKEND_CONFIG, validateBackendConfig } from '../backends/config.js';
2
2
  import { checkBackendHealth } from '../backends/doctor.js';
3
- import { DynamicBorder, getSettingsListTheme } from '@mariozechner/pi-coding-agent';
4
- import { Container, SelectList, SettingsList, Text } from '@mariozechner/pi-tui';
3
+ import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
4
+ import { Container, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
5
5
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
6
6
  import { loadPresentationConfigLayers, resetPresentationConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
7
7
  import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
8
+ import { getLatestChangelogEntry } from '../changelog-notice.js';
8
9
  const PRESENTATION_TOOL_NAMES = ['web_explore'];
9
10
  function parseScopeToken(token) {
10
11
  return token === 'global' || token === 'project' ? token : undefined;
@@ -24,16 +25,33 @@ async function defaultCheckTypebox() {
24
25
  return false;
25
26
  }
26
27
  }
28
+ function formatSearchOptions(config) {
29
+ return [
30
+ config.fallback ? `fallback ${config.fallback}` : undefined,
31
+ config.options?.categories?.length ? `categories ${config.options.categories.join(',')}` : undefined,
32
+ config.options?.language ? `language ${config.options.language}` : undefined,
33
+ config.options?.safesearch !== undefined ? `safesearch ${config.options.safesearch}` : undefined
34
+ ].filter(Boolean).join(' ');
35
+ }
36
+ function formatFetchOptions(config) {
37
+ return [
38
+ config.fallback ? `fallback ${config.fallback}` : undefined,
39
+ config.options?.formats?.length ? `formats ${config.options.formats.join(',')}` : undefined,
40
+ config.options?.onlyMainContent !== undefined ? `onlyMainContent ${config.options.onlyMainContent}` : undefined
41
+ ].filter(Boolean).join(' ');
42
+ }
27
43
  function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
28
- const search = config.search.baseUrl
44
+ const searchSuffix = formatSearchOptions(config.search);
45
+ const fetchSuffix = formatFetchOptions(config.fetch);
46
+ const searchBase = config.search.baseUrl
29
47
  ? `search: ${config.search.provider} (${config.search.baseUrl})`
30
48
  : `search: ${config.search.provider}`;
31
- const fetch = config.fetch.baseUrl
49
+ const fetchBase = config.fetch.baseUrl
32
50
  ? `fetch: ${config.fetch.provider} (${config.fetch.baseUrl})`
33
51
  : `fetch: ${config.fetch.provider}`;
34
52
  return [
35
- search,
36
- fetch,
53
+ searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
54
+ fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
37
55
  `headless: ${config.headless.provider}`
38
56
  ].join('\n');
39
57
  }
@@ -249,6 +267,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
249
267
  };
250
268
  const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
251
269
  const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config));
270
+ const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
252
271
  pi.registerCommand('web-agent', {
253
272
  description: 'Open settings or manage pi-web-agent presentation config',
254
273
  handler: async (args, ctx) => {
@@ -303,6 +322,11 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
303
322
  ].join('\n'), 'info');
304
323
  return;
305
324
  }
325
+ if (action === 'changelog') {
326
+ const changelog = await getChangelog();
327
+ ctx.ui.notify(changelog ?? 'No pi-web-agent changelog entries found.', 'info');
328
+ return;
329
+ }
306
330
  if (action === 'reset') {
307
331
  const scope = parseScopeToken(maybeScope) ?? 'project';
308
332
  await reset(scope);
@@ -351,7 +375,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
351
375
  ctx.ui.notify(`Saved ${result.scope} config`, 'info');
352
376
  return;
353
377
  }
354
- ctx.ui.notify('Use /web-agent, /web-agent show, /web-agent doctor, /web-agent reset project, or /web-agent settings', 'info');
378
+ ctx.ui.notify('Use /web-agent, /web-agent show, /web-agent doctor, /web-agent changelog, /web-agent reset project, or /web-agent settings', 'info');
355
379
  }
356
380
  });
357
381
  }
@@ -1,2 +1,2 @@
1
- import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
2
  export default function extension(pi: ExtensionAPI): void;
package/dist/extension.js CHANGED
@@ -6,6 +6,7 @@ import { createResearchWorkflow } from './orchestration/index.js';
6
6
  import { loadPresentationConfigLayers } from './presentation/config-store.js';
7
7
  import { selectPresentationView } from './presentation/select-view.js';
8
8
  import { createWebExploreTool } from './tools/web-explore.js';
9
+ import { getUpdateChangelogNotice } from './changelog-notice.js';
9
10
  async function loadWebAgentConfig(pi) {
10
11
  const store = pi.__presentationConfigStore;
11
12
  return store?.load?.() ?? loadPresentationConfigLayers();
@@ -51,6 +52,17 @@ export default function extension(pi) {
51
52
  }
52
53
  return cachedWebExplore;
53
54
  }
55
+ pi.on('session_start', async (_event, ctx) => {
56
+ try {
57
+ const notice = await getUpdateChangelogNotice();
58
+ if (notice) {
59
+ ctx.ui.notify(`pi-web-agent updated\n\n${notice}`, 'info');
60
+ }
61
+ }
62
+ catch {
63
+ // Never block extension startup on changelog display.
64
+ }
65
+ });
54
66
  pi.on('before_agent_start', async (event) => ({
55
67
  systemPrompt: `${event.systemPrompt}\n\n` +
56
68
  'For web research questions that require finding and comparing sources, use web_explore. ' +
@@ -1,6 +1,8 @@
1
+ import type { FirecrawlOptions } from '../backends/config.js';
1
2
  import type { WebFetchResponse } from '../types.js';
2
- export declare function createFirecrawlFetcher({ baseUrl, apiKey, fetchImpl }: {
3
+ export declare function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl }: {
3
4
  baseUrl: string;
4
5
  apiKey?: string;
6
+ options?: FirecrawlOptions;
5
7
  fetchImpl?: typeof fetch;
6
8
  }): (url: string) => Promise<WebFetchResponse>;
@@ -4,17 +4,22 @@ function buildScrapeUrl(baseUrl) {
4
4
  function errorMessage(error) {
5
5
  return error instanceof Error ? error.message : String(error);
6
6
  }
7
- export function createFirecrawlFetcher({ baseUrl, apiKey, fetchImpl = fetch }) {
7
+ export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = fetch }) {
8
8
  return async function firecrawlFetch(url) {
9
9
  try {
10
10
  const headers = { 'content-type': 'application/json' };
11
11
  if (apiKey) {
12
12
  headers.Authorization = `Bearer ${apiKey}`;
13
13
  }
14
+ const body = {
15
+ url,
16
+ formats: options?.formats ?? ['markdown'],
17
+ ...(options?.onlyMainContent !== undefined ? { onlyMainContent: options.onlyMainContent } : {})
18
+ };
14
19
  const response = await fetchImpl(buildScrapeUrl(baseUrl), {
15
20
  method: 'POST',
16
21
  headers,
17
- body: JSON.stringify({ url, formats: ['markdown'] })
22
+ body: JSON.stringify(body)
18
23
  });
19
24
  if (!response.ok) {
20
25
  throw new Error(`HTTP ${response.status}`);
@@ -9,11 +9,14 @@ function firstExcerpt(text, maxChars = 240) {
9
9
  }
10
10
  export function buildFetchPresentation(result) {
11
11
  const wordCount = countWords(result.content?.text);
12
+ const fallbackPrefix = result.metadata.fallbackFrom
13
+ ? `${result.metadata.fallbackFrom} failed; used ${result.metadata.method} fallback. `
14
+ : '';
12
15
  const compact = result.status === 'ok'
13
- ? `Fetched page · article extracted${wordCount ? ` · ${wordCount} words` : ''}`
16
+ ? `${fallbackPrefix}Fetched page · article extracted${wordCount ? ` · ${wordCount} words` : ''}`
14
17
  : result.status === 'needs_headless'
15
- ? `Needs headless rendering: ${result.error?.message ?? 'Headless rendering recommended.'}`
16
- : `Fetch failed: ${result.error?.message ?? 'Unknown fetch failure.'}`;
18
+ ? `${fallbackPrefix}Needs headless rendering: ${result.error?.message ?? 'Headless rendering recommended.'}`
19
+ : `${fallbackPrefix}Fetch failed: ${result.error?.message ?? 'Unknown fetch failure.'}`;
17
20
  return {
18
21
  mode: 'compact',
19
22
  views: {
@@ -1,9 +1,12 @@
1
1
  function formatCompact(result) {
2
+ const fallbackPrefix = result.metadata.fallbackFrom
3
+ ? `${result.metadata.fallbackFrom} failed; used ${result.metadata.backend} fallback. `
4
+ : '';
2
5
  if (result.status === 'error') {
3
- return `Search failed: ${result.error?.message ?? 'Unknown search failure.'}`;
6
+ return `${fallbackPrefix}Search failed: ${result.error?.message ?? 'Unknown search failure.'}`;
4
7
  }
5
8
  const suffix = result.results.length === 1 ? 'result' : 'results';
6
- return `Found ${result.results.length} ${suffix}`;
9
+ return `${fallbackPrefix}Found ${result.results.length} ${suffix}`;
7
10
  }
8
11
  export function buildSearchPresentation(result) {
9
12
  const preview = result.results
@@ -1,6 +1,8 @@
1
+ import type { SearxngOptions } from '../backends/config.js';
1
2
  import type { WebSearchResponse } from '../types.js';
2
- export declare function createSearxngSearchTool({ baseUrl, fetchImpl }: {
3
+ export declare function createSearxngSearchTool({ baseUrl, options, fetchImpl }: {
3
4
  baseUrl: string;
5
+ options?: SearxngOptions;
4
6
  fetchImpl?: typeof fetch;
5
7
  }): ({ query }: {
6
8
  query: string;
@@ -1,8 +1,14 @@
1
1
  import { buildSearchPresentation } from '../presentation/search-presentation.js';
2
- function buildSearchUrl(baseUrl, query) {
2
+ function buildSearchUrl(baseUrl, query, options = {}) {
3
3
  const url = new URL('/search', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`);
4
4
  url.searchParams.set('q', query);
5
5
  url.searchParams.set('format', 'json');
6
+ if (options.categories?.length)
7
+ url.searchParams.set('categories', options.categories.join(','));
8
+ if (options.language)
9
+ url.searchParams.set('language', options.language);
10
+ if (options.safesearch !== undefined)
11
+ url.searchParams.set('safesearch', String(options.safesearch));
6
12
  return url.toString();
7
13
  }
8
14
  function normalizeResults(response) {
@@ -19,7 +25,7 @@ function normalizeResults(response) {
19
25
  ];
20
26
  });
21
27
  }
22
- export function createSearxngSearchTool({ baseUrl, fetchImpl = fetch }) {
28
+ export function createSearxngSearchTool({ baseUrl, options, fetchImpl = fetch }) {
23
29
  return async function searxngSearch({ query }) {
24
30
  const normalizedQuery = query.trim();
25
31
  if (!normalizedQuery) {
@@ -32,7 +38,7 @@ export function createSearxngSearchTool({ baseUrl, fetchImpl = fetch }) {
32
38
  return { ...result, presentation: buildSearchPresentation(result) };
33
39
  }
34
40
  try {
35
- const response = await fetchImpl(buildSearchUrl(baseUrl, normalizedQuery));
41
+ const response = await fetchImpl(buildSearchUrl(baseUrl, normalizedQuery, options));
36
42
  if (!response.ok) {
37
43
  throw new Error(`HTTP ${response.status}`);
38
44
  }
package/dist/types.d.ts CHANGED
@@ -13,10 +13,14 @@ export type ToolError = {
13
13
  export type SearchMetadata = {
14
14
  backend: 'duckduckgo' | 'searxng';
15
15
  cacheHit: boolean;
16
+ fallbackFrom?: 'searxng';
17
+ fallbackReason?: string;
16
18
  };
17
19
  export type FetchMetadata = {
18
20
  method: 'http' | 'headless' | 'firecrawl';
19
21
  cacheHit: boolean;
22
+ fallbackFrom?: 'firecrawl';
23
+ fallbackReason?: string;
20
24
  contentType?: string;
21
25
  truncated?: boolean;
22
26
  browser?: 'configured' | 'chrome' | 'edge' | 'brave' | 'chromium';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "0.6.0",
3
+ "version": "1.1.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",
@@ -13,7 +13,8 @@
13
13
  },
14
14
  "files": [
15
15
  "dist",
16
- "README.md"
16
+ "README.md",
17
+ "CHANGELOG.md"
17
18
  ],
18
19
  "keywords": [
19
20
  "pi-package",
@@ -60,7 +61,8 @@
60
61
  "typebox": "^1.1.37"
61
62
  },
62
63
  "devDependencies": {
63
- "@mariozechner/pi-coding-agent": "^0.69.0",
64
+ "@earendil-works/pi-coding-agent": "^0.74.0",
65
+ "@earendil-works/pi-tui": "^0.74.0",
64
66
  "@types/jsdom": "^21.1.7",
65
67
  "@types/node": "^24.0.0",
66
68
  "@vitest/coverage-v8": "^3.2.4",
@@ -69,6 +71,7 @@
69
71
  "vitest": "^3.2.0"
70
72
  },
71
73
  "peerDependencies": {
72
- "@mariozechner/pi-coding-agent": "*"
74
+ "@earendil-works/pi-coding-agent": "*",
75
+ "@earendil-works/pi-tui": "*"
73
76
  }
74
77
  }