pi-agent-browser-native 0.1.4 → 0.1.6
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.
- package/CHANGELOG.md +15 -0
- package/docs/RELEASE.md +1 -1
- package/extensions/agent-browser/index.ts +66 -40
- package/extensions/agent-browser/lib/process.ts +91 -6
- package/extensions/agent-browser/lib/results/envelope.ts +102 -0
- package/extensions/agent-browser/lib/results/presentation.ts +249 -0
- package/extensions/agent-browser/lib/results/shared.ts +74 -0
- package/extensions/agent-browser/lib/results/snapshot.ts +632 -0
- package/extensions/agent-browser/lib/results.ts +8 -934
- package/extensions/agent-browser/lib/runtime.ts +44 -12
- package/extensions/agent-browser/lib/temp.ts +159 -16
- package/package.json +4 -1
|
@@ -0,0 +1,249 @@
|
|
|
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 type { CommandInfo } from "../runtime.js";
|
|
13
|
+
import { buildSnapshotPresentation, formatRawSnapshotText, formatSnapshotSummary } from "./snapshot.js";
|
|
14
|
+
import { type AgentBrowserBatchResult, type AgentBrowserEnvelope, type ToolPresentation, isRecord, parsePositiveInteger, stringifyUnknown } from "./shared.js";
|
|
15
|
+
|
|
16
|
+
const IMAGE_EXTENSION_TO_MIME_TYPE: Record<string, string> = {
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const INLINE_IMAGE_MAX_BYTES_ENV = "PI_AGENT_BROWSER_INLINE_IMAGE_MAX_BYTES";
|
|
25
|
+
const DEFAULT_INLINE_IMAGE_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
26
|
+
|
|
27
|
+
function getImageMimeType(filePath: string): string | undefined {
|
|
28
|
+
const extension = filePath.toLowerCase().slice(filePath.lastIndexOf("."));
|
|
29
|
+
return IMAGE_EXTENSION_TO_MIME_TYPE[extension];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getInlineImageMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
33
|
+
return parsePositiveInteger(env[INLINE_IMAGE_MAX_BYTES_ENV]) ?? DEFAULT_INLINE_IMAGE_MAX_BYTES;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatByteCount(bytes: number): string {
|
|
37
|
+
if (bytes < 1_024) return `${bytes} B`;
|
|
38
|
+
if (bytes < 1_024 * 1_024) return `${(bytes / 1_024).toFixed(1)} KiB`;
|
|
39
|
+
return `${(bytes / (1_024 * 1_024)).toFixed(1)} MiB`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendPresentationNotice(presentation: ToolPresentation, message: string): void {
|
|
43
|
+
const existingText = presentation.content[0]?.type === "text" ? presentation.content[0].text : "";
|
|
44
|
+
presentation.content[0] = {
|
|
45
|
+
type: "text",
|
|
46
|
+
text: existingText.length > 0 ? `${existingText}\n\n${message}` : message,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getTabSummary(data: Record<string, unknown>): string | undefined {
|
|
51
|
+
const tabs = Array.isArray(data.tabs) ? data.tabs : undefined;
|
|
52
|
+
if (!tabs) return undefined;
|
|
53
|
+
|
|
54
|
+
const lines = tabs.map((tab, index) => {
|
|
55
|
+
if (!isRecord(tab)) return `${index}: <invalid tab>`;
|
|
56
|
+
const marker = tab.active === true ? "*" : "-";
|
|
57
|
+
const title = typeof tab.title === "string" ? tab.title : "(untitled)";
|
|
58
|
+
const url = typeof tab.url === "string" ? tab.url : "(no url)";
|
|
59
|
+
const tabIndex = typeof tab.index === "number" ? tab.index : index;
|
|
60
|
+
return `${marker} [${tabIndex}] ${title} — ${url}`;
|
|
61
|
+
});
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getStreamSummary(data: Record<string, unknown>): string | undefined {
|
|
66
|
+
if (typeof data.enabled !== "boolean" || typeof data.connected !== "boolean") {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = [
|
|
71
|
+
`Enabled: ${data.enabled}`,
|
|
72
|
+
`Connected: ${data.connected}`,
|
|
73
|
+
`Screencasting: ${data.screencasting === true}`,
|
|
74
|
+
];
|
|
75
|
+
if (typeof data.port === "number") {
|
|
76
|
+
lines.push(`Port: ${data.port}`);
|
|
77
|
+
}
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getPageSummary(data: Record<string, unknown>): string | undefined {
|
|
82
|
+
const title = typeof data.title === "string" ? data.title : undefined;
|
|
83
|
+
const url = typeof data.url === "string" ? data.url : undefined;
|
|
84
|
+
if (!title && !url) return undefined;
|
|
85
|
+
if (title && url) return `${title}\n${url}`;
|
|
86
|
+
return title ?? url;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getScreenshotSummary(data: Record<string, unknown>): string | undefined {
|
|
90
|
+
return typeof data.path === "string" ? `Saved image: ${data.path}` : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatBatchContent(data: AgentBrowserBatchResult[]): string {
|
|
94
|
+
return data
|
|
95
|
+
.map((item, index) => {
|
|
96
|
+
const command = Array.isArray(item.command) ? item.command.join(" ") : `step-${index + 1}`;
|
|
97
|
+
if (item.success === false) {
|
|
98
|
+
return `${command}\nError: ${stringifyUnknown(item.error)}`;
|
|
99
|
+
}
|
|
100
|
+
return `${command}\n${stringifyUnknown(item.result)}`;
|
|
101
|
+
})
|
|
102
|
+
.join("\n\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatSummary(commandInfo: CommandInfo, data: unknown): string {
|
|
106
|
+
if (Array.isArray(data) && commandInfo.command === "batch") {
|
|
107
|
+
const successCount = data.filter((item) => isRecord(item) && item.success !== false).length;
|
|
108
|
+
return `Batch: ${successCount}/${data.length} succeeded`;
|
|
109
|
+
}
|
|
110
|
+
if (isRecord(data)) {
|
|
111
|
+
if (commandInfo.command === "snapshot") {
|
|
112
|
+
return formatSnapshotSummary(data);
|
|
113
|
+
}
|
|
114
|
+
if (commandInfo.command === "tab" && Array.isArray(data.tabs)) {
|
|
115
|
+
return `Tabs: ${data.tabs.length}`;
|
|
116
|
+
}
|
|
117
|
+
if (commandInfo.command === "stream" && commandInfo.subcommand === "status") {
|
|
118
|
+
const port = typeof data.port === "number" ? ` on port ${data.port}` : "";
|
|
119
|
+
return `Stream ${data.enabled === true ? "enabled" : "disabled"}${port}`;
|
|
120
|
+
}
|
|
121
|
+
if (commandInfo.command === "screenshot" && typeof data.path === "string") {
|
|
122
|
+
return `Screenshot saved: ${data.path}`;
|
|
123
|
+
}
|
|
124
|
+
const pageSummary = getPageSummary(data);
|
|
125
|
+
if (pageSummary) {
|
|
126
|
+
return pageSummary.split("\n", 1)[0] ?? "agent-browser result";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof data === "string" && data.length > 0) {
|
|
131
|
+
return data.split("\n", 1)[0] ?? data;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const primaryCommand = commandInfo.command ?? "agent-browser";
|
|
135
|
+
return `${primaryCommand} completed`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatContentText(commandInfo: CommandInfo, data: unknown): string {
|
|
139
|
+
if (Array.isArray(data) && commandInfo.command === "batch") {
|
|
140
|
+
return formatBatchContent(data as AgentBrowserBatchResult[]);
|
|
141
|
+
}
|
|
142
|
+
if (typeof data === "string") {
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
if (typeof data === "number" || typeof data === "boolean") {
|
|
146
|
+
return String(data);
|
|
147
|
+
}
|
|
148
|
+
if (!isRecord(data)) {
|
|
149
|
+
return stringifyUnknown(data);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (commandInfo.command === "snapshot") {
|
|
153
|
+
return formatRawSnapshotText(data);
|
|
154
|
+
}
|
|
155
|
+
if (commandInfo.command === "tab") {
|
|
156
|
+
const tabSummary = getTabSummary(data);
|
|
157
|
+
if (tabSummary) return tabSummary;
|
|
158
|
+
}
|
|
159
|
+
if (commandInfo.command === "stream" && commandInfo.subcommand === "status") {
|
|
160
|
+
const streamSummary = getStreamSummary(data);
|
|
161
|
+
if (streamSummary) return streamSummary;
|
|
162
|
+
}
|
|
163
|
+
if (commandInfo.command === "screenshot") {
|
|
164
|
+
const screenshotSummary = getScreenshotSummary(data);
|
|
165
|
+
if (screenshotSummary) return screenshotSummary;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pageSummary = getPageSummary(data);
|
|
169
|
+
if (pageSummary) {
|
|
170
|
+
return pageSummary;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return stringifyUnknown(data);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function extractImagePath(cwd: string, data: unknown): string | undefined {
|
|
177
|
+
if (typeof data === "string") {
|
|
178
|
+
const mimeType = getImageMimeType(data);
|
|
179
|
+
return mimeType ? resolve(cwd, data) : undefined;
|
|
180
|
+
}
|
|
181
|
+
if (!isRecord(data) || typeof data.path !== "string") {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const mimeType = getImageMimeType(data.path);
|
|
185
|
+
return mimeType ? resolve(cwd, data.path) : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function attachInlineImage(presentation: ToolPresentation, imagePath: string): Promise<ToolPresentation> {
|
|
189
|
+
const mimeType = getImageMimeType(imagePath);
|
|
190
|
+
if (!mimeType) {
|
|
191
|
+
return presentation;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const fileStats = await stat(imagePath);
|
|
196
|
+
const inlineImageMaxBytes = getInlineImageMaxBytes();
|
|
197
|
+
if (fileStats.size > inlineImageMaxBytes) {
|
|
198
|
+
appendPresentationNotice(
|
|
199
|
+
presentation,
|
|
200
|
+
`Image attachment skipped: ${formatByteCount(fileStats.size)} exceeds the inline limit of ${formatByteCount(inlineImageMaxBytes)}.`,
|
|
201
|
+
);
|
|
202
|
+
presentation.imagePath = imagePath;
|
|
203
|
+
return presentation;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const file = await readFile(imagePath);
|
|
207
|
+
presentation.content.push({ type: "image", data: file.toString("base64"), mimeType });
|
|
208
|
+
presentation.imagePath = imagePath;
|
|
209
|
+
return presentation;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
appendPresentationNotice(presentation, `Image attachment failed: ${message}`);
|
|
213
|
+
presentation.imagePath = imagePath;
|
|
214
|
+
return presentation;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function buildToolPresentation(options: {
|
|
219
|
+
commandInfo: CommandInfo;
|
|
220
|
+
cwd: string;
|
|
221
|
+
envelope?: AgentBrowserEnvelope;
|
|
222
|
+
errorText?: string;
|
|
223
|
+
}): Promise<ToolPresentation> {
|
|
224
|
+
const { commandInfo, cwd, envelope, errorText } = options;
|
|
225
|
+
if (errorText) {
|
|
226
|
+
return {
|
|
227
|
+
content: [{ type: "text", text: errorText }],
|
|
228
|
+
summary: errorText,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const data = envelope?.data;
|
|
233
|
+
const summary = formatSummary(commandInfo, data);
|
|
234
|
+
const presentation =
|
|
235
|
+
commandInfo.command === "snapshot" && isRecord(data)
|
|
236
|
+
? await buildSnapshotPresentation(data)
|
|
237
|
+
: {
|
|
238
|
+
content: [{ type: "text" as const, text: formatContentText(commandInfo, data) }],
|
|
239
|
+
data,
|
|
240
|
+
summary,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const imagePath = extractImagePath(cwd, data);
|
|
244
|
+
if (!imagePath) {
|
|
245
|
+
return presentation;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return await attachInlineImage(presentation, imagePath);
|
|
249
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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 ToolPresentation {
|
|
23
|
+
content: Array<{ text: string; type: "text" } | { data: string; mimeType: string; type: "image" }>;
|
|
24
|
+
data?: unknown;
|
|
25
|
+
fullOutputPath?: string;
|
|
26
|
+
imagePath?: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === "object" && value !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stringifyUnknown(value: unknown): string {
|
|
35
|
+
if (typeof value === "string") return value;
|
|
36
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
37
|
+
if (value === null || value === undefined) return "";
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(value, null, 2);
|
|
40
|
+
} catch {
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parsePositiveInteger(rawValue: string | undefined): number | undefined {
|
|
46
|
+
if (typeof rawValue !== "string") return undefined;
|
|
47
|
+
const normalizedValue = rawValue.trim();
|
|
48
|
+
if (!/^\d+$/.test(normalizedValue)) return undefined;
|
|
49
|
+
const parsedValue = Number(normalizedValue);
|
|
50
|
+
if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
|
|
51
|
+
return parsedValue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function countLines(text: string): number {
|
|
55
|
+
return text.length === 0 ? 0 : text.split("\n").length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeWhitespace(text: string): string {
|
|
59
|
+
return text.replace(/\s+/g, " ").trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function truncateText(text: string, maxChars: number): string {
|
|
63
|
+
if (text.length <= maxChars) return text;
|
|
64
|
+
return `${text.slice(0, Math.max(1, maxChars - 1))}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function compareRefIds(left: string, right: string): number {
|
|
68
|
+
const leftMatch = left.match(/^(?:[a-zA-Z]+)?(\d+)$/);
|
|
69
|
+
const rightMatch = right.match(/^(?:[a-zA-Z]+)?(\d+)$/);
|
|
70
|
+
if (leftMatch && rightMatch) {
|
|
71
|
+
return Number(leftMatch[1]) - Number(rightMatch[1]);
|
|
72
|
+
}
|
|
73
|
+
return left.localeCompare(right);
|
|
74
|
+
}
|