diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-06 00:06:49 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-06 00:06:49 +0530 |
| commit | 589cd294931c01b3748779635d17a13dd240af19 (patch) | |
| tree | b635f9fa443cd120de905660fe0d2665184e9a8c /src/cdp | |
| parent | 20ceaa1c6ade4950ed7d16caa5269ad2f4805db4 (diff) | |
release: v0.4.0 — accessibility snapshot, network capture, snapshot-first prompts
Adds two new tools and pivots the agent's default workflow away from
screenshots toward structured page data.
browser_snapshot — CDP accessibility tree (roles, names, states, hierarchy)
with click coordinates @(x,y) attached to every interactive element via
DOM.getBoxModel (parallel, 1.5s aggregate budget). Pass directly to
browser_click — no screenshot round-trip. Optional includeScreenshot:true
for visual confirmation. format:"json" returns a slim structure with full
box per node.
browser_network_requests — replaces the deprecated browser_get_network_log
placeholder. Filters by urlPattern (substring or /regex/), method, status,
resource type, recency. Optional response-body capture under a 5s aggregate
budget with 50KB per-body cap. Buffer is page-scoped (cleared on tab switch),
500 records/tab, with a bufferOverflowed flag on drain.
CDP plumbing: in-process aggregator in src/cdp/network-buffer.ts wired into
the existing single-consumer event loop. Accessibility domain enabled
alongside Page/DOM/Runtime/Network on every attach.
Ctrl+O expand/collapse on three tools' renderResult — browser_snapshot,
browser_network_requests, browser_execute_js. Compact-by-default views with
keyHint("app.tools.expand") that respects user keybinding remaps.
Tool prompts pivoted snapshot-first: browser_screenshot is documented as
LAST RESORT for visual verification only; browser_snapshot is the default
for understanding pages; browser_execute_js is the default for surgical
reads. browser_click and browser_open_urls guidance follows.
SKILL.md collapsed from ~342 to ~53 lines — keeping only what isn't already
in tool prompts: the decision tree, connection rules, browser_run_script
daemon bindings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/cdp')
| -rw-r--r-- | src/cdp/network-buffer.ts | 213 | ||||
| -rw-r--r-- | src/cdp/session.ts | 15 |
2 files changed, 227 insertions, 1 deletions
diff --git a/src/cdp/network-buffer.ts b/src/cdp/network-buffer.ts new file mode 100644 index 0000000..f841719 --- /dev/null +++ b/src/cdp/network-buffer.ts @@ -0,0 +1,213 @@ +/** + * In-memory aggregator for CDP Network.* events. + * + * The CDP event stream is consumed by a single internal loop in session.ts. + * That loop hands Network.* event params to this module, which joins them into + * one record per requestId (request → response → finished/failed). Tools then + * read records via drain(), filtered to what the caller asked for. + * + * Bounded by a fixed capacity (default 500): on overflow the oldest record is + * evicted FIFO. The overflow flag is reported once per drain so tools can warn + * the LLM that data may be missing. + * + * This module is pure (no CDP dependency) so the joining/filtering logic stays + * trivially testable in isolation. + */ + +export type NetworkRecord = { + requestId: string; + method: string; + url: string; + status?: number; + statusText?: string; + type: string; + mimeType?: string; + /** Wall-clock ms when requestWillBeSent fired, in Date.now() units. */ + requestStartedMs: number; + durationMs?: number; + requestBodySize: number; + responseBodySize?: number; + failed?: boolean; + errorText?: string; + /** Populated by the tool layer via Network.getResponseBody, never by the aggregator. */ + body?: string | null; +}; + +export type NetworkFilter = { + /** Substring match by default. Wrap in slashes (`/foo/`) for regex. */ + urlPattern?: string; + methodFilter?: string[]; + statusFilter?: { min?: number; max?: number }; + resourceTypes?: string[]; + /** Only requests whose start is at most N ms ago. */ + sinceMs?: number; + /** Cap on the returned records. */ + limit?: number; +}; + +export type DrainResult = { + readonly records: NetworkRecord[]; + /** Total matches before `limit` was applied. */ + readonly total: number; + /** True if the ring buffer evicted at least one record since the last drain. */ + readonly bufferOverflowed: boolean; +}; + +export type NetworkBuffer = { + ingestRequestWillBeSent(p: unknown): void; + ingestResponseReceived(p: unknown): void; + ingestLoadingFinished(p: unknown): void; + ingestLoadingFailed(p: unknown): void; + drain(filter: NetworkFilter): DrainResult; + clear(): void; +}; + +const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => { + if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) { + try { + const re = new RegExp(pattern.slice(1, -1)); + return (url) => re.test(url); + } catch { + // Fall through to substring match if the pattern is not a valid regex. + } + } + return (url) => url.includes(pattern); +}; + +export const createNetworkBuffer = (capacity = 500): NetworkBuffer => { + // Insertion-ordered map keyed by requestId. Map preserves insertion order in JS, + // so iteration gives us oldest-first eviction without a separate index. + const records = new Map<string, NetworkRecord>(); + let overflowed = false; + + const evictOldestIfFull = (): void => { + while (records.size >= capacity) { + const oldest = records.keys().next(); + if (oldest.done) return; + records.delete(oldest.value); + overflowed = true; + } + }; + + return { + ingestRequestWillBeSent(p) { + // CDP boundary cast: Network.requestWillBeSent + const params = p as + | { + requestId?: string; + request?: { + url?: string; + method?: string; + postData?: string; + headers?: Record<string, string>; + }; + type?: string; + timestamp?: number; + wallTime?: number; + } + | undefined; + const id = params?.requestId; + const url = params?.request?.url; + const method = params?.request?.method; + if (!id || !url || !method) return; + // Re-fired requestWillBeSent for the same id (e.g. redirect) — refresh in place. + records.delete(id); + evictOldestIfFull(); + const postData = params.request?.postData; + records.set(id, { + requestId: id, + method, + url, + type: params.type ?? "Other", + requestStartedMs: Date.now(), + requestBodySize: postData ? Buffer.byteLength(postData, "utf8") : 0, + }); + }, + + ingestResponseReceived(p) { + // CDP boundary cast: Network.responseReceived + const params = p as + | { + requestId?: string; + response?: { + status?: number; + statusText?: string; + mimeType?: string; + }; + type?: string; + } + | undefined; + const id = params?.requestId; + if (!id) return; + const r = records.get(id); + if (!r) return; + if (params?.response?.status !== undefined) r.status = params.response.status; + if (params?.response?.statusText !== undefined && params.response.statusText !== "") r.statusText = params.response.statusText; + if (params?.response?.mimeType !== undefined) r.mimeType = params.response.mimeType; + // type is more accurate on responseReceived (CDP sometimes refines it) + if (params?.type !== undefined) r.type = params.type; + }, + + ingestLoadingFinished(p) { + // CDP boundary cast: Network.loadingFinished + const params = p as + | { requestId?: string; encodedDataLength?: number } + | undefined; + const id = params?.requestId; + if (!id) return; + const r = records.get(id); + if (!r) return; + if (params?.encodedDataLength !== undefined) r.responseBodySize = params.encodedDataLength; + r.durationMs = Date.now() - r.requestStartedMs; + }, + + ingestLoadingFailed(p) { + // CDP boundary cast: Network.loadingFailed + const params = p as + | { requestId?: string; errorText?: string; canceled?: boolean } + | undefined; + const id = params?.requestId; + if (!id) return; + const r = records.get(id); + if (!r) return; + r.failed = true; + if (params?.errorText !== undefined) r.errorText = params.errorText; + r.durationMs = Date.now() - r.requestStartedMs; + }, + + drain(filter) { + const matchUrl = filter.urlPattern !== undefined ? compileUrlMatcher(filter.urlPattern) : undefined; + const methods = filter.methodFilter ? new Set(filter.methodFilter.map((m) => m.toUpperCase())) : undefined; + const types = filter.resourceTypes ? new Set(filter.resourceTypes.map((t) => t.toLowerCase())) : undefined; + const minStatus = filter.statusFilter?.min; + const maxStatus = filter.statusFilter?.max; + const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined; + + const matched: NetworkRecord[] = []; + for (const r of records.values()) { + if (matchUrl && !matchUrl(r.url)) continue; + if (methods && !methods.has(r.method.toUpperCase())) continue; + if (types && !types.has(r.type.toLowerCase())) continue; + if (cutoff !== undefined && r.requestStartedMs < cutoff) continue; + if (minStatus !== undefined && (r.status === undefined || r.status < minStatus)) continue; + if (maxStatus !== undefined && (r.status === undefined || r.status > maxStatus)) continue; + // Clone so callers can mutate (e.g. attach body) without polluting the buffer. + matched.push({ ...r }); + } + + const total = matched.length; + const limit = Math.min(filter.limit ?? 50, 500); + const limited = matched.slice(-limit); // most recent N + + const bufferOverflowed = overflowed; + overflowed = false; + + return { records: limited, total, bufferOverflowed }; + }, + + clear() { + records.clear(); + overflowed = false; + }, + }; +}; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 42f6107..89bdfdd 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -3,6 +3,7 @@ import type { CdpError } from "./errors"; import type { DialogInfo } from "./types"; import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./transport"; +import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; export type CdpSession = { attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>; @@ -13,6 +14,7 @@ export type CdpSession = { callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>; takeDialog(): DialogInfo | null; drainPageInfoInvalidations(): boolean; + drainNetworkBuffer(filter: NetworkFilter): DrainResult; }; export const createCdpSession = ( @@ -23,6 +25,7 @@ export const createCdpSession = ( let targetId: string | null = null; let dialog: DialogInfo | null = null; let pageInfoDirty = false; + const networkBuffer = createNetworkBuffer(); let activeConsumer: Promise<void> = Promise.resolve(); @@ -48,6 +51,10 @@ export const createCdpSession = ( const params = ev.params as { targetId?: string } | undefined; if (params?.targetId) ownership.remove(params.targetId); } + if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params); + else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params); + else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params); + else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params); } }; @@ -75,7 +82,7 @@ export const createCdpSession = ( // Switching to Promise.all over a single WS pipelines the round-trips and // saves ~3× on tab-switch latency. Defer until session.ts has tests. const enableDomains = async (sid: string): Promise<void> => { - for (const d of ["Page", "DOM", "Runtime", "Network"]) { + for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) { await transport.request(`${d}.enable`, {}, { sessionId: sid }); } }; @@ -143,6 +150,9 @@ export const createCdpSession = ( sessionId = a.sessionId; targetId = tid; pageInfoDirty = true; + // Buffer is page-scoped: on switch, the network history of the previous + // tab is no longer relevant to what the agent is now looking at. + networkBuffer.clear(); await enableDomains(a.sessionId); return ok(undefined); }, @@ -168,5 +178,8 @@ export const createCdpSession = ( pageInfoDirty = false; return dirty; }, + drainNetworkBuffer(filter) { + return networkBuffer.drain(filter); + }, }; }; |
