summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'README.md')
-rw-r--r--README.md170
1 files changed, 170 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a97d105
--- /dev/null
+++ b/README.md
@@ -0,0 +1,170 @@
+# pi-castle-cdp-search
+
+Web search for [pi](https://pi.dev) through a Chrome that is **already running**
+on the wire network, driven over the Chrome DevTools Protocol.
+
+It never launches a browser. It attaches to one a person or a launchd job
+started — by default castle's shared Chrome on `10.88.0.25:9223` — opens a
+background tab, reads the results page, and closes the tab again.
+
+Because the search runs in a real browser with a real profile, it reaches pages
+that reject datacentre traffic, and it costs nothing per query.
+
+## The tool
+
+One tool, `castle_cdp_search`. Deliberately **not** named `web_search`, so it
+coexists with `pi-web-access` rather than shadowing it — both stay registered and
+the model picks.
+
+| Parameter | Type | Notes |
+|---|---|---|
+| `query` | string, required | As you would type it into a search box |
+| `numResults` | integer, optional | 1–20, default 10 |
+| `recency` | `day` \| `week` \| `month` \| `year`, optional | Omit for no time limit |
+
+It returns titles, URLs and snippets — **search results only**. It does not fetch
+the linked pages; follow up with a read/fetch tool for full content.
+
+## Which browser it drives
+
+Exactly the same configuration as [`pi-browser-harness`](../pi-browser-harness),
+on purpose: one decision governs both packages, so they can never end up driving
+different browsers in the same session.
+
+| `BU_CDP_HTTP` | Behaviour |
+|---|---|
+| unset | the `/browser-target` choice stored by pi-browser-harness, else castle (`10.88.0.25:9223`) |
+| `host:port` | that endpoint |
+| `local` (also `localhost`, `off`, `none`, `0`, `no`) | this machine's own browser on `127.0.0.1:9222` |
+
+Precedence is `BU_CDP_HTTP` > `/browser-target` > built-in default, matching the
+harness. The `/browser-target` file (`~/.pi/agent/browser-target.json`) is read
+only, never written — the harness owns that setting.
+
+**One deliberate difference from the harness.** When the configured endpoint is
+unreachable, this extension reports that and stops. The harness falls back from
+its built-in default to a local browser; here that fallback would mean silently
+running searches through the operator's own signed-in Chrome without saying so.
+Say `local` explicitly if that is what you want.
+
+The endpoint is re-resolved from `/json/version` on every connect, never cached:
+castle recycles Chrome hourly and Chrome re-mints its browser UUID each launch,
+so a pinned `ws://` URL breaks on the first recycle. A dropped socket reconnects
+transparently on the next search.
+
+## Behaviour worth knowing
+
+- **Nothing is dialled at load.** pi runs extension factories in invocations that
+ never start a session, so the connection is made on first search and closed by
+ an idempotent `session_shutdown` handler.
+- **One fresh background tab per search**, closed on every path including errors
+ and aborts. `background: true` so it does not steal focus from whoever is
+ looking at that screen.
+- **Two concurrent searches**, queued beyond that.
+- **20-second deadline** per search, covering connect, navigate and extract
+ together. Esc aborts an in-flight navigation.
+- **Every failure throws.** pi only sets `isError: true` when `execute()` throws;
+ a returned `{ error }` object would read to the model as a search that simply
+ found nothing.
+
+## What it touches
+
+The browser is shared and belongs to a person. Worth being aware of:
+
+- Queries the agent runs land in that browser profile's history and cookies, and
+ in the Google account's search history if that profile is signed in.
+- Tabs open and close on someone's screen. `background: true` keeps them from
+ stealing focus, but they are visible.
+- Searching hard trips Google's rate limiter for the whole host — a handful of
+ queries in a few seconds is enough to earn a `/sorry/` page that affects the
+ human using that browser too. The cap of two concurrent searches limits this;
+ it does not eliminate it. Measured once: roughly 30 queries in a few minutes
+ cost about 90 minutes of blocking.
+- **A decaying block does not look like a block.** Once the `/sorry/` page stops
+ being served, Google returns an *empty results page* for a while instead —
+ which is indistinguishable from a query that genuinely has no hits. It
+ surfaces as `NoResultsError`, not `SearchChallengeError`. If several unrelated
+ queries all come back empty, that is rate limiting; wait rather than retrying.
+
+## When a captcha appears
+
+Google will eventually serve a `/sorry/` interstitial, a consent wall, or a
+recaptcha — especially if searches come in fast. The extension detects this and
+throws a `SearchChallengeError` naming the challenge, the browser, and the URL a
+human has to visit.
+
+The agent cannot solve it. The guidelines tell it to stop searching and hand off
+to the user, who opens that URL in the browser at the endpoint, clears the
+challenge, and lets the agent retry. The cookie is profile-wide, so one pass
+unblocks later searches.
+
+The tab is closed rather than left open on the challenge page: leaving it would
+let the operator solve it in place, but would also litter a shared browser with
+abandoned tabs on every failure. The URL in the error is enough.
+
+## Search parameters, and two surprises
+
+Plain searches use `udm=14` — Google's "Web" tab: no AI overview, no carousels,
+just ranked links, which is both cheaper to parse and closer to what was asked
+for. Verified against the real browser:
+
+- `tbs=qdr:*`, the parameter Google's own Tools menu writes, renders an **empty
+ page** for this profile — `#search` present, no `#rso`, no results. The older
+ `as_qdr=*` works and genuinely filters.
+- Any date restriction combined with `udm=14` also renders that empty page.
+
+So a time-limited search drops `udm` and uses `as_qdr`. The extractor handles
+both layouts.
+
+## Result extraction
+
+`src/extract.ts` avoids Google's generated class names (`MjjYud`, `kb0PBd`, …)
+entirely. It prefers the `data-snhf` / `data-sncf` hooks and otherwise falls back
+to a structural walk: from each `<h3>`, take the enclosing link, climb until the
+text grows past the header's, and abandon the climb if a second `<h3>` comes into
+scope. Checked against both the classic SERP and the `udm=14` layout.
+
+Google's markup will change anyway. When it does, a search returns
+`NoResultsError` whose message distinguishes "results container rendered but
+unparseable" (extractor needs updating) from "no results area at all" (query or
+parameters).
+
+## Install
+
+```
+pi install /Volumes/Sense/src/soarez/pi-castle-cdp-search
+```
+
+Or, once it is on castle alongside the harness:
+
+```
+pi install ssh://sz@10.88.0.25/Users/sz/repos/pi-castle-cdp-search.git
+```
+
+The ssh form is what pi-browser-harness uses, and it is the portable one: pi
+records local-path installs by resolved absolute path, and `settings.json` is
+shared across hosts through the dotfiles symlink.
+
+To try it without installing:
+
+```
+pi -e ./src/index.ts
+```
+
+## Development
+
+```
+npm install
+npm run typecheck
+npm test # unit tests, no browser needed
+```
+
+The tests cover endpoint resolution, URL building, formatting, the challenge
+error, and the concurrency semaphore. The semaphore tests point at an
+unreachable endpoint on purpose — the questions there are about slot
+bookkeeping, and hitting a real browser to test a counter would be slow, flaky,
+and rude to whoever is using it.
+
+Requires Node 22+, for the global `WebSocket`. There are no runtime
+dependencies; pi supplies `@earendil-works/pi-coding-agent`,
+`@earendil-works/pi-ai` and `typebox` to extensions through its own loader.