@demigodmode/pi-web-agent 0.2.1 → 0.3.1

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
@@ -1,199 +1,119 @@
1
1
  # pi-web-agent
2
2
 
3
- `@demigodmode/pi-web-agent` is a Pi package for reliable web access.
3
+ [![CI](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@demigodmode/pi-web-agent)](https://www.npmjs.com/package/@demigodmode/pi-web-agent)
5
+ [![Docs](https://img.shields.io/badge/docs-github%20pages-blue)](https://demigodmode.github.io/pi-web-agent/)
4
6
 
5
- It is built around a simple rule: searching for a page is not the same thing as reading it. This package keeps those steps separate, prefers plain HTTP by default, and is designed to say "I couldn't read this reliably" instead of making something up.
7
+ `@demigodmode/pi-web-agent` is a Pi package for web access.
6
8
 
7
- ## What it does
9
+ The whole point is keeping the boundaries straight:
8
10
 
9
- The package is built around three tools:
11
+ - `web_search` is for discovery
12
+ - `web_fetch` is for plain HTTP reads
13
+ - `web_fetch_headless` is the explicit browser path
14
+ - `web_explore` is the bounded research path
10
15
 
11
- - `web_search` finds relevant pages and returns titles, URLs, and snippets
12
- - `web_fetch` fetches a specific page over plain HTTP and tries to extract readable content
13
- - `web_fetch_headless` is the explicit browser-based path for pages that need rendering
14
-
15
- The boundary between those tools is intentional.
16
-
17
- `web_search` is for discovery. It should not imply that a page was fetched.
18
-
19
- `web_fetch` is for reading a page over HTTP. If the result looks weak, incomplete, blocked, or too script-heavy, it should return `needs_headless` instead of bluffing.
20
-
21
- `web_fetch_headless` exists for the cases where a browser really is required. It is opt-in only.
22
-
23
- ## Why this exists
24
-
25
- A lot of web tooling in coding agents gets fuzzy in exactly the wrong places. Search results get treated like page reads. Browser fallback happens behind the scenes. Failures get softened into fake confidence.
26
-
27
- This package is trying to do the opposite.
28
-
29
- The rules are straightforward:
30
-
31
- - no hidden browser launch
32
- - no automatic HTTP-to-headless fallback
33
- - no claiming a page was read when only snippets were available
34
- - explicit structured failure when the result is incomplete or blocked
35
-
36
- ## What makes it different
37
-
38
- The main thing is the contract.
39
-
40
- `web_search` discovers sources.
41
-
42
- `web_fetch` reads over HTTP only.
43
-
44
- `web_fetch_headless` is the explicit browser path.
45
-
46
- That separation is the whole point. It makes failures easier to reason about and avoids the weird behavior where a tool quietly changes execution mode behind your back.
16
+ That sounds obvious, but a lot of agent tooling gets fuzzy right there. This package is meant to be stricter about what it actually did and more willing to say when a read was not good enough to trust.
47
17
 
48
18
  ## Install
49
19
 
50
- Install it through Pi:
51
-
52
20
  ```bash
53
21
  pi install npm:@demigodmode/pi-web-agent
54
22
  ```
55
23
 
56
- Update installed packages later with:
24
+ Later on, update installed packages with:
57
25
 
58
26
  ```bash
59
27
  pi update
60
28
  ```
61
29
 
62
- If you just want to inspect the package from npm directly, the package name is:
30
+ ## Docs
63
31
 
64
- ```bash
65
- npm view @demigodmode/pi-web-agent
66
- ```
32
+ Docs site:
67
33
 
68
- ## Current status
34
+ - https://demigodmode.github.io/pi-web-agent/
69
35
 
70
- This repo is in early MVP shape, but it is no longer just a design doc.
36
+ Work on the docs locally:
71
37
 
72
- Right now it has:
38
+ ```bash
39
+ npm run docs:dev
40
+ ```
73
41
 
74
- - a TypeScript project scaffold
75
- - shared result and status contracts
76
- - a DuckDuckGo HTML parser for `web_search`
77
- - an HTTP fetch path with readability-based extraction and conservative escalation to `needs_headless`
78
- - a real browser-backed `web_fetch_headless` implementation with local browser resolution
79
- - repo-local Pi extension wiring for development
80
- - a test suite around parser behavior, contracts, extraction, caching, and tool adapters
81
- - optional smoke coverage for local installed browsers
42
+ Build the docs:
82
43
 
83
- So the project is real and usable, but still early.
44
+ ```bash
45
+ npm run docs:build
46
+ ```
84
47
 
85
- ## Example behavior
48
+ ## Presentation modes
86
49
 
87
- These are conceptual examples of the contract the package is aiming to expose.
50
+ `pi-web-agent` renders web tool output in one visible mode at a time:
88
51
 
89
- ### Search
52
+ - `compact` — short summary, default everywhere
53
+ - `preview` — slightly richer bounded view
54
+ - `verbose` — fuller bounded view
90
55
 
91
- `web_search("pi coding agent")`
56
+ ## Settings
92
57
 
93
- Returns discovery results like:
58
+ Primary UI:
94
59
 
95
- - title
96
- - URL
97
- - snippet
60
+ ```text
61
+ /web-agent settings
62
+ ```
98
63
 
99
- It does not imply the page was fetched.
64
+ Helper commands:
100
65
 
101
- ### HTTP fetch
66
+ ```text
67
+ /web-agent show
68
+ /web-agent reset project
69
+ /web-agent reset global
70
+ /web-agent mode preview
71
+ /web-agent mode web_search verbose
72
+ /web-agent mode web_search inherit
73
+ ```
102
74
 
103
- `web_fetch("https://example.com/article")`
75
+ Config files:
104
76
 
105
- If the page is readable over plain HTTP, it should return extracted content.
77
+ ```text
78
+ Global: ~/.pi/agent/extensions/pi-web-agent/config.json
79
+ Project: .pi/extensions/pi-web-agent/config.json
80
+ ```
106
81
 
107
- If the page looks too script-heavy, too thin, blocked, or otherwise unreliable, it should return `needs_headless` instead of pretending the extraction is good enough.
82
+ Precedence:
108
83
 
109
- ### Explicit headless fetch
84
+ - built-in defaults
85
+ - global config
86
+ - project config
110
87
 
111
- `web_fetch_headless("https://example.com/app")`
88
+ Project config overrides global config.
112
89
 
113
- This is the browser-based path for pages that really need rendering.
90
+ Example:
114
91
 
115
- This path now launches a local browser explicitly, waits for the rendered page to settle, and then extracts readable content from the rendered HTML.
92
+ ```json
93
+ {
94
+ "presentation": {
95
+ "defaultMode": "compact",
96
+ "tools": {
97
+ "web_search": { "mode": "preview" },
98
+ "web_explore": { "mode": "verbose" }
99
+ }
100
+ }
101
+ }
102
+ ```
116
103
 
117
104
  ## Local development
118
105
 
119
- Install dependencies:
120
-
121
106
  ```bash
122
107
  npm install
123
- ```
124
-
125
- Run tests with coverage:
126
-
127
- ```bash
128
108
  npm test
129
- ```
130
-
131
- Run the typecheck used as lint:
132
-
133
- ```bash
134
109
  npm run lint
135
- ```
136
-
137
- Build the project:
138
-
139
- ```bash
140
110
  npm run build
141
111
  ```
142
112
 
143
- To run the optional real-browser smoke test for headless fetch, set `PI_HEADLESS_SMOKE=1` before running Vitest. It stays skipped by default so local browser install differences do not make the normal test suite flaky.
144
-
145
- Coverage is now part of the normal `npm test` flow. Vitest prints a text summary in the terminal and writes the full HTML report to `coverage/`.
146
-
147
- ### Trying it in Pi locally
148
-
149
- This repo includes a project-local Pi extension entrypoint at `.pi/extensions/pi-web-agent.ts` for development and hot reload.
113
+ For local Pi work, this repo includes `.pi/extensions/pi-web-agent.ts`.
150
114
 
151
- For the published npm package, Pi loads the compiled runtime from `dist/extension.js` via the `pi.extensions` entry in `package.json`.
152
-
153
- After starting Pi in this project, use `/reload` if you change the extension code and want Pi to pick up the latest version.
154
-
155
- ## Project layout
156
-
157
- The code is split into small modules on purpose.
158
-
159
- - `src/extension.ts` - package entry surface
160
- - `src/tools/` - thin tool adapters
161
- - `src/search/` - search backend logic
162
- - `src/fetch/` - HTTP and headless fetch logic
163
- - `src/extract/` - readable-content extraction
164
- - `src/cache/` - small cache utilities
165
- - `src/types.ts` - shared contracts
166
- - `tests/` - parser, contract, extraction, fetch, and adapter tests
115
+ If Pi is already running, use `/reload` after changes.
167
116
 
168
117
  ## License
169
118
 
170
119
  AGPL-3.0-only. See `LICENSE`.
171
-
172
- ## Release process
173
-
174
- 1. Update `CHANGELOG.md` under `## Unreleased`.
175
- 2. Run `npm run release:dry-run` to preview the next version.
176
- 3. Run `npm run release` to bump version, rewrite the changelog release heading, create a release commit, and create a tag.
177
- 4. Push the branch and tag.
178
- 5. GitHub Actions publishes the tagged release to npm.
179
-
180
- ## Maintainer release notes
181
-
182
- This repo is set up for npm Trusted Publishing from GitHub Actions.
183
-
184
- In npm package settings, add a trusted publisher for:
185
- - package: `@demigodmode/pi-web-agent`
186
- - provider: GitHub Actions
187
- - repository: `demigodmode/pi-web-agent`
188
-
189
- That replaces the old `NPM_TOKEN` secret flow.
190
-
191
- ## Near-term next steps
192
-
193
- The next chunk of work is pretty clear:
194
-
195
- - keep tightening weak-content escalation on tricky HTTP targets
196
- - improve cleanup of noisy rendered-page extraction on busy sites
197
- - expand fixtures and end-to-end coverage
198
- - add alternate search backends behind a first-class provider abstraction
199
-
@@ -0,0 +1,23 @@
1
+ import { type ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ import { loadPresentationConfigLayers, type LoadedPresentationConfig } from '../presentation/config-store.js';
3
+ import type { PresentationConfig, PresentationConfigOverride, PresentationScope } from '../presentation/types.js';
4
+ type CommandDeps = {
5
+ load?: () => ReturnType<typeof loadPresentationConfigLayers>;
6
+ save?: (scope: PresentationScope, config: PresentationConfigOverride) => Promise<void>;
7
+ reset?: (scope: PresentationScope) => Promise<void>;
8
+ };
9
+ export type SettingsDraftState = {
10
+ scope: PresentationScope;
11
+ drafts: Record<PresentationScope, PresentationConfig>;
12
+ config: PresentationConfig;
13
+ };
14
+ export declare function getInheritedConfigForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
15
+ export declare function getScopeDisplayConfig(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
16
+ export declare function createSettingsDraftState(loaded: Awaited<LoadedPresentationConfig>, initialScope: PresentationScope): SettingsDraftState;
17
+ export declare function applySettingsValue(state: SettingsDraftState, id: string, newValue: string): SettingsDraftState;
18
+ export declare function collapsePresentationConfigToOverride(config: PresentationConfig, inheritedConfig: PresentationConfig): PresentationConfigOverride;
19
+ export declare function handleSettingsShortcut(data: string): {
20
+ action: 'cancel' | 'reset' | 'save';
21
+ } | undefined;
22
+ export declare function registerWebAgentConfigCommands(pi: ExtensionAPI, deps?: CommandDeps): void;
23
+ export {};
@@ -0,0 +1,254 @@
1
+ import { getSettingsListTheme } from '@mariozechner/pi-coding-agent';
2
+ import { Container, SettingsList, Text } from '@mariozechner/pi-tui';
3
+ import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
4
+ import { loadPresentationConfigLayers, resetPresentationConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
5
+ const PRESENTATION_TOOL_NAMES = [
6
+ 'web_search',
7
+ 'web_fetch',
8
+ 'web_fetch_headless',
9
+ 'web_explore'
10
+ ];
11
+ function parseScopeToken(token) {
12
+ return token === 'global' || token === 'project' ? token : undefined;
13
+ }
14
+ function clonePresentationConfig(config) {
15
+ return {
16
+ defaultMode: config.defaultMode,
17
+ tools: { ...config.tools }
18
+ };
19
+ }
20
+ function formatConfigSummary(config) {
21
+ const lines = [`defaultMode: ${config.defaultMode}`];
22
+ for (const toolName of PRESENTATION_TOOL_NAMES) {
23
+ lines.push(`${toolName}: ${config.tools[toolName]?.mode ?? 'inherit'}`);
24
+ }
25
+ return lines.join('\n');
26
+ }
27
+ function buildSettingsItems(scope, config) {
28
+ return [
29
+ {
30
+ id: 'scope',
31
+ label: 'Write scope',
32
+ currentValue: scope,
33
+ values: ['project', 'global']
34
+ },
35
+ {
36
+ id: 'defaultMode',
37
+ label: 'Default mode',
38
+ currentValue: config.defaultMode,
39
+ values: ['compact', 'preview', 'verbose']
40
+ },
41
+ ...PRESENTATION_TOOL_NAMES.map((toolName) => ({
42
+ id: `tool:${toolName}`,
43
+ label: toolName,
44
+ currentValue: config.tools[toolName]?.mode ?? 'inherit',
45
+ values: ['inherit', 'compact', 'preview', 'verbose']
46
+ }))
47
+ ];
48
+ }
49
+ function isToolName(value) {
50
+ return PRESENTATION_TOOL_NAMES.includes(value);
51
+ }
52
+ function isModeOrInherit(value) {
53
+ return ['inherit', 'compact', 'preview', 'verbose'].includes(value);
54
+ }
55
+ export function getInheritedConfigForScope(loaded, scope) {
56
+ if (scope === 'global') {
57
+ return DEFAULT_PRESENTATION_CONFIG;
58
+ }
59
+ return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
60
+ }
61
+ export function getScopeDisplayConfig(loaded, scope) {
62
+ if (scope === 'global') {
63
+ return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig);
64
+ }
65
+ return mergePresentationConfigLayers(DEFAULT_PRESENTATION_CONFIG, loaded.global.rawConfig, loaded.project.rawConfig);
66
+ }
67
+ export function createSettingsDraftState(loaded, initialScope) {
68
+ const drafts = {
69
+ global: getScopeDisplayConfig(loaded, 'global'),
70
+ project: getScopeDisplayConfig(loaded, 'project')
71
+ };
72
+ return {
73
+ scope: initialScope,
74
+ drafts,
75
+ config: clonePresentationConfig(drafts[initialScope])
76
+ };
77
+ }
78
+ export function applySettingsValue(state, id, newValue) {
79
+ const nextDrafts = {
80
+ global: clonePresentationConfig(state.drafts.global),
81
+ project: clonePresentationConfig(state.drafts.project)
82
+ };
83
+ let nextScope = state.scope;
84
+ if (id === 'scope' && (newValue === 'project' || newValue === 'global')) {
85
+ nextScope = newValue;
86
+ return {
87
+ scope: nextScope,
88
+ drafts: nextDrafts,
89
+ config: clonePresentationConfig(nextDrafts[nextScope])
90
+ };
91
+ }
92
+ const currentDraft = clonePresentationConfig(nextDrafts[nextScope]);
93
+ if (id === 'defaultMode' && (newValue === 'compact' || newValue === 'preview' || newValue === 'verbose')) {
94
+ currentDraft.defaultMode = newValue;
95
+ }
96
+ if (id.startsWith('tool:')) {
97
+ const toolName = id.slice('tool:'.length);
98
+ const nextTools = { ...currentDraft.tools };
99
+ if (newValue === 'inherit') {
100
+ delete nextTools[toolName];
101
+ }
102
+ else if (newValue === 'compact' ||
103
+ newValue === 'preview' ||
104
+ newValue === 'verbose') {
105
+ nextTools[toolName] = { mode: newValue };
106
+ }
107
+ currentDraft.tools = nextTools;
108
+ }
109
+ nextDrafts[nextScope] = currentDraft;
110
+ return {
111
+ scope: nextScope,
112
+ drafts: nextDrafts,
113
+ config: clonePresentationConfig(nextDrafts[nextScope])
114
+ };
115
+ }
116
+ export function collapsePresentationConfigToOverride(config, inheritedConfig) {
117
+ const tools = Object.fromEntries(PRESENTATION_TOOL_NAMES.flatMap((toolName) => {
118
+ const configuredMode = config.tools[toolName]?.mode;
119
+ if (!configuredMode) {
120
+ return [];
121
+ }
122
+ const inheritedMode = resolvePresentationMode(toolName, inheritedConfig);
123
+ if (configuredMode === inheritedMode) {
124
+ return [];
125
+ }
126
+ return [[toolName, { mode: configuredMode }]];
127
+ }));
128
+ return {
129
+ defaultMode: config.defaultMode === inheritedConfig.defaultMode ? undefined : config.defaultMode,
130
+ tools
131
+ };
132
+ }
133
+ export function handleSettingsShortcut(data) {
134
+ if (data === '\u001b') {
135
+ return { action: 'cancel' };
136
+ }
137
+ if (data === '\u0012') {
138
+ return { action: 'reset' };
139
+ }
140
+ if (data === '\u0013') {
141
+ return { action: 'save' };
142
+ }
143
+ return undefined;
144
+ }
145
+ async function openSettingsUi(ctx, loaded, initialScope) {
146
+ return ctx.ui.custom((_tui, theme, _kb, done) => {
147
+ let state = createSettingsDraftState(loaded, initialScope);
148
+ let settingsList;
149
+ const container = new Container();
150
+ container.addChild(new Text(theme.fg('accent', theme.bold('pi-web-agent settings')), 1, 1));
151
+ container.addChild(new Text(theme.fg('muted', 'Ctrl+S save · Ctrl+R reset scope · Esc cancel'), 1, 2));
152
+ const rebuildSettingsList = () => {
153
+ if (settingsList) {
154
+ container.removeChild(settingsList);
155
+ }
156
+ settingsList = new SettingsList(buildSettingsItems(state.scope, state.config), Math.min(PRESENTATION_TOOL_NAMES.length + 8, 18), getSettingsListTheme(), (id, newValue) => {
157
+ state = applySettingsValue(state, id, newValue);
158
+ rebuildSettingsList();
159
+ container.invalidate();
160
+ }, () => done({ action: 'save', scope: state.scope, config: state.config }), { enableSearch: true });
161
+ container.addChild(settingsList);
162
+ };
163
+ rebuildSettingsList();
164
+ return {
165
+ render: (width) => container.render(width),
166
+ invalidate: () => container.invalidate(),
167
+ handleInput: (data) => {
168
+ const shortcut = handleSettingsShortcut(JSON.stringify(data).slice(1, -1));
169
+ if (shortcut?.action === 'cancel') {
170
+ done({ action: 'cancel' });
171
+ return;
172
+ }
173
+ if (shortcut?.action === 'reset') {
174
+ done({ action: 'reset', scope: state.scope });
175
+ return;
176
+ }
177
+ if (shortcut?.action === 'save') {
178
+ done({ action: 'save', scope: state.scope, config: state.config });
179
+ return;
180
+ }
181
+ settingsList.handleInput?.(data);
182
+ }
183
+ };
184
+ });
185
+ }
186
+ export function registerWebAgentConfigCommands(pi, deps = {}) {
187
+ const load = deps.load ?? (() => loadPresentationConfigLayers());
188
+ const save = deps.save ?? ((scope, config) => savePresentationConfigScope({}, scope, config));
189
+ const reset = deps.reset ?? ((scope) => resetPresentationConfigScope({}, scope));
190
+ pi.registerCommand('web-agent', {
191
+ description: 'Open settings or manage pi-web-agent presentation config',
192
+ handler: async (args, ctx) => {
193
+ const [action, maybeScope] = (args ?? '').trim().split(/\s+/).filter(Boolean);
194
+ if (action === 'show') {
195
+ const loaded = await load();
196
+ ctx.ui.notify([
197
+ formatConfigSummary(loaded.effectiveConfig),
198
+ `global: ${loaded.global.path}${loaded.global.exists ? '' : ' (missing)'}`,
199
+ `project: ${loaded.project.path}${loaded.project.exists ? '' : ' (missing)'}`
200
+ ].join('\n'), 'info');
201
+ return;
202
+ }
203
+ if (action === 'reset') {
204
+ const scope = parseScopeToken(maybeScope) ?? 'project';
205
+ await reset(scope);
206
+ ctx.ui.notify(`Reset ${scope} config`, 'info');
207
+ return;
208
+ }
209
+ if (action === 'mode') {
210
+ const [, first, second] = (args ?? '').trim().split(/\s+/).filter(Boolean);
211
+ const loaded = await load();
212
+ const scope = 'project';
213
+ const baseConfig = getScopeDisplayConfig(loaded, scope);
214
+ const inheritedConfig = getInheritedConfigForScope(loaded, scope);
215
+ if (first && isModeOrInherit(first) && first !== 'inherit') {
216
+ await save(scope, collapsePresentationConfigToOverride({ ...baseConfig, defaultMode: first }, inheritedConfig));
217
+ ctx.ui.notify(`Saved project default mode = ${first}`, 'info');
218
+ return;
219
+ }
220
+ if (first && second && isToolName(first) && isModeOrInherit(second)) {
221
+ const nextTools = { ...baseConfig.tools };
222
+ if (second === 'inherit') {
223
+ delete nextTools[first];
224
+ }
225
+ else {
226
+ nextTools[first] = { mode: second };
227
+ }
228
+ await save(scope, collapsePresentationConfigToOverride({ ...baseConfig, tools: nextTools }, inheritedConfig));
229
+ ctx.ui.notify(`Saved project ${first} = ${second}`, 'info');
230
+ return;
231
+ }
232
+ ctx.ui.notify('Usage: /web-agent mode <compact|preview|verbose> or /web-agent mode <tool> <inherit|compact|preview|verbose>', 'info');
233
+ return;
234
+ }
235
+ if (!action || action === 'settings') {
236
+ const loaded = await load();
237
+ const initialScope = 'project';
238
+ const result = await openSettingsUi(ctx, loaded, initialScope);
239
+ if (!result || result.action === 'cancel') {
240
+ return;
241
+ }
242
+ if (result.action === 'reset') {
243
+ await reset(result.scope);
244
+ ctx.ui.notify(`Reset ${result.scope} config`, 'info');
245
+ return;
246
+ }
247
+ await save(result.scope, collapsePresentationConfigToOverride(result.config, getInheritedConfigForScope(loaded, result.scope)));
248
+ ctx.ui.notify(`Saved ${result.scope} config`, 'info');
249
+ return;
250
+ }
251
+ ctx.ui.notify('Use /web-agent, /web-agent show, /web-agent reset project, or /web-agent settings', 'info');
252
+ }
253
+ });
254
+ }