pi-agent-browser-native 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Purpose: Render parsed agent-browser results into concise pi-facing summaries, text content, and optional inline image attachments.
3
+ * Responsibilities: Format command summaries, delegate snapshot-specific rendering to the snapshot module, attach inline images within size limits, and keep generic record formatting distinct from envelope parsing.
4
+ * Scope: Presentation shaping only; upstream stdout parsing and snapshot compaction internals live in separate modules.
5
+ * Usage: Imported by the public `lib/results.ts` facade and consumed by the extension entrypoint after envelope parsing.
6
+ * Invariants/Assumptions: Presentation logic should stay close to upstream data while remaining small enough to reason about without mixing in snapshot-parser or envelope-parser internals.
7
+ */
8
+
9
+ import { readFile, stat } from "node:fs/promises";
10
+ import { resolve } from "node:path";
11
+
12
+ import { parseCommandInfo, type CommandInfo } from "../runtime.js";
13
+ import { buildSnapshotPresentation, formatRawSnapshotText, formatSnapshotSummary } from "./snapshot.js";
14
+ import {
15
+ type AgentBrowserBatchResult,
16
+ type AgentBrowserEnvelope,
17
+ type BatchStepPresentationDetails,
18
+ type ToolPresentation,
19
+ isRecord,
20
+ parsePositiveInteger,
21
+ stringifyUnknown,
22
+ } from "./shared.js";
23
+
24
+ const IMAGE_EXTENSION_TO_MIME_TYPE: Record<string, string> = {
25
+ ".gif": "image/gif",
26
+ ".jpeg": "image/jpeg",
27
+ ".jpg": "image/jpeg",
28
+ ".png": "image/png",
29
+ ".webp": "image/webp",
30
+ };
31
+
32
+ const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
33
+ const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
34
+ const NAVIGATION_SUMMARY_COMMANDS = new Set(["back", "click", "dblclick", "forward", "reload"]);
35
+ const NAVIGATION_SUMMARY_FIELD = "navigationSummary";
36
+
37
+ interface NavigationSummary {
38
+ title?: string;
39
+ url?: string;
40
+ }
41
+
42
+ function getImageMimeType(filePath: string): string | undefined {
43
+ const extension = filePath.toLowerCase().slice(filePath.lastIndexOf("."));
44
+ return IMAGE_EXTENSION_TO_MIME_TYPE[extension];
45
+ }
46
+
47
+ function getInlineImageMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
48
+ return parsePositiveInteger(env[INLINE_IMAGE_MAX_BYTES_ENV]) ?? DEFAULT_INLINE_IMAGE_MAX_BYTES;
49
+ }
50
+
51
+ function formatByteCount(bytes: number): string {
52
+ if (bytes < 1_024) return `${bytes} B`;
53
+ if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)} KiB`;
54
+ return `${(bytes / (1_024 * 1_024)).toFixed(1)} MiB`;
55
+ }
56
+
57
+ function appendPresentationNotice(presentation: ToolPresentation, message: string): void {
58
+ const existingText = presentation.content[0]?.type === "text" ? presentation.content[0].text : "";
59
+ presentation.content[0] = {
60
+ type: "text",
61
+ text: existingText.length > 0 ? `${existingText}\n\n${message}` : message,
62
+ };
63
+ }
64
+
65
+ function getTabSummary(data: Record<string, unknown>): string | undefined {
66
+ const tabs = Array.isArray(data.tabs) ? data.tabs : undefined;
67
+ if (!tabs) return undefined;
68
+
69
+ const lines = tabs.map((tab, index) => {
70
+ if (!isRecord(tab)) return `${index}: <invalid tab>`;
71
+ const marker = tab.active === true ? "*" : "-";
72
+ const title = typeof tab.title === "string" ? tab.title : "(untitled)";
73
+ const url = typeof tab.url === "string" ? tab.url : "(no url)";
74
+ const tabIndex = typeof tab.index === "number" ? tab.index : index;
75
+ return `${marker} [${tabIndex}] ${title} — ${url}`;
76
+ });
77
+ return lines.join("\n");
78
+ }
79
+
80
+ function getStreamSummary(data: Record<string, unknown>): string | undefined {
81
+ if (typeof data.enabled !== "boolean" || typeof data.connected !== "boolean") {
82
+ return undefined;
83
+ }
84
+
85
+ const lines = [
86
+ `Enabled: ${data.enabled}`,
87
+ `Connected: ${data.connected}`,
88
+ `Screencasting: ${data.screencasting === true}`,
89
+ ];
90
+ if (typeof data.port === "number") {
91
+ lines.push(`Port: ${data.port}`);
92
+ }
93
+ return lines.join("\n");
94
+ }
95
+
96
+ function getPageSummary(data: Record<string, unknown>): string | undefined {
97
+ const title = typeof data.title === "string" ? data.title : undefined;
98
+ const url = typeof data.url === "string" ? data.url : undefined;
99
+ if (!title && !url) return undefined;
100
+ if (title && url) return `${title}\n${url}`;
101
+ return title ?? url;
102
+ }
103
+
104
+ function getScreenshotSummary(data: Record<string, unknown>): string | undefined {
105
+ return typeof data.path === "string" ? `Saved image: ${data.path}` : undefined;
106
+ }
107
+
108
+ function isNavigationObservableCommand(command: string | undefined): boolean {
109
+ return command !== undefined && NAVIGATION_SUMMARY_COMMANDS.has(command);
110
+ }
111
+
112
+ function isNavigationSummary(value: unknown): value is NavigationSummary {
113
+ return isRecord(value) && (typeof value.title === "string" || typeof value.url === "string");
114
+ }
115
+
116
+ function getNavigationSummary(data: Record<string, unknown>): NavigationSummary | undefined {
117
+ const candidate = data[NAVIGATION_SUMMARY_FIELD];
118
+ return isNavigationSummary(candidate) ? candidate : undefined;
119
+ }
120
+
121
+ function formatNavigationSummary(summary: NavigationSummary): string | undefined {
122
+ const title = typeof summary.title === "string" && summary.title.trim().length > 0 ? summary.title.trim() : undefined;
123
+ const url = typeof summary.url === "string" && summary.url.trim().length > 0 ? summary.url.trim() : undefined;
124
+ if (!title && !url) return undefined;
125
+ if (title && url) return `${title}\n${url}`;
126
+ return title ?? url;
127
+ }
128
+
129
+ function stripNavigationSummary(data: Record<string, unknown>): Record<string, unknown> {
130
+ const { [NAVIGATION_SUMMARY_FIELD]: _navigationSummary, ...rest } = data;
131
+ return rest;
132
+ }
133
+
134
+ function formatNavigationActionResult(data: Record<string, unknown>): string | undefined {
135
+ const actionData = stripNavigationSummary(data);
136
+ const lines: string[] = [];
137
+ if (typeof actionData.clicked === "string" || typeof actionData.clicked === "boolean") {
138
+ lines.push(`Clicked: ${String(actionData.clicked)}`);
139
+ }
140
+ if (typeof actionData.href === "string") {
141
+ lines.push(`Href: ${actionData.href}`);
142
+ }
143
+ if (typeof actionData.navigated === "boolean") {
144
+ lines.push(`Navigated: ${actionData.navigated}`);
145
+ }
146
+ if (lines.length > 0) {
147
+ return lines.join("\n");
148
+ }
149
+
150
+ const actionText = stringifyUnknown(actionData).trim();
151
+ if (actionText.length === 0 || actionText === "{}") {
152
+ return undefined;
153
+ }
154
+ return actionText;
155
+ }
156
+
157
+ function isStringArray(value: unknown): value is string[] {
158
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
159
+ }
160
+
161
+ function getPresentationText(presentation: ToolPresentation): string {
162
+ return presentation.content
163
+ .filter((part): part is Extract<ToolPresentation["content"][number], { type: "text" }> => part.type === "text")
164
+ .map((part) => part.text.trim())
165
+ .filter((text) => text.length > 0)
166
+ .join("\n\n");
167
+ }
168
+
169
+ function getPresentationImages(presentation: ToolPresentation): Array<Extract<ToolPresentation["content"][number], { type: "image" }>> {
170
+ return presentation.content.filter(
171
+ (part): part is Extract<ToolPresentation["content"][number], { type: "image" }> => part.type === "image",
172
+ );
173
+ }
174
+
175
+ function getPresentationPaths(options: {
176
+ primaryPath?: string;
177
+ secondaryPaths?: string[];
178
+ }): string[] {
179
+ return options.secondaryPaths ?? (options.primaryPath ? [options.primaryPath] : []);
180
+ }
181
+
182
+ function formatBatchStepCommand(command: string[] | undefined, index: number): string {
183
+ return command && command.length > 0 ? command.join(" ") : `step-${index + 1}`;
184
+ }
185
+
186
+ function formatBatchStepError(error: unknown): string {
187
+ const errorText = stringifyUnknown(error).trim();
188
+ return errorText.length > 0 ? `Error: ${errorText}` : "Error: batch step failed.";
189
+ }
190
+
191
+ async function buildBatchStepPresentation(options: {
192
+ cwd: string;
193
+ index: number;
194
+ item: AgentBrowserBatchResult;
195
+ }): Promise<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> {
196
+ const { cwd, index, item } = options;
197
+ const command = isStringArray(item.command) ? item.command : undefined;
198
+ const commandText = formatBatchStepCommand(command, index);
199
+
200
+ if (item.success === false) {
201
+ const errorText = formatBatchStepError(item.error);
202
+ const presentation: ToolPresentation = {
203
+ content: [{ type: "text", text: errorText }],
204
+ summary: errorText,
205
+ };
206
+ return {
207
+ details: {
208
+ command,
209
+ commandText,
210
+ data: item.error,
211
+ index,
212
+ success: false,
213
+ summary: errorText,
214
+ text: errorText,
215
+ },
216
+ presentation,
217
+ };
218
+ }
219
+
220
+ const presentation = await buildToolPresentation({
221
+ commandInfo: parseCommandInfo(command ?? []),
222
+ cwd,
223
+ envelope: { data: item.result, success: true },
224
+ });
225
+ const fullOutputPaths = getPresentationPaths({
226
+ primaryPath: presentation.fullOutputPath,
227
+ secondaryPaths: presentation.fullOutputPaths,
228
+ });
229
+ const imagePaths = getPresentationPaths({
230
+ primaryPath: presentation.imagePath,
231
+ secondaryPaths: presentation.imagePaths,
232
+ });
233
+ const text = getPresentationText(presentation) || presentation.summary;
234
+
235
+ return {
236
+ details: {
237
+ command,
238
+ commandText,
239
+ data: presentation.data,
240
+ fullOutputPath: fullOutputPaths[0],
241
+ fullOutputPaths: fullOutputPaths.length > 0 ? fullOutputPaths : undefined,
242
+ imagePath: imagePaths[0],
243
+ imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
244
+ index,
245
+ success: true,
246
+ summary: presentation.summary,
247
+ text,
248
+ },
249
+ presentation,
250
+ };
251
+ }
252
+
253
+ async function buildBatchPresentation(options: {
254
+ cwd: string;
255
+ data: AgentBrowserBatchResult[];
256
+ summary: string;
257
+ }): Promise<ToolPresentation> {
258
+ const { cwd, data, summary } = options;
259
+ const steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> = [];
260
+ for (const [index, item] of data.entries()) {
261
+ steps.push(await buildBatchStepPresentation({ cwd, index, item }));
262
+ }
263
+
264
+ const images = steps.flatMap((step) => getPresentationImages(step.presentation));
265
+ const fullOutputPaths = steps.flatMap((step) => getPresentationPaths({
266
+ primaryPath: step.presentation.fullOutputPath,
267
+ secondaryPaths: step.presentation.fullOutputPaths,
268
+ }));
269
+ const imagePaths = steps.flatMap((step) => getPresentationPaths({
270
+ primaryPath: step.presentation.imagePath,
271
+ secondaryPaths: step.presentation.imagePaths,
272
+ }));
273
+ const text =
274
+ steps.length === 0
275
+ ? "(no batch steps)"
276
+ : steps
277
+ .map(({ details, presentation }) => {
278
+ const inlineImageCount = getPresentationImages(presentation).length;
279
+ const lines = [`Step ${details.index + 1} — ${details.commandText}`];
280
+ if (details.text.length > 0) {
281
+ lines.push(details.text);
282
+ }
283
+ if (inlineImageCount > 0) {
284
+ lines.push(`(${inlineImageCount} inline image attachment${inlineImageCount === 1 ? "" : "s"} below)`);
285
+ }
286
+ return lines.join("\n");
287
+ })
288
+ .join("\n\n");
289
+
290
+ return {
291
+ batchSteps: steps.map((step) => step.details),
292
+ content: [{ type: "text", text }, ...images],
293
+ data,
294
+ fullOutputPath: fullOutputPaths[0],
295
+ fullOutputPaths: fullOutputPaths.length > 0 ? fullOutputPaths : undefined,
296
+ imagePath: imagePaths[0],
297
+ imagePaths: imagePaths.length > 0 ? imagePaths : undefined,
298
+ summary,
299
+ };
300
+ }
301
+
302
+ function formatSummary(commandInfo: CommandInfo, data: unknown): string {
303
+ if (Array.isArray(data) && commandInfo.command === "batch") {
304
+ const successCount = data.filter((item) => isRecord(item) && item.success !== false).length;
305
+ return `Batch: ${successCount}/${data.length} succeeded`;
306
+ }
307
+ if (isRecord(data)) {
308
+ const navigationSummary = getNavigationSummary(data);
309
+ if (navigationSummary && isNavigationObservableCommand(commandInfo.command)) {
310
+ const navigationText = formatNavigationSummary(navigationSummary);
311
+ if (navigationText) {
312
+ return `${commandInfo.command ?? "navigation"} → ${navigationText.split("\n", 1)[0] ?? navigationText}`;
313
+ }
314
+ }
315
+ if (commandInfo.command === "snapshot") {
316
+ return formatSnapshotSummary(data);
317
+ }
318
+ if (commandInfo.command === "tab" && Array.isArray(data.tabs)) {
319
+ return `Tabs: ${data.tabs.length}`;
320
+ }
321
+ if (commandInfo.command === "stream" && commandInfo.subcommand === "status") {
322
+ const port = typeof data.port === "number" ? ` on port ${data.port}` : "";
323
+ return `Stream ${data.enabled === true ? "enabled" : "disabled"}${port}`;
324
+ }
325
+ if (commandInfo.command === "screenshot" && typeof data.path === "string") {
326
+ return `Screenshot saved: ${data.path}`;
327
+ }
328
+ const pageSummary = getPageSummary(data);
329
+ if (pageSummary) {
330
+ return pageSummary.split("\n", 1)[0] ?? "agent-browser result";
331
+ }
332
+ }
333
+
334
+ if (typeof data === "string" && data.length > 0) {
335
+ return data.split("\n", 1)[0] ?? data;
336
+ }
337
+
338
+ const primaryCommand = commandInfo.command ?? "agent-browser";
339
+ return `${primaryCommand} completed`;
340
+ }
341
+
342
+ function formatContentText(commandInfo: CommandInfo, data: unknown): string {
343
+ if (typeof data === "string") {
344
+ return data;
345
+ }
346
+ if (typeof data === "number" || typeof data === "boolean") {
347
+ return String(data);
348
+ }
349
+ if (!isRecord(data)) {
350
+ return stringifyUnknown(data);
351
+ }
352
+
353
+ const navigationSummary = getNavigationSummary(data);
354
+ if (navigationSummary && isNavigationObservableCommand(commandInfo.command)) {
355
+ const navigationText = formatNavigationSummary(navigationSummary);
356
+ if (navigationText) {
357
+ const actionText = formatNavigationActionResult(data);
358
+ return actionText ? `${actionText}\n\nCurrent page:\n${navigationText}` : `Current page:\n${navigationText}`;
359
+ }
360
+ }
361
+
362
+ if (commandInfo.command === "snapshot") {
363
+ return formatRawSnapshotText(data);
364
+ }
365
+ if (commandInfo.command === "tab") {
366
+ const tabSummary = getTabSummary(data);
367
+ if (tabSummary) return tabSummary;
368
+ }
369
+ if (commandInfo.command === "stream" && commandInfo.subcommand === "status") {
370
+ const streamSummary = getStreamSummary(data);
371
+ if (streamSummary) return streamSummary;
372
+ }
373
+ if (commandInfo.command === "screenshot") {
374
+ const screenshotSummary = getScreenshotSummary(data);
375
+ if (screenshotSummary) return screenshotSummary;
376
+ }
377
+
378
+ const pageSummary = getPageSummary(data);
379
+ if (pageSummary) {
380
+ return pageSummary;
381
+ }
382
+
383
+ return stringifyUnknown(data);
384
+ }
385
+
386
+ function extractImagePath(cwd: string, data: unknown): string | undefined {
387
+ if (typeof data === "string") {
388
+ const mimeType = getImageMimeType(data);
389
+ return mimeType ? resolve(cwd, data) : undefined;
390
+ }
391
+ if (!isRecord(data) || typeof data.path !== "string") {
392
+ return undefined;
393
+ }
394
+ const mimeType = getImageMimeType(data.path);
395
+ return mimeType ? resolve(cwd, data.path) : undefined;
396
+ }
397
+
398
+ async function attachInlineImage(presentation: ToolPresentation, imagePath: string): Promise<ToolPresentation> {
399
+ const mimeType = getImageMimeType(imagePath);
400
+ if (!mimeType) {
401
+ return presentation;
402
+ }
403
+
404
+ try {
405
+ const fileStats = await stat(imagePath);
406
+ const inlineImageMaxBytes = getInlineImageMaxBytes();
407
+ if (fileStats.size > inlineImageMaxBytes) {
408
+ appendPresentationNotice(
409
+ presentation,
410
+ `Image attachment skipped: ${formatByteCount(fileStats.size)} exceeds the inline limit of ${formatByteCount(inlineImageMaxBytes)}.`,
411
+ );
412
+ presentation.imagePath = imagePath;
413
+ return presentation;
414
+ }
415
+
416
+ const file = await readFile(imagePath);
417
+ presentation.content.push({ type: "image", data: file.toString("base64"), mimeType });
418
+ presentation.imagePath = imagePath;
419
+ return presentation;
420
+ } catch (error) {
421
+ const message = error instanceof Error ? error.message : String(error);
422
+ appendPresentationNotice(presentation, `Image attachment failed: ${message}`);
423
+ presentation.imagePath = imagePath;
424
+ return presentation;
425
+ }
426
+ }
427
+
428
+ export async function buildToolPresentation(options: {
429
+ commandInfo: CommandInfo;
430
+ cwd: string;
431
+ envelope?: AgentBrowserEnvelope;
432
+ errorText?: string;
433
+ }): Promise<ToolPresentation> {
434
+ const { commandInfo, cwd, envelope, errorText } = options;
435
+ if (errorText) {
436
+ return {
437
+ content: [{ type: "text", text: errorText }],
438
+ summary: errorText,
439
+ };
440
+ }
441
+
442
+ const data = envelope?.data;
443
+ const summary = formatSummary(commandInfo, data);
444
+ const presentation =
445
+ commandInfo.command === "batch" && Array.isArray(data)
446
+ ? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], summary })
447
+ : commandInfo.command === "snapshot" && isRecord(data)
448
+ ? await buildSnapshotPresentation(data)
449
+ : {
450
+ content: [{ type: "text" as const, text: formatContentText(commandInfo, data) }],
451
+ data,
452
+ summary,
453
+ };
454
+
455
+ const imagePath = extractImagePath(cwd, data);
456
+ if (!imagePath) {
457
+ return presentation;
458
+ }
459
+
460
+ return await attachInlineImage(presentation, imagePath);
461
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Purpose: Share stable result-rendering types and small data-shaping helpers across the focused result modules.
3
+ * Responsibilities: Define upstream envelope/presentation types, provide safe record/string utilities, and expose lightweight text helpers used by envelope parsing, snapshot compaction, and presentation rendering.
4
+ * Scope: Shared result helpers only; higher-level parsing, snapshot compaction, and image attachment orchestration live in neighboring modules.
5
+ * Usage: Imported by the focused result modules that back the public `lib/results.ts` facade.
6
+ * Invariants/Assumptions: Helpers stay generic, side-effect free, and small enough to reuse without reintroducing a new god module.
7
+ */
8
+
9
+ export interface AgentBrowserEnvelope {
10
+ data?: unknown;
11
+ error?: unknown;
12
+ success?: boolean;
13
+ }
14
+
15
+ export interface AgentBrowserBatchResult {
16
+ command?: string[];
17
+ error?: unknown;
18
+ result?: unknown;
19
+ success?: boolean;
20
+ }
21
+
22
+ export interface BatchStepPresentationDetails {
23
+ command?: string[];
24
+ commandText: string;
25
+ data?: unknown;
26
+ fullOutputPath?: string;
27
+ fullOutputPaths?: string[];
28
+ imagePath?: string;
29
+ imagePaths?: string[];
30
+ index: number;
31
+ success: boolean;
32
+ summary: string;
33
+ text: string;
34
+ }
35
+
36
+ export interface ToolPresentation {
37
+ batchSteps?: BatchStepPresentationDetails[];
38
+ content: Array<{ text: string; type: "text" } | { data: string; mimeType: string; type: "image" }>;
39
+ data?: unknown;
40
+ fullOutputPath?: string;
41
+ fullOutputPaths?: string[];
42
+ imagePath?: string;
43
+ imagePaths?: string[];
44
+ summary: string;
45
+ }
46
+
47
+ export function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null;
49
+ }
50
+
51
+ export function stringifyUnknown(value: unknown): string {
52
+ if (typeof value === "string") return value;
53
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
54
+ if (value === null || value === undefined) return "";
55
+ try {
56
+ return JSON.stringify(value, null, 2);
57
+ } catch {
58
+ return String(value);
59
+ }
60
+ }
61
+
62
+ export function parsePositiveInteger(rawValue: string | undefined): number | undefined {
63
+ if (typeof rawValue !== "string") return undefined;
64
+ const normalizedValue = rawValue.trim();
65
+ if (!/^\d+$/.test(normalizedValue)) return undefined;
66
+ const parsedValue = Number(normalizedValue);
67
+ if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
68
+ return parsedValue;
69
+ }
70
+
71
+ export function countLines(text: string): number {
72
+ return text.length === 0 ? 0 : text.split("\n").length;
73
+ }
74
+
75
+ export function normalizeWhitespace(text: string): string {
76
+ return text.replace(/\s+/g, " ").trim();
77
+ }
78
+
79
+ export function truncateText(text: string, maxChars: number): string {
80
+ if (text.length <= maxChars) return text;
81
+ return `${text.slice(0, Math.max(1, maxChars - 1))}…`;
82
+ }
83
+
84
+ export function compareRefIds(left: string, right: string): number {
85
+ const leftMatch = left.match(/^(?:[a-zA-Z]+)?(\d+)$/);
86
+ const rightMatch = right.match(/^(?:[a-zA-Z]+)?(\d+)$/);
87
+ if (leftMatch && rightMatch) {
88
+ return Number(leftMatch[1]) - Number(rightMatch[1]);
89
+ }
90
+ return left.localeCompare(right);
91
+ }