aboutsummaryrefslogtreecommitdiff
path: root/src/domains
diff options
context:
space:
mode:
Diffstat (limited to 'src/domains')
-rw-r--r--src/domains/console.ts47
-rw-r--r--src/domains/js.ts38
-rw-r--r--src/domains/network.ts54
-rw-r--r--src/domains/ref-resolve.ts4
-rw-r--r--src/domains/render.ts14
-rw-r--r--src/domains/screenshot.ts8
-rw-r--r--src/domains/snapshot.ts35
7 files changed, 146 insertions, 54 deletions
diff --git a/src/domains/console.ts b/src/domains/console.ts
index 9696cd9..c38da2a 100644
--- a/src/domains/console.ts
+++ b/src/domains/console.ts
@@ -5,6 +5,7 @@ import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
import type { ConsoleRecord, ConsoleLevel } from "../cdp/console-buffer";
+import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
const ConsoleArgs = Type.Object({
levels: Type.Optional(
@@ -106,6 +107,50 @@ type ConsoleDetails = {
nextCursor?: number;
};
+const CONSOLE_LEVELS: ReadonlyArray<ConsoleLevel> = ["log", "info", "warn", "error", "debug"];
+
+const asConsoleLevel = (v: unknown): ConsoleLevel | undefined =>
+ CONSOLE_LEVELS.find((l) => l === v);
+
+const asConsoleRecord = (v: unknown): ConsoleRecord | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const level = asConsoleLevel(o["level"]);
+ const text = asString(o["text"]);
+ if (level === undefined || text === undefined) return undefined;
+ const url = asString(o["url"]);
+ const lineNumber = asNumber(o["lineNumber"]);
+ const stackTrace = asString(o["stackTrace"]);
+ return {
+ seq: asNumber(o["seq"]) ?? 0,
+ timestampMs: asNumber(o["timestampMs"]) ?? 0,
+ level,
+ text,
+ source: o["source"] === "log-entry" ? "log-entry" : "console-api",
+ ...(url !== undefined ? { url } : {}),
+ ...(lineNumber !== undefined ? { lineNumber } : {}),
+ ...(stackTrace !== undefined ? { stackTrace } : {}),
+ };
+};
+
+const asConsoleDetails = (v: unknown): ConsoleDetails | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const records = asArrayOf(o["records"], asConsoleRecord);
+ if (records === undefined) return undefined;
+ const fullOutputPath = asString(o["fullOutputPath"]);
+ const nextCursor = asNumber(o["nextCursor"]);
+ return {
+ total: asNumber(o["total"]) ?? records.length,
+ returned: asNumber(o["returned"]) ?? records.length,
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
+ truncated: asBoolean(o["truncated"]) ?? false,
+ records,
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
+ };
+};
+
export const consoleTool = defineBrowserTool({
name: "browser_console",
label: "Browser Console",
@@ -156,7 +201,7 @@ export const consoleTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as ConsoleDetails | undefined;
+ const details = asConsoleDetails(result.details);
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
const md = renderConsoleMarkdown(details.records, {
diff --git a/src/domains/js.ts b/src/domains/js.ts
index 61dbe53..6d9ace0 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -8,6 +8,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
+import { asNumber, asRecord, asString, isRecord } from "../util/guards";
const COMPACT_PREVIEW_BYTES = 120;
@@ -80,10 +81,14 @@ export const executeJsTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as
- | { valueLength?: number; full?: string; pretty?: string; fullOutputPath?: string }
- | undefined;
- if (!details) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
+ const raw = asRecord(result.details);
+ if (raw === undefined) return new Text(theme.fg("error", "execute_js: no details"), 0, 0);
+ const details = {
+ valueLength: asNumber(raw["valueLength"]),
+ full: asString(raw["full"]),
+ pretty: asString(raw["pretty"]),
+ fullOutputPath: asString(raw["fullOutputPath"]),
+ };
const len = details.valueLength ?? 0;
const full = details.full ?? "";
@@ -141,9 +146,7 @@ const MAX_SOURCE_BYTES = 1_000_000;
type ContentItem = { readonly type: "text"; readonly text: string };
const isContentItem = (v: unknown): v is ContentItem =>
- typeof v === "object" && v !== null
- && (v as Readonly<Record<string, unknown>>)["type"] === "text"
- && typeof (v as Readonly<Record<string, unknown>>)["text"] === "string";
+ isRecord(v) && v["type"] === "text" && typeof v["text"] === "string";
export const runScriptTool = defineBrowserTool({
name: "browser_run_script",
@@ -211,12 +214,12 @@ export const runScriptTool = defineBrowserTool({
requireFromHere,
ac.signal,
(u: unknown) => {
- if (typeof u !== "object" || u === null) return;
- const content = (u as Readonly<Record<string, unknown>>)["content"];
+ if (!isRecord(u)) return;
+ const content = u["content"];
if (!Array.isArray(content) || content.length === 0) return;
- const first = content[0];
- if (typeof first !== "object" || first === null) return;
- const txt = (first as Readonly<Record<string, unknown>>)["text"];
+ const first: unknown = content[0];
+ if (!isRecord(first)) return;
+ const txt = first["text"];
if (typeof txt !== "string") return;
try { onUpdate({ text: txt }); } catch { /* swallow */ }
},
@@ -230,10 +233,10 @@ export const runScriptTool = defineBrowserTool({
clearTimeout(timeoutTimer);
if (signal !== undefined) signal.removeEventListener("abort", onAbort);
- if (typeof result !== "object" || result === null) {
+ if (!isRecord(result)) {
return err({ kind: "invalid_state", message: `Script must return an object; got ${JSON.stringify(result)}` });
}
- const content = (result as Readonly<Record<string, unknown>>)["content"];
+ const content = result["content"];
if (!Array.isArray(content)) {
return err({ kind: "invalid_state", message: "Script return value must have a content array" });
}
@@ -241,11 +244,8 @@ export const runScriptTool = defineBrowserTool({
return err({ kind: "invalid_state", message: "Script content array must contain { type: 'text', text: string } items" });
}
const textOut = content.map((c) => c.text).join("\n");
- const details = (result as Readonly<Record<string, unknown>>)["details"];
- const isPlainObject = typeof details === "object" && details !== null && !Array.isArray(details);
- return isPlainObject
- ? ok({ text: textOut, details: details as Readonly<Record<string, unknown>> })
- : ok({ text: textOut });
+ const details = result["details"];
+ return isRecord(details) ? ok({ text: textOut, details }) : ok({ text: textOut });
} catch (e) {
clearTimeout(timeoutTimer);
if (signal !== undefined) signal.removeEventListener("abort", onAbort);
diff --git a/src/domains/network.ts b/src/domains/network.ts
index dc23c6d..fa755b3 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -4,6 +4,7 @@ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
+import { asArrayOf, asBoolean, asNumber, asRecord, asString } from "../util/guards";
import type { NetworkRecord } from "../cdp/network-buffer";
const HttpGetArgs = Type.Object({
@@ -168,6 +169,57 @@ type NetworkDetails = {
includeBodies: boolean;
};
+const asNetworkRecord = (v: unknown): NetworkRecord | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const method = asString(o["method"]);
+ const url = asString(o["url"]);
+ const type = asString(o["type"]);
+ if (method === undefined || url === undefined || type === undefined) return undefined;
+ const status = asNumber(o["status"]);
+ const statusText = asString(o["statusText"]);
+ const mimeType = asString(o["mimeType"]);
+ const durationMs = asNumber(o["durationMs"]);
+ const responseBodySize = asNumber(o["responseBodySize"]);
+ const failed = asBoolean(o["failed"]);
+ const errorText = asString(o["errorText"]);
+ const bodyRaw = o["body"];
+ const body = typeof bodyRaw === "string" || bodyRaw === null ? bodyRaw : undefined;
+ return {
+ requestId: asString(o["requestId"]) ?? "",
+ method,
+ url,
+ type,
+ requestStartedMs: asNumber(o["requestStartedMs"]) ?? 0,
+ requestBodySize: asNumber(o["requestBodySize"]) ?? 0,
+ ...(status !== undefined ? { status } : {}),
+ ...(statusText !== undefined ? { statusText } : {}),
+ ...(mimeType !== undefined ? { mimeType } : {}),
+ ...(durationMs !== undefined ? { durationMs } : {}),
+ ...(responseBodySize !== undefined ? { responseBodySize } : {}),
+ ...(failed !== undefined ? { failed } : {}),
+ ...(errorText !== undefined ? { errorText } : {}),
+ ...(body !== undefined ? { body } : {}),
+ };
+};
+
+const asNetworkDetails = (v: unknown): NetworkDetails | undefined => {
+ const o = asRecord(v);
+ if (o === undefined) return undefined;
+ const requests = asArrayOf(o["requests"], asNetworkRecord);
+ if (requests === undefined) return undefined;
+ const fullOutputPath = asString(o["fullOutputPath"]);
+ return {
+ total: asNumber(o["total"]) ?? requests.length,
+ returned: asNumber(o["returned"]) ?? requests.length,
+ bufferOverflowed: asBoolean(o["bufferOverflowed"]) ?? false,
+ truncated: asBoolean(o["truncated"]) ?? false,
+ requests,
+ includeBodies: asBoolean(o["includeBodies"]) ?? false,
+ ...(fullOutputPath !== undefined ? { fullOutputPath } : {}),
+ };
+};
+
export const networkRequestsTool = defineBrowserTool({
name: "browser_network_requests",
label: "Browser Network Requests",
@@ -235,7 +287,7 @@ export const networkRequestsTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as NetworkDetails | undefined;
+ const details = asNetworkDetails(result.details);
if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
const md = renderNetworkMarkdown(details.requests, {
diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts
index de83521..a80c620 100644
--- a/src/domains/ref-resolve.ts
+++ b/src/domains/ref-resolve.ts
@@ -2,7 +2,7 @@ import type { BrowserClient } from "../client";
import { type Result, err, ok } from "../util/result";
import type { ToolErr } from "../util/tool";
import { type Box, boxOf } from "./box";
-import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
+import { buildTree, collectInteractiveTargets, type SlimNode } from "./snapshot";
const staleErr = (ref: string): ToolErr => ({
kind: "invalid_state",
@@ -78,7 +78,7 @@ export const interactiveDiff = async (client: BrowserClient): Promise<string> =>
const axRes = await session.call("Accessibility.getFullAXTree", {});
if (!axRes.success) return "";
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
+ const rawNodes = axRes.data.nodes;
const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 });
const targets = collectInteractiveTargets(slim);
diff --git a/src/domains/render.ts b/src/domains/render.ts
index f6a6535..1e4e30d 100644
--- a/src/domains/render.ts
+++ b/src/domains/render.ts
@@ -11,6 +11,7 @@ import { Markdown, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { Component } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
+import { isRecord } from "../util/guards";
const COMPACT_PREVIEW_LINES = 6;
@@ -24,16 +25,11 @@ export type ExpandableText = {
readonly fullOutputPath?: string;
};
-const isExpandableText = (value: unknown): value is ExpandableText => {
- if (typeof value !== "object" || value === null) return false;
- const v = value as Record<string, unknown>;
- return typeof v["summary"] === "string" && typeof v["body"] === "string";
-};
+const isExpandableText = (value: unknown): value is ExpandableText =>
+ isRecord(value) && typeof value["summary"] === "string" && typeof value["body"] === "string";
-const extractRender = (details: unknown): unknown => {
- if (typeof details !== "object" || details === null) return undefined;
- return (details as Record<string, unknown>)["render"];
-};
+const extractRender = (details: unknown): unknown =>
+ isRecord(details) ? details["render"] : undefined;
/**
* Build a renderResult over an ExpandableText stored in `details.render`.
diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts
index c6cc87b..2e9cfdc 100644
--- a/src/domains/screenshot.ts
+++ b/src/domains/screenshot.ts
@@ -7,6 +7,7 @@ import { type Result, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { screenshotPath } from "../util/paths";
import { loadSharp } from "../util/sharp-shim";
+import { asRecord, asString } from "../util/guards";
import { safeJs } from "../util/js-template";
import { cdpCall } from "./cdp-call";
@@ -91,15 +92,16 @@ export const screenshotTool = defineBrowserTool({
},
renderResult(result, _expanded, theme) {
- const details = result.details as { path?: string; format?: string } | undefined;
- const filePath = details?.path;
+ const raw = asRecord(result.details);
+ const filePath = raw === undefined ? undefined : asString(raw["path"]);
if (!filePath) {
return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
}
+ const format = raw === undefined ? undefined : asString(raw["format"]);
try {
const buf = readFileSync(filePath);
const b64 = buf.toString("base64");
- const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png";
+ const mimeType = format === "jpeg" ? "image/jpeg" : "image/png";
const imageTheme: ImageTheme = {
fallbackColor: (str: string) => theme.fg("dim", str),
};
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index ec5c894..c015203 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -3,12 +3,14 @@ import { Type } from "typebox";
import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
import type { CdpSession } from "../cdp/session";
+import type { AxNodeResult } from "../cdp/commands";
import { type Box, boxOf } from "./box";
import { cdpCall } from "./cdp-call";
import { type Result, err, ok } from "../util/result";
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
import { applyTruncation } from "../util/truncate";
import { screenshotPath } from "../util/paths";
+import { asBoolean, asNumber, asRecord, asString } from "../util/guards";
const SnapshotArgs = Type.Object({
includeScreenshot: Type.Optional(
@@ -28,20 +30,8 @@ const SnapshotArgs = Type.Object({
),
});
-// Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
-type AxValue = { value?: unknown };
-export type RawAxNode = {
- nodeId: string;
- parentId?: string;
- childIds?: ReadonlyArray<string>;
- ignored?: boolean;
- role?: AxValue;
- name?: AxValue;
- value?: AxValue;
- description?: AxValue;
- properties?: ReadonlyArray<{ name?: string; value?: AxValue }>;
- backendDOMNodeId?: number;
-};
+export type RawAxNode = AxNodeResult;
+type AxValue = NonNullable<RawAxNode["role"]>;
export type SlimNode = {
role: string;
@@ -322,7 +312,7 @@ export const snapshotTool = defineBrowserTool({
// 1. AX tree
const axRes = await cdpCall(client, "Accessibility.getFullAXTree", {});
if (!axRes.success) return axRes;
- const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
+ const rawNodes = axRes.data.nodes;
// 2. Page url/title
const piRes = await client.pageInfo();
@@ -412,10 +402,17 @@ export const snapshotTool = defineBrowserTool({
},
renderResult(result, expanded, theme) {
- const details = result.details as
- | (SnapshotDetails & { summary?: string; ok?: boolean })
- | undefined;
- if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
+ const raw = asRecord(result.details);
+ if (raw === undefined) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
+ const details = {
+ nodeCount: asNumber(raw["nodeCount"]),
+ truncated: asBoolean(raw["truncated"]),
+ fullOutputPath: asString(raw["fullOutputPath"]),
+ screenshotPath: asString(raw["screenshotPath"]),
+ url: asString(raw["url"]),
+ title: asString(raw["title"]),
+ summary: asString(raw["summary"]),
+ };
const titleLine = details.title ? ` "${details.title}"` : "";
const summary = details.summary ?? "";