pi-chrome-search 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/assets/anchor.html +165 -0
- package/index.ts +12 -0
- package/package.json +64 -4
- package/src/cdp/ai-wait.ts +148 -0
- package/src/cdp/browser.ts +1286 -0
- package/src/cdp/bytes.ts +257 -0
- package/src/cdp/discovery.ts +513 -0
- package/src/cdp/humanize.ts +633 -0
- package/src/cdp/page-lock.ts +124 -0
- package/src/cdp/raise.ts +230 -0
- package/src/cdp/session.ts +1446 -0
- package/src/cdp/types.ts +151 -0
- package/src/cli/doctor.ts +412 -0
- package/src/commands/doctor.ts +133 -0
- package/src/commands/focus.ts +40 -0
- package/src/config.ts +651 -0
- package/src/engines/duckduckgo.ts +693 -0
- package/src/engines/google.ts +1204 -0
- package/src/engines/images.ts +756 -0
- package/src/engines/index.ts +208 -0
- package/src/engines/types.ts +215 -0
- package/src/engines/yandex.ts +953 -0
- package/src/extract/claims.ts +442 -0
- package/src/extract/compact.ts +613 -0
- package/src/extract/goto.ts +516 -0
- package/src/extract/pdf.ts +1406 -0
- package/src/extract/read.ts +880 -0
- package/src/extract/urls.ts +305 -0
- package/src/index.ts +248 -0
- package/src/reflex/classifier.ts +1578 -0
- package/src/reflex/index.ts +172 -0
- package/src/reflex/policy.ts +792 -0
- package/src/reflex/rules.ts +557 -0
- package/src/reflex/types.ts +117 -0
- package/src/runtime/artifacts.ts +731 -0
- package/src/runtime/attention.ts +153 -0
- package/src/runtime/cache.ts +253 -0
- package/src/runtime/clock.ts +41 -0
- package/src/runtime/doctor.ts +1139 -0
- package/src/runtime/error-codes.ts +168 -0
- package/src/runtime/errors.ts +258 -0
- package/src/runtime/host.ts +412 -0
- package/src/runtime/limiter.ts +324 -0
- package/src/runtime/log.ts +361 -0
- package/src/runtime/notify.ts +256 -0
- package/src/runtime/panel.ts +338 -0
- package/src/runtime/ssrf.ts +276 -0
- package/src/runtime/types.ts +83 -0
- package/src/tools/image_search.ts +626 -0
- package/src/tools/index.ts +37 -0
- package/src/tools/types.ts +38 -0
- package/src/tools/web_doctor.ts +216 -0
- package/src/tools/web_read.ts +882 -0
- package/src/tools/web_research.ts +1268 -0
- package/src/tools/web_search.ts +1629 -0
- package/src/version.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sh2nk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-chrome-search
|
|
2
|
+
Free, keyless web search for [pi](https://pi.dev) through **your own, visible Chrome** — same profile,
|
|
3
|
+
same cookies, same IP — returning compact Markdown from Google, DuckDuckGo and Yandex with each engine's
|
|
4
|
+
native AI summary (AI Overview, Search Assist, "Быстрый ответ Алисы AI"), plus page reading and image
|
|
5
|
+
search. The window staying visible is intentional: you can watch the search and solve a captcha yourself.
|
|
6
|
+
It is not an automation browser: raw CDP, one owned window, one work tab per engine, ≤2500 chars of
|
|
7
|
+
output, every failure carrying a code, a stage and a hint. No paid search APIs, no keys, no captcha
|
|
8
|
+
solving, no fingerprint spoofing, no headless mode.
|
|
2
9
|
|
|
3
|
-
|
|
10
|
+
## Install
|
|
11
|
+
Node ≥ 26 (native TypeScript, no build step), Chrome 136+ (tested on 157, Wayland), pi, a headed desktop
|
|
12
|
+
session. Then `/web-doctor` inside pi must report a live CDP endpoint and `navigator.webdriver === false`.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install # ws + typescript (dev); no build step
|
|
16
|
+
pi install . # register this checkout as a pi package
|
|
17
|
+
pi -e ./src/index.ts # …or load it for one throwaway session
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Enable CDP (once per Chrome session)
|
|
21
|
+
Chrome 136+ ignores `--remote-debugging-port` for the default profile; debugging is enabled from inside
|
|
22
|
+
the browser and needs an explicit **Allow** click.
|
|
23
|
+
|
|
24
|
+
1. Open `chrome://inspect/#remote-debugging` in the Chrome window the agent should use.
|
|
25
|
+
2. Tick **Discover network targets**, then click **Allow** on the prompt.
|
|
26
|
+
3. Check the endpoint file (line 1 = port, line 2 = `/devtools/browser/<uuid>`):
|
|
27
|
+
`cat ~/.config/google-chrome/DevToolsActivePort`
|
|
28
|
+
|
|
29
|
+
`curl 127.0.0.1:9222/json/version` may return nothing in this mode — expected; the file is authoritative.
|
|
30
|
+
Discovery order: `PI_CHROME_CDP_WS` → `PI_CHROME_CDP_PORTS` → `DevToolsActivePort` → `cdp.ports`. The
|
|
31
|
+
extension never launches, restarts or kills Chrome, never edits your profile, never closes foreign tabs.
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
`web_search` is `direct`; everything else is `deferred` (tool search + `codemode`, not paid per prompt).
|
|
35
|
+
All descriptions together stay under 900 characters.
|
|
36
|
+
|
|
37
|
+
| Tool | Exposure | Arguments |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `web_search` | direct | `q`; `n` 1–10 (5); `engines` `auto`\|id\|list; `summary` (true); `freshness` `d\|w\|m\|y`; `page`; `format` `md\|json` |
|
|
40
|
+
| `web_read` | deferred | `url` (http/https); `offset`; `maxChars` (6000, ceiling 20000); `format` `md\|text` |
|
|
41
|
+
| `image_search` | deferred | `q`; `n` ≤12 (4); `engine` `google\|yandex`; `attach` (≤4 thumbnails) |
|
|
42
|
+
| `web_research` | deferred | `q`; `budget` `quick` (1 search + 2 reads) \| `normal` (2+4) \| `deep` (4+8) |
|
|
43
|
+
| `web_doctor` | deferred | `probe` (true), `fix` (false: reconnect, close owned tabs, prune artifacts) |
|
|
44
|
+
|
|
45
|
+
Output is capped at 2500 chars (summary ≤1200, snippets ≤140). The AI block has three states: `ai:yes`
|
|
46
|
+
only for ≥200 chars of prose, `ai:lead` (block present, no answer — never rendered as one), `ai:no`.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
`~/.pi/agent/web-search.json` (or `$PI_CODING_AGENT_DIR/web-search.json`), optional and strict: an
|
|
50
|
+
unknown key is `E_CONFIG`. All keys: [`docs/config.md`](docs/config.md). The six that matter most:
|
|
51
|
+
`engines.google.enabled` (`true`, per-engine switch) · `search.n` (`5`, ≤`search.nMax` 10) ·
|
|
52
|
+
`search.aiWaitMs` (`6000`, bounded AI-block wait) · `read.maxCharsCeiling` (`20000`) ·
|
|
53
|
+
`window.follow` (`"work"`, which tab is raised during a stage) · `reflex.pageGate` (`true`,
|
|
54
|
+
source-quality gate in `web_research`, rules floor in `web_read`).
|
|
55
|
+
|
|
56
|
+
Env overrides: `PI_CHROME_CDP_WS`, `PI_CHROME_CDP_PORTS`, `PI_CHROME_SEARCH_PROFILE`,
|
|
57
|
+
`PI_CHROME_SEARCH_DEBUG`, `PI_CODING_AGENT_DIR`. System 1 triage stays off (`reflex.enabled: false`):
|
|
58
|
+
the benchmark showed no honest token win.
|
|
59
|
+
|
|
60
|
+
## When it fails
|
|
61
|
+
| Code | Action |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `E_CDP_UNAVAILABLE` | Open `chrome://inspect/#remote-debugging`, tick **Discover network targets**, click **Allow**; or set `PI_CHROME_CDP_WS` |
|
|
64
|
+
| `E_CDP_AWAITING_ALLOW` | The prompt is waiting; click it — the call resumes by itself (`cdp.attachWaitMs`, 60 s) |
|
|
65
|
+
| `E_WALL` | A captcha/consent page: solve it in the visible window, then retry. Never automated |
|
|
66
|
+
| `E_BUSY` | Another pi session holds `~/.pi/agent/web-search.lock`: close it or wait for the heartbeat |
|
|
67
|
+
| `E_PARSE` | A selector canary failed: open the artifact dir, compare `page.txt` with `docs/engines.md` |
|
|
68
|
+
|
|
69
|
+
All 18 codes with hints: [`docs/errors.md`](docs/errors.md). `/web-doctor` (or
|
|
70
|
+
`node src/cli/doctor.ts --json`) is the first stop; `--fix` reconnects, closes only owned tabs and prunes
|
|
71
|
+
artifacts under `~/.pi/agent/web-search/`.
|
|
72
|
+
|
|
73
|
+
## Security and ToS
|
|
74
|
+
Your session, your cookies: searches and reads happen in your browser as you. `web_read` can open pages
|
|
75
|
+
you are logged into but never exfiltrates content, and it refuses loopback/private/`file:`/`data:` URLs
|
|
76
|
+
(re-checked on redirect). No telemetry; artifacts are sanitized and git-ignored. CDP is powerful — any
|
|
77
|
+
local process that reaches the debugging port can drive the browser; it binds loopback only and you
|
|
78
|
+
enable it explicitly. The optional page gate sends only URL/title/snippet of ≤10 sources (never page
|
|
79
|
+
text) to your configured classifier; `reflex.localOnly: true` keeps it local.
|
|
80
|
+
|
|
81
|
+
Google and Yandex prohibit automated access in their terms: this project does not claim compliance and
|
|
82
|
+
must not be run as a service. If an engine shows a captcha, solve it in the window or stop.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
`npm run lint` (typecheck + audit) · `npm test` (offline contract suite) · `npm run test:live` (opt-in,
|
|
86
|
+
one query per engine, no retries, `PI_CHROME_LIVE=1`). Decisions: [`docs/architecture.md`](docs/architecture.md)
|
|
87
|
+
· selectors: [`docs/engines.md`](docs/engines.md). Never launch a browser from tests; never loop probes.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
MIT — see [`LICENSE`](LICENSE).
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
7
|
+
<title>pi-chrome-search — anchor</title>
|
|
8
|
+
<!--
|
|
9
|
+
P10-A: the anchor control panel.
|
|
10
|
+
|
|
11
|
+
A static `file://` asset. The connector paints it with `Runtime.evaluate`, so
|
|
12
|
+
this document has no network of its own: the CSP above forbids every fetch,
|
|
13
|
+
there is no cookie access and no history. `render(state)` uses textContent
|
|
14
|
+
only, never innerHTML, so a query cannot inject markup.
|
|
15
|
+
|
|
16
|
+
Keep this file dependency-free: it must render in a browser that has no
|
|
17
|
+
internet access and no extension context.
|
|
18
|
+
-->
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
color-scheme: light dark;
|
|
22
|
+
--bg: #0f1115; --panel: #171a21; --line: #262b36; --fg: #e6e9ef;
|
|
23
|
+
--dim: #8b93a3; --accent: #6ea8fe; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
|
24
|
+
}
|
|
25
|
+
@media (prefers-color-scheme: light) {
|
|
26
|
+
:root { --bg: #f5f6f8; --panel: #ffffff; --line: #e3e6ec; --fg: #111418;
|
|
27
|
+
--dim: #5b6472; --accent: #1d4ed8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c; }
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
html, body { height: 100%; }
|
|
31
|
+
body {
|
|
32
|
+
margin: 0; padding: 40px 32px; background: var(--bg); color: var(--fg);
|
|
33
|
+
font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
34
|
+
}
|
|
35
|
+
main { max-width: 760px; margin: 0 auto; }
|
|
36
|
+
header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; }
|
|
37
|
+
h1 { font-size: 15px; letter-spacing: .08em; text-transform: uppercase; margin: 0; color: var(--dim); font-weight: 600; }
|
|
38
|
+
#session { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--dim); }
|
|
39
|
+
.card {
|
|
40
|
+
margin-top: 18px; padding: 18px 20px; background: var(--panel);
|
|
41
|
+
border: 1px solid var(--line); border-radius: 14px;
|
|
42
|
+
}
|
|
43
|
+
.row { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; }
|
|
44
|
+
.label { font-size: 11px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); }
|
|
45
|
+
#stage {
|
|
46
|
+
display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px;
|
|
47
|
+
border: 1px solid var(--line); color: var(--accent);
|
|
48
|
+
}
|
|
49
|
+
#stage[data-state="error"] { color: var(--bad); }
|
|
50
|
+
#stage[data-state="done"] { color: var(--ok); }
|
|
51
|
+
#stage[data-state="pace"] { color: var(--warn); }
|
|
52
|
+
#engine { font-weight: 600; }
|
|
53
|
+
#query { margin: 10px 0 0; font-size: 20px; line-height: 1.3; word-break: break-word; }
|
|
54
|
+
#query.empty { color: var(--dim); font-size: 16px; }
|
|
55
|
+
#meta { margin-top: 14px; display: flex; gap: 22px; flex-wrap: wrap; color: var(--dim); font-size: 13px; }
|
|
56
|
+
#error { margin-top: 14px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); color: var(--bad); display: none; }
|
|
57
|
+
#error.show { display: block; }
|
|
58
|
+
#error .code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
59
|
+
h2 { margin: 0 0 10px; font-size: 11px; letter-spacing: .09em; text-transform: uppercase; color: var(--dim); font-weight: 600; }
|
|
60
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
61
|
+
td { padding: 6px 0; border-top: 1px solid var(--line); }
|
|
62
|
+
tr:first-child td { border-top: 0; }
|
|
63
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
64
|
+
footer { margin-top: 18px; color: var(--dim); font-size: 12px; }
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<main>
|
|
69
|
+
<header>
|
|
70
|
+
<h1>pi · chrome · search</h1>
|
|
71
|
+
<span id="session">session —</span>
|
|
72
|
+
</header>
|
|
73
|
+
|
|
74
|
+
<section class="card">
|
|
75
|
+
<div class="row">
|
|
76
|
+
<span id="stage" data-state="connect">connect</span>
|
|
77
|
+
<span id="engine">no engine</span>
|
|
78
|
+
<span id="pace"></span>
|
|
79
|
+
</div>
|
|
80
|
+
<p id="query" class="empty">waiting for the first search…</p>
|
|
81
|
+
<div id="error"><span class="code"></span> <span class="hint"></span></div>
|
|
82
|
+
<div id="meta"><span id="updated">—</span></div>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<section class="card">
|
|
86
|
+
<h2>Daily counters</h2>
|
|
87
|
+
<table>
|
|
88
|
+
<tbody>
|
|
89
|
+
<tr><td>google</td><td class="num" id="count-google">—</td></tr>
|
|
90
|
+
<tr><td>duckduckgo</td><td class="num" id="count-duckduckgo">—</td></tr>
|
|
91
|
+
<tr><td>yandex</td><td class="num" id="count-yandex">—</td></tr>
|
|
92
|
+
</tbody>
|
|
93
|
+
</table>
|
|
94
|
+
</section>
|
|
95
|
+
|
|
96
|
+
<footer>local page · no network · no cookies · this window is owned by the extension</footer>
|
|
97
|
+
</main>
|
|
98
|
+
<script>
|
|
99
|
+
(function () {
|
|
100
|
+
"use strict";
|
|
101
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
102
|
+
var counters = { google: $("count-google"), duckduckgo: $("count-duckduckgo"), yandex: $("count-yandex") };
|
|
103
|
+
|
|
104
|
+
function text(id, value) {
|
|
105
|
+
var node = $(id);
|
|
106
|
+
if (node) node.textContent = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function render(state) {
|
|
110
|
+
if (!state || typeof state !== "object") return;
|
|
111
|
+
var stage = typeof state.stage === "string" ? state.stage : "connect";
|
|
112
|
+
var badge = $("stage");
|
|
113
|
+
if (badge) {
|
|
114
|
+
badge.textContent = stage;
|
|
115
|
+
badge.setAttribute("data-state", stage);
|
|
116
|
+
}
|
|
117
|
+
text("engine", typeof state.engine === "string" ? state.engine : "no engine");
|
|
118
|
+
var query = typeof state.query === "string" ? state.query : "";
|
|
119
|
+
var q = $("query");
|
|
120
|
+
if (q) {
|
|
121
|
+
q.textContent = query || "waiting for the first search…";
|
|
122
|
+
q.className = query ? "" : "empty";
|
|
123
|
+
}
|
|
124
|
+
text("session", "session " + (typeof state.sessionId === "string" ? state.sessionId.slice(0, 8) : "—"));
|
|
125
|
+
var pace = Array.isArray(state.pace) && state.pace.length === 2
|
|
126
|
+
? "pace " + (state.pace[0] / 1000).toFixed(1) + "–" + (state.pace[1] / 1000).toFixed(1) + " s"
|
|
127
|
+
: "";
|
|
128
|
+
text("pace", pace);
|
|
129
|
+
|
|
130
|
+
var list = Array.isArray(state.counters) ? state.counters : [];
|
|
131
|
+
var seen = {};
|
|
132
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
133
|
+
var entry = list[i];
|
|
134
|
+
if (!entry || typeof entry.engine !== "string") continue;
|
|
135
|
+
var cell = counters[entry.engine];
|
|
136
|
+
if (!cell) continue;
|
|
137
|
+
seen[entry.engine] = true;
|
|
138
|
+
cell.textContent = String(entry.today) + " / " + String(entry.cap);
|
|
139
|
+
}
|
|
140
|
+
for (var name in counters) {
|
|
141
|
+
if (!seen[name] && counters[name]) counters[name].textContent = "—";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var box = $("error");
|
|
145
|
+
if (box) {
|
|
146
|
+
var error = state.error;
|
|
147
|
+
if (error && typeof error.code === "string") {
|
|
148
|
+
box.className = "show";
|
|
149
|
+
box.querySelector(".code").textContent = error.code;
|
|
150
|
+
box.querySelector(".hint").textContent = typeof error.hint === "string" ? error.hint : "";
|
|
151
|
+
} else {
|
|
152
|
+
box.className = "";
|
|
153
|
+
box.querySelector(".code").textContent = "";
|
|
154
|
+
box.querySelector(".hint").textContent = "";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
text("updated", typeof state.updatedAt === "string" ? "updated " + state.updatedAt.replace("T", " ").replace("Z", " UTC") : "—");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
window.__wcsPanel = { render: render };
|
|
161
|
+
window.__wcsPanelReady = true;
|
|
162
|
+
})();
|
|
163
|
+
</script>
|
|
164
|
+
</body>
|
|
165
|
+
</html>
|
package/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package entry point.
|
|
3
|
+
*
|
|
4
|
+
* pi names a locally installed extension by the path of its entry file, not by `package.json#name`:
|
|
5
|
+
* an entry at `src/index.ts` loses the trailing `index.ts` and the Extensions panel shows `src`.
|
|
6
|
+
* Declaring the entry here keeps the implementation in `src/` and makes the label the package
|
|
7
|
+
* directory (`pi-chrome-search`). `src/index.ts` stays the real module and remains loadable with
|
|
8
|
+
* `pi -e ./src/index.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { default } from "./src/index.ts";
|
|
12
|
+
export * from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-chrome-search",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Free, keyless web search through the user's own visible Chrome (Google, DuckDuckGo, Yandex) for pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=26"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"lint": "tsc --noEmit && node scripts/audit.mjs",
|
|
18
|
+
"test": "node --test --test-timeout=15000 \"tests/unit/**/*.test.ts\"",
|
|
19
|
+
"test:live": "PI_CHROME_LIVE=1 node --test \"tests/live/**/*.test.ts\"",
|
|
20
|
+
"audit": "node scripts/audit.mjs",
|
|
21
|
+
"release": "node scripts/release.mjs"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"typebox": "*"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"ws": "^8.18.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
32
|
+
"@types/node": "^24.0.0",
|
|
33
|
+
"@types/ws": "^8.5.12",
|
|
34
|
+
"linkedom": "^0.18.15",
|
|
35
|
+
"typebox": "^1.3.0",
|
|
36
|
+
"typescript": "^5.9.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"pi-package",
|
|
40
|
+
"chrome",
|
|
41
|
+
"cdp",
|
|
42
|
+
"web-search",
|
|
43
|
+
"google",
|
|
44
|
+
"duckduckgo",
|
|
45
|
+
"yandex"
|
|
46
|
+
],
|
|
47
|
+
"files": [
|
|
48
|
+
"index.ts",
|
|
49
|
+
"src",
|
|
50
|
+
"assets",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE"
|
|
53
|
+
],
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/sh2nk/pi-chrome-search.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/sh2nk/pi-chrome-search/issues"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/sh2nk/pi-chrome-search#readme",
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"provenance": true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-summary wait (PLAN-v2 P8-A, gates V8.1–V8.5).
|
|
3
|
+
*
|
|
4
|
+
* The bug this module exists for: the engine's SERP shell settles while the AI
|
|
5
|
+
* block is still streaming (or not rendered yet), extraction runs, and a
|
|
6
|
+
* labelled-but-empty block was shipped as `ai:yes`. The fix is to *wait for
|
|
7
|
+
* prose*, bounded, and to report what was actually seen:
|
|
8
|
+
*
|
|
9
|
+
* - the wait ends the moment the probe reports `state: "answer"` with at least
|
|
10
|
+
* `minChars` characters, so a page that already has the answer pays one probe;
|
|
11
|
+
* - a block that is present but not an answer keeps the wait alive until the
|
|
12
|
+
* deadline (it may still turn into prose);
|
|
13
|
+
* - a page with no AI block at all stops after `noneGraceMs` (default 1.4 s)
|
|
14
|
+
* instead of burning the whole deadline. Without that grace a slow Overview
|
|
15
|
+
* would never be waited for; without the grace *limit* every ordinary
|
|
16
|
+
* summary-less search would pay six seconds (V8.4 caps the added latency at
|
|
17
|
+
* 1.5 s).
|
|
18
|
+
*
|
|
19
|
+
* Nothing here sleeps for a fixed amount, nothing retries a navigation, and the
|
|
20
|
+
* clock/rng/deadline seams are the same ones `waitForMarker` uses
|
|
21
|
+
* (`cdp/humanize.ts`), so tests advance virtual time.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CdpPage } from "./types.ts";
|
|
25
|
+
import type { AiProbe, AiState, EngineAi, EngineId } from "../engines/types.ts";
|
|
26
|
+
import { AI_ANSWER_MIN_CHARS } from "../engines/types.ts";
|
|
27
|
+
import type { Clock } from "../runtime/types.ts";
|
|
28
|
+
import { systemClock } from "../runtime/clock.ts";
|
|
29
|
+
import { asFailure, jitteredPollMs, normalizeDeadline, normalizePoll, throwIfAborted } from "./humanize.ts";
|
|
30
|
+
|
|
31
|
+
/** Long enough for a streaming Overview, short enough to stay under V8.4. */
|
|
32
|
+
export const DEFAULT_AI_WAIT_MS = 6_000;
|
|
33
|
+
/**
|
|
34
|
+
* How long a page with *no* AI block is watched before giving up. Kept below the
|
|
35
|
+
* V8.4 ceiling (1.5 s) on purpose: probe round-trips and the jittered poll land
|
|
36
|
+
* on top of the grace, and a budget that can only be met by measuring the clock
|
|
37
|
+
* is not a budget.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_NONE_GRACE_MS = 1_400;
|
|
40
|
+
const DEFAULT_POLL_MS = 150;
|
|
41
|
+
|
|
42
|
+
/** Readiness answer of the wait. `timedOut` means "the deadline/grace ran out first". */
|
|
43
|
+
export interface AiWaitResult {
|
|
44
|
+
/** Best state observed; never downgraded by a later empty poll. */
|
|
45
|
+
state: AiState;
|
|
46
|
+
/** Characters of answer prose in the best observation. */
|
|
47
|
+
chars: number;
|
|
48
|
+
/** Clock time spent in the wait (0 when the first probe was already an answer). */
|
|
49
|
+
waitMs: number;
|
|
50
|
+
/** True when the wait ended without an answer. */
|
|
51
|
+
timedOut: boolean;
|
|
52
|
+
/** Number of probes executed (evidence for the P8 latency table). */
|
|
53
|
+
polls: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WaitForAiOptions {
|
|
57
|
+
/** Answer width required to stop early; default `AI_ANSWER_MIN_CHARS` (200). */
|
|
58
|
+
minChars?: number;
|
|
59
|
+
/** Overall budget; default 6000 ms, `0` means "probe once and do not wait". */
|
|
60
|
+
deadlineMs?: number;
|
|
61
|
+
/** How long `none` is tolerated before the wait gives up; default 1500 ms. */
|
|
62
|
+
noneGraceMs?: number;
|
|
63
|
+
pollMs?: number;
|
|
64
|
+
rng?: () => number;
|
|
65
|
+
clock?: Clock;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
engine?: EngineId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A probe result crosses a boundary we do not own (it is produced by an
|
|
72
|
+
* adapter). Anything malformed is treated as "nothing seen yet" rather than as
|
|
73
|
+
* an answer: guessing an answer is the failure mode this phase forbids.
|
|
74
|
+
*/
|
|
75
|
+
function normalizeProbe(value: unknown): AiProbe {
|
|
76
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
77
|
+
return { state: "none", chars: 0 };
|
|
78
|
+
}
|
|
79
|
+
const record = value as { state?: unknown; chars?: unknown };
|
|
80
|
+
const chars =
|
|
81
|
+
typeof record.chars === "number" && Number.isFinite(record.chars) && record.chars > 0
|
|
82
|
+
? Math.floor(record.chars)
|
|
83
|
+
: 0;
|
|
84
|
+
const state = record.state === "answer" || record.state === "lead" ? record.state : "none";
|
|
85
|
+
// An "answer" with no measured prose is a contradiction; keep the state but
|
|
86
|
+
// report zero chars, and the length gate below will refuse to stop on it.
|
|
87
|
+
return { state, chars };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Poll the adapter's AI probe until it reports a full answer or the budget runs
|
|
92
|
+
* out. A probe failure is *not* swallowed: a torn socket during the wait is the
|
|
93
|
+
* same failure it would be one millisecond later in `extract`, and pretending
|
|
94
|
+
* the page is simply summary-less would hide a disconnect behind `ai:no`.
|
|
95
|
+
*/
|
|
96
|
+
export async function waitForAi(
|
|
97
|
+
page: CdpPage,
|
|
98
|
+
ai: EngineAi,
|
|
99
|
+
options: WaitForAiOptions = {},
|
|
100
|
+
): Promise<AiWaitResult> {
|
|
101
|
+
const clock = options.clock ?? systemClock;
|
|
102
|
+
const rng = options.rng ?? Math.random;
|
|
103
|
+
const deadlineMs = normalizeDeadline(options.deadlineMs ?? DEFAULT_AI_WAIT_MS);
|
|
104
|
+
const noneGraceMs = Math.max(
|
|
105
|
+
0,
|
|
106
|
+
Math.min(deadlineMs, Math.floor(options.noneGraceMs ?? DEFAULT_NONE_GRACE_MS)),
|
|
107
|
+
);
|
|
108
|
+
const pollMs = normalizePoll(options.pollMs ?? DEFAULT_POLL_MS);
|
|
109
|
+
const minChars = Math.max(
|
|
110
|
+
1,
|
|
111
|
+
Math.floor(options.minChars ?? AI_ANSWER_MIN_CHARS),
|
|
112
|
+
);
|
|
113
|
+
const start = clock.now();
|
|
114
|
+
|
|
115
|
+
let best: AiProbe = { state: "none", chars: 0 };
|
|
116
|
+
let polls = 0;
|
|
117
|
+
const rank = (state: AiState): number => (state === "answer" ? 2 : state === "lead" ? 1 : 0);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
for (;;) {
|
|
121
|
+
throwIfAborted(options.signal, "settle", options.engine);
|
|
122
|
+
const observed = normalizeProbe(await ai.probe(page));
|
|
123
|
+
polls += 1;
|
|
124
|
+
// A later empty poll must not erase a block that was already seen: the AI
|
|
125
|
+
// block can be re-rendered mid-stream. Keep the strongest observation.
|
|
126
|
+
if (rank(observed.state) >= rank(best.state)) best = observed;
|
|
127
|
+
|
|
128
|
+
const elapsed = Math.max(0, clock.now() - start);
|
|
129
|
+
if (observed.state === "answer" && observed.chars >= minChars) {
|
|
130
|
+
return { state: "answer", chars: observed.chars, waitMs: elapsed, timedOut: false, polls };
|
|
131
|
+
}
|
|
132
|
+
if (elapsed >= deadlineMs) {
|
|
133
|
+
return { state: best.state, chars: best.chars, waitMs: elapsed, timedOut: true, polls };
|
|
134
|
+
}
|
|
135
|
+
if (best.state === "none" && elapsed >= noneGraceMs) {
|
|
136
|
+
return { state: "none", chars: 0, waitMs: elapsed, timedOut: true, polls };
|
|
137
|
+
}
|
|
138
|
+
// While nothing has been seen, never sleep past the grace: that budget is a
|
|
139
|
+
// hard ceiling on the latency a summary-less page may cost (V8.4), and an
|
|
140
|
+
// unclamped jittered poll would overshoot it by up to 25%.
|
|
141
|
+
const remainingGrace = best.state === "none" ? noneGraceMs - elapsed : Number.POSITIVE_INFINITY;
|
|
142
|
+
const delay = Math.min(jitteredPollMs(pollMs, rng), remainingGrace);
|
|
143
|
+
await clock.sleep(delay, options.signal);
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw asFailure(error, "settle", options.engine);
|
|
147
|
+
}
|
|
148
|
+
}
|