src/domains/console.ts (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 |
import { Type } from "typebox";
import { Markdown, Text } from "@mariozechner/pi-tui";
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
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(
Type.Array(
Type.Union(
[
Type.Literal("log"),
Type.Literal("info"),
Type.Literal("warn"),
Type.Literal("error"),
Type.Literal("debug"),
],
),
{ description: 'Filter to these levels only, e.g. ["error","warn"]. Default: all levels.' },
),
),
textPattern: Type.Optional(
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /TypeError/) for regex." }),
),
sinceSeq: Type.Optional(
Type.Integer({ minimum: 0, description: "Only records with seq strictly greater than this. Use the previous call's nextCursor to see only what's new." }),
),
sinceMs: Type.Optional(
Type.Integer({ minimum: 0, description: "Only records from the last N ms." }),
),
limit: Type.Optional(
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
),
});
const levelTag = (lvl: ConsoleLevel): string => {
switch (lvl) {
case "error": return "[error]";
case "warn": return "[warn] ";
case "info": return "[info] ";
case "debug": return "[debug]";
default: return "[log] ";
}
};
const formatLocation = (r: ConsoleRecord): string => {
if (r.url === undefined || r.url === "") return "";
const short = r.url.length > 60 ? "…" + r.url.slice(-59) : r.url;
return r.lineNumber !== undefined ? `${short}:${r.lineNumber}` : short;
};
const renderConsoleMarkdown = (
records: ReadonlyArray<ConsoleRecord>,
opts: { total: number; bufferOverflowed: boolean; expanded: boolean; nextCursor: number | undefined },
): string => {
if (records.length === 0) {
if (opts.bufferOverflowed) return "_No matching console records. (Buffer overflowed since last drain — some events were dropped.)_";
return "_No matching console records captured yet._";
}
const counts = records.reduce<Record<ConsoleLevel, number>>(
(acc, r) => { acc[r.level]++; return acc; },
{ log: 0, info: 0, warn: 0, error: 0, debug: 0 },
);
const summaryParts: string[] = [];
if (counts.error > 0) summaryParts.push(`${counts.error} error${counts.error === 1 ? "" : "s"}`);
if (counts.warn > 0) summaryParts.push(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`);
if (counts.info > 0) summaryParts.push(`${counts.info} info`);
if (counts.log > 0) summaryParts.push(`${counts.log} log${counts.log === 1 ? "" : "s"}`);
if (counts.debug > 0) summaryParts.push(`${counts.debug} debug`);
const headerLines: string[] = [`**${summaryParts.join(" · ")}**`];
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older entries dropped");
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
const renderRow = (r: ConsoleRecord, compact: boolean): string => {
const tag = levelTag(r.level);
const loc = formatLocation(r);
const maxText = compact ? 80 : 200;
const text = r.text.length > maxText ? r.text.slice(0, maxText - 1) + "…" : r.text;
const locSuffix = loc !== "" ? ` _at ${loc}_` : "";
const main = `\`${tag}\` ${text}${locSuffix}`;
if (!compact && r.stackTrace !== undefined) return `${main}\n\`\`\`\n${r.stackTrace}\n\`\`\``;
return main;
};
const visible = !opts.expanded && records.length > 5 ? records.slice(-5) : records;
const rows = visible.map((r) => renderRow(r, !opts.expanded)).join(opts.expanded ? "\n\n" : "\n");
const more = !opts.expanded && records.length > 5 ? `\n … ${records.length - 5} earlier (expand to see all)` : "";
const cursor = opts.nextCursor !== undefined ? `\n\n_nextCursor=${opts.nextCursor}_` : "";
return `${headerLines.join("\n")}\n\n${rows}${more}${cursor}`;
};
type ConsoleDetails = {
total: number;
returned: number;
bufferOverflowed: boolean;
truncated: boolean;
fullOutputPath?: string;
records: ReadonlyArray<ConsoleRecord>;
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",
description:
"Read JS errors and console messages from the active tab. Diagnostic — use when an action looks broken or silent.",
promptSnippet: "Read JS errors / console output on the current tab",
promptGuidelines: [
"Buffer is reset on tab switch (capacity 500 records). bufferOverflowed:true means older entries were dropped.",
"Don't use as a default observation tool — prefer browser_snapshot for page structure.",
],
parameters: ConsoleArgs,
concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const session = client.session();
const drained = session.drainConsoleBuffer({
...(args.levels !== undefined ? { levels: args.levels } : {}),
...(args.textPattern !== undefined ? { textPattern: args.textPattern } : {}),
...(args.sinceSeq !== undefined ? { sinceSeq: args.sinceSeq } : {}),
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
limit: args.limit ?? 50,
});
const last = drained.records.at(-1);
const nextCursor = last?.seq;
const text = renderConsoleMarkdown(drained.records, {
total: drained.total,
bufferOverflowed: drained.bufferOverflowed,
expanded: true,
nextCursor,
});
const trunc = await applyTruncation(text, "console");
const details: ConsoleDetails = {
total: drained.total,
returned: drained.records.length,
bufferOverflowed: drained.bufferOverflowed,
truncated: trunc.wasTruncated,
records: drained.records,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
return ok({ text: trunc.text, details });
},
renderResult(result, expanded, theme) {
const details = asConsoleDetails(result.details);
if (!details) return new Text(theme.fg("error", "console: no details"), 0, 0);
const md = renderConsoleMarkdown(details.records, {
total: details.total,
bufferOverflowed: details.bufferOverflowed,
expanded,
nextCursor: details.nextCursor,
});
const tail = expanded
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
},
});
|