aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/ARCHITECTURE.md9
-rw-r--r--src/cdp/evaluate.ts30
-rw-r--r--src/cdp/event-queue.ts38
-rw-r--r--src/cdp/session.ts33
-rw-r--r--src/cdp/transport.ts18
-rw-r--r--src/cdp/types.ts17
-rw-r--r--src/client.ts2
-rw-r--r--src/daemon/queue.ts (renamed from src/cdp/pending-requests.ts)40
-rw-r--r--src/daemon/transport.ts5
-rw-r--r--src/domains/isolated-tab.ts2
-rw-r--r--src/domains/js.ts2
-rw-r--r--src/domains/keyboard.ts10
-rw-r--r--src/domains/network.ts2
-rw-r--r--src/domains/page.ts18
-rw-r--r--src/domains/readpage/capture.ts34
-rw-r--r--src/domains/readpage/read-page.ts36
-rw-r--r--src/domains/search/extract.ts28
-rw-r--r--src/domains/search/web-search.ts30
-rw-r--r--src/profile/command.ts50
-rw-r--r--src/profile/picker.ts50
-rw-r--r--src/util/bytes.ts5
-rw-r--r--src/util/keycodes.ts8
-rw-r--r--src/util/time.ts16
-rw-r--r--src/util/truncate.ts6
-rw-r--r--test/cdp/session-call-test.ts2
-rw-r--r--test/cdp/session-evaluate-test.ts (renamed from test/cdp/evaluate-test.ts)2
-rw-r--r--test/daemon/queue-test.ts (renamed from test/cdp/event-queue-test.ts)3
-rw-r--r--test/domains/fake-client.ts2
-rw-r--r--test/manual/window-binding-test.ts2
29 files changed, 246 insertions, 254 deletions
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 450d680..92ea16f 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -36,6 +36,7 @@ cdp/session.ts typed call/callOnTarget/callBrowser, event routing,
console + network buffers
|
daemon/transport.ts -> daemon/* out-of-process daemon owning the Chrome socket
+ (CdpTransport is declared in cdp/types.ts)
|
Chrome (CDP over WebSocket)
```
@@ -46,7 +47,7 @@ Side stacks:
|---|---|---|
| `profile/*` | decides *which* Chrome and which profile to drive | `index.ts`, `setup.ts`, `client.ts` — never a domain |
| `schemas/*` | shared typebox arg fragments (`Coords`, `MouseButton`) and `parseJson` | anything |
-| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `time`, `keycodes`, `truncate`, `paths`, `js-template`, `sharp-shim`) | anything |
+| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `truncate`, `paths`, `js-template`, `sharp-shim`, `debug`) | anything |
`src/util/tool.ts` sits in `util/` for locality but is not a leaf — it is the tool runtime layer
in the diagram above and imports `client` and `daemon/spawn`.
@@ -54,10 +55,10 @@ in the diagram above and imports `client` and `daemon/spawn`.
### The import rule for domains
A `domains/*` file may import from `schemas/`, `util/`, other `domains/`, the `BrowserClient`
-type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/evaluate`,
-`cdp/target-factory`, the buffers, the `CdpSession` type).
+type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/target-factory`,
+the buffers, and `cdp/session` for the `CdpSession` type and `evaluateJson`).
-**It must never import from `daemon/`, `cdp/transport`, or `profile/`.** A domain that reaches
+**It must never import from `daemon/` or `profile/`.** A domain that reaches
the raw transport has escaped the validated boundary in section 2; a domain that reaches
`profile/` has coupled a tool to Chrome-launch policy.
diff --git a/src/cdp/evaluate.ts b/src/cdp/evaluate.ts
deleted file mode 100644
index 733a11b..0000000
--- a/src/cdp/evaluate.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { type Result, err, ok } from "../util/result";
-import { type CdpError, cdpError } from "./errors";
-import type { CdpSession } from "./session";
-
-export const evaluateJson = async <T>(
- session: CdpSession,
- expression: string,
- check: (v: unknown) => v is T,
- opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined },
-): Promise<Result<T, CdpError>> => {
- const params = {
- expression,
- returnByValue: true,
- awaitPromise: true,
- };
- const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
- const r = opts?.sessionId !== undefined
- ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts)
- : await session.call("Runtime.evaluate", params, callOpts);
- if (!r.success) return r;
- const { result, exceptionDetails } = r.data;
- if (exceptionDetails !== undefined) {
- return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate"));
- }
- const value = result.value;
- if (!check(value)) {
- return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate"));
- }
- return ok(value);
-};
diff --git a/src/cdp/event-queue.ts b/src/cdp/event-queue.ts
deleted file mode 100644
index 08024b0..0000000
--- a/src/cdp/event-queue.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { CdpEvent } from "./types";
-
-export type EventQueue = {
- readonly push: (e: CdpEvent) => void;
- readonly end: () => void;
- readonly iter: AsyncIterable<CdpEvent>;
-};
-
-export const makeEventQueue = (): EventQueue => {
- const buf: CdpEvent[] = [];
- const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
- let ended = false;
- return {
- push(e) {
- if (ended) return;
- const w = waiters.shift();
- if (w) w({ value: e, done: false });
- else buf.push(e);
- },
- end() {
- ended = true;
- for (const w of waiters.splice(0)) w({ value: undefined, done: true });
- },
- iter: {
- [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
- return {
- next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
- new Promise((resolve) => {
- const next = buf.shift();
- if (next) resolve({ value: next, done: false });
- else if (ended) resolve({ value: undefined, done: true });
- else waiters.push(resolve);
- }),
- };
- },
- },
- };
-};
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
index 0eb83fa..5adf7e0 100644
--- a/src/cdp/session.ts
+++ b/src/cdp/session.ts
@@ -1,8 +1,8 @@
-import { map, type Result, ok } from "../util/result";
-import type { CdpError } from "./errors";
+import { map, type Result, err, ok } from "../util/result";
+import { type CdpError, cdpError } from "./errors";
import type { DialogInfo } from "./types";
import type { OwnershipRegistry } from "./ownership";
-import type { CdpTransport } from "./transport";
+import type { CdpTransport } from "./types";
import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
import { decodeEvent } from "./events";
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
@@ -374,3 +374,30 @@ export const createCdpSession = (
};
return session;
};
+
+export const evaluateJson = async <T>(
+ session: CdpSession,
+ expression: string,
+ check: (v: unknown) => v is T,
+ opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined },
+): Promise<Result<T, CdpError>> => {
+ const params = {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ };
+ const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
+ const r = opts?.sessionId !== undefined
+ ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts)
+ : await session.call("Runtime.evaluate", params, callOpts);
+ if (!r.success) return r;
+ const { result, exceptionDetails } = r.data;
+ if (exceptionDetails !== undefined) {
+ return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate"));
+ }
+ const value = result.value;
+ if (!check(value)) {
+ return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate"));
+ }
+ return ok(value);
+};
diff --git a/src/cdp/transport.ts b/src/cdp/transport.ts
deleted file mode 100644
index 4036ff7..0000000
--- a/src/cdp/transport.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Result } from "../util/result";
-import type { CdpError } from "./errors";
-import type { CdpEvent } from "./types";
-
-export const DEFAULT_TIMEOUT_MS = 15_000;
-
-export type CdpTransport = {
- connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
- close(): Promise<void>;
- request(
- method: string,
- params: Record<string, unknown>,
- opts?: { sessionId?: string | null; timeoutMs?: number },
- ): Promise<Result<unknown, CdpError>>;
- events(): AsyncIterable<CdpEvent>;
- state(): "open" | "closed" | "connecting";
- onClose(cb: () => void): () => void;
-};
diff --git a/src/cdp/types.ts b/src/cdp/types.ts
index 984b930..6119b69 100644
--- a/src/cdp/types.ts
+++ b/src/cdp/types.ts
@@ -1,4 +1,6 @@
+import type { Result } from "../util/result";
import { isRecord } from "../util/guards";
+import type { CdpError } from "./errors";
export type CdpEvent = {
readonly method: string;
@@ -63,3 +65,18 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => {
}
return true;
};
+
+export const DEFAULT_TIMEOUT_MS = 15_000;
+
+export type CdpTransport = {
+ connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
+ close(): Promise<void>;
+ request(
+ method: string,
+ params: Record<string, unknown>,
+ opts?: { sessionId?: string | null; timeoutMs?: number },
+ ): Promise<Result<unknown, CdpError>>;
+ events(): AsyncIterable<CdpEvent>;
+ state(): "open" | "closed" | "connecting";
+ onClose(cb: () => void): () => void;
+};
diff --git a/src/client.ts b/src/client.ts
index f1d2cb7..6a5419a 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -6,7 +6,7 @@ import { safeJs } from "./util/js-template";
import { type Mutex, createMutex } from "./util/mutex";
import { discoverEndpoint } from "./cdp/discovery";
import { type CdpError, cdpError } from "./cdp/errors";
-import type { CdpTransport } from "./cdp/transport";
+import type { CdpTransport } from "./cdp/types";
import { type CdpSession, createCdpSession } from "./cdp/session";
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
diff --git a/src/cdp/pending-requests.ts b/src/daemon/queue.ts
index 03b19cd..a19efd0 100644
--- a/src/cdp/pending-requests.ts
+++ b/src/daemon/queue.ts
@@ -1,5 +1,43 @@
import { type Result, err } from "../util/result";
-import { type CdpError, cdpError } from "./errors";
+import { type CdpError, cdpError } from "../cdp/errors";
+import type { CdpEvent } from "../cdp/types";
+
+export type EventQueue = {
+ readonly push: (e: CdpEvent) => void;
+ readonly end: () => void;
+ readonly iter: AsyncIterable<CdpEvent>;
+};
+
+export const makeEventQueue = (): EventQueue => {
+ const buf: CdpEvent[] = [];
+ const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
+ let ended = false;
+ return {
+ push(e) {
+ if (ended) return;
+ const w = waiters.shift();
+ if (w) w({ value: e, done: false });
+ else buf.push(e);
+ },
+ end() {
+ ended = true;
+ for (const w of waiters.splice(0)) w({ value: undefined, done: true });
+ },
+ iter: {
+ [Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
+ return {
+ next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
+ new Promise((resolve) => {
+ const next = buf.shift();
+ if (next) resolve({ value: next, done: false });
+ else if (ended) resolve({ value: undefined, done: true });
+ else waiters.push(resolve);
+ }),
+ };
+ },
+ },
+ };
+};
export type CdpResult = Result<unknown, CdpError>;
diff --git a/src/daemon/transport.ts b/src/daemon/transport.ts
index 4e75215..3c42336 100644
--- a/src/daemon/transport.ts
+++ b/src/daemon/transport.ts
@@ -2,10 +2,9 @@ import { createConnection, type Socket } from "node:net";
import { createInterface } from "node:readline";
import { type Result, err, ok } from "../util/result";
import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
-import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/transport";
+import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types";
import type { CdpEvent } from "../cdp/types";
-import { makeEventQueue } from "../cdp/event-queue";
-import { type Pending, makeOnClose, rejectAllPending, sendWithTimeout } from "../cdp/pending-requests";
+import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./queue";
import {
DAEMON_SOCKET_PATH,
type WireRequest,
diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts
index 103c9cc..b76b101 100644
--- a/src/domains/isolated-tab.ts
+++ b/src/domains/isolated-tab.ts
@@ -2,7 +2,7 @@ import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import { safeJs } from "../util/js-template";
import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory";
-import { evaluateJson } from "../cdp/evaluate";
+import { evaluateJson } from "../cdp/session";
import type { ToolErr } from "../util/tool";
import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call";
diff --git a/src/domains/js.ts b/src/domains/js.ts
index d2588d0..6ba21cd 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -6,7 +6,7 @@ import { Type } from "typebox";
import { Markdown, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
-import { formatBytes } from "../util/bytes";
+import { formatBytes } from "../util/truncate";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { evalJs } from "./cdp-call";
import { applyTruncation } from "../util/truncate";
diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts
index 50bee62..17b56e7 100644
--- a/src/domains/keyboard.ts
+++ b/src/domains/keyboard.ts
@@ -1,11 +1,19 @@
import { Type } from "typebox";
import { safeJs } from "../util/js-template";
-import { virtualKeyCode } from "../util/keycodes";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { cdpCall, evalJs } from "./cdp-call";
import { resolveRefToObjectId } from "./ref-resolve";
+const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
+ Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
+ Home: 36, End: 35, PageUp: 33, PageDown: 34,
+};
+
+const virtualKeyCode = (key: string): number =>
+ VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
+
const TypeArgs = Type.Object({
text: Type.String({ description: "Text to type" }),
});
diff --git a/src/domains/network.ts b/src/domains/network.ts
index aeb1aa6..a34dde3 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -2,7 +2,7 @@ import { Type } from "typebox";
import { Markdown, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
-import { formatBytes } from "../util/bytes";
+import { formatBytes } from "../util/truncate";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { cdpCall } from "./cdp-call";
import { applyTruncation } from "../util/truncate";
diff --git a/src/domains/page.ts b/src/domains/page.ts
index c202ef1..909963f 100644
--- a/src/domains/page.ts
+++ b/src/domains/page.ts
@@ -2,9 +2,25 @@ import { Type } from "typebox";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { evalJs } from "./cdp-call";
-import { sleep } from "../util/time";
import { safeJs } from "../util/js-template";
+const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
+ new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(signal.reason ?? new Error("aborted"));
+ return;
+ }
+ const onAbort = (): void => {
+ clearTimeout(timer);
+ reject(signal?.reason ?? new Error("aborted"));
+ };
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+
export const pageInfoTool = defineBrowserTool({
name: "browser_page_info",
label: "Browser Page Info",
diff --git a/src/domains/readpage/capture.ts b/src/domains/readpage/capture.ts
deleted file mode 100644
index b0b7ae0..0000000
--- a/src/domains/readpage/capture.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-const BODY_TEXT_LIMIT = 20_000;
-
-export const buildPageCaptureExpr = (): string => `
- (() => {
- const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
- const inBoilerplate = (el) => {
- for (let n = el; n; n = n.parentElement) {
- if (BOILERPLATE.has(n.tagName)) return true;
- const role = n.getAttribute && n.getAttribute('role');
- if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
- }
- return false;
- };
- const kindOf = (tag) => {
- if (/^H[1-6]$/.test(tag)) return 'heading';
- if (tag === 'LI') return 'listitem';
- if (tag === 'BLOCKQUOTE') return 'blockquote';
- if (tag === 'P') return 'paragraph';
- return 'other';
- };
- const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
- const root = document.querySelector('article, main, [role=main]') || document.body;
- const blocks = [];
- for (const el of root.querySelectorAll(selector)) {
- const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
- if (!text) continue;
- let linkTextLength = 0;
- for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
- blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
- }
- const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
- return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
- })()
-`;
diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts
index 9d46c7e..fadee38 100644
--- a/src/domains/readpage/read-page.ts
+++ b/src/domains/readpage/read-page.ts
@@ -15,7 +15,6 @@ import {
waitForIsolatedLoad,
} from "../isolated-tab";
import { renderExpandableText } from "../render";
-import { buildPageCaptureExpr } from "./capture";
import { extractReadable, type PageCapture, type ReadablePage } from "./readability";
const LOAD_TIMEOUT_MS = 15_000;
@@ -49,6 +48,41 @@ const PageCaptureSchema = Type.Object({
const pageCaptureValidator = Compile(PageCaptureSchema);
+const BODY_TEXT_LIMIT = 20_000;
+
+const buildPageCaptureExpr = (): string => `
+ (() => {
+ const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
+ const inBoilerplate = (el) => {
+ for (let n = el; n; n = n.parentElement) {
+ if (BOILERPLATE.has(n.tagName)) return true;
+ const role = n.getAttribute && n.getAttribute('role');
+ if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true;
+ }
+ return false;
+ };
+ const kindOf = (tag) => {
+ if (/^H[1-6]$/.test(tag)) return 'heading';
+ if (tag === 'LI') return 'listitem';
+ if (tag === 'BLOCKQUOTE') return 'blockquote';
+ if (tag === 'P') return 'paragraph';
+ return 'other';
+ };
+ const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote';
+ const root = document.querySelector('article, main, [role=main]') || document.body;
+ const blocks = [];
+ for (const el of root.querySelectorAll(selector)) {
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim();
+ if (!text) continue;
+ let linkTextLength = 0;
+ for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length;
+ blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) });
+ }
+ const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT});
+ return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText });
+ })()
+`;
+
const toPageCapture = (raw: unknown): PageCapture | undefined => {
if (typeof raw === "string") {
const parsed = parseJson(raw, pageCaptureValidator);
diff --git a/src/domains/search/extract.ts b/src/domains/search/extract.ts
deleted file mode 100644
index 4d9e892..0000000
--- a/src/domains/search/extract.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-const PAGE_TEXT_LIMIT = 4000;
-
-export const buildSerpExtractionExpr = (): string => `
- (() => {
- const main = document.querySelector('#search, #rso, #main') || document.body;
- const headings = Array.from(main.querySelectorAll('a h3'));
- const seen = new Set();
- const anchors = [];
- for (const h3 of headings) {
- const a = h3.closest('a[href]');
- if (!a) continue;
- const href = a.href;
- if (!href || seen.has(href)) continue;
- seen.add(href);
- const container =
- a.closest('div.g') ||
- a.closest('div[data-hveid]') ||
- a.parentElement?.parentElement ||
- a.parentElement;
- const containerText = container ? container.innerText || '' : '';
- const heading = h3.innerText || h3.textContent || '';
- const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500);
- anchors.push({ href, heading, snippet });
- }
- const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT});
- return JSON.stringify({ anchors, pageText });
- })()
-`;
diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts
index 9327f15..553135c 100644
--- a/src/domains/search/web-search.ts
+++ b/src/domains/search/web-search.ts
@@ -13,7 +13,6 @@ import {
waitForIsolatedLoad,
} from "../isolated-tab";
import { renderExpandableText } from "../render";
-import { buildSerpExtractionExpr } from "./extract";
import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
const LOAD_TIMEOUT_MS = 15_000;
@@ -25,6 +24,35 @@ const WebSearchArgs = Type.Object({
),
});
+const PAGE_TEXT_LIMIT = 4000;
+
+const buildSerpExtractionExpr = (): string => `
+ (() => {
+ const main = document.querySelector('#search, #rso, #main') || document.body;
+ const headings = Array.from(main.querySelectorAll('a h3'));
+ const seen = new Set();
+ const anchors = [];
+ for (const h3 of headings) {
+ const a = h3.closest('a[href]');
+ if (!a) continue;
+ const href = a.href;
+ if (!href || seen.has(href)) continue;
+ seen.add(href);
+ const container =
+ a.closest('div.g') ||
+ a.closest('div[data-hveid]') ||
+ a.parentElement?.parentElement ||
+ a.parentElement;
+ const containerText = container ? container.innerText || '' : '';
+ const heading = h3.innerText || h3.textContent || '';
+ const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500);
+ anchors.push({ href, heading, snippet });
+ }
+ const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT});
+ return JSON.stringify({ anchors, pageText });
+ })()
+`;
+
const buildSerpUrl = (query: string, limit: number): string =>
`https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
diff --git a/src/profile/command.ts b/src/profile/command.ts
index 0703ae8..270b42f 100644
--- a/src/profile/command.ts
+++ b/src/profile/command.ts
@@ -4,8 +4,54 @@ import { discoverEndpoint } from "../cdp/discovery";
import { type Result, err, ok } from "../util/result";
import { type BrowserProfile, listProfiles } from "./list";
import { browserNameForUserDataDir, userDataDirCandidates } from "./paths";
-import { pinFor, promptForProfile } from "./picker";
-import { clearPin, readPin, writePin } from "./store";
+import { clearPin, type ProfilePin, readPin, writePin } from "./store";
+
+// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing".
+
+const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
+
+type ProfileChoice =
+ | { readonly kind: "profile"; readonly profile: BrowserProfile }
+ | { readonly kind: "clear" }
+ | { readonly kind: "cancelled" };
+
+const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
+ const counts = new Map<string, number>();
+ for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
+ return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
+};
+
+const promptForProfile = async (
+ ctx: ExtensionContext,
+ profiles: ReadonlyArray<BrowserProfile>,
+ currentPin: ProfilePin | null,
+ title = "Select browser profile",
+): Promise<ProfileChoice> => {
+ if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
+
+ const labels = disambiguate(profiles);
+ const rows = labels.map((label, i) => {
+ const isPinned = currentPin?.profileDir === profiles[i]?.dir;
+ return isPinned ? `✓ ${label}` : ` ${label}`;
+ });
+ const options = [...rows, CLEAR_SELECTION_LABEL];
+
+ const answer = await ctx.ui.select(title, options);
+ if (answer === undefined) return { kind: "cancelled" };
+ if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
+
+ const index = options.indexOf(answer);
+ const profile = index >= 0 ? profiles[index] : undefined;
+ if (!profile) return { kind: "cancelled" };
+ return { kind: "profile", profile };
+};
+
+const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
+ userDataDir,
+ profileDir: profile.dir,
+ label: profile.label,
+ savedAt: new Date().toISOString(),
+});
const resolveUserDataDir = async (client: BrowserClient): Promise<Result<string, string>> => {
const connected = client.userDataDir();
diff --git a/src/profile/picker.ts b/src/profile/picker.ts
deleted file mode 100644
index ede43a6..0000000
--- a/src/profile/picker.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing".
-
-import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
-import type { BrowserProfile } from "./list";
-import type { ProfilePin } from "./store";
-
-const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —";
-
-export type ProfileChoice =
- | { readonly kind: "profile"; readonly profile: BrowserProfile }
- | { readonly kind: "clear" }
- | { readonly kind: "cancelled" };
-
-const disambiguate = (profiles: ReadonlyArray<BrowserProfile>): ReadonlyArray<string> => {
- const counts = new Map<string, number>();
- for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1);
- return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label));
-};
-
-export const promptForProfile = async (
- ctx: ExtensionContext,
- profiles: ReadonlyArray<BrowserProfile>,
- currentPin: ProfilePin | null,
- title = "Select browser profile",
-): Promise<ProfileChoice> => {
- if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" };
-
- const labels = disambiguate(profiles);
- const rows = labels.map((label, i) => {
- const isPinned = currentPin?.profileDir === profiles[i]?.dir;
- return isPinned ? `✓ ${label}` : ` ${label}`;
- });
- const options = [...rows, CLEAR_SELECTION_LABEL];
-
- const answer = await ctx.ui.select(title, options);
- if (answer === undefined) return { kind: "cancelled" };
- if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" };
-
- const index = options.indexOf(answer);
- const profile = index >= 0 ? profiles[index] : undefined;
- if (!profile) return { kind: "cancelled" };
- return { kind: "profile", profile };
-};
-
-export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({
- userDataDir,
- profileDir: profile.dir,
- label: profile.label,
- savedAt: new Date().toISOString(),
-});
diff --git a/src/util/bytes.ts b/src/util/bytes.ts
deleted file mode 100644
index 43ad49c..0000000
--- a/src/util/bytes.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const formatBytes = (n: number): string => {
- if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
- if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
- return `${n} B`;
-};
diff --git a/src/util/keycodes.ts b/src/util/keycodes.ts
deleted file mode 100644
index 68be059..0000000
--- a/src/util/keycodes.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-const VIRTUAL_KEY_CODES: Readonly<Record<string, number>> = {
- Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32,
- ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
- Home: 36, End: 35, PageUp: 33, PageDown: 34,
-};
-
-export const virtualKeyCode = (key: string): number =>
- VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
diff --git a/src/util/time.ts b/src/util/time.ts
deleted file mode 100644
index ae751f8..0000000
--- a/src/util/time.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
- new Promise((resolve, reject) => {
- if (signal?.aborted) {
- reject(signal.reason ?? new Error("aborted"));
- return;
- }
- const onAbort = (): void => {
- clearTimeout(timer);
- reject(signal?.reason ?? new Error("aborted"));
- };
- const timer = setTimeout(() => {
- signal?.removeEventListener("abort", onAbort);
- resolve();
- }, ms);
- signal?.addEventListener("abort", onAbort, { once: true });
- });
diff --git a/src/util/truncate.ts b/src/util/truncate.ts
index 2bf0600..7a1fa02 100644
--- a/src/util/truncate.ts
+++ b/src/util/truncate.ts
@@ -33,3 +33,9 @@ export const applyTruncation = async (output: string, prefix: string): Promise<T
const text = `${t.content}\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${formatSize(omitted)} omitted. Full output: ${file}]`;
return { text, fullOutputPath: file, wasTruncated: true };
};
+
+export const formatBytes = (n: number): string => {
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${n} B`;
+};
diff --git a/test/cdp/session-call-test.ts b/test/cdp/session-call-test.ts
index 6a14e17..ef9a725 100644
--- a/test/cdp/session-call-test.ts
+++ b/test/cdp/session-call-test.ts
@@ -2,7 +2,7 @@ import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { decodeResult } from "../../src/cdp/commands";
import { createCdpSession } from "../../src/cdp/session";
-import type { CdpTransport } from "../../src/cdp/transport";
+import type { CdpTransport } from "../../src/cdp/types";
import type { CdpEvent } from "../../src/cdp/types";
import { type Result, ok, err } from "../../src/util/result";
import { type CdpError, cdpError } from "../../src/cdp/errors";
diff --git a/test/cdp/evaluate-test.ts b/test/cdp/session-evaluate-test.ts
index cffd513..c03a4cb 100644
--- a/test/cdp/evaluate-test.ts
+++ b/test/cdp/session-evaluate-test.ts
@@ -1,6 +1,6 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
-import { evaluateJson } from "../../src/cdp/evaluate";
+import { evaluateJson } from "../../src/cdp/session";
import type { CdpSession } from "../../src/cdp/session";
import { err, ok } from "../../src/util/result";
diff --git a/test/cdp/event-queue-test.ts b/test/daemon/queue-test.ts
index 771ce8e..6ea95f8 100644
--- a/test/cdp/event-queue-test.ts
+++ b/test/daemon/queue-test.ts
@@ -1,7 +1,6 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
-import { makeEventQueue } from "../../src/cdp/event-queue";
-import { rejectAllPending, sendWithTimeout, type Pending } from "../../src/cdp/pending-requests";
+import { makeEventQueue, rejectAllPending, sendWithTimeout, type Pending } from "../../src/daemon/queue";
describe("event queue iteration", () => {
test("yields pushed events in order", async () => {
diff --git a/test/domains/fake-client.ts b/test/domains/fake-client.ts
index 6bf2e54..f8a0c81 100644
--- a/test/domains/fake-client.ts
+++ b/test/domains/fake-client.ts
@@ -1,7 +1,7 @@
import { createCdpSession, type CdpSession } from "../../src/cdp/session";
import { createOwnershipRegistry, type OwnershipRegistry } from "../../src/cdp/ownership";
import { type CdpError, cdpError } from "../../src/cdp/errors";
-import type { CdpTransport } from "../../src/cdp/transport";
+import type { CdpTransport } from "../../src/cdp/types";
import type { CdpEvent } from "../../src/cdp/types";
import type { BrowserClient } from "../../src/client";
import { createMutex } from "../../src/util/mutex";
diff --git a/test/manual/window-binding-test.ts b/test/manual/window-binding-test.ts
index cad526a..d040614 100644
--- a/test/manual/window-binding-test.ts
+++ b/test/manual/window-binding-test.ts
@@ -1,6 +1,6 @@
import { createBrowserClient } from "../../src/client";
import { createOwnershipRegistry } from "../../src/cdp/ownership";
-import type { CdpTransport } from "../../src/cdp/transport";
+import type { CdpTransport } from "../../src/cdp/types";
import type { CdpEvent } from "../../src/cdp/types";
import { ok, type Result } from "../../src/util/result";
import type { CdpError } from "../../src/cdp/errors";