diff --git a/pi/files/agent/extensions/pi-web-access b/pi/files/agent/extensions/pi-web-access deleted file mode 160000 index 4461c2b..0000000 --- a/pi/files/agent/extensions/pi-web-access +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4461c2bcb2d871bd8b7b54ee5ecadee20bf3a1f8 diff --git a/pi/files/agent/extensions/pi-web-access/.gitignore b/pi/files/agent/extensions/pi-web-access/.gitignore new file mode 100644 index 0000000..d77474a --- /dev/null +++ b/pi/files/agent/extensions/pi-web-access/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/pi/files/agent/extensions/pi-web-access/CHANGELOG.md b/pi/files/agent/extensions/pi-web-access/CHANGELOG.md new file mode 100644 index 0000000..c41bdb3 --- /dev/null +++ b/pi/files/agent/extensions/pi-web-access/CHANGELOG.md @@ -0,0 +1,262 @@ +# Pi Web Access - Changelog + +All notable changes to this project will be documented in this file. + +## [0.10.2] - 2026-02-18 + +### Added +- **Interactive search curation.** Press Ctrl+Shift+S during or after a multi-query search to open a browser-based review UI. Results stream in live via SSE. Pick which queries to keep, add new searches on the fly, switch providers — then submit to send only the curated results to the agent. +- **Auto-condense pipeline.** When the countdown expires without manual curation, a single LLM call (Claude Haiku by default) condenses all search results into a deduplicated briefing organized by topic. Preprocessing enriches the prompt with URL overlap, answer similarity, and source quality analysis. Configure via `"autoFilter"` in `~/.pi/web-search.json`. Full uncondensed results stored and retrievable via `get_search_content`. +- **Configurable keyboard shortcuts.** Both shortcuts (curate: Ctrl+Shift+S, activity monitor: Ctrl+Shift+W) can be remapped via `"shortcuts"` in `~/.pi/web-search.json`. Changes take effect on restart. +- **`/websearch` command** — opens the curator directly from pi without an agent round-trip. Accepts optional comma-separated queries or opens empty. +- **Task-aware condensation.** Optional `context` parameter on `web_search` — a brief description of the user's task. The condenser uses it to focus the briefing on what matters. +- **Provider selection** — global dropdown in the curator UI to switch between Perplexity and Gemini. Persists to `~/.pi/web-search.json`. +- **Live condense status in countdown.** Shows "condensing..." while the LLM is working, then "N searches condensed" once complete. +- Markdown rendering in curator result cards via marked.js. +- Query-level result cards with expandable answers and source lists. Check/uncheck to include or exclude. +- SSE streaming with keepalive, socket health checks, and buffered delivery. +- Idle-based timer (60s default, adjustable). Timeout sends all results as safe default. +- Keyboard shortcuts: Enter (submit), Escape (skip), A (toggle all). +- Dark/light theme via `prefers-color-scheme` with teal accent palette. + +### Changed +- **Curate enabled by default.** Multi-query searches show a 10-second review window; single queries send immediately. Pass `curate: false` to opt out. +- **Curate shortcut opens browser immediately, even mid-search.** Remaining results stream in live via SSE. +- **Tool descriptions encourage multi-query research.** The `queries` param explains how to vary phrasing and scope across 2-4 queries, with good/bad examples. +- **Curated results instruct the LLM.** Tool output prefixed with an instruction telling the LLM to use curated results as-is. +- Expanded view shows full answer text per query with source titles and domains. +- Non-curated `web_search` calls now respect the saved provider preference. +- Config helpers generalized from `loadSavedProvider`/`saveProvider` to `loadConfig`/`saveConfig`. + +### Fixed +- Curated `onSubmit` passed the original full query list instead of the filtered list, inflating `queryCount`. +- Collapsed curated status mixed source URL counts with query counts. + +### New files +- `curator-server.ts` — ephemeral HTTP server with SSE streaming, state machine, heartbeat watchdog, and token auth. +- `curator-page.ts` — HTML/CSS/JS for the curator UI with markdown rendering and overlay transitions. +- `search-filter.ts` — auto-condense pipeline: preprocessing, LLM condensation via pi's model registry, and post-processing (citation verification, source list completion). + +## [0.7.3] - 2026-02-05 + +### Added +- Jina Reader fallback for JS-rendered pages. When Readability returns insufficient content (cookie notices, consent walls, SPA shells), the extraction chain now tries Jina Reader (`r.jina.ai`) before falling back to Gemini. Jina handles JavaScript rendering server-side and returns clean markdown. No API key required. +- JS-render detection heuristic (`isLikelyJSRendered`) produces more specific error messages when pages appear to load content dynamically. +- Actionable guidance when all extraction methods fail, listing steps to configure Gemini API or use `web_search` instead. + +### Changed +- HTTP fetch headers now mimic Chrome (realistic `User-Agent`, `Sec-Fetch-*`, `Accept-Language`) instead of the default Node.js user agent. Reduces blocks from bot-detection systems. +- Short Readability output (< 500 chars) is now treated as a content failure, triggering the fallback chain. Previously, a 266-char cookie notice was returned as "successful" content. +- Extraction fallback order is now: HTTP+Readability → RSC → Jina Reader → Gemini URL Context → Gemini Web → error with guidance. + +### Fixed +- `parseTimestamp` now rejects negative values in colon-separated format (`-1:30`, `1:-30`). Previously only the numeric path (`-90`) rejected negatives, while the colon path computed and returned negative seconds. + +## [0.7.2] - 2026-02-03 + +### Added +- `model` parameter on `fetch_content` to override the Gemini model per-request (e.g. `model: "gemini-2.5-flash"`) +- Collapsed TUI results now show a 200-char text preview instead of just the status line +- LICENSE file (MIT) + +### Changed +- Default Gemini model updated from `gemini-2.5-flash` to `gemini-3-flash-preview` across all API, search, URL context, YouTube, and video paths. Gemini Web gracefully falls back to `gemini-2.5-flash` when the model header isn't available. +- README rewritten: added tagline, badges, "Why" section, Quick Start, corrected "How It Works" routing order, fixed inaccurate env var precedence claim, added missing `/v/` YouTube format, restored `/search` command docs, collapsible Files table + +### Fixed +- `PERPLEXITY_API_KEY` env var now takes precedence over config file value, matching `GEMINI_API_KEY` behavior and README documentation (was reversed) +- `package.json` now includes `repository`, `homepage`, `bugs`, and `description` fields (repo link was missing from pi packages site) + +## [0.7.0] - 2026-02-03 + +### Added +- **Multi-provider web search**: `web_search` now supports Perplexity, Gemini API (with Google Search grounding), and Gemini Web (cookie auth) as search providers. New `provider` parameter (`auto`, `perplexity`, `gemini`) controls selection. In `auto` mode (default): Perplexity → Gemini API → Gemini Web. Backwards-compatible — existing Perplexity users see no change. +- **Gemini API grounded search**: Structured citations via `groundingMetadata` with source URIs and text-to-source mappings. Google proxy URLs are resolved via HEAD redirects. Configured via `GEMINI_API_KEY` or `geminiApiKey` in config. +- **Gemini Web search**: Zero-config web search for users signed into Google in Chrome. Prompt instructs Gemini to cite sources; URLs extracted from markdown response. +- **Gemini extraction fallback**: When `fetch_content` fails (HTTP 403/429, Readability fails, network errors), automatically retries via Gemini URL Context API then Gemini Web extraction. Each has an independent 60s timeout. Handles SPAs, JS-heavy pages, and anti-bot protections. +- **Local video file analysis**: `fetch_content` accepts file paths to video files (MP4, MOV, WebM, AVI, etc.). Detected by path prefix (`/`, `./`, `../`, `file://`), validated by extension and 50MB limit. Two-tier fallback: Gemini API (resumable upload via Files API with proper MIME types, poll-until-active and cleanup) → Gemini Web (free, cookie auth). +- **Video prompt parameter**: `fetch_content` gains optional `prompt` parameter for asking specific questions about video content. Threads through YouTube and local video extraction. Without prompt, uses default extraction (transcript + visual descriptions). +- **Video thumbnails**: YouTube results include the video thumbnail (fetched from `img.youtube.com`). Local video results include a frame extracted via ffmpeg (at ~1 second). Returned as image content parts alongside text — the agent sees the thumbnail as vision context. +- **Configurable frame extraction**: `frames` parameter (1-12) on `fetch_content` for pulling visual frames from YouTube or local video. Works in five modes: frames alone (sample across entire video), single timestamp (one frame), single+frames (N frames at 5s intervals), range (default 6 frames), range+frames (N frames across the range). Endpoint-inclusive distribution with 5-second minimum spacing. +- **Video duration in responses**: Frame extraction results include the video duration for context. +- `searchProvider` config option in `~/.pi/web-search.json` for global provider default +- `video` config section: `enabled`, `preferredModel`, `maxSizeMB` + +### Changed +- `PerplexityResponse` renamed to `SearchResponse` (shared interface for all search providers) +- Extracted HTTP pipeline from `extractContent` into `extractViaHttp` for cleaner Gemini fallback orchestration +- `getApiKey()`, `API_BASE`, `DEFAULT_MODEL` exported from `gemini-api.ts` for use by search and URL Context modules +- `isPerplexityAvailable()` added to `perplexity.ts` as non-throwing API key check +- Content-type routing in `extract.ts`: only `text/html` and `application/xhtml+xml` go through Readability; all other text types (`text/markdown`, `application/json`, `text/csv`, etc.) returned directly. Fixes the OpenAI cookbook `.md` URL that returned "Untitled (30 chars)". +- Title extraction for non-HTML content: `extractTextTitle()` pulls from markdown `#`/`##` headings, falls back to URL filename +- Combined `yt-dlp --print duration -g` call fetches stream URL and duration in a single invocation, reused across all frame extraction paths via `streamInfo` passthrough +- Shared helpers in `utils.ts` (`formatSeconds`, error mapping) eliminate circular imports and duplication across youtube-extract.ts and video-extract.ts + +### Fixed +- `fetch_content` TUI rendered `undefined/undefined URLs` during progress updates (renderResult didn't handle `isPartial`, now shows a progress bar like `web_search` does) +- RSC extractor produced malformed markdown for `
` blocks (backticks inside fenced code blocks) -- extremely common on Next.js documentation pages
+- Multi-URL fetch failures rendered in green "success" color even when 0 URLs succeeded (now red)
+- `web_search` queries parameter described as "parallel" in schema but execution is sequential (changed to "batch"; `urls` correctly remains "parallel")
+- Proper error propagation for frame extraction: missing binaries (yt-dlp, ffmpeg, ffprobe), private/age-restricted/region-blocked videos, expired stream URLs (403), timestamp-exceeds-duration, and timeouts all produce specific user-facing messages instead of silent nulls
+- `isTimeoutError` now detects `execFileSync` timeouts via the `killed` flag (SIGTERM from timeout was previously unrecognized)
+- Float video durations (e.g. 15913.7s from yt-dlp) no longer produce out-of-range timestamps — durations are floored before computing frame positions
+- `parseTimestamp` consistently floors results across both bare-number ("90.5" → 90) and colon ("1:30.5" → 90) paths — previously the colon path returned floats
+- YouTube thumbnail assignment no longer sets `null` on the optional `thumbnail` field when fetch fails (was a type mismatch; now only assigned on success)
+
+### New files
+- `gemini-search.ts` -- search routing + Gemini Web/API search providers with grounding
+- `gemini-url-context.ts` -- URL Context API extraction + Gemini Web extraction fallback
+- `video-extract.ts` -- local video file detection, Gemini Web/API analysis with Files API upload
+- `utils.ts` -- shared formatting and error helpers for frame extraction
+
+## [0.6.0] - 2026-02-02
+
+### Added
+- YouTube video understanding in `fetch_content` via three-tier fallback chain:
+ - **Gemini Web** (primary): reads Chrome session cookies from macOS Keychain + SQLite, authenticates to gemini.google.com, sends YouTube URL via StreamGenerate endpoint. Full visual + audio understanding with timestamps. Zero config needed if signed into Google in Chrome.
+ - **Gemini API** (secondary): direct REST calls with `GEMINI_API_KEY`. YouTube URLs passed as `file_data.file_uri`. Configure via `GEMINI_API_KEY` env var or `geminiApiKey` in `~/.pi/web-search.json`.
+ - **Perplexity** (fallback): uses existing `searchWithPerplexity` for a topic summary when neither Gemini path is available. Output labeled as "Summary (via Perplexity)" so the agent knows it's not a full transcript.
+- YouTube URL detection for all common formats: `/watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/`, `/v/`, `m.youtube.com`
+- Configurable via `~/.pi/web-search.json` under `youtube` key (`enabled`, `preferredModel`)
+- Actionable error messages when extraction fails (directs user to sign into Chrome or set API key)
+- YouTube URLs no longer fall through to HTTP/Readability (which returns garbage); returns error instead
+
+### New files
+- `chrome-cookies.ts` -- macOS Chrome cookie extraction using Node builtins (`node:crypto`, `node:sqlite`, `child_process`)
+- `gemini-web.ts` -- Gemini Web client ported from surf's gemini-client.cjs (cookie auth, StreamGenerate, model fallback)
+- `gemini-api.ts` -- Gemini REST API client (generateContent, file upload/processing/cleanup for Phase 2)
+- `youtube-extract.ts` -- YouTube extraction orchestrator with three-tier fallback and activity logging
+
+## [0.5.1] - 2026-02-02
+
+### Added
+- Bundled `librarian` skill -- structured research workflow for open-source libraries with GitHub permalinks, combining fetch_content (cloning), web_search (recent info), and git operations (blame, log, show)
+
+### Fixed
+- Session fork event handler was registered as `session_branch` (non-existent event) instead of `session_fork`, meaning forks never triggered cleanup (abort pending fetches, clear clone cache, restore session data)
+- API fallback title for tree URLs with a path (e.g. `/tree/main/src`) now includes the path (`owner/repo - src`), consistent with clone-based results
+- Removed unnecessary export on `getDefaultBranch` (only used internally by `fetchViaApi`)
+
+## [0.5.0] - 2026-02-01
+
+### Added
+- GitHub repository clone extraction for `fetch_content` -- detects GitHub code URLs, clones repos to `/tmp/pi-github-repos/`, and returns actual file contents plus local path for further exploration with `read` and `bash`
+- Lightweight API fallback for oversized repos (>350MB) and commit SHA URLs via `gh api`
+- Clone cache with concurrent request deduplication (second request awaits first's clone)
+- `forceClone` parameter on `fetch_content` to override the size threshold
+- Configurable via `~/.pi/web-search.json` under `githubClone` key (enabled, maxRepoSizeMB, cloneTimeoutSeconds, clonePath)
+- Falls back to `git clone` when `gh` CLI is unavailable (public repos only)
+- README: GitHub clone documentation with config, flow diagram, and limitations
+
+### Changed
+- Refactored `extractContent`/`fetchAllContent` signatures from positional `timeoutMs` to `ExtractOptions` object
+- Blob/tree fetch titles now include file path (e.g. `owner/repo - src/index.ts`) for better disambiguation in multi-URL results and TUI
+
+### Fixed
+- README: Activity monitor keybinding corrected from `Ctrl+Shift+O` to `Ctrl+Shift+W`
+
+## [0.4.5] - 2026-02-01
+
+### Changed
+- Added package keywords for npm discoverability
+
+## [0.4.4] - 2026-02-01
+
+### Fixed
+- Adapt execute signatures to pi v0.51.0: reorder signal, onUpdate, ctx parameters across all three tools
+
+## [0.4.3] - 2026-01-27
+
+### Fixed
+- Google API compatibility: Use `StringEnum` for `recencyFilter` to avoid unsupported `anyOf`/`const` JSON Schema patterns
+
+## [0.4.2] - 2026-01-27
+
+### Fixed
+
+- Single-URL fetches now store content for retrieval via `get_search_content` (previously only multi-URL)
+- Corrected `get_search_content` usage syntax in fetch_content help messages
+
+### Changed
+
+- Increased inline content limit from 10K to 30K chars (larger content truncated but fully retrievable)
+
+### Added
+
+- Banner image for README
+
+## [0.4.1] - 2026-01-26
+
+### Changed
+- Added `pi` manifest to package.json for pi v0.50.0 package system compliance
+- Added `pi-package` keyword for npm discoverability
+
+## [0.4.0] - 2026-01-19
+
+### Added
+
+- PDF extraction via `unpdf` - fetches PDFs from URLs and saves as markdown to `~/Downloads/`
+ - Extracts text, metadata (title, author), page count
+ - Supports PDFs up to 20MB (vs 5MB for HTML)
+ - Handles arxiv URLs with smart title fallback
+
+### Fixed
+
+- Plain text URL detection now uses hostname check instead of substring match
+
+## [0.3.0] - 2026-01-19
+
+### Added
+
+- RSC (React Server Components) content extraction for Next.js App Router pages
+ - Parses flight data from `` tags
+ - Reconstructs markdown with headings, tables, code blocks, links
+ - Handles chunk references and nested components
+ - Falls back to RSC extraction when Readability fails
+- Content-type validation rejects binary files (images, PDFs, audio, video, zip)
+- 5MB response size limit (checked via Content-Length header) to prevent memory issues
+
+### Fixed
+
+- `fetch_content` now handles plain text URLs (raw.githubusercontent.com, gist.githubusercontent.com, any text/plain response) instead of failing with "Could not extract readable content"
+
+## [0.2.0] - 2026-01-11
+
+### Added
+
+- Activity monitor widget (`Ctrl+Shift+O`) showing live request/response activity
+ - Displays last 10 API calls and URL fetches with status codes and timing
+ - Shows rate limit usage and reset countdown
+ - Live updates as requests complete
+ - Auto-clears on session switch
+
+### Changed
+
+- Refactored activity tracking into dedicated `activity.ts` module
+
+## [0.1.0] - 2026-01-06
+
+Initial release. Designed for pi v0.37.3.
+
+### Added
+
+- `web_search` tool - Search via Perplexity AI with synthesized answers and citations
+ - Single or batch queries (parallel execution)
+ - Recency filter (day/week/month/year)
+ - Domain filter (include or exclude)
+ - Optional async content fetching with agent notification
+- `fetch_content` tool - Fetch and extract readable content from URLs
+ - Single URL returns content directly
+ - Multiple URLs store for retrieval via `get_search_content`
+ - Concurrent fetching (3 max) with 30s timeout
+- `get_search_content` tool - Retrieve stored search results or fetched content
+ - Access by response ID, URL, query, or index
+- `/search` command - Interactive browser for stored results
+- TUI rendering with progress bars, URL lists, and expandable previews
+- Session-aware storage with 1-hour TTL
+- Rate limiting (10 req/min for Perplexity API)
+- Config file support (`~/.pi/web-search.json`)
+- Content extraction via Readability + Turndown (max 10k chars)
+- Proper session isolation - pending fetches abort on session switch
+- URL validation before fetch attempts
+- Defensive JSON parsing for API responses
diff --git a/pi/files/agent/extensions/pi-web-access/LICENSE b/pi/files/agent/extensions/pi-web-access/LICENSE
new file mode 100644
index 0000000..20aff8a
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Nico Bailon
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/pi/files/agent/extensions/pi-web-access/README.md b/pi/files/agent/extensions/pi-web-access/README.md
new file mode 100644
index 0000000..a05c61b
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/README.md
@@ -0,0 +1,334 @@
+
+
+
+
+# Pi Web Access
+
+**Web search, content extraction, and video understanding for Pi agent. Zero config with Chrome, or bring your own API keys.**
+
+[](https://www.npmjs.com/package/pi-web-access)
+[](https://opensource.org/licenses/MIT)
+[]()
+
+https://github.com/user-attachments/assets/cac6a17a-1eeb-4dde-9818-cdf85d8ea98f
+
+## Why Pi Web Access
+
+**Zero Config** — Signed into Google in Chrome? That's it. The extension reads your Chrome session cookies to access Gemini directly. No API keys, no setup, no subscriptions.
+
+**Video Understanding** — Point it at a YouTube video or local screen recording and ask questions about what's on screen. Full transcripts, visual descriptions, and frame extraction at exact timestamps.
+
+**Smart Fallbacks** — Every capability has a fallback chain. Search tries Perplexity, then Gemini API, then Gemini Web. YouTube tries Gemini Web, then API, then Perplexity. Blocked pages retry through Jina Reader and Gemini extraction. Something always works.
+
+**GitHub Cloning** — GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore, not rendered HTML.
+
+## Install
+
+```bash
+pi install npm:pi-web-access
+```
+
+If you're not signed into Chrome, or prefer a different provider, add API keys to `~/.pi/web-search.json`:
+
+```json
+{
+ "perplexityApiKey": "pplx-...",
+ "geminiApiKey": "AIza..."
+}
+```
+
+You can configure one or both. In `auto` mode (default), `web_search` tries Perplexity first, then Gemini API, then Gemini Web.
+
+Optional dependencies for video frame extraction:
+
+```bash
+brew install ffmpeg # frame extraction, video thumbnails, local video duration
+brew install yt-dlp # YouTube stream URLs for frame extraction
+```
+
+Without these, video content analysis (transcripts, visual descriptions via Gemini) still works. The binaries are only needed for extracting individual frames as images.
+
+Requires Pi v0.37.3+.
+
+## Quick Start
+
+```typescript
+// Search the web
+web_search({ query: "TypeScript best practices 2025" })
+
+// Fetch a page
+fetch_content({ url: "https://docs.example.com/guide" })
+
+// Clone a GitHub repo
+fetch_content({ url: "https://github.com/owner/repo" })
+
+// Understand a YouTube video
+fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" })
+
+// Analyze a screen recording
+fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" })
+```
+
+## Tools
+
+### web_search
+
+Search the web via Perplexity AI or Gemini. Returns a synthesized answer with source citations.
+
+```typescript
+web_search({ query: "rust async programming" })
+web_search({ queries: ["query 1", "query 2"] })
+web_search({ query: "latest news", numResults: 10, recencyFilter: "week" })
+web_search({ query: "...", domainFilter: ["github.com"] })
+web_search({ query: "...", provider: "gemini" })
+web_search({ query: "...", includeContent: true })
+web_search({ queries: ["query 1", "query 2"], curate: true })
+```
+
+| Parameter | Description |
+|-----------|-------------|
+| `query` / `queries` | Single query or batch of queries |
+| `numResults` | Results per query (default: 5, max: 20) |
+| `recencyFilter` | `day`, `week`, `month`, or `year` |
+| `domainFilter` | Limit to domains (prefix with `-` to exclude) |
+| `provider` | `auto` (default), `perplexity`, or `gemini` |
+| `includeContent` | Fetch full page content from sources in background |
+| `curate` | Hold results for browser review (default: true for multi-query). Press Ctrl+Shift+S to open browser UI, or wait for countdown to auto-condense and send. Set to false to skip both curation and condensation. |
+
+### fetch_content
+
+Fetch URL(s) and extract readable content as markdown. Automatically detects and handles GitHub repos, YouTube videos, PDFs, local video files, and regular web pages.
+
+```typescript
+fetch_content({ url: "https://example.com/article" })
+fetch_content({ urls: ["url1", "url2", "url3"] })
+fetch_content({ url: "https://github.com/owner/repo" })
+fetch_content({ url: "https://youtube.com/watch?v=abc", prompt: "What libraries are shown?" })
+fetch_content({ url: "/path/to/recording.mp4", prompt: "What error appears on screen?" })
+fetch_content({ url: "https://youtube.com/watch?v=abc", timestamp: "23:41-25:00", frames: 4 })
+```
+
+| Parameter | Description |
+|-----------|-------------|
+| `url` / `urls` | Single URL/path or multiple URLs |
+| `prompt` | Question to ask about a YouTube video or local video file |
+| `timestamp` | Extract frame(s) — single (`"23:41"`), range (`"23:41-25:00"`), or seconds (`"85"`) |
+| `frames` | Number of frames to extract (max 12) |
+| `forceClone` | Clone GitHub repos that exceed the 350MB size threshold |
+
+### get_search_content
+
+Retrieve stored content from previous searches or fetches. Content over 30,000 chars is truncated in tool responses but stored in full for retrieval here.
+
+```typescript
+get_search_content({ responseId: "abc123", urlIndex: 0 })
+get_search_content({ responseId: "abc123", url: "https://..." })
+get_search_content({ responseId: "abc123", query: "original query" })
+```
+
+## Capabilities
+
+### GitHub repos
+
+GitHub URLs are cloned locally instead of scraped. The agent gets real file contents and a local path to explore with `read` and `bash`. Root URLs return the repo tree + README, `/tree/` paths return directory listings, `/blob/` paths return file contents.
+
+Repos over 350MB get a lightweight API-based view instead of a full clone (override with `forceClone: true`). Commit SHA URLs are handled via the API. Clones are cached for the session and wiped on session change. Private repos require the `gh` CLI.
+
+### YouTube videos
+
+YouTube URLs are processed via Gemini for full video understanding — visual descriptions, transcripts with timestamps, and chapter markers. Pass a `prompt` to ask specific questions about the video. Results include the video thumbnail so the agent gets visual context alongside the transcript.
+
+Fallback: Gemini Web → Gemini API → Perplexity (text summary only). Handles all URL formats: `/watch?v=`, `youtu.be/`, `/shorts/`, `/live/`, `/embed/`, `/v/`.
+
+### Local video files
+
+Pass a file path (`/`, `./`, `../`, or `file://` prefix) to analyze video content via Gemini. Supports MP4, MOV, WebM, AVI, and other common formats up to 50MB. Pass a `prompt` to ask about specific content. If ffmpeg is installed, a thumbnail frame is included alongside the analysis.
+
+Fallback: Gemini API (Files API upload) → Gemini Web.
+
+### Video frame extraction
+
+Use `timestamp` and/or `frames` on any YouTube URL or local video file to extract visual frames as images.
+
+```typescript
+fetch_content({ url: "...", timestamp: "23:41" }) // single frame
+fetch_content({ url: "...", timestamp: "23:41-25:00" }) // range, 6 frames
+fetch_content({ url: "...", timestamp: "23:41-25:00", frames: 3 }) // range, custom count
+fetch_content({ url: "...", timestamp: "23:41", frames: 5 }) // 5 frames at 5s intervals
+fetch_content({ url: "...", frames: 6 }) // sample whole video
+```
+
+Requires `ffmpeg` (and `yt-dlp` for YouTube). Timestamps accept `H:MM:SS`, `MM:SS`, or bare seconds.
+
+### PDFs
+
+PDF URLs are extracted as text and saved to `~/Downloads/` as markdown. The agent can then `read` specific sections without loading the full document into context. Text-based extraction only — no OCR.
+
+### Blocked pages
+
+When Readability fails or returns only a cookie notice, the extension retries via Jina Reader (handles JS rendering server-side, no API key needed), then Gemini URL Context API, then Gemini Web extraction. Handles SPAs, JS-heavy pages, and anti-bot protections transparently. Also parses Next.js RSC flight data when present.
+
+## How It Works
+
+```
+fetch_content(url)
+ → Video file? Gemini API (Files API) → Gemini Web
+ → GitHub URL? Clone repo, return file contents + local path
+ → YouTube URL? Gemini Web → Gemini API → Perplexity
+ → HTTP fetch → PDF? Extract text, save to ~/Downloads/
+ → HTML? Readability → RSC parser → Jina Reader → Gemini fallback
+ → Text/JSON/Markdown? Return directly
+```
+
+## Skills
+
+### librarian
+
+Bundled research workflow for investigating open-source libraries. Combines GitHub cloning, web search, and git operations (blame, log, show) to produce evidence-backed answers with permalinks. Pi loads it automatically based on your prompt. Also available via `/skill:librarian` with [pi-skill-palette](https://github.com/nicobailon/pi-skill-palette).
+
+## Commands
+
+### /websearch
+
+Open the search curator directly in the browser. Runs searches and lets you review, add, and select results to send back to the agent — no LLM round-trip needed.
+
+```
+/websearch # empty page, type your own searches
+/websearch react hooks, next.js caching # pre-fill with comma-separated queries
+```
+
+Results get injected into the conversation when you click Send. The agent sees them and can use them immediately.
+
+### /search
+
+Browse stored search results interactively. Lists all results from the current session with their response IDs for easy retrieval.
+
+## Activity Monitor
+
+Toggle with **Ctrl+Shift+W** to see live request/response activity:
+
+```
+─── Web Search Activity ────────────────────────────────────
+ API "typescript best practices" 200 2.1s ✓
+ GET docs.example.com/article 200 0.8s ✓
+ GET blog.example.com/post 404 0.3s ✗
+────────────────────────────────────────────────────────────
+```
+
+## Configuration
+
+All config lives in `~/.pi/web-search.json`. Every field is optional.
+
+```json
+{
+ "perplexityApiKey": "pplx-...",
+ "geminiApiKey": "AIza...",
+ "provider": "perplexity",
+ "curateWindow": 10,
+ "autoFilter": true,
+ "githubClone": {
+ "enabled": true,
+ "maxRepoSizeMB": 350,
+ "cloneTimeoutSeconds": 30,
+ "clonePath": "/tmp/pi-github-repos"
+ },
+ "youtube": {
+ "enabled": true,
+ "preferredModel": "gemini-3-flash-preview"
+ },
+ "video": {
+ "enabled": true,
+ "preferredModel": "gemini-3-flash-preview",
+ "maxSizeMB": 50
+ },
+ "shortcuts": {
+ "curate": "ctrl+shift+s",
+ "activity": "ctrl+shift+w"
+ }
+}
+```
+
+`GEMINI_API_KEY` and `PERPLEXITY_API_KEY` env vars take precedence over config file values. `provider` sets the default search provider: `"perplexity"` or `"gemini"`. This is also updated automatically when you change the provider in the curator UI. `curateWindow` controls how many seconds multi-query searches wait before auto-sending results (default: 10). During the countdown, press Ctrl+Shift+S to open the browser curator. Set to 0 to always send immediately (Ctrl+Shift+S still works during the search itself).
+
+### Shortcuts
+
+Both shortcuts are configurable via `~/.pi/web-search.json`:
+
+```json
+{
+ "shortcuts": {
+ "curate": "ctrl+shift+s",
+ "activity": "ctrl+shift+w"
+ }
+}
+```
+
+Values use the same format as pi keybindings (e.g. `ctrl+s`, `ctrl+shift+s`, `alt+r`). Changes take effect on next pi restart.
+
+### Auto-Condense
+
+Multi-query searches are automatically condensed into a deduplicated briefing when the countdown expires without manual curation. A single LLM call receives all search results — enriched with preprocessing analysis (URL overlap, answer similarity, source quality tiers) — and produces a concise synthesis organized by topic. Irrelevant or off-topic results are skipped automatically.
+
+```json
+{
+ "autoFilter": {
+ "enabled": true,
+ "model": "anthropic/claude-haiku-4-5",
+ "prompt": "You are a research assistant..."
+ }
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `enabled` | `true` to enable, `false` to disable. Omit `autoFilter` entirely to enable with defaults. |
+| `model` | LLM model in `provider/model` format. Uses pi's model registry for auth. Default: `anthropic/claude-haiku-4-5`. |
+| `prompt` | System prompt for the condenser. Should instruct the model to synthesize, deduplicate, and cite sources. Omit to use the built-in prompt. |
+
+Shorthand: `"autoFilter": true` or `"autoFilter": false` for enable/disable without customizing model or prompt.
+
+The model uses pi's model registry, so any configured provider works — including custom gateways, OAuth tokens, and API keys. If the model isn't found in the registry or has no credentials, condensation is silently skipped.
+
+The `web_search` tool also accepts an optional `context` parameter — a brief description of the user's current task or goal. When provided, the condenser uses it to focus the briefing (e.g., "building a Shopify checkout extension" helps it emphasize relevant findings over general noise).
+
+Set `"enabled": false` under any feature to disable it. Config changes require a Pi restart.
+
+Rate limits: Perplexity is capped at 10 requests/minute (client-side). Content fetches run 3 concurrent with a 30s timeout per URL.
+
+## Limitations
+
+- Chrome cookie extraction is macOS-only — other platforms fall through to API keys. First-time access may trigger a Keychain dialog.
+- YouTube private/age-restricted videos may fail on all extraction paths.
+- Gemini can process videos up to ~1 hour; longer videos may be truncated.
+- PDFs are text-extracted only (no OCR for scanned documents).
+- GitHub branch names with slashes may misresolve file paths; the clone still works and the agent can navigate manually.
+- Non-code GitHub URLs (issues, PRs, wiki) fall through to normal web extraction.
+
+
+Files
+
+| File | Purpose |
+|------|---------|
+| `index.ts` | Extension entry, tool definitions, commands, widget |
+| `curator-page.ts` | HTML/CSS/JS generation for the curator UI with markdown rendering |
+| `curator-server.ts` | Ephemeral HTTP server with SSE streaming and state machine |
+| `search-filter.ts` | Auto-condense pipeline — preprocessing, LLM condensation, and post-processing for multi-query results |
+| `extract.ts` | URL/file path routing, HTTP extraction, fallback orchestration |
+| `gemini-search.ts` | Search routing across Perplexity, Gemini API, Gemini Web |
+| `gemini-url-context.ts` | Gemini URL Context + Web extraction fallbacks |
+| `gemini-web.ts` | Gemini Web client (cookie auth, StreamGenerate) |
+| `gemini-api.ts` | Gemini REST API client (generateContent) |
+| `chrome-cookies.ts` | macOS Chrome cookie extraction (Keychain + SQLite) |
+| `youtube-extract.ts` | YouTube detection, three-tier extraction, frame extraction |
+| `video-extract.ts` | Local video detection, Files API upload, Gemini analysis |
+| `github-extract.ts` | GitHub URL parsing, clone cache, content generation |
+| `github-api.ts` | GitHub API fallback for large repos and commit SHAs |
+| `perplexity.ts` | Perplexity API client with rate limiting |
+| `pdf-extract.ts` | PDF text extraction, saves to markdown |
+| `rsc-extract.ts` | RSC flight data parser for Next.js pages |
+| `utils.ts` | Shared formatting and error helpers |
+| `storage.ts` | Session-aware result storage |
+| `activity.ts` | Activity tracking for the observability widget |
+| `skills/librarian/` | Bundled skill for library research |
+
+
diff --git a/pi/files/agent/extensions/pi-web-access/activity.ts b/pi/files/agent/extensions/pi-web-access/activity.ts
new file mode 100644
index 0000000..9c18304
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/activity.ts
@@ -0,0 +1,102 @@
+// Types
+export interface ActivityEntry {
+ id: string;
+ type: "api" | "fetch";
+ startTime: number;
+ endTime?: number;
+
+ // For API calls
+ query?: string;
+
+ // For URL fetches
+ url?: string;
+
+ // Result - status is number (HTTP code) or null (pending/network error)
+ status: number | null;
+ error?: string;
+}
+
+export interface RateLimitInfo {
+ used: number;
+ max: number;
+ oldestTimestamp: number | null;
+ windowMs: number;
+}
+
+export class ActivityMonitor {
+ private entries: ActivityEntry[] = [];
+ private readonly maxEntries = 10;
+ private listeners = new Set<() => void>();
+ private rateLimitInfo: RateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 };
+ private nextId = 1;
+
+ logStart(partial: Omit): string {
+ const id = `act-${this.nextId++}`;
+ const entry: ActivityEntry = {
+ ...partial,
+ id,
+ startTime: Date.now(),
+ status: null,
+ };
+ this.entries.push(entry);
+ if (this.entries.length > this.maxEntries) {
+ this.entries.shift();
+ }
+ this.notify();
+ return id;
+ }
+
+ logComplete(id: string, status: number): void {
+ const entry = this.entries.find((e) => e.id === id);
+ if (entry) {
+ entry.endTime = Date.now();
+ entry.status = status;
+ this.notify();
+ }
+ }
+
+ logError(id: string, error: string): void {
+ const entry = this.entries.find((e) => e.id === id);
+ if (entry) {
+ entry.endTime = Date.now();
+ entry.error = error;
+ this.notify();
+ }
+ }
+
+ getEntries(): readonly ActivityEntry[] {
+ return this.entries;
+ }
+
+ getRateLimitInfo(): RateLimitInfo {
+ return this.rateLimitInfo;
+ }
+
+ updateRateLimit(info: RateLimitInfo): void {
+ this.rateLimitInfo = info;
+ this.notify();
+ }
+
+ onUpdate(callback: () => void): () => void {
+ this.listeners.add(callback);
+ return () => this.listeners.delete(callback);
+ }
+
+ clear(): void {
+ this.entries = [];
+ this.rateLimitInfo = { used: 0, max: 10, oldestTimestamp: null, windowMs: 60000 };
+ this.notify();
+ }
+
+ private notify(): void {
+ for (const cb of this.listeners) {
+ try {
+ cb();
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+}
+
+export const activityMonitor = new ActivityMonitor();
diff --git a/pi/files/agent/extensions/pi-web-access/banner.png b/pi/files/agent/extensions/pi-web-access/banner.png
new file mode 100644
index 0000000..8968506
Binary files /dev/null and b/pi/files/agent/extensions/pi-web-access/banner.png differ
diff --git a/pi/files/agent/extensions/pi-web-access/chrome-cookies.ts b/pi/files/agent/extensions/pi-web-access/chrome-cookies.ts
new file mode 100644
index 0000000..18546c2
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/chrome-cookies.ts
@@ -0,0 +1,321 @@
+import { execFile } from "node:child_process";
+import { pbkdf2Sync, createDecipheriv } from "node:crypto";
+import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
+import { tmpdir, homedir, platform } from "node:os";
+import { join } from "node:path";
+
+export type CookieMap = Record;
+
+const GOOGLE_ORIGINS = [
+ "https://gemini.google.com",
+ "https://accounts.google.com",
+ "https://www.google.com",
+];
+
+const ALL_COOKIE_NAMES = new Set([
+ "__Secure-1PSID",
+ "__Secure-1PSIDTS",
+ "__Secure-1PSIDCC",
+ "__Secure-1PAPISID",
+ "NID",
+ "AEC",
+ "SOCS",
+ "__Secure-BUCKET",
+ "__Secure-ENID",
+ "SID",
+ "HSID",
+ "SSID",
+ "APISID",
+ "SAPISID",
+ "__Secure-3PSID",
+ "__Secure-3PSIDTS",
+ "__Secure-3PAPISID",
+ "SIDCC",
+]);
+
+function getChromeCookiesPath(): string | null {
+ const plat = platform();
+ if (plat === "darwin") {
+ return join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies");
+ }
+ if (plat === "linux") {
+ const chromiumPath = join(homedir(), ".config/chromium/Default/Cookies");
+ if (existsSync(chromiumPath)) return chromiumPath;
+
+ const chromePath = join(homedir(), ".config/google-chrome/Default/Cookies");
+ if (existsSync(chromePath)) return chromePath;
+
+ return null;
+ }
+ return null;
+}
+
+export async function getGoogleCookies(): Promise<{ cookies: CookieMap; warnings: string[] } | null> {
+ const plat = platform();
+ if (plat !== "darwin" && plat !== "linux") return null;
+
+ const cookiesPath = getChromeCookiesPath();
+ if (!cookiesPath) return null;
+
+ const warnings: string[] = [];
+
+ const password = await readEncryptionPassword();
+ if (!password) {
+ warnings.push("Could not read Chrome encryption password");
+ return { cookies: {}, warnings };
+ }
+
+ const macKey = plat === "darwin" ? pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1") : Buffer.alloc(0);
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-"));
+
+ try {
+ const tempDb = join(tempDir, "Cookies");
+ copyFileSync(cookiesPath, tempDb);
+ copySidecar(cookiesPath, tempDb, "-wal");
+ copySidecar(cookiesPath, tempDb, "-shm");
+
+ const metaVersion = await readMetaVersion(tempDb);
+ const stripHash = metaVersion >= 24;
+
+ const hosts = GOOGLE_ORIGINS.map((o) => new URL(o).hostname);
+ const rows = await queryCookieRows(tempDb, hosts);
+ if (!rows) {
+ warnings.push("Failed to query Chrome cookie database");
+ return { cookies: {}, warnings };
+ }
+
+ const cookies: CookieMap = {};
+ for (const row of rows) {
+ const name = row.name as string;
+ if (!ALL_COOKIE_NAMES.has(name)) continue;
+ if (cookies[name]) continue;
+
+ let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null;
+ if (!value) {
+ const encrypted = row.encrypted_value;
+ if (encrypted instanceof Uint8Array) {
+ if (plat === "linux") {
+ value = decryptLinuxCookieValue(encrypted, password, stripHash);
+ } else {
+ value = decryptCookieValue(encrypted, macKey, stripHash);
+ }
+ }
+ }
+ if (value) cookies[name] = value;
+ }
+
+ return { cookies, warnings };
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+}
+
+function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null {
+ const buf = Buffer.from(encrypted);
+ if (buf.length < 3) return null;
+
+ const prefix = buf.subarray(0, 3).toString("utf8");
+ if (!/^v\d\d$/.test(prefix)) return null;
+
+ const ciphertext = buf.subarray(3);
+ if (!ciphertext.length) return "";
+
+ try {
+ const iv = Buffer.alloc(16, 0x20);
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
+ decipher.setAutoPadding(false);
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+ const unpadded = removePkcs7Padding(plaintext);
+ const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ let i = 0;
+ while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
+ return decoded.slice(i);
+ } catch {
+ return null;
+ }
+}
+
+function decryptLinuxCookieValue(encrypted: Uint8Array, keyringSecret: string, stripHash: boolean): string | null {
+ const buf = Buffer.from(encrypted);
+ if (buf.length < 3) return null;
+
+ const prefix = buf.subarray(0, 3).toString("utf8");
+ if (prefix !== "v10" && prefix !== "v11") return null;
+
+ const ciphertext = buf.subarray(3);
+ if (!ciphertext.length) return "";
+
+ try {
+ const key = pbkdf2Sync(keyringSecret, "saltysalt", 1, 16, "sha1");
+ const iv = Buffer.alloc(16, 0x20);
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
+ decipher.setAutoPadding(false);
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+ const unpadded = removePkcs7Padding(plaintext);
+ const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ let i = 0;
+ while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
+ return decoded.slice(i);
+ } catch {
+ return null;
+ }
+}
+
+function removePkcs7Padding(buf: Buffer): Buffer {
+ if (!buf.length) return buf;
+ const padding = buf[buf.length - 1];
+ if (!padding || padding > 16) return buf;
+ return buf.subarray(0, buf.length - padding);
+}
+
+function readKeychainPassword(): Promise {
+ return new Promise((resolve) => {
+ execFile(
+ "security",
+ ["find-generic-password", "-w", "-a", "Chrome", "-s", "Chrome Safe Storage"],
+ { timeout: 5000 },
+ (err, stdout) => {
+ if (err) { resolve(null); return; }
+ resolve(stdout.trim() || null);
+ },
+ );
+ });
+}
+
+function readLinuxKeyringPassword(): Promise {
+ return new Promise((resolve) => {
+ execFile(
+ "secret-tool",
+ ["lookup", "application", "chromium"],
+ { timeout: 5000 },
+ (err, stdout) => {
+ if (!err && stdout.trim()) {
+ resolve(stdout.trim());
+ return;
+ }
+ execFile(
+ "secret-tool",
+ ["lookup", "application", "chrome"],
+ { timeout: 5000 },
+ (err2, stdout2) => {
+ if (!err2 && stdout2.trim()) {
+ resolve(stdout2.trim());
+ return;
+ }
+ resolve("peanuts");
+ },
+ );
+ },
+ );
+ });
+}
+
+async function readEncryptionPassword(): Promise {
+ const plat = platform();
+ if (plat === "darwin") return readKeychainPassword();
+ if (plat === "linux") return readLinuxKeyringPassword();
+ return null;
+}
+
+let sqliteModule: typeof import("node:sqlite") | null = null;
+
+async function importSqlite(): Promise {
+ if (sqliteModule) return sqliteModule;
+ const orig = process.emitWarning.bind(process);
+ process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
+ const msg = typeof warning === "string" ? warning : warning?.message ?? "";
+ if (msg.includes("SQLite is an experimental feature")) return;
+ return (orig as Function)(warning, ...args);
+ }) as typeof process.emitWarning;
+ try {
+ sqliteModule = await import("node:sqlite");
+ return sqliteModule;
+ } catch {
+ return null;
+ } finally {
+ process.emitWarning = orig;
+ }
+}
+
+function supportsReadBigInts(): boolean {
+ const [major, minor] = process.versions.node.split(".").map(Number);
+ if (major > 24) return true;
+ if (major < 24) return false;
+ return minor >= 4;
+}
+
+async function readMetaVersion(dbPath: string): Promise {
+ const sqlite = await importSqlite();
+ if (!sqlite) return 0;
+ const opts: Record = { readOnly: true };
+ if (supportsReadBigInts()) opts.readBigInts = true;
+ const db = new sqlite.DatabaseSync(dbPath, opts);
+ try {
+ const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array>;
+ const val = rows[0]?.value;
+ if (typeof val === "number") return Math.floor(val);
+ if (typeof val === "bigint") return Number(val);
+ if (typeof val === "string") return parseInt(val, 10) || 0;
+ return 0;
+ } catch {
+ return 0;
+ } finally {
+ db.close();
+ }
+}
+
+async function queryCookieRows(
+ dbPath: string,
+ hosts: string[],
+): Promise> | null> {
+ const sqlite = await importSqlite();
+ if (!sqlite) return null;
+
+ const clauses: string[] = [];
+ for (const host of hosts) {
+ for (const candidate of expandHosts(host)) {
+ const esc = candidate.replaceAll("'", "''");
+ clauses.push(`host_key = '${esc}'`);
+ clauses.push(`host_key = '.${esc}'`);
+ clauses.push(`host_key LIKE '%.${esc}'`);
+ }
+ }
+ const where = clauses.join(" OR ");
+
+ const opts: Record = { readOnly: true };
+ if (supportsReadBigInts()) opts.readBigInts = true;
+ const db = new sqlite.DatabaseSync(dbPath, opts);
+ try {
+ return db
+ .prepare(
+ `SELECT name, value, host_key, encrypted_value FROM cookies WHERE (${where}) ORDER BY expires_utc DESC`,
+ )
+ .all() as Array>;
+ } catch {
+ return null;
+ } finally {
+ db.close();
+ }
+}
+
+function expandHosts(host: string): string[] {
+ const parts = host.split(".").filter(Boolean);
+ if (parts.length <= 1) return [host];
+ const candidates = new Set();
+ candidates.add(host);
+ for (let i = 1; i <= parts.length - 2; i++) {
+ const c = parts.slice(i).join(".");
+ if (c) candidates.add(c);
+ }
+ return Array.from(candidates);
+}
+
+function copySidecar(srcDb: string, targetDb: string, suffix: string): void {
+ const sidecar = `${srcDb}${suffix}`;
+ if (!existsSync(sidecar)) return;
+ try {
+ copyFileSync(sidecar, `${targetDb}${suffix}`);
+ } catch {}
+}
diff --git a/pi/files/agent/extensions/pi-web-access/curator-page.ts b/pi/files/agent/extensions/pi-web-access/curator-page.ts
new file mode 100644
index 0000000..5441080
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/curator-page.ts
@@ -0,0 +1,1209 @@
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function safeInlineJSON(data: unknown): string {
+ return JSON.stringify(data)
+ .replace(//g, "\\u003e")
+ .replace(/&/g, "\\u0026");
+}
+
+function buildProviderOptions(
+ available: { perplexity: boolean; gemini: boolean },
+ selected: string,
+): string {
+ const options = [
+ { value: "perplexity", label: "Perplexity", disabled: !available.perplexity },
+ { value: "gemini", label: "Gemini", disabled: !available.gemini },
+ ];
+
+ return options
+ .map(o => ``)
+ .join("");
+}
+
+export function generateCuratorPage(
+ queries: string[],
+ sessionToken: string,
+ timeout: number,
+ availableProviders: { perplexity: boolean; gemini: boolean },
+ defaultProvider: string,
+): string {
+ const providerOptionsHtml = buildProviderOptions(availableProviders, defaultProvider);
+ const inlineData = safeInlineJSON({ queries, sessionToken, timeout });
+
+ return `
+
+
+
+
+Curate Search Results
+
+
+
+
+
+`;
+}
+
+const CSS = `
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+
+:root {
+ --bg: #18181e;
+ --bg-card: #1e1e24;
+ --bg-elevated: #252530;
+ --bg-hover: #2b2b37;
+ --fg: #e0e0e0;
+ --fg-muted: #909098;
+ --fg-dim: #606068;
+ --accent: #8abeb7;
+ --accent-hover: #9dcec7;
+ --accent-muted: rgba(138, 190, 183, 0.15);
+ --accent-subtle: rgba(138, 190, 183, 0.08);
+ --border: #2a2a34;
+ --border-muted: #353540;
+ --border-checked: #8abeb7;
+ --check-bg: #8abeb7;
+ --btn-primary: #8abeb7;
+ --btn-primary-hover: #9dcec7;
+ --btn-primary-fg: #18181e;
+ --btn-secondary: #252530;
+ --btn-secondary-hover: #2b2b37;
+ --timer-bg: #252530;
+ --timer-fg: #909098;
+ --timer-warn-bg: rgba(240, 198, 116, 0.15);
+ --timer-warn-fg: #f0c674;
+ --timer-urgent-bg: rgba(204, 102, 102, 0.15);
+ --timer-urgent-fg: #cc6666;
+ --overlay-bg: rgba(24, 24, 30, 0.92);
+ --success: #b5bd68;
+ --warning: #f0c674;
+ --font: 'Outfit', system-ui, -apple-system, sans-serif;
+ --font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
+ --font-mono: 'SF Mono', Consolas, monospace;
+ --radius: 10px;
+ --radius-sm: 6px;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #f5f5f7;
+ --bg-card: #ffffff;
+ --bg-elevated: #eeeef0;
+ --bg-hover: #e4e4e8;
+ --fg: #1a1a1e;
+ --fg-muted: #6c6c74;
+ --fg-dim: #9a9aa2;
+ --accent: #5f8787;
+ --accent-hover: #4a7272;
+ --accent-muted: rgba(95, 135, 135, 0.12);
+ --accent-subtle: rgba(95, 135, 135, 0.06);
+ --border: #dcdce0;
+ --border-muted: #c8c8d0;
+ --border-checked: #5f8787;
+ --check-bg: #5f8787;
+ --btn-primary: #5f8787;
+ --btn-primary-hover: #4a7272;
+ --btn-primary-fg: #ffffff;
+ --btn-secondary: #e4e4e8;
+ --btn-secondary-hover: #d4d4d8;
+ --timer-bg: #e4e4e8;
+ --timer-fg: #6c6c74;
+ --timer-warn-bg: rgba(217, 119, 6, 0.10);
+ --timer-warn-fg: #92400e;
+ --timer-urgent-bg: rgba(175, 95, 95, 0.10);
+ --timer-urgent-fg: #991b1b;
+ --overlay-bg: rgba(255, 255, 255, 0.92);
+ --success: #4d7c0f;
+ --warning: #b45309;
+ }
+}
+
+body {
+ font-family: var(--font);
+ background: var(--bg);
+ background-image: radial-gradient(ellipse at 50% 0%, var(--accent-muted) 0%, transparent 60%);
+ color: var(--fg);
+ line-height: 1.5;
+ min-height: 100dvh;
+ padding-bottom: 72px;
+}
+
+.timer-badge {
+ position: fixed;
+ top: 20px;
+ right: 24px;
+ z-index: 50;
+ font-family: var(--font);
+ font-size: 12px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ padding: 5px 14px;
+ border-radius: 999px;
+ background: var(--bg-elevated);
+ color: var(--timer-fg);
+ border: 1px solid var(--border);
+ transition: background 0.3s, color 0.3s, border-color 0.3s, opacity 0.3s;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+ cursor: pointer;
+ user-select: none;
+ opacity: 0.5;
+}
+.timer-badge:hover { opacity: 1; }
+.timer-badge.active { opacity: 1; }
+.timer-badge.warn {
+ opacity: 1;
+ background: var(--timer-warn-bg);
+ color: var(--timer-warn-fg);
+ border-color: color-mix(in srgb, var(--timer-warn-fg) 30%, transparent);
+}
+.timer-badge.urgent {
+ opacity: 1;
+ background: var(--timer-urgent-bg);
+ color: var(--timer-urgent-fg);
+ border-color: color-mix(in srgb, var(--timer-urgent-fg) 30%, transparent);
+}
+.timer-adjust {
+ position: fixed;
+ top: 20px;
+ right: 24px;
+ z-index: 51;
+ display: none;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 6px 4px 12px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--accent);
+ border-radius: 999px;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.3);
+}
+.timer-adjust.visible { display: flex; }
+.timer-adjust input {
+ width: 48px;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ text-align: center;
+}
+.timer-adjust-label { font-size: 11px; color: var(--fg-dim); }
+.timer-adjust-btn {
+ font-family: var(--font);
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: none;
+ background: var(--accent);
+ color: var(--btn-primary-fg);
+ cursor: pointer;
+}
+.timer-adjust-btn:hover { background: var(--accent-hover); }
+
+main {
+ max-width: 640px;
+ margin: 0 auto;
+ padding: 56px 24px 16px;
+}
+
+.hero { margin-bottom: 28px; }
+.hero-kicker {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--accent);
+ margin-bottom: 8px;
+}
+.hero-title {
+ font-family: var(--font-display);
+ font-size: 40px;
+ font-weight: 400;
+ font-style: italic;
+ letter-spacing: -0.01em;
+ line-height: 1.1;
+ color: var(--fg);
+ margin-bottom: 10px;
+ text-wrap: balance;
+}
+.hero-desc {
+ font-size: 14px;
+ color: var(--fg-muted);
+ line-height: 1.5;
+ margin-bottom: 12px;
+ max-width: 480px;
+}
+.hero-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ color: var(--fg-dim);
+}
+.hero-meta-sep {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--fg-dim);
+ flex-shrink: 0;
+}
+#hero-status:empty + .hero-meta-sep { display: none; }
+.hero-meta select {
+ font-family: var(--font);
+ font-size: 13px;
+ padding: 3px 8px;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--fg-muted);
+ border-radius: var(--radius-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s;
+}
+.hero-meta select:hover {
+ border-color: var(--border-muted);
+ color: var(--fg);
+}
+.hero-meta select:focus {
+ outline: none;
+ border-color: var(--accent);
+ color: var(--fg);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
+}
+
+#result-cards { display: flex; flex-direction: column; gap: 8px; }
+
+.result-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ transition: border-color 0.12s;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
+}
+.result-card.checked { border-color: var(--border-checked); }
+.result-card.searching {
+ opacity: 0.7;
+ border-style: dashed;
+}
+.result-card.error { border-color: var(--timer-urgent-fg); }
+
+.result-card-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ cursor: pointer;
+ user-select: none;
+ transition: background 0.12s;
+}
+.result-card-header:hover { background: var(--bg-hover); }
+
+.result-card-header input[type="checkbox"] {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ min-width: 16px;
+ border: 1.5px solid var(--border-muted);
+ border-radius: 4px;
+ margin-top: 2px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s;
+ display: grid;
+ place-content: center;
+}
+.result-card-header input[type="checkbox"]:checked {
+ background: var(--check-bg);
+ border-color: var(--check-bg);
+}
+.result-card-header input[type="checkbox"]:checked::after {
+ content: "";
+ width: 9px;
+ height: 6px;
+ border-left: 2px solid var(--btn-primary-fg);
+ border-bottom: 2px solid var(--btn-primary-fg);
+ transform: rotate(-45deg);
+ margin-top: -1px;
+}
+
+.result-card-info { flex: 1; min-width: 0; }
+
+.result-card-query {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--fg);
+ margin-bottom: 2px;
+}
+.result-card-meta {
+ font-size: 12px;
+ color: var(--fg-dim);
+}
+.result-card-preview {
+ font-size: 12.5px;
+ color: var(--fg-muted);
+ margin-top: 6px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.45;
+}
+
+.result-card-expand {
+ color: var(--fg-dim);
+ font-size: 11px;
+ margin-top: 2px;
+ flex-shrink: 0;
+ padding-top: 3px;
+ transition: color 0.12s;
+}
+.result-card-header:hover .result-card-expand { color: var(--fg-muted); }
+
+.result-card-body {
+ display: none;
+ border-top: 1px solid var(--border);
+}
+.result-card-body.open { display: block; }
+
+.result-card-answer {
+ padding: 14px 16px;
+ font-size: 13.5px;
+ color: var(--fg-muted);
+ line-height: 1.6;
+ max-height: 400px;
+ overflow-y: auto;
+}
+.result-card-answer h1,
+.result-card-answer h2,
+.result-card-answer h3,
+.result-card-answer h4 {
+ color: var(--fg);
+ font-family: var(--font);
+ font-weight: 600;
+ margin: 16px 0 6px;
+ line-height: 1.3;
+}
+.result-card-answer h1 { font-size: 16px; }
+.result-card-answer h2 { font-size: 14.5px; }
+.result-card-answer h3 { font-size: 13.5px; }
+.result-card-answer h4 { font-size: 13px; color: var(--fg-muted); }
+.result-card-answer p { margin: 0 0 10px; }
+.result-card-answer p:last-child { margin-bottom: 0; }
+.result-card-answer strong { color: var(--fg); font-weight: 600; }
+.result-card-answer a { color: var(--accent); text-decoration: none; }
+.result-card-answer a:hover { text-decoration: underline; }
+.result-card-answer ul, .result-card-answer ol {
+ margin: 6px 0 10px;
+ padding-left: 20px;
+}
+.result-card-answer li { margin-bottom: 4px; }
+.result-card-answer li::marker { color: var(--fg-dim); }
+.result-card-answer code {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 1px 5px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ color: var(--fg);
+}
+.result-card-answer pre {
+ margin: 8px 0 12px;
+ padding: 12px 14px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ line-height: 1.45;
+}
+.result-card-answer pre code {
+ padding: 0;
+ background: none;
+ border: none;
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+.result-card-answer blockquote {
+ margin: 8px 0;
+ padding: 8px 14px;
+ border-left: 3px solid var(--accent);
+ color: var(--fg-dim);
+ background: var(--accent-subtle);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+}
+.result-card-answer table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 8px 0 12px;
+ font-size: 12.5px;
+}
+.result-card-answer th, .result-card-answer td {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ text-align: left;
+}
+.result-card-answer th {
+ background: var(--bg-elevated);
+ color: var(--fg);
+ font-weight: 600;
+ font-size: 11.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+.result-card-answer hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 14px 0;
+}
+
+.result-card-sources {
+ padding: 10px 16px 14px;
+ border-top: 1px solid var(--border);
+}
+.result-card-sources-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--fg-dim);
+ margin-bottom: 6px;
+}
+.source-link {
+ display: block;
+ padding: 4px 0;
+ font-size: 12.5px;
+ color: var(--fg-muted);
+ text-decoration: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ transition: color 0.12s;
+}
+.source-link:hover { color: var(--accent); }
+.source-domain {
+ color: var(--fg-dim);
+ margin-left: 6px;
+}
+
+.result-card-error-msg {
+ padding: 12px 16px;
+ font-size: 13px;
+ color: var(--timer-urgent-fg);
+}
+
+.searching-dots::after {
+ content: "";
+ animation: dots 1.5s steps(4, end) infinite;
+}
+@keyframes dots {
+ 0% { content: ""; }
+ 25% { content: "."; }
+ 50% { content: ".."; }
+ 75% { content: "..."; }
+}
+
+.add-search {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+ padding: 11px 14px;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ cursor: text;
+ transition: border-color 0.15s, background 0.15s;
+}
+.add-search:hover {
+ border-color: var(--border-muted);
+ background: var(--accent-subtle);
+}
+.add-search:focus-within {
+ border-color: var(--accent);
+ border-style: solid;
+ background: var(--accent-subtle);
+}
+.add-search-icon {
+ color: var(--fg-dim);
+ font-size: 16px;
+ font-weight: 300;
+ line-height: 1;
+ flex-shrink: 0;
+ transition: color 0.15s;
+}
+.add-search:focus-within .add-search-icon { color: var(--accent); }
+.add-search input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 13.5px;
+ font-weight: 500;
+}
+.add-search input::placeholder {
+ color: var(--fg-dim);
+ font-weight: 400;
+}
+.add-search.loading {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.action-bar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 24px;
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-top: 1px solid var(--border);
+}
+.action-shortcuts { display: flex; align-items: center; gap: 16px; }
+.shortcut { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-dim); }
+.shortcut kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 4px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-muted);
+ border-radius: 3px;
+ color: var(--fg-muted);
+}
+.action-buttons { display: flex; gap: 8px; }
+
+.btn {
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 7px 16px;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background 0.12s, opacity 0.12s;
+}
+.btn:disabled { opacity: 0.35; cursor: default; }
+.btn-submit { background: var(--btn-primary); color: var(--btn-primary-fg); }
+.btn-submit:hover:not(:disabled) { background: var(--btn-primary-hover); }
+.btn-secondary { background: var(--btn-secondary); color: var(--fg-muted); border: 1px solid var(--border); }
+.btn-secondary:hover:not(:disabled) { background: var(--btn-secondary-hover); color: var(--fg); }
+
+.success-overlay {
+ position: fixed; inset: 0; z-index: 200;
+ background: var(--overlay-bg);
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
+ transition: opacity 200ms;
+}
+.success-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; }
+.success-icon {
+ width: 56px; height: 56px; border-radius: 50%;
+ border: 2px solid var(--success);
+ display: flex; align-items: center; justify-content: center;
+ font-size: 18px; font-weight: 700; color: var(--success);
+}
+.success-overlay p { margin: 0; font-size: 13px; font-weight: 600; color: var(--success); letter-spacing: 0.06em; text-transform: uppercase; }
+
+.expired-overlay {
+ position: fixed; inset: 0;
+ background: var(--overlay-bg);
+ display: flex; align-items: center; justify-content: center;
+ opacity: 0; transition: opacity 400ms; pointer-events: none; z-index: 200;
+}
+.expired-overlay.visible { opacity: 1; pointer-events: auto; }
+.expired-overlay.hidden { display: flex !important; opacity: 0; pointer-events: none; }
+.expired-content {
+ text-align: center; max-width: 480px; padding: 48px 56px;
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px;
+}
+.expired-overlay.visible .expired-content { animation: slide-up 400ms ease-out; }
+@keyframes slide-up { from { transform: translateY(20px); } to { transform: translateY(0); } }
+.expired-icon {
+ width: 72px; height: 72px; border-radius: 50%; border: 2px solid var(--warning);
+ display: flex; align-items: center; justify-content: center;
+ font-size: 32px; font-weight: bold; color: var(--warning); margin: 0 auto 24px;
+}
+.expired-content h2 { color: var(--fg); margin: 0 0 16px; font-size: 22px; font-weight: 600; }
+.expired-content p { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; line-height: 1.6; }
+.expired-countdown { font-size: 13px; color: var(--fg-dim); font-variant-numeric: tabular-nums; }
+.expired-countdown span { color: var(--warning); font-weight: 600; }
+
+.error-banner {
+ position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%); z-index: 50;
+ padding: 10px 20px; background: var(--timer-urgent-bg); color: var(--timer-urgent-fg);
+ border-radius: var(--radius); font-size: 13px; font-weight: 500;
+}
+
+@media (max-width: 500px) {
+ main { padding: 32px 16px 16px; }
+ .hero-title { font-size: 28px; }
+ .hero-desc { font-size: 13px; }
+ .action-bar { padding: 10px 14px; }
+ .action-shortcuts { display: none; }
+ .result-card-header { padding: 12px 14px; }
+ .expired-content { padding: 32px 24px; }
+ .timer-badge { top: 12px; right: 16px; }
+}
+`;
+
+const SCRIPT = `(function() {
+ var DATA = __INLINE_DATA__;
+ var token = DATA.sessionToken;
+ var timeoutSec = DATA.timeout;
+ var queries = DATA.queries;
+ var submitted = false;
+ var timerExpired = false;
+ var searchesDone = false;
+ var lastInteraction = Date.now();
+ var completedCount = 0;
+ var es = null;
+
+ var timerEl = document.getElementById("timer");
+ var timerAdjustEl = document.getElementById("timer-adjust");
+ var timerInput = document.getElementById("timer-input");
+ var timerSetBtn = document.getElementById("timer-set");
+ var heroTitle = document.querySelector(".hero-title");
+ var heroDesc = document.querySelector(".hero-desc");
+ var resultCardsEl = document.getElementById("result-cards");
+ var btnSendAll = document.getElementById("btn-send-all");
+ var btnSend = document.getElementById("btn-send");
+ var successOverlay = document.getElementById("success-overlay");
+ var successText = document.getElementById("success-text");
+ var expiredOverlay = document.getElementById("expired-overlay");
+ var expiredText = document.getElementById("expired-text");
+ var closeCountdown = document.getElementById("close-countdown");
+ var errorBanner = document.getElementById("error-banner");
+ var addSearchInput = document.getElementById("add-search-input");
+ var addSearchEl = document.getElementById("add-search");
+ var heroStatus = document.getElementById("hero-status");
+ var globalProvider = document.getElementById("global-provider");
+
+ function escHtml(s) {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+
+ function post(path, body) {
+ return fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(Object.assign({ token: token }, body)),
+ });
+ }
+
+ function formatTime(sec) {
+ var m = Math.floor(sec / 60);
+ var s = sec % 60;
+ return m + ":" + (s < 10 ? "0" : "") + s;
+ }
+
+ function populateResultCard(card, data, queryText) {
+ var sourceCount = data.results ? data.results.length : 0;
+ var domains = [];
+ if (data.results) {
+ for (var i = 0; i < Math.min(data.results.length, 3); i++) {
+ domains.push(data.results[i].domain);
+ }
+ }
+ var metaText = sourceCount + " source" + (sourceCount !== 1 ? "s" : "");
+ if (domains.length > 0) metaText += " \\u00B7 " + domains.join(", ");
+ if (sourceCount > 3) metaText += ", +" + (sourceCount - 3);
+
+ var preview = "";
+ if (data.answer) {
+ preview = data.answer.substring(0, 200).replace(/\\n+/g, " ").replace(/[#*_\\[\\]]/g, "");
+ }
+
+ var bodyHtml = "";
+ if (data.answer) {
+ var rendered = typeof marked !== "undefined" && marked.parse
+ ? marked.parse(data.answer, { breaks: true })
+ : '' + escHtml(data.answer) + '
';
+ bodyHtml += '' + rendered + '';
+ }
+ if (data.results && data.results.length > 0) {
+ bodyHtml += 'Sources';
+ for (var k = 0; k < data.results.length; k++) {
+ var r = data.results[k];
+ var label = r.title && r.title.indexOf("Source ") !== 0 ? r.title : r.url;
+ bodyHtml += '' + escHtml(label) + '' + escHtml(r.domain) + '';
+ }
+ bodyHtml += '';
+ }
+
+ card.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' + escHtml(queryText) + '' +
+ '' +
+ (preview ? '' + escHtml(preview) + '' : '') +
+ '' +
+ '' +
+ '' +
+ '' + bodyHtml + '';
+ }
+
+ // ── Timer ──
+
+ function resetTimer() { lastInteraction = Date.now(); }
+
+ function updateTimer() {
+ var idleSec = Math.floor((Date.now() - lastInteraction) / 1000);
+ var remaining = Math.max(0, timeoutSec - idleSec);
+ timerEl.textContent = formatTime(remaining);
+
+ timerEl.classList.remove("warn", "urgent", "active");
+ if (remaining <= 15) timerEl.classList.add("urgent");
+ else if (remaining <= 30) timerEl.classList.add("warn");
+ else if (remaining < timeoutSec) timerEl.classList.add("active");
+
+ if (remaining <= 0 && !submitted && !timerExpired) onTimeout();
+ }
+
+ setInterval(updateTimer, 1000);
+ updateTimer();
+
+ ["click", "keydown", "input", "change"].forEach(function(evt) {
+ document.addEventListener(evt, resetTimer, { passive: true });
+ });
+ document.addEventListener("scroll", resetTimer, { passive: true });
+ document.addEventListener("mousemove", resetTimer, { passive: true });
+
+ // ── Timer adjust ──
+
+ timerEl.addEventListener("click", function(e) {
+ e.stopPropagation();
+ timerInput.value = timeoutSec;
+ timerAdjustEl.classList.add("visible");
+ timerEl.style.display = "none";
+ timerInput.focus();
+ timerInput.select();
+ });
+
+ function applyTimerAdjust() {
+ var val = parseInt(timerInput.value, 10);
+ if (val && val > 0) timeoutSec = Math.min(val, 600);
+ timerAdjustEl.classList.remove("visible");
+ timerEl.style.display = "";
+ resetTimer();
+ }
+
+ timerSetBtn.addEventListener("click", function(e) { e.stopPropagation(); applyTimerAdjust(); });
+ timerInput.addEventListener("keydown", function(e) {
+ if (e.key === "Enter") { e.preventDefault(); applyTimerAdjust(); }
+ if (e.key === "Escape") { timerAdjustEl.classList.remove("visible"); timerEl.style.display = ""; }
+ e.stopPropagation();
+ });
+ document.addEventListener("click", function() {
+ if (timerAdjustEl.classList.contains("visible")) {
+ timerAdjustEl.classList.remove("visible");
+ timerEl.style.display = "";
+ }
+ });
+
+ // ── Provider ──
+
+ if (globalProvider) {
+ globalProvider.addEventListener("change", function() {
+ post("/provider", { provider: globalProvider.value });
+ });
+ }
+
+ // ── Add search ──
+
+ addSearchInput.addEventListener("keydown", function(e) {
+ if (e.key !== "Enter") return;
+ var text = addSearchInput.value.trim();
+ if (!text || submitted) return;
+ e.preventDefault();
+ e.stopPropagation();
+
+ addSearchEl.classList.add("loading");
+ addSearchInput.value = "";
+
+ var card = document.createElement("div");
+ card.className = "result-card searching";
+ card.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' + escHtml(text) + '' +
+ '' +
+ '' +
+ '';
+ resultCardsEl.appendChild(card);
+ resetTimer();
+
+ post("/search", { query: text }).then(function(res) {
+ return res.json();
+ }).then(function(data) {
+ addSearchEl.classList.remove("loading");
+ if (!data.ok) { card.remove(); return; }
+
+ card.dataset.qi = data.queryIndex;
+
+ if (data.error) {
+ card.classList.remove("searching");
+ card.classList.add("error");
+ card.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' + escHtml(text) + '' +
+ '' +
+ '' +
+ '' +
+ '' + escHtml(data.error) + '';
+ return;
+ }
+
+ card.classList.remove("searching");
+ card.classList.add("checked");
+ completedCount++;
+
+ populateResultCard(card, data, text);
+ setupCardInteraction(card);
+ updateSendButton();
+ heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete";
+ heroDesc.textContent = "Review the results and send what you want back to your agent.";
+ if (heroStatus) heroStatus.textContent = completedCount + " completed";
+ resetTimer();
+ }).catch(function() {
+ addSearchEl.classList.remove("loading");
+ card.remove();
+ });
+ });
+
+ // ── Overlays ──
+
+ function showSuccess(text) {
+ if (es) { es.close(); es = null; }
+ successText.textContent = text;
+ successOverlay.classList.remove("hidden");
+ setTimeout(function() { window.close(); }, 800);
+ }
+
+ function showExpired(text) {
+ if (es) { es.close(); es = null; }
+ expiredText.textContent = text;
+ expiredOverlay.classList.remove("hidden");
+ requestAnimationFrame(function() { expiredOverlay.classList.add("visible"); });
+ }
+
+ function showError(text) {
+ errorBanner.textContent = text;
+ errorBanner.hidden = false;
+ }
+
+ function onTimeout() {
+ if (submitted || timerExpired) return;
+ timerExpired = true;
+ submitted = true;
+ showExpired("Time\\u2019s up \\u2014 sending all results to your agent.");
+ post("/cancel", { reason: "timeout" });
+ var count = 5;
+ closeCountdown.textContent = count;
+ var iv = setInterval(function() {
+ count--;
+ closeCountdown.textContent = count;
+ if (count <= 0) { clearInterval(iv); window.close(); }
+ }, 1000);
+ }
+
+ // ── Create placeholder cards for each query ──
+
+ if (queries.length === 0) {
+ heroTitle.textContent = "What do you need?";
+ heroDesc.textContent = "Search for anything below. Results get sent back to your agent.";
+ if (heroStatus) heroStatus.textContent = "";
+ btnSend.textContent = "No results yet";
+ } else {
+ for (var i = 0; i < queries.length; i++) {
+ var card = document.createElement("div");
+ card.className = "result-card searching";
+ card.dataset.qi = i;
+ card.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' + escHtml(queries[i]) + '' +
+ '' +
+ '' +
+ '';
+ resultCardsEl.appendChild(card);
+ }
+ }
+
+ // ── SSE ──
+
+ es = new EventSource("/events?session=" + encodeURIComponent(token));
+
+ es.addEventListener("result", function(e) {
+ var data = JSON.parse(e.data);
+ var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]');
+ if (!card) return;
+
+ card.classList.remove("searching");
+ card.classList.add("checked");
+ completedCount++;
+
+ populateResultCard(card, data, data.query || queries[data.queryIndex]);
+ setupCardInteraction(card);
+ updateSendButton();
+ resetTimer();
+ });
+
+ es.addEventListener("search-error", function(e) {
+ var data = JSON.parse(e.data);
+ var card = resultCardsEl.querySelector('.result-card[data-qi="' + data.queryIndex + '"]');
+ if (!card) return;
+
+ card.classList.remove("searching");
+ card.classList.add("error");
+ completedCount++;
+
+ card.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' + escHtml(data.query || queries[data.queryIndex]) + '' +
+ '' +
+ '' +
+ '' +
+ '' + escHtml(data.error || "Search failed") + '';
+
+ updateSendButton();
+ resetTimer();
+ });
+
+ es.addEventListener("done", function() {
+ searchesDone = true;
+ if (completedCount > 0) {
+ heroTitle.textContent = completedCount + " Search" + (completedCount !== 1 ? "es" : "") + " Complete";
+ heroDesc.textContent = "Review the results and send what you want back to your agent.";
+ if (heroStatus) heroStatus.textContent = completedCount + " completed";
+ }
+ updateSendButton();
+ resetTimer();
+ });
+
+ es.onerror = function() {};
+
+ // ── Card interaction ──
+
+ function setupCardInteraction(card) {
+ var header = card.querySelector(".result-card-header");
+ var body = card.querySelector(".result-card-body");
+ var cb = card.querySelector("input[type=checkbox]");
+ var expandEl = card.querySelector(".result-card-expand");
+
+ header.addEventListener("click", function(e) {
+ if (e.target.tagName === "A") return;
+ if (e.target === cb) {
+ card.classList.toggle("checked", cb.checked);
+ updateSendButton();
+ return;
+ }
+ var isExpanded = body && body.classList.contains("open");
+ if (body) body.classList.toggle("open");
+ if (expandEl) expandEl.textContent = isExpanded ? "\\u25BC" : "\\u25B2";
+ });
+
+ if (body) {
+ body.addEventListener("click", function(e) {
+ e.stopPropagation();
+ });
+ }
+ }
+
+ // ── Send button ──
+
+ function getSelectedIndices() {
+ var indices = [];
+ var cards = resultCardsEl.querySelectorAll(".result-card");
+ cards.forEach(function(card) {
+ var cb = card.querySelector("input[type=checkbox]");
+ if (cb && cb.checked && !cb.disabled) {
+ indices.push(parseInt(card.dataset.qi, 10));
+ }
+ });
+ return indices;
+ }
+
+ function updateSendButton() {
+ var sel = getSelectedIndices();
+ var hasResults = completedCount > 0;
+ btnSend.disabled = !hasResults || sel.length === 0;
+ if (!hasResults) {
+ btnSend.textContent = searchesDone ? "No results yet" : "Waiting for results\\u2026";
+ } else {
+ btnSend.textContent = "Send Selected (" + sel.length + ")";
+ }
+ btnSendAll.hidden = !searchesDone || !hasResults;
+ }
+
+ // ── Submit / Cancel ──
+
+ function doSubmit(indices) {
+ if (submitted) return;
+ submitted = true;
+ post("/submit", { selected: indices }).then(function(res) {
+ if (!res.ok) throw new Error("submit failed");
+ showSuccess("Results sent");
+ }).catch(function() {
+ submitted = false;
+ showError("Failed to send \\u2014 the agent may have moved on");
+ });
+ }
+
+ function doCancel() {
+ if (submitted) return;
+ submitted = true;
+ post("/cancel", { reason: "user" }).then(function(res) {
+ if (!res.ok) throw new Error("cancel failed");
+ showSuccess("Skipped");
+ }).catch(function() {
+ submitted = false;
+ showError("Failed \\u2014 the agent may have moved on");
+ });
+ }
+
+ // ── Button handlers ──
+
+ btnSend.addEventListener("click", function() {
+ var sel = getSelectedIndices();
+ if (sel.length > 0) doSubmit(sel);
+ });
+
+ btnSendAll.addEventListener("click", function() {
+ var all = [];
+ resultCardsEl.querySelectorAll(".result-card").forEach(function(card) {
+ var cb = card.querySelector("input[type=checkbox]");
+ if (cb && !cb.disabled) all.push(parseInt(card.dataset.qi, 10));
+ });
+ if (all.length > 0) doSubmit(all);
+ });
+
+ // ── Keyboard ──
+
+ document.addEventListener("keydown", function(e) {
+ if (submitted || timerExpired) return;
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return;
+
+ if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault();
+ var sel = getSelectedIndices();
+ if (sel.length > 0) doSubmit(sel);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ doCancel();
+ } else if (e.key === "a" && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault();
+ var boxes = resultCardsEl.querySelectorAll("input[type=checkbox]:not(:disabled)");
+ var allChecked = true;
+ boxes.forEach(function(cb) { if (!cb.checked) allChecked = false; });
+ boxes.forEach(function(cb) {
+ cb.checked = !allChecked;
+ cb.closest(".result-card").classList.toggle("checked", cb.checked);
+ });
+ updateSendButton();
+ resetTimer();
+ }
+ });
+
+ // ── Heartbeat ──
+
+ setInterval(function() {
+ if (submitted) return;
+ post("/heartbeat", {});
+ }, 10000);
+
+ // ── Focus add-search input when no initial queries ──
+
+ if (queries.length === 0 && addSearchInput) {
+ addSearchInput.focus();
+ }
+})();`;
diff --git a/pi/files/agent/extensions/pi-web-access/curator-server.ts b/pi/files/agent/extensions/pi-web-access/curator-server.ts
new file mode 100644
index 0000000..22badc7
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/curator-server.ts
@@ -0,0 +1,325 @@
+import http, { type IncomingMessage, type ServerResponse } from "node:http";
+import { generateCuratorPage } from "./curator-page.js";
+
+const STALE_THRESHOLD_MS = 30000;
+const WATCHDOG_INTERVAL_MS = 5000;
+const MAX_BODY_SIZE = 64 * 1024;
+
+type ServerState = "SEARCHING" | "RESULT_SELECTION" | "COMPLETED";
+
+export interface CuratorServerOptions {
+ queries: string[];
+ sessionToken: string;
+ timeout: number;
+ availableProviders: { perplexity: boolean; gemini: boolean };
+ defaultProvider: string;
+}
+
+export interface CuratorServerCallbacks {
+ onSubmit: (selectedQueryIndices: number[]) => void;
+ onCancel: (reason: "user" | "timeout" | "stale") => void;
+ onProviderChange: (provider: string) => void;
+ onAddSearch: (query: string, queryIndex: number) => Promise<{ answer: string; results: Array<{ title: string; url: string; domain: string }> }>;
+}
+
+export interface CuratorServerHandle {
+ server: http.Server;
+ url: string;
+ close: () => void;
+ pushResult: (queryIndex: number, data: { answer: string; results: Array<{ title: string; url: string; domain: string }> }) => void;
+ pushError: (queryIndex: number, error: string) => void;
+ searchesDone: () => void;
+}
+
+function sendJson(res: ServerResponse, status: number, payload: unknown): void {
+ res.writeHead(status, {
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store",
+ });
+ res.end(JSON.stringify(payload));
+}
+
+function parseJSONBody(req: IncomingMessage): Promise {
+ return new Promise((resolve, reject) => {
+ let body = "";
+ let size = 0;
+ req.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > MAX_BODY_SIZE) {
+ req.destroy();
+ reject(new Error("Request body too large"));
+ return;
+ }
+ body += chunk.toString();
+ });
+ req.on("end", () => {
+ try { resolve(JSON.parse(body)); }
+ catch { reject(new Error("Invalid JSON")); }
+ });
+ req.on("error", reject);
+ });
+}
+
+export function startCuratorServer(
+ options: CuratorServerOptions,
+ callbacks: CuratorServerCallbacks,
+): Promise {
+ const { queries, sessionToken, timeout, availableProviders, defaultProvider } = options;
+ let browserConnected = false;
+ let lastHeartbeatAt = Date.now();
+ let completed = false;
+ let watchdog: NodeJS.Timeout | null = null;
+ let state: ServerState = "SEARCHING";
+ let sseResponse: ServerResponse | null = null;
+ const sseBuffer: string[] = [];
+ let nextQueryIndex = queries.length;
+
+ let sseKeepalive: NodeJS.Timeout | null = null;
+
+ const markCompleted = (): boolean => {
+ if (completed) return false;
+ completed = true;
+ state = "COMPLETED";
+ if (watchdog) { clearInterval(watchdog); watchdog = null; }
+ if (sseKeepalive) { clearInterval(sseKeepalive); sseKeepalive = null; }
+ if (sseResponse) {
+ try { sseResponse.end(); } catch {}
+ sseResponse = null;
+ }
+ return true;
+ };
+
+ const touchHeartbeat = (): void => {
+ lastHeartbeatAt = Date.now();
+ browserConnected = true;
+ };
+
+ function validateToken(body: unknown, res: ServerResponse): boolean {
+ if (!body || typeof body !== "object") {
+ sendJson(res, 400, { ok: false, error: "Invalid body" });
+ return false;
+ }
+ if ((body as { token?: string }).token !== sessionToken) {
+ sendJson(res, 403, { ok: false, error: "Invalid session" });
+ return false;
+ }
+ return true;
+ }
+
+ function sendSSE(event: string, data: unknown): void {
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ const res = sseResponse;
+ if (res && !res.writableEnded && res.socket && !res.socket.destroyed) {
+ try {
+ const ok = res.write(payload);
+ if (!ok) res.once("drain", () => {});
+ } catch {
+ sseBuffer.push(payload);
+ }
+ } else {
+ sseBuffer.push(payload);
+ }
+ }
+
+ const pageHtml = generateCuratorPage(queries, sessionToken, timeout, availableProviders, defaultProvider);
+
+ const server = http.createServer(async (req, res) => {
+ try {
+ const method = req.method || "GET";
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
+
+ if (method === "GET" && url.pathname === "/") {
+ const token = url.searchParams.get("session");
+ if (token !== sessionToken) {
+ res.writeHead(403, { "Content-Type": "text/plain" });
+ res.end("Invalid session");
+ return;
+ }
+ touchHeartbeat();
+ res.writeHead(200, {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store",
+ });
+ res.end(pageHtml);
+ return;
+ }
+
+ if (method === "GET" && url.pathname === "/events") {
+ const token = url.searchParams.get("session");
+ if (token !== sessionToken) {
+ res.writeHead(403, { "Content-Type": "text/plain" });
+ res.end("Invalid session");
+ return;
+ }
+ if (state === "COMPLETED") {
+ sendJson(res, 409, { ok: false, error: "No events available" });
+ return;
+ }
+ if (sseResponse) {
+ try { sseResponse.end(); } catch {}
+ }
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+ res.flushHeaders();
+ if (res.socket) res.socket.setNoDelay(true);
+ sseResponse = res;
+ for (const msg of sseBuffer) {
+ try { res.write(msg); } catch {}
+ }
+ sseBuffer.length = 0;
+ if (sseKeepalive) clearInterval(sseKeepalive);
+ sseKeepalive = setInterval(() => {
+ if (sseResponse) {
+ try { sseResponse.write(":keepalive\n\n"); } catch {}
+ }
+ }, 15000);
+ req.on("close", () => {
+ if (sseResponse === res) sseResponse = null;
+ });
+ return;
+ }
+
+ if (method === "POST" && url.pathname === "/heartbeat") {
+ const body = await parseJSONBody(req).catch(() => null);
+ if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
+ if (!validateToken(body, res)) return;
+ touchHeartbeat();
+ sendJson(res, 200, { ok: true });
+ return;
+ }
+
+ if (method === "POST" && url.pathname === "/provider") {
+ const body = await parseJSONBody(req).catch(() => null);
+ if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
+ if (!validateToken(body, res)) return;
+ const { provider } = body as { provider?: string };
+ if (typeof provider === "string" && provider.length > 0) {
+ setImmediate(() => callbacks.onProviderChange(provider));
+ }
+ sendJson(res, 200, { ok: true });
+ return;
+ }
+
+ if (method === "POST" && url.pathname === "/search") {
+ const body = await parseJSONBody(req).catch(() => null);
+ if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
+ if (!validateToken(body, res)) return;
+ if (state === "COMPLETED") {
+ sendJson(res, 409, { ok: false, error: "Session closed" });
+ return;
+ }
+ const { query } = body as { query?: string };
+ if (typeof query !== "string" || query.trim().length === 0) {
+ sendJson(res, 400, { ok: false, error: "Invalid query" });
+ return;
+ }
+ const qi = nextQueryIndex++;
+ touchHeartbeat();
+ try {
+ const result = await callbacks.onAddSearch(query.trim(), qi);
+ sendJson(res, 200, { ok: true, queryIndex: qi, answer: result.answer, results: result.results });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Search failed";
+ sendJson(res, 200, { ok: true, queryIndex: qi, error: message });
+ }
+ return;
+ }
+
+ if (method === "POST" && url.pathname === "/submit") {
+ const body = await parseJSONBody(req).catch(() => null);
+ if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
+ if (!validateToken(body, res)) return;
+ const { selected } = body as { selected?: number[] };
+ if (!Array.isArray(selected) || !selected.every(n => typeof n === "number")) {
+ sendJson(res, 400, { ok: false, error: "Invalid selection" });
+ return;
+ }
+ if (state !== "SEARCHING" && state !== "RESULT_SELECTION") {
+ sendJson(res, 409, { ok: false, error: "Cannot submit in current state" });
+ return;
+ }
+ if (!markCompleted()) {
+ sendJson(res, 409, { ok: false, error: "Session closed" });
+ return;
+ }
+ sendJson(res, 200, { ok: true });
+ setImmediate(() => callbacks.onSubmit(selected));
+ return;
+ }
+
+ if (method === "POST" && url.pathname === "/cancel") {
+ const body = await parseJSONBody(req).catch(() => null);
+ if (!body) { sendJson(res, 400, { ok: false, error: "Invalid body" }); return; }
+ if (!validateToken(body, res)) return;
+ if (!markCompleted()) {
+ sendJson(res, 200, { ok: true });
+ return;
+ }
+ const { reason } = body as { reason?: string };
+ sendJson(res, 200, { ok: true });
+ const cancelReason = reason === "timeout" ? "timeout" : "user";
+ setImmediate(() => callbacks.onCancel(cancelReason));
+ return;
+ }
+
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end("Not found");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Server error";
+ sendJson(res, 500, { ok: false, error: message });
+ }
+ });
+
+ return new Promise((resolve, reject) => {
+ const onError = (err: Error) => {
+ reject(new Error(`Curator server failed to start: ${err.message}`));
+ };
+
+ server.once("error", onError);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", onError);
+ const addr = server.address();
+ if (!addr || typeof addr === "string") {
+ reject(new Error("Curator server: invalid address"));
+ return;
+ }
+ const url = `http://localhost:${addr.port}/?session=${sessionToken}`;
+
+ watchdog = setInterval(() => {
+ if (completed || !browserConnected) return;
+ if (Date.now() - lastHeartbeatAt <= STALE_THRESHOLD_MS) return;
+ if (!markCompleted()) return;
+ setImmediate(() => callbacks.onCancel("stale"));
+ }, WATCHDOG_INTERVAL_MS);
+
+ resolve({
+ server,
+ url,
+ close: () => {
+ const wasOpen = markCompleted();
+ try { server.close(); } catch {}
+ if (wasOpen) {
+ setImmediate(() => callbacks.onCancel("stale"));
+ }
+ },
+ pushResult: (queryIndex, data) => {
+ if (completed) return;
+ sendSSE("result", { queryIndex, query: queries[queryIndex] ?? "", ...data });
+ },
+ pushError: (queryIndex, error) => {
+ if (completed) return;
+ sendSSE("search-error", { queryIndex, query: queries[queryIndex] ?? "", error });
+ },
+ searchesDone: () => {
+ if (completed) return;
+ sendSSE("done", {});
+ state = "RESULT_SELECTION";
+ },
+ });
+ });
+ });
+}
diff --git a/pi/files/agent/extensions/pi-web-access/ddg-search.ts b/pi/files/agent/extensions/pi-web-access/ddg-search.ts
new file mode 100644
index 0000000..3b4f439
--- /dev/null
+++ b/pi/files/agent/extensions/pi-web-access/ddg-search.ts
@@ -0,0 +1,187 @@
+import { activityMonitor } from "./activity.js";
+
+export interface SearchResult {
+ title: string;
+ url: string;
+ snippet: string;
+}
+
+export interface SearchResponse {
+ answer: string;
+ results: SearchResult[];
+}
+
+export interface SearchOptions {
+ numResults?: number;
+ recencyFilter?: "day" | "week" | "month" | "year";
+ domainFilter?: string[];
+ signal?: AbortSignal;
+}
+
+const DDG_HTML = "https://duckduckgo.com/html/";
+const DDG_LITE = "https://lite.duckduckgo.com/lite/";
+
+function applyDomainFilter(urls: SearchResult[], domains?: string[]): SearchResult[] {
+ if (!domains || domains.length === 0) return urls;
+ const includes = domains.filter((d) => !d.startsWith("-")).map((d) => d.toLowerCase());
+ const excludes = domains.filter((d) => d.startsWith("-")).map((d) => d.slice(1).toLowerCase());
+ return urls.filter((r) => {
+ try {
+ const host = new URL(r.url).hostname.toLowerCase();
+ if (includes.length && !includes.some((d) => host === d || host.endsWith(`.${d}`))) {
+ return false;
+ }
+ if (excludes.some((d) => host === d || host.endsWith(`.${d}`))) {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ });
+}
+
+function extractResultsFromHtml(html: string): SearchResult[] {
+ const results: SearchResult[] = [];
+ const resultRegex = /]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>|