summaryrefslogtreecommitdiff
path: root/README.md
blob: a97d1059a3e8d6454bd218da067ac8594ca0d267 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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.