diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 20:30:49 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | b67faca60b21b0cf74d5c967f0f8d215ad601e93 (patch) | |
| tree | 12e8580e1b88c3ce6a2d9157cfa678cb7a55a73c /src | |
| parent | a6ec1d0915106b127affbd6b5fcc9efec48ef712 (diff) | |
refactor(domains): strip comment noise
Removes module banners, step markers, and comments restating the code.
Keeps one-line notes for safeJs's trust boundary, the select-match null
sentinels, the TUI width clamp, the single-window tab rule, the input
synthesis quirks (char events, keyCode, mousemove, focus, compositor),
the stale AX value, ignored-wrapper recursion, and isolated-tab routing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/domains/click.ts | 4 | ||||
| -rw-r--r-- | src/domains/console.ts | 2 | ||||
| -rw-r--r-- | src/domains/drag.ts | 1 | ||||
| -rw-r--r-- | src/domains/files.ts | 3 | ||||
| -rw-r--r-- | src/domains/form.ts | 15 | ||||
| -rw-r--r-- | src/domains/isolated-tab.ts | 24 | ||||
| -rw-r--r-- | src/domains/js.ts | 17 | ||||
| -rw-r--r-- | src/domains/keyboard.ts | 10 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 17 | ||||
| -rw-r--r-- | src/domains/network.ts | 3 | ||||
| -rw-r--r-- | src/domains/readpage/capture.ts | 11 | ||||
| -rw-r--r-- | src/domains/readpage/readability.ts | 23 | ||||
| -rw-r--r-- | src/domains/ref-resolve.ts | 30 | ||||
| -rw-r--r-- | src/domains/render.ts | 17 | ||||
| -rw-r--r-- | src/domains/screenshot.ts | 6 | ||||
| -rw-r--r-- | src/domains/scroll.ts | 7 | ||||
| -rw-r--r-- | src/domains/search/extract.ts | 15 | ||||
| -rw-r--r-- | src/domains/search/google-serp.ts | 28 | ||||
| -rw-r--r-- | src/domains/setup.ts | 10 | ||||
| -rw-r--r-- | src/domains/snapshot.ts | 58 | ||||
| -rw-r--r-- | src/domains/tabs.ts | 2 |
21 files changed, 18 insertions, 285 deletions
diff --git a/src/domains/click.ts b/src/domains/click.ts index 0215d9a..cb4b007 100644 --- a/src/domains/click.ts +++ b/src/domains/click.ts @@ -36,9 +36,7 @@ const dispatchClick = async ( button: "left" | "right" | "middle", count: number, ): Promise<Result<void, ToolErr>> => { - // Move the pointer to the target first. Some focus/hover handlers (and React - // synthetic events) only fire when a mousemove precedes the press, so without - // it a click can land without focusing the element. + // Some focus/hover handlers and React synthetic events only fire when a mousemove precedes the press, so without it a click lands without focusing. const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y }); if (!moved.success) return moved; const pressed = await cdpCall(client, "Input.dispatchMouseEvent", { diff --git a/src/domains/console.ts b/src/domains/console.ts index c38da2a..7809f77 100644 --- a/src/domains/console.ts +++ b/src/domains/console.ts @@ -182,7 +182,7 @@ export const consoleTool = defineBrowserTool({ const text = renderConsoleMarkdown(drained.records, { total: drained.total, bufferOverflowed: drained.bufferOverflowed, - expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans + expanded: true, nextCursor, }); const trunc = await applyTruncation(text, "console"); diff --git a/src/domains/drag.ts b/src/domains/drag.ts index 49a5a58..34a0154 100644 --- a/src/domains/drag.ts +++ b/src/domains/drag.ts @@ -40,7 +40,6 @@ export const dragAndDropTool = defineBrowserTool({ const calls: ReadonlyArray<InputCall> = [ ["Input.dispatchMouseEvent", { type: "mousePressed", x: args.startX, y: args.startY, button: "left", clickCount: 1 }], ["Input.dispatchDragEvent", { type: "dragEnter", x: args.startX, y: args.startY, data, modifiers: 0 }], - // 5 interpolated dragOver steps from start → end so the page sees a smooth drag. ...Array.from({ length: 5 }, (_, i): InputCall => { const t = (i + 1) / 5; return [ diff --git a/src/domains/files.ts b/src/domains/files.ts index ebc5a83..534a674 100644 --- a/src/domains/files.ts +++ b/src/domains/files.ts @@ -17,8 +17,6 @@ const UploadArgs = Type.Object({ filePath: Type.String({ description: "Absolute path to the file to upload" }), }); -// Ref path: set files directly on the resolved backendNodeId via CDP — simplest -// and most reliable, no selector needed. const tryRefUpload = async ( client: BrowserClient, backendId: number, @@ -115,7 +113,6 @@ export const uploadFileTool = defineBrowserTool({ const readable = await verifyReadable(args.filePath); if (!readable.success) return readable; - // Ref path: resolve to backendNodeId and set files directly via CDP. if (args.ref !== undefined) { const backendId = resolveRefToBackendId(client, args.ref); if (!backendId.success) return backendId; diff --git a/src/domains/form.ts b/src/domains/form.ts index 78e3db1..b97b22d 100644 --- a/src/domains/form.ts +++ b/src/domains/form.ts @@ -18,16 +18,6 @@ const FillArgs = Type.Object({ value: Type.String({ description: "Value to set in the field" }), }); -/** - * Run an element-scoped function either against a CSS selector (querySelector + - * apply) or a ref (resolve to objectId, callFunctionOn with element as `this`). - * The shared `fnBody` is a function body where `this` is the target element and - * the single argument is the value — identical logic on both paths. Returns the - * function's return value (parsed) or a ToolErr. - * - * On the ref path a detached node yields a stale-ref error; on the selector path - * a missing element returns { status: "not_found" } from the function body. - */ const runOnElement = async ( client: BrowserClient, opts: { @@ -60,9 +50,7 @@ const runOnElement = async ( if (opts.selector === undefined) { return err({ kind: "invalid_state", message: "Provide either `ref` or `selector`." }); } - // Selector path: bind the same trusted function body to the matched element - // via .call(). fnBody is harness-authored (not user input); only the selector - // and value are interpolated, and those go through safeJs. + // fnBody is harness-authored, not user input; only the selector and value are interpolated, and those go through safeJs. const prelude = safeJs` (() => { const el = document.querySelector(${opts.selector}); @@ -232,7 +220,6 @@ const isSelectResult = (v: unknown): v is SelectResult => { return false; }; -// `this` is the <select>; `arg` is { wv, wl, wi } (null = not provided). const SELECT_FN = ` const el = this; if (el.tagName !== "SELECT") return { status: "not_select", tag: el.tagName }; diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts index d0f3def..ba00756 100644 --- a/src/domains/isolated-tab.ts +++ b/src/domains/isolated-tab.ts @@ -1,15 +1,3 @@ -/** - * Isolated-attach tab lifecycle (ADR-0002 v1). Opens a harness-owned tab, - * attaches to it by its own CDP sessionId, and drives it via callOnTarget / - * evaluateJs(expr, sid) WITHOUT ever touching the global "current" tab — so a - * search or page-read never disturbs the tab the user is looking at, even when - * several subagents run concurrently. - * - * Mirrors the create/attach sequence in browser_open_urls - * (src/domains/navigate.ts:84-139). Every page evaluation is bounded by a - * timeout, so a JS dialog on the (uninstrumented) isolated tab surfaces as a - * typed timeout rather than hanging — see the dialog-hang caveat in plan.md. - */ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; @@ -17,7 +5,6 @@ import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; import { evaluateJson } from "../cdp/evaluate"; import type { ToolErr } from "../util/tool"; -/** A tab attached by its own sessionId, ready to drive via callOnTarget. */ export type IsolatedTab = { readonly targetId: string; readonly sessionId: string; @@ -28,14 +15,8 @@ const READY_POLL_INTERVAL_MS = 50; const toToolErr = (message: string, kind: ToolErr["kind"] = "cdp_error"): ToolErr => ({ kind, message }); -/** - * Open a harness-owned tab and attach to it, returning its targetId + private - * sessionId. Does not navigate. The caller MUST `closeIsolatedTab` when done. - */ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<IsolatedTab, ToolErr>> => { - // Routed through the target factory so an isolated tab lands in the pinned - // profile like every other harness tab — otherwise a search or page read - // would run with a different profile's cookies than the visible tabs. + // Routed through the target factory so an isolated tab lands in the pinned profile, not another profile's cookies. const window = await ensureHarnessWindow(client); if (!window.success) return err(toToolErr(window.error.message)); let targetId: string; @@ -62,7 +43,6 @@ export const openIsolatedTab = async (client: BrowserClient): Promise<Result<Iso return ok({ targetId, sessionId }); }; -/** Navigate an isolated tab to a URL (does not wait for load). */ export const navigateIsolatedTab = async ( client: BrowserClient, tab: IsolatedTab, @@ -75,7 +55,6 @@ export const navigateIsolatedTab = async ( return ok(undefined); }; -/** Poll the isolated tab until document.readyState === 'complete' or timeout. */ export const waitForIsolatedLoad = async ( client: BrowserClient, tab: IsolatedTab, @@ -108,7 +87,6 @@ export const evalInIsolatedTab = async <T>( return ok(r.data); }; -/** Close an isolated tab and drop its ownership. Best-effort; never throws. */ export const closeIsolatedTab = async (client: BrowserClient, tab: IsolatedTab): Promise<void> => { await client.closeTab(tab.targetId).catch(() => {}); }; diff --git a/src/domains/js.ts b/src/domains/js.ts index 6d9ace0..a64b4ac 100644 --- a/src/domains/js.ts +++ b/src/domains/js.ts @@ -18,17 +18,10 @@ const formatBytes = (n: number): string => { return `${n} B`; }; -// AsyncFunction constructor — the documented Node.js mechanism for compiling -// arbitrary user-supplied source. Equivalent to `new Function` but produces an -// async function we can `await`. The full RCE risk is contained at the source -// boundary: see the path allowlist + timeout below. A plain `unknown` cast -// would lose the constructor signature; we keep this single typed cast. +// The AsyncFunction constructor is the documented way to compile user source; a plain `unknown` cast would lose the constructor signature, so this typed cast stays. const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: ReadonlyArray<string>) => (...args: ReadonlyArray<unknown>) => Promise<unknown>; -// ESNext module — bare `require` is not in scope. createRequire provides the -// CJS resolver anchored to this file's URL so scripts can require() builtins -// and installed CJS packages. const requireFromHere = createRequire(import.meta.url); const ExecuteJsArgs = Type.Object({ @@ -58,8 +51,6 @@ export const executeJsTool = defineBrowserTool({ if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); const valueStr = r.data === undefined ? "undefined" : JSON.stringify(r.data); const truncated = await applyTruncation(valueStr, "js"); - // If the value is JSON, pretty-print for the expanded TUI view. Falls back - // to the raw string if parse fails (e.g. value is "undefined" sentinel). let pretty: string | undefined; try { const parsed: unknown = JSON.parse(valueStr); @@ -71,8 +62,6 @@ export const executeJsTool = defineBrowserTool({ text: truncated.text, details: { valueLength: valueStr.length, - // Full value for the expanded renderer. Capped at 200 KB so a giant - // dump doesn't bloat the conversation transcript on disk. full: valueStr.length > 200_000 ? valueStr.slice(0, 200_000) : valueStr, ...(pretty !== undefined ? { pretty: pretty.length > 200_000 ? pretty.slice(0, 200_000) : pretty } : {}), ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}), @@ -210,7 +199,7 @@ export const runScriptTool = defineBrowserTool({ try { const scriptPromise = executeFn( args.params ?? {}, - client, // bound as `daemon` — name preserved for back-compat + client, requireFromHere, ac.signal, (u: unknown) => { @@ -221,7 +210,7 @@ export const runScriptTool = defineBrowserTool({ if (!isRecord(first)) return; const txt = first["text"]; if (typeof txt !== "string") return; - try { onUpdate({ text: txt }); } catch { /* swallow */ } + try { onUpdate({ text: txt }); } catch {} }, extensionCtx ?? { cwd: process.cwd() }, console, fetch, JSON, Buffer, setTimeout, clearTimeout, diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts index 4ee8243..f0f1a6d 100644 --- a/src/domains/keyboard.ts +++ b/src/domains/keyboard.ts @@ -97,9 +97,7 @@ export const pressKeyTool = defineBrowserTool({ }; const down = await cdpCall(client, "Input.dispatchKeyEvent", downParams); if (!down.success) return down; - // For a printable character with no command modifier (Ctrl/Meta/Alt = 1|2|4), - // emit a `char` event so the page receives keypress/textInput and the - // character is actually inserted. Shift (8) is allowed (capitals/symbols). + // A printable character needs a `char` event for the page to receive keypress/textInput and actually insert it; Shift (8) still counts as printable. const hasCommandModifier = (modifiers & (1 | 2 | 4)) !== 0; if (isChar && !hasCommandModifier) { const charEv = await cdpCall(client, "Input.dispatchKeyEvent", { type: "char", text: k, key: k }); @@ -143,12 +141,8 @@ export const dispatchKeyTool = defineBrowserTool({ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { const eventType = args.eventType ?? "keydown"; const target = args.ref ?? args.selector ?? ""; - // Legacy handlers commonly branch on e.keyCode/e.which (e.g. === 13 for - // Enter) rather than e.key, so populate both. virtualKeyCode maps named - // keys and single chars; 0 for anything unknown. + // Legacy handlers branch on e.keyCode/e.which rather than e.key, so populate both. const code = virtualKeyCode(args.key); - // Ref path: dispatch on the single resolved node. Selector path: dispatch on - // all matches (preserves the original multi-match behavior). if (args.ref !== undefined) { const objectId = await resolveRefToObjectId(client, args.ref); if (!objectId.success) return objectId; diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts index 93c6a89..86cc272 100644 --- a/src/domains/navigate.ts +++ b/src/domains/navigate.ts @@ -88,28 +88,21 @@ export const openUrlsTool = defineBrowserTool({ renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0), async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> { const total = args.urls.length; - // Phase 1: create tabs. All must land in ONE window. If the harness window - // already exists, open every url as its child in parallel. If it doesn't - // (fresh/closed), create the FIRST url as a new dedicated window (binding - // its windowId) and open the rest as children of it — a parallel - // newWindow:true per url would otherwise scatter tabs across N windows. + // All tabs must land in ONE window: a parallel newWindow:true per url would scatter them across N windows, so the first url creates the window and the rest open as its children. const url0 = args.urls[0]; if (url0 === undefined) return err({ kind: "invalid_state", message: "no URLs provided" }); const window = await ensureHarnessWindow(client); if (!window.success) return err({ kind: "cdp_error", message: window.error.message }); const seededWindow = window.data.targetId; - // A freshly created window's seed tab serves as the first URL's tab. const seedResult: TabResult | undefined = window.data.freshlyCreated ? { url: url0, targetId: seededWindow, ok: true } : undefined; const created = await Promise.all(args.urls.map(async (url, i): Promise<TabResult> => { - // Reuse the seed tab (created above) for the first url instead of duplicating it. if (i === 0 && seedResult) return seedResult; const r = await openHarnessTab(client, seededWindow); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; return { url, targetId: r.data, ok: true }; })); - // Phase 2: attach + navigate each created tab in parallel. let completed = 0; const settled = await Promise.all( created.filter((t) => t.ok).map(async (tab): Promise<TabResult> => { @@ -121,8 +114,6 @@ export const openUrlsTool = defineBrowserTool({ if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message }; const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid); if (!nav.success) return { ...tab, ok: false, error: nav.error.message }; - // Mark the tab with a green circle so the user can see which - // tabs the agent opened. Best-effort; don't fail on CSP blocks. client.evaluateJs( safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, sid @@ -130,11 +121,10 @@ export const openUrlsTool = defineBrowserTool({ return tab; } finally { completed++; - // onUpdate must never escape: a renderer crash would otherwise - // tear down the tool call. + // onUpdate must never escape: a renderer crash would otherwise tear down the tool call. try { onUpdate({ text: `Opening URLs… ${completed}/${created.filter((t) => t.ok).length} navigated` }); - } catch { /* swallow */ } + } catch {} } }), ); @@ -142,7 +132,6 @@ export const openUrlsTool = defineBrowserTool({ const all: ReadonlyArray<TabResult> = [...settled, ...failures]; const okTabs = all.filter((r) => r.ok); const failTabs = all.filter((r) => !r.ok); - // Phase 3: activate the last successfully-opened tab so the user sees something. if (okTabs.length > 0) { const last = okTabs[okTabs.length - 1]; if (last) { diff --git a/src/domains/network.ts b/src/domains/network.ts index fa755b3..fc54eb2 100644 --- a/src/domains/network.ts +++ b/src/domains/network.ts @@ -131,7 +131,6 @@ const renderNetworkMarkdown = ( return `${headerLines.join("\n")}\n\n${rows}${more}`; } - // Expanded: full table + optional body sections. const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`; const tableRows = records .map((r, i) => { @@ -269,7 +268,7 @@ export const networkRequestsTool = defineBrowserTool({ includeBodies: args.includeResponseBodies ?? false, total: drained.total, bufferOverflowed: drained.bufferOverflowed, - expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans + expanded: true, }); const trunc = await applyTruncation(text, "network"); diff --git a/src/domains/readpage/capture.ts b/src/domains/readpage/capture.ts index 1ee4500..cf9d055 100644 --- a/src/domains/readpage/capture.ts +++ b/src/domains/readpage/capture.ts @@ -1,17 +1,6 @@ -/** - * The JavaScript source evaluated in a loaded page to capture candidate content - * blocks + metadata for the pure readability extractor (readability.ts). Crosses - * the CDP boundary via Runtime.evaluate (returnByValue) and returns a JSON - * string shaped like PageCapture. - * - * The DOM walk lives here (unavoidable); all scoring/selection is pure and lives - * in readability.ts, so the decision logic stays unit-testable. - */ -/** Max bytes of body-text fallback captured for thin/structure-less pages. */ const BODY_TEXT_LIMIT = 20_000; -/** Build the capture expression. No interpolated values ⇒ no injection surface. */ export const buildPageCaptureExpr = (): string => ` (() => { const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); diff --git a/src/domains/readpage/readability.ts b/src/domains/readpage/readability.ts index 37f2a38..b20cacd 100644 --- a/src/domains/readpage/readability.ts +++ b/src/domains/readpage/readability.ts @@ -1,35 +1,18 @@ -/** - * Pure reader-mode extraction — no CDP, no DOM. The in-page script - * (see capture.ts) serializes candidate content blocks; these pure functions - * score them and assemble clean article text, so the selection logic is - * unit-testable against saved fixtures (test/deep-research/readability-test.ts). - * - * Dependency-free by design (no jsdom / Readability): the harness ships no new - * runtime deps. The heuristic favors blocks with high text density and low link - * density, which is what separates article body from nav/ads/boilerplate. - */ -/** One candidate content block captured in-page. */ export type ContentBlock = { - /** Semantic tag: paragraph | heading | listitem | blockquote | other. */ readonly kind: "paragraph" | "heading" | "listitem" | "blockquote" | "other"; readonly text: string; - /** Chars of text that sit inside <a> tags — high ratio ⇒ nav/menu, not prose. */ readonly linkTextLength: number; - /** True when an ancestor is nav/header/footer/aside — structural boilerplate. */ readonly inBoilerplate: boolean; }; -/** The JSON payload capture.ts returns from the page. */ export type PageCapture = { readonly url: string; readonly title: string; readonly blocks: ReadonlyArray<ContentBlock>; - /** Bounded innerText fallback when block extraction finds nothing usable. */ readonly bodyText: string; }; -/** The tool's public output shape. */ export type ReadablePage = { readonly title: string; readonly url: string; @@ -46,7 +29,6 @@ const linkDensity = (block: ContentBlock): number => { return block.linkTextLength / block.text.length; }; -/** A block is article prose if it is substantive, text-dense, and not chrome. */ const isArticleBlock = (block: ContentBlock): boolean => { if (block.inBoilerplate) return false; if (linkDensity(block) > MAX_LINK_DENSITY) return false; @@ -69,11 +51,6 @@ const wordCountOf = (text: string): number => { return trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length; }; -/** - * Assemble readable article text from a page capture. Keeps text-dense, - * non-boilerplate blocks; falls back to the bounded body text when the article - * is too thin (e.g. a page with no clear content structure). - */ export const extractReadable = (capture: PageCapture): ReadablePage => { const articleText = capture.blocks.filter(isArticleBlock).map(render).join("\n\n").replace(/\n{3,}/g, "\n\n").trim(); diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts index 822f8e8..ece308e 100644 --- a/src/domains/ref-resolve.ts +++ b/src/domains/ref-resolve.ts @@ -10,12 +10,6 @@ const staleErr = (ref: string): ToolErr => ({ details: { ref }, }); -/** - * Resolve a ref to its CDP objectId (a handle to the live JS node). Use when a - * tool needs to run JS against the element (fill, focus, select, dispatch_key). - * Re-resolves through the backendNodeId at call time, so it reflects the current - * DOM even after re-renders — and fails loudly if the node was detached. - */ export const resolveRefToObjectId = async ( client: BrowserClient, ref: string, @@ -24,8 +18,6 @@ export const resolveRefToObjectId = async ( if (backendId === undefined) return err(staleErr(ref)); const resolved = await client.session().call("DOM.resolveNode", { backendNodeId: backendId }); if (!resolved.success) { - // A detached node (removed by a re-render) fails to resolve — surface it as - // a stale ref rather than a raw CDP error so the agent knows to re-snapshot. return err(staleErr(ref)); } const objectId = resolved.data.object.objectId; @@ -33,18 +25,12 @@ export const resolveRefToObjectId = async ( return ok(objectId); }; -/** Resolve a ref to its current backendNodeId (for CDP calls that take one, e.g. DOM.setFileInputFiles). */ export const resolveRefToBackendId = (client: BrowserClient, ref: string): Result<number, ToolErr> => { const backendId = client.session().resolveRef(ref); if (backendId === undefined) return err(staleErr(ref)); return ok(backendId); }; -/** - * Resolve a ref to the current center of its bounding box, in viewport CSS - * pixels. Use for browser_click. Fetched fresh via DOM.getBoxModel, so a - * re-rendered/moved element is clicked at its new position. - */ export const resolveRefToBox = async ( client: BrowserClient, ref: string, @@ -59,19 +45,6 @@ export const resolveRefToBox = async ( const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`; const keyOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}`; -/** - * Recompute the interactive elements after a mutation and return a compact, - * human/LLM-readable diff vs. the prior snapshot's baseline — changed values, - * newly-appeared elements (marked `*`), and removed ones. Re-publishes the fresh - * ref map so subsequent ref-based calls target the post-mutation DOM. - * - * Deliberately cheap: it re-reads the AX tree (no per-node box fetch — the - * expensive part of a full snapshot) and emits a few lines, not a full tree, so - * a long action sequence doesn't blow up the context. - * - * Returns "" on any failure or when nothing changed — callers append it only if - * non-empty, so a diff is best-effort and never blocks the primary action. - */ export const interactiveDiff = async (client: BrowserClient): Promise<string> => { const session = client.session(); const prev = session.refSignatures(); @@ -82,7 +55,6 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> => const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 }); const targets = collectInteractiveTargets(slim); - // Re-publish fresh refs so later ref calls resolve against the new DOM. const priorRefs = session.refMappings(); const refByBackendId = new Map<number, string>(); for (const [ref, backendId] of priorRefs) refByBackendId.set(backendId, ref); @@ -104,8 +76,6 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> => } session.setRefMap(refMap, refSig); - // Compare by stable identity (role|name) rather than ref position, since refs - // renumber when the element set changes. Build prev/current identity → value. const prevByKey = new Map<string, string>(); for (const sig of prev.values()) { const parts = sig.split("|"); diff --git a/src/domains/render.ts b/src/domains/render.ts index 1e4e30d..3f7f0dc 100644 --- a/src/domains/render.ts +++ b/src/domains/render.ts @@ -1,12 +1,3 @@ -/** - * Shared TUI renderer for text-heavy tool output (browser_web_search, - * browser_read_page). Gives the native pi expand/collapse (Ctrl+O) behavior: - * a compact preview by default, the full body when expanded — mirroring the - * pattern in browser_execute_js (src/domains/js.ts:81-109). - * - * The full text always goes to the LLM via ToolOk.text; this only governs the - * human-facing TUI view. - */ import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui"; @@ -15,13 +6,9 @@ import { isRecord } from "../util/guards"; const COMPACT_PREVIEW_LINES = 6; -/** Structured payload a text-heavy tool stores so renderResult can rebuild views. */ export type ExpandableText = { - /** One-line summary shown above the body in both states (e.g. "5 results · google"). */ readonly summary: string; - /** The full rendered body (markdown/plain). */ readonly body: string; - /** Path to the on-disk full output when the LLM text was truncated, if any. */ readonly fullOutputPath?: string; }; @@ -31,10 +18,6 @@ const isExpandableText = (value: unknown): value is ExpandableText => const extractRender = (details: unknown): unknown => isRecord(details) ? details["render"] : undefined; -/** - * Build a renderResult over an ExpandableText stored in `details.render`. - * `label` names the tool for the error fallback (e.g. "web_search"). - */ export const renderExpandableText = ( label: string, result: { readonly details?: unknown }, diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts index 2e9cfdc..04f0fa9 100644 --- a/src/domains/screenshot.ts +++ b/src/domains/screenshot.ts @@ -110,10 +110,7 @@ export const screenshotTool = defineBrowserTool({ maxHeightCells: 24, filename: filePath, }); - // Image's text fallback (when terminal lacks inline-image support) does - // not respect the rendered width, so a long file path can blow past the - // terminal width and crash the host TUI. Wrap the component to truncate - // each rendered line to fit. + // Image's text fallback does not respect the rendered width, so an unwrapped long path can blow past the terminal width and crash the host TUI. return { invalidate: () => image.invalidate(), render: (width: number) => @@ -151,7 +148,6 @@ export const captureWithCrosshair = async ( await rename(tmp, cap.data.path); return ok({ path: cap.data.path }); } catch { - // Crosshair overlay is best-effort; on failure return the plain screenshot. return ok({ path: cap.data.path }); } }; diff --git a/src/domains/scroll.ts b/src/domains/scroll.ts index 1819a48..832f584 100644 --- a/src/domains/scroll.ts +++ b/src/domains/scroll.ts @@ -38,13 +38,10 @@ export const scrollTool = defineBrowserTool({ const cy = args.y ?? Math.round(info.data.height / 2); const dx = args.deltaX ?? 0; const dy = args.deltaY ?? 300; - // The page target must be focused or Input events are dropped silently - // (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll). - // Page.bringToFront makes the target the active one in the browser. + // The page target must be focused or Input events are dropped silently: mouseWheel hangs and synthesizeScrollGesture returns ok without scrolling. const front = await cdpCall(client, "Page.bringToFront", {}); if (!front.success) return front; - // Establish compositor mouse position before mouseWheel; without this, - // CDP sometimes drops the wheel event with a timeout. + // Establish compositor mouse position first, or CDP sometimes drops the wheel event with a timeout. const moved = await cdpCall(client, "Input.dispatchMouseEvent", { type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0, }); diff --git a/src/domains/search/extract.ts b/src/domains/search/extract.ts index 52f7f86..05ee182 100644 --- a/src/domains/search/extract.ts +++ b/src/domains/search/extract.ts @@ -1,21 +1,6 @@ -/** - * The JavaScript source evaluated inside the Google SERP page to capture raw - * anchors + page text. This string crosses the CDP boundary via - * Runtime.evaluate (returnByValue) and returns a JSON string that - * `parseGoogleSerp` / `classifySerp` consume on the harness side. - * - * ADR-0001: anchors are selected structurally (a link carrying an <h3>), not by - * Google CSS class names, so cosmetic markup churn does not break extraction. - */ -/** Max bytes of page text captured for CAPTCHA / no-results classification. */ const PAGE_TEXT_LIMIT = 4000; -/** - * Build the extraction expression. Takes no interpolated values, so it is a - * fixed source string (no injection surface). Returns - * `JSON.stringify({ anchors, pageText })`. - */ export const buildSerpExtractionExpr = (): string => ` (() => { const main = document.querySelector('#search, #rso, #main') || document.body; diff --git a/src/domains/search/google-serp.ts b/src/domains/search/google-serp.ts index 7e49bea..4b8a319 100644 --- a/src/domains/search/google-serp.ts +++ b/src/domains/search/google-serp.ts @@ -1,27 +1,15 @@ -/** - * Pure Google SERP parsing — no CDP, no browser. Everything here is a pure - * function over data captured from the page, so it is unit-testable against - * saved fixtures (see test/deep-research/serp-parser-test.ts). - * - * ADR-0001: results are extracted by structure/semantics (anchors carrying an - * <h3> heading in the main results column), never by brittle Google CSS class - * names, and Google redirect wrappers are unwound to the real target URL. - */ -/** One raw anchor captured in-page: a link with a heading and nearby snippet. */ export type SerpAnchor = { readonly href: string; readonly heading: string; readonly snippet: string; }; -/** The JSON payload the in-page extraction expression returns. */ export type SerpExtraction = { readonly anchors: ReadonlyArray<SerpAnchor>; readonly pageText: string; }; -/** A parsed, ranked search result — the tool's public output shape. */ export type SearchResult = { readonly title: string; readonly url: string; @@ -29,7 +17,6 @@ export type SearchResult = { readonly rank: number; }; -/** Why a SERP yielded no usable results, or that it did. */ export type SerpVerdict = "ok" | "captcha" | "no_results"; const CAPTCHA_MARKERS: ReadonlyArray<string> = [ @@ -42,10 +29,6 @@ const CAPTCHA_MARKERS: ReadonlyArray<string> = [ "our systems have detected unusual traffic", ]; -/** - * Decode a Google redirect wrapper (`/url?q=<real>&sa=...`) to its target. - * Returns the input unchanged when it is not a wrapper. - */ const unwrapRedirect = (href: string): string => { const wrapper = href.match(/^(?:https?:\/\/[^/]*google\.[^/]*)?\/url\?/i); if (!wrapper) return href; @@ -66,7 +49,6 @@ const isExternalResult = (url: string): boolean => { } }; -/** Normalized key for dedupe: origin + pathname, trailing slash and hash removed. */ const dedupeKey = (url: string): string => { try { const u = new URL(url); @@ -76,11 +58,6 @@ const dedupeKey = (url: string): string => { } }; -/** - * Parse captured anchors into ranked results: unwrap redirects, keep only - * external http(s) links, dedupe by normalized url (first wins), rank by input - * order, cap to `limit`. - */ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: number): ReadonlyArray<SearchResult> => { const seen = new Set<string>(); const results: SearchResult[] = []; @@ -101,11 +78,6 @@ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: numbe return results; }; -/** - * Decide whether the SERP is usable, blocked by a CAPTCHA, or genuinely empty. - * `resultCount` is the number of parsed results; `pageText` is a bounded slice - * of the page's visible text. - */ export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => { if (resultCount > 0) return "ok"; const lower = pageText.toLowerCase(); diff --git a/src/domains/setup.ts b/src/domains/setup.ts index c2d8a4f..21fa3b3 100644 --- a/src/domains/setup.ts +++ b/src/domains/setup.ts @@ -1,10 +1,3 @@ -/** - * browser_setup tool — agent-callable browser initialization. - * - * The agent can call this directly when browser tools fail with - * "not initialized" instead of asking the user. Idempotent: safe - * to call even when already connected. - */ import { Type } from "typebox"; import { Text } from "@mariozechner/pi-tui"; @@ -29,12 +22,9 @@ export const setupTool = defineBrowserTool({ ], parameters: SetupArgs, concurrency: "parallel", - // ensureAlive:false — this tool IS the setup; it must bypass the socket guard ensureAlive: false, renderCall: () => new Text("🔧 Initializing browser...", 0, 0), async handler(_args, { client, extensionCtx }): Promise<Result<ToolOk, ToolErr>> { - // extensionCtx carries the UI, so the agent calling browser_setup gets the - // same first-run profile picker the user gets from /browser-setup. const result = await performSetup(client, extensionCtx); if (result.success) { return ok({ text: result.data }); diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts index c015203..b33c8f9 100644 --- a/src/domains/snapshot.ts +++ b/src/domains/snapshot.ts @@ -40,11 +40,8 @@ export type SlimNode = { description?: string; state?: string; children: SlimNode[]; - /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */ _backendId?: number; - /** Stable handle (e.g. "e12") for interaction tools. Survives re-renders via backendNodeId. */ ref?: string; - /** Click target — center of the element's bounding box, in viewport CSS pixels. */ box?: Box; }; @@ -63,9 +60,7 @@ const INTERACTIVE_ROLES = new Set([ "spinbutton", ]); -// Roles whose live DOM value/checked state should override the (often stale) -// AX-tree value. The AX `value` is unreliable for freshly-typed or controlled -// inputs, so for these we read the real DOM property at snapshot time. +// The AX `value` is stale for freshly-typed or controlled inputs, so for these roles the live DOM property is read instead. const VALUE_ROLES = new Set([ "textbox", "searchbox", @@ -91,7 +86,6 @@ const collectState = (props: RawAxNode["properties"]): string | undefined => { const name = p.name; if (!name) continue; const raw = p.value?.value; - // Boolean flags worth surfacing in the outline. if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) { flags.push(name); } @@ -104,12 +98,9 @@ export const buildTree = ( rawNodes: ReadonlyArray<RawAxNode>, opts: { interestingOnly: boolean; maxNodes: number }, ): SlimNode[] => { - // Index for parent/child lookups. const byId = new Map<string, RawAxNode>(); for (const n of rawNodes) byId.set(n.nodeId, n); - // CDP doesn't mark a single root explicitly; the root nodes are the ones - // whose parentId isn't in the index (typically just one — RootWebArea). const rootIds: string[] = []; for (const n of rawNodes) { if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId); @@ -120,8 +111,7 @@ export const buildTree = ( const slim = (node: RawAxNode): SlimNode | undefined => { if (budget <= 0) return undefined; if (opts.interestingOnly && node.ignored) { - // Skip the ignored node but recurse through its children — Chrome often - // ignores wrapper divs but the meaningful descendants are still there. + // Chrome often ignores wrapper divs while their descendants remain meaningful, so recurse through an ignored node's children. const out: SlimNode[] = []; for (const cid of node.childIds ?? []) { const child = byId.get(cid); @@ -129,10 +119,6 @@ export const buildTree = ( const slimChild = slim(child); if (slimChild) out.push(slimChild); } - // Hoist children into the parent. We return undefined; the parent's - // recursion will see them by re-walking — but that double-counts. Instead - // we handle hoisting at the parent level. So here just signal "skip me" - // by returning undefined; the parent will hoist by checking ignored flag. return out.length > 0 ? { role: "_hoist", children: out } : undefined; } budget--; @@ -147,7 +133,6 @@ export const buildTree = ( if (!child) continue; const slimChild = slim(child); if (!slimChild) continue; - // Hoist any synthetic _hoist nodes' children into this level. if (slimChild.role === "_hoist") children.push(...slimChild.children); else children.push(slimChild); } @@ -187,7 +172,6 @@ const summarize = (nodes: ReadonlyArray<SlimNode>): string => { } }; walk(nodes); - // Group landmarks (roles that are top-level structural). const landmarkRoles = new Set(["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region"]); let landmarks = 0; for (const [role, c] of counts) if (landmarkRoles.has(role)) landmarks += c; @@ -211,8 +195,6 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => { if (n.name) line += ` "${n.name}"`; if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`; if (n.state) line += ` (${n.state})`; - // Surface the stable ref + click coordinates for interactive nodes. Agents - // pass `ref` to interaction tools (survives re-renders); @(x,y) is a fallback. if (n.ref) line += ` [${n.ref}]`; if (n.box && INTERACTIVE_ROLES.has(n.role)) { line += ` @(${n.box.cx},${n.box.cy})`; @@ -225,18 +207,12 @@ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => { return lines.join("\n"); }; -/** Strip internal _backendId from a slim tree before returning to callers. */ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] => nodes.map((n) => { const { _backendId: _bid, ...rest } = n; return { ...rest, children: stripInternals(n.children) }; }); -/** - * Walk all slim nodes and collect (slimNode, backendId) pairs for interactive - * roles. Caller fetches DOM.getBoxModel for each and writes box back into the - * slim node by reference. - */ export const collectInteractiveTargets = ( nodes: ReadonlyArray<SlimNode>, ): Array<{ node: SlimNode; backendId: number }> => { @@ -253,13 +229,6 @@ export const collectInteractiveTargets = ( return out; }; -/** - * Read the live DOM value for a form control by backend node id. Resolves the - * node to a JS object then reads the property that matters for its type: - * `.value` for inputs/selects/textareas, `.checked` (→ "checked"/"unchecked") - * for checkboxes/radios. Returns undefined on any failure so the caller falls - * back to the AX value. Mirrors the box-fetch error handling: never throws. - */ const liveValue = async (session: CdpSession, backendId: number): Promise<string | undefined> => { const resolved = await session.call("DOM.resolveNode", { backendNodeId: backendId }); if (!resolved.success) return undefined; @@ -309,33 +278,22 @@ export const snapshotTool = defineBrowserTool({ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { const session = client.session(); - // 1. AX tree const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {}); if (!axRes.success) return axRes; const rawNodes = axRes.data.nodes; - // 2. Page url/title const piRes = await client.pageInfo(); if (!piRes.success) return err({ kind: "cdp_error", message: piRes.error.message }); const pageUrl = "dialog" in piRes.data ? "" : piRes.data.url; const pageTitle = "dialog" in piRes.data ? "" : piRes.data.title; - // 3. Slim + format const slim = buildTree(rawNodes, { interestingOnly: args.interestingOnly ?? true, maxNodes: args.maxNodes ?? 1000, }); - // 4. Fetch bounding boxes for interactive nodes so the agent can click - // without a screenshot round-trip. Capped budget so a slow page can't - // wedge the call. const targets = collectInteractiveTargets(slim); - // Assign stable refs (e1, e2, …) to every interactive target. Interaction - // tools resolve these refs to backendNodeIds at call time, so they stay - // valid across re-renders. The ref → backendNodeId map (and the per-ref - // signature baseline for the post-mutation diff) is published below, after - // the box/live-value fetch so the signature reflects the live DOM value. const refMap = new Map<string, number>(); targets.forEach(({ node, backendId }, i) => { const ref = `e${i + 1}`; @@ -353,10 +311,6 @@ export const snapshotTool = defineBrowserTool({ if (!box.success) return; if (box.data.x < 0 || box.data.y < 0) return; node.box = box.data; - // Override the AX value with the live DOM property for form controls. - // The AX `value` is stale for freshly-typed / controlled inputs, so the - // agent can't trust it as a read-back signal. Reading .value/.checked - // from the actual element fixes that. if (ac.signal.aborted || !VALUE_ROLES.has(node.role)) return; const live = await liveValue(session, backendId); if (live !== undefined) node.value = live; @@ -365,9 +319,6 @@ export const snapshotTool = defineBrowserTool({ clearTimeout(timer); } - // Publish refs to the active tab. The signature (role|name|value|state) is - // the baseline the post-mutation auto-diff compares against, so it must use - // the live values resolved above. const refSig = new Map<string, string>(); for (const { node } of targets) { if (node.ref === undefined) continue; @@ -379,7 +330,6 @@ export const snapshotTool = defineBrowserTool({ const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2); const trunc = await applyTruncation(text, "snapshot"); - // 4. Optional screenshot — JPEG q=80 to match screenshotTool's default. let shotPath: string | undefined; if (args.includeScreenshot) { const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 }); @@ -429,9 +379,6 @@ export const snapshotTool = defineBrowserTool({ return new Markdown(md, 0, 0, getMarkdownTheme()); } - // Expanded view: read truncated text from result.content (the spilled file - // is too large to inline) — the same `text` we returned from handler is in - // result.content[0]. Fall back to a placeholder if shape is unexpected. const content = result.content[0]; const treeText = content && content.type === "text" ? content.text : ""; @@ -451,7 +398,6 @@ export const snapshotTool = defineBrowserTool({ if (!details.screenshotPath) return treeBlock; - // Mirror screenshotTool's width-clamp wrapper for the inline image. try { const buf = readFileSync(details.screenshotPath); const b64 = buf.toString("base64"); diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts index 2971a6b..f932fbf 100644 --- a/src/domains/tabs.ts +++ b/src/domains/tabs.ts @@ -87,8 +87,6 @@ export const switchTabTool = defineBrowserTool({ parameters: SwitchTabArgs, concurrency: "serialized", async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { - // Resolve to a concrete targetId (exact, or unique hex prefix). We resolve - // first so the ownership check sees the canonical id, not the prefix. let resolved = args.targetId; if (!client.owns(resolved)) { const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId); |
