pi-lean-portal 0.1.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.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- package/verify-ship-manifest.ts +126 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-console tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { taskId } from "../core/shared/task-id.js";
|
|
10
|
+
|
|
11
|
+
export const browserConsoleTool = defineTool({
|
|
12
|
+
name: "browser-console",
|
|
13
|
+
label: "Browser Console",
|
|
14
|
+
description:
|
|
15
|
+
"Execute JavaScript in the current page context and see the result, " +
|
|
16
|
+
"or read captured console output (log, warn, error, info). " +
|
|
17
|
+
"Useful for inspecting page state, reading hidden content, or debugging.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
expression: Type.Optional(
|
|
20
|
+
Type.String({
|
|
21
|
+
description:
|
|
22
|
+
"JavaScript expression to evaluate in the page context. If omitted, returns captured console messages.",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
clear: Type.Optional(
|
|
26
|
+
Type.Boolean({
|
|
27
|
+
description:
|
|
28
|
+
"If true, clear the captured console log (no other action)",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
34
|
+
const p = params as {
|
|
35
|
+
expression?: string;
|
|
36
|
+
clear?: boolean;
|
|
37
|
+
taskId?: string;
|
|
38
|
+
};
|
|
39
|
+
const tid = p?.taskId ?? taskId(ctx);
|
|
40
|
+
|
|
41
|
+
if (p?.clear) {
|
|
42
|
+
await router.clearConsole(tid);
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: "Console log cleared." }],
|
|
45
|
+
details: { cleared: true },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (p?.expression) {
|
|
50
|
+
const result = await router.evaluate(tid, p.expression);
|
|
51
|
+
|
|
52
|
+
if (!result.success) {
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "text",
|
|
57
|
+
text: `Evaluation failed: ${result.error ?? "unknown"}`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
details: { error: true },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let formatted: string;
|
|
65
|
+
if (typeof result.result === "string") {
|
|
66
|
+
formatted = result.result;
|
|
67
|
+
} else if (result.result === undefined || result.result === null) {
|
|
68
|
+
formatted = String(result.result);
|
|
69
|
+
} else {
|
|
70
|
+
try {
|
|
71
|
+
formatted = JSON.stringify(result.result, null, 2);
|
|
72
|
+
} catch {
|
|
73
|
+
formatted = String(result.result);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const TRUNCATE_LIMIT = 10_000;
|
|
77
|
+
if (formatted.length > TRUNCATE_LIMIT) {
|
|
78
|
+
formatted =
|
|
79
|
+
formatted.slice(0, TRUNCATE_LIMIT) +
|
|
80
|
+
`\n… (truncated, ${formatted.length - TRUNCATE_LIMIT} more chars)`;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text", text: formatted || "undefined" }],
|
|
84
|
+
details: { result: result.result },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No expression, no clear — read console messages
|
|
89
|
+
const consoleResult = await router.getConsoleMessages(tid);
|
|
90
|
+
if (!consoleResult.success) {
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "text",
|
|
95
|
+
text: `Failed to read console: ${consoleResult.error}`,
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
details: { error: true },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (consoleResult.messages.length === 0) {
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: "text", text: "No console messages captured." }],
|
|
105
|
+
details: { count: 0 },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const formatted = consoleResult.messages
|
|
110
|
+
.map((m) => `[${m.type}] ${m.text}`)
|
|
111
|
+
.join("\n");
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text", text: formatted }],
|
|
114
|
+
details: {
|
|
115
|
+
count: consoleResult.messages.length,
|
|
116
|
+
messages: consoleResult.messages,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
renderCall(args, theme, _context) {
|
|
122
|
+
if (args.clear)
|
|
123
|
+
return new Text(
|
|
124
|
+
theme.fg("toolTitle", theme.bold("browser-console clear")),
|
|
125
|
+
0,
|
|
126
|
+
0,
|
|
127
|
+
);
|
|
128
|
+
if (args.expression) {
|
|
129
|
+
return new Text(
|
|
130
|
+
`${theme.fg("toolTitle", theme.bold("browser-console"))} ${theme.fg("dim", args.expression.slice(0, 60))}`,
|
|
131
|
+
0,
|
|
132
|
+
0,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return new Text(
|
|
136
|
+
theme.fg("toolTitle", theme.bold("browser-console read")),
|
|
137
|
+
0,
|
|
138
|
+
0,
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
renderResult(result, _options, theme, _context) {
|
|
143
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
144
|
+
if (d?.error)
|
|
145
|
+
return new Text(theme.fg("error", "Console operation failed"), 0, 0);
|
|
146
|
+
if (d?.cleared) return new Text(theme.fg("dim", "Console cleared"), 0, 0);
|
|
147
|
+
if (d?.result !== undefined)
|
|
148
|
+
return new Text(
|
|
149
|
+
theme.fg(
|
|
150
|
+
"dim",
|
|
151
|
+
`JS → ${JSON.stringify(d.result)?.slice(0, 80) || "ok"}`,
|
|
152
|
+
),
|
|
153
|
+
0,
|
|
154
|
+
0,
|
|
155
|
+
);
|
|
156
|
+
if (d?.count !== undefined)
|
|
157
|
+
return new Text(theme.fg("dim", `📋 ${d.count} console messages`), 0, 0);
|
|
158
|
+
return new Text(theme.fg("dim", "Console ok"), 0, 0);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-inspect tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { taskId } from "../core/shared/task-id.js";
|
|
10
|
+
|
|
11
|
+
export const browserInspectTool = defineTool({
|
|
12
|
+
name: "browser-inspect",
|
|
13
|
+
label: "Inspect Page",
|
|
14
|
+
description:
|
|
15
|
+
"Query the current page for specific elements or extract readable " +
|
|
16
|
+
"text content. Use for targeted discovery instead of full snapshots.",
|
|
17
|
+
promptSnippet:
|
|
18
|
+
"Query the current page for elements or text content with @e ref annotations",
|
|
19
|
+
promptGuidelines: [
|
|
20
|
+
"Use browser-inspect role=... name=... to find a specific element without loading a full snapshot",
|
|
21
|
+
"Use browser-inspect text=true to read page content (article, form, etc.) with correlated @e refs",
|
|
22
|
+
"Use browser-inspect subtree=... to scope queries inside dialogs, menus, or navigation regions",
|
|
23
|
+
"After clicking or scrolling, take a fresh browser-snapshot before trusting @e refs from browser-inspect",
|
|
24
|
+
"Do NOT use browser-inspect as a replacement for browser-navigate — it requires an existing session",
|
|
25
|
+
"When browser-inspect returns multiple @e refs for the same name, disambiguate by reading the cached full snapshot",
|
|
26
|
+
],
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
role: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
'Filter by ARIA role. Comma-separated for multiple. E.g. "link,button"',
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
name: Type.Optional(
|
|
35
|
+
Type.String({
|
|
36
|
+
description:
|
|
37
|
+
"Filter by accessible name (case-insensitive substring match)",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
ref: Type.Optional(
|
|
41
|
+
Type.String({
|
|
42
|
+
description:
|
|
43
|
+
"Look up a specific @e ref (e.g. 'e5'). When combined with text=true, scopes DOM walker to that element's subtree.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
subtree: Type.Optional(
|
|
47
|
+
Type.String({
|
|
48
|
+
description:
|
|
49
|
+
"Scope to elements inside a container role. E.g. 'dialog', 'navigation'",
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
text: Type.Optional(
|
|
53
|
+
Type.Boolean({
|
|
54
|
+
description:
|
|
55
|
+
"Include text content from DOM walker with @e ref annotations. Default: false",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
maxChars: Type.Optional(
|
|
59
|
+
Type.Number({
|
|
60
|
+
description:
|
|
61
|
+
'Max output characters. 0 = no limit. Default: ~2500 when omitted. Truncated output shows "… X more chars (use maxChars=0 for full content)"',
|
|
62
|
+
minimum: 0,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
query: Type.Optional(
|
|
66
|
+
Type.String({
|
|
67
|
+
description:
|
|
68
|
+
"Filter extracted text to only include content matching this case-insensitive keyword. " +
|
|
69
|
+
"Useful for finding specific topics on large pages without retrieving all text. " +
|
|
70
|
+
"Only active when text=true.",
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
76
|
+
const p = params as Record<string, unknown>;
|
|
77
|
+
const tid = (p?.taskId as string | undefined) ?? taskId(ctx);
|
|
78
|
+
|
|
79
|
+
const result = await router.browserInspect(tid, {
|
|
80
|
+
...(p?.role !== undefined ? { role: p.role as string } : {}),
|
|
81
|
+
...(p?.name !== undefined ? { name: p.name as string } : {}),
|
|
82
|
+
...(p?.ref !== undefined ? { ref: p.ref as string } : {}),
|
|
83
|
+
...(p?.subtree !== undefined ? { subtree: p.subtree as string } : {}),
|
|
84
|
+
...(p?.text !== undefined ? { text: Boolean(p.text) } : {}),
|
|
85
|
+
...(p?.maxChars !== undefined ? { maxChars: p.maxChars as number } : {}),
|
|
86
|
+
...(p?.query !== undefined ? { query: p.query as string } : {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!result.success) {
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: result.error ?? "browser-inspect failed",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
details: { error: true },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: result.content }],
|
|
103
|
+
details: {
|
|
104
|
+
stalenessWarning: result.staleCacheWarning,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
renderCall(args, theme, _context) {
|
|
110
|
+
const parts = [theme.fg("toolTitle", theme.bold("browser-inspect"))];
|
|
111
|
+
if (args.text) parts.push(theme.fg("accent", "📖 text"));
|
|
112
|
+
if (args.role) parts.push(theme.fg("dim", `role=${args.role}`));
|
|
113
|
+
if (args.name) parts.push(theme.fg("dim", `name="${args.name}"`));
|
|
114
|
+
if (args.ref) parts.push(theme.fg("dim", `@${args.ref}`));
|
|
115
|
+
if (args.subtree) parts.push(theme.fg("dim", `subtree=${args.subtree}`));
|
|
116
|
+
if (args.query) parts.push(theme.fg("accent", `query="${args.query}"`));
|
|
117
|
+
return new Text(parts.join(" "), 0, 0);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
renderResult(result, _options, theme, _context) {
|
|
121
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
122
|
+
if (d?.error) return new Text(theme.fg("error", "Inspect failed"), 0, 0);
|
|
123
|
+
const content = (result.content?.[0] as any)?.text ?? "";
|
|
124
|
+
const staleness = d?.stalenessWarning
|
|
125
|
+
? ` ${theme.fg("warning", "(stale)")}`
|
|
126
|
+
: "";
|
|
127
|
+
const preview = content.replace(/\n{3,}/g, "\n\n").slice(0, 100);
|
|
128
|
+
let text = theme.fg("accent", `🔍 ${content.length} chars`) + staleness;
|
|
129
|
+
if (preview) {
|
|
130
|
+
text += `\n${theme.fg("dim", preview)}`;
|
|
131
|
+
if (content.length > 100)
|
|
132
|
+
text += `\n${theme.fg("muted", `… ${content.length - 100} more chars`)}`;
|
|
133
|
+
}
|
|
134
|
+
return new Text(text, 0, 0);
|
|
135
|
+
},
|
|
136
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-navigate tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { resolveApplicableGuides, formatGuideFooter } from "../core/guides.js";
|
|
10
|
+
import type { ApplicableGuide } from "../core/guides.js";
|
|
11
|
+
import { getConversationDefaultProfile } from "../browser-toggle.js";
|
|
12
|
+
import { sessionManager } from "../core/shared/session-manager.js";
|
|
13
|
+
import { removeSnapshotFiles } from "../core/shared/snapshot-cache.js";
|
|
14
|
+
import { taskId } from "../core/shared/task-id.js";
|
|
15
|
+
import { updateFooterStatus, profileLine } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
export const browserNavigateTool = defineTool({
|
|
18
|
+
name: "browser-navigate",
|
|
19
|
+
label: "Browse Web",
|
|
20
|
+
description:
|
|
21
|
+
"Navigate a browser to a URL and return the page as an accessibility tree with @e1, @e2 element references. " +
|
|
22
|
+
"Uses the configured browser plugin (default: Chromium). For stateless HTTP fetches without interactive elements, use web-fetch instead.",
|
|
23
|
+
promptSnippet: "Fetch and read web pages in text form",
|
|
24
|
+
promptGuidelines: [
|
|
25
|
+
"Use browser-navigate when you need to interact with a web page using @e1/@e2 element references (click, type, scroll, etc.).",
|
|
26
|
+
"If you just need the page content as Markdown without interactive elements, use web-fetch instead.",
|
|
27
|
+
"Use @e1, @e2 references from the accessibility tree with browser-click and browser-type to interact with page elements.",
|
|
28
|
+
"If snapshot or interaction returns 'No active session', the previous navigation was in a different context. Use browser-navigate first to establish a session.",
|
|
29
|
+
"After auto-launch, @e refs may have changed — a fresh accessibility tree is returned automatically. Use the new refs for interaction.",
|
|
30
|
+
"If the snapshot is truncated, use browser-inspect role=... name=... to find specific elements, or read the cached snapshot file. Avoid browser-snapshot full=true unless you need the entire tree.",
|
|
31
|
+
"When a 📖 guide footer appears in a browser-navigate result, call web-guide for each listed guide before interacting with the page (once each per conversation — skip guides you've already reviewed).",
|
|
32
|
+
],
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
url: Type.String({ description: "The URL to navigate to" }),
|
|
35
|
+
strategy: Type.Optional(
|
|
36
|
+
Type.String({
|
|
37
|
+
description:
|
|
38
|
+
'Backend strategy: "auto" (default) uses the first available plugin; ' +
|
|
39
|
+
'specify a registered plugin name (e.g. "chromium", "firefox", "chromium-py") to use that backend. ' +
|
|
40
|
+
"For stateless HTTP fetches, use web-fetch instead.",
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
timeout: Type.Optional(
|
|
44
|
+
Type.Number({
|
|
45
|
+
description: "Timeout in seconds (default: 30, max: 120)",
|
|
46
|
+
minimum: 1,
|
|
47
|
+
maximum: 120,
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
profile: Type.Optional(
|
|
51
|
+
Type.Union(
|
|
52
|
+
[Type.Literal("none"), Type.Literal("session"), Type.String()],
|
|
53
|
+
{
|
|
54
|
+
description:
|
|
55
|
+
"Profile mode: 'session' (default, persist for this conversation), " +
|
|
56
|
+
"'none' (clean slate), or a named profile (e.g. 'shopping', 'work'). " +
|
|
57
|
+
"Named profiles share cookies across subagents like browser tabs.",
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
64
|
+
const {
|
|
65
|
+
url,
|
|
66
|
+
strategy = "auto",
|
|
67
|
+
timeout = 30,
|
|
68
|
+
profile,
|
|
69
|
+
} = params as {
|
|
70
|
+
url: string;
|
|
71
|
+
strategy?: string;
|
|
72
|
+
timeout?: number;
|
|
73
|
+
profile?: string;
|
|
74
|
+
};
|
|
75
|
+
const tid = taskId(ctx);
|
|
76
|
+
const piSessionId = ctx?.sessionManager?.getSessionId?.();
|
|
77
|
+
|
|
78
|
+
signal?.addEventListener(
|
|
79
|
+
"abort",
|
|
80
|
+
() => {
|
|
81
|
+
sessionManager.removeSession(tid);
|
|
82
|
+
removeSnapshotFiles(tid);
|
|
83
|
+
updateFooterStatus(ctx);
|
|
84
|
+
},
|
|
85
|
+
{ once: true },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const navOptions: router.NavigateOptions = {
|
|
89
|
+
strategy,
|
|
90
|
+
timeout,
|
|
91
|
+
taskId: tid,
|
|
92
|
+
};
|
|
93
|
+
if (piSessionId) navOptions.piSessionId = piSessionId;
|
|
94
|
+
if (signal) navOptions.signal = signal;
|
|
95
|
+
// Profile resolution: explicit param > conversation default > config file default
|
|
96
|
+
if (profile) {
|
|
97
|
+
// Explicit user parameter
|
|
98
|
+
navOptions.profile = profile as "none" | "session" | string;
|
|
99
|
+
} else {
|
|
100
|
+
// Conversation-scoped default (set via /web profile <name>)
|
|
101
|
+
const convDefault = getConversationDefaultProfile();
|
|
102
|
+
if (convDefault) {
|
|
103
|
+
navOptions.profile = convDefault as "none" | "session" | string;
|
|
104
|
+
}
|
|
105
|
+
// else: router falls back to browserConfig.defaultProfile
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await router.navigate(url, navOptions);
|
|
109
|
+
|
|
110
|
+
updateFooterStatus(ctx);
|
|
111
|
+
|
|
112
|
+
if (!result.success) {
|
|
113
|
+
// If the browser executable is not installed, notify the user with install instructions.
|
|
114
|
+
const isInstallError =
|
|
115
|
+
result.error && result.error.toLowerCase().includes("not installed");
|
|
116
|
+
if (isInstallError) {
|
|
117
|
+
ctx?.ui?.notify(
|
|
118
|
+
"Browser not installed. Run: npx playwright install chromium firefox",
|
|
119
|
+
"warning",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{
|
|
126
|
+
type: "text",
|
|
127
|
+
text: `Failed to load page: ${result.error ?? "unknown error"}`,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
details: {
|
|
131
|
+
error: true,
|
|
132
|
+
backendUsed: result.backendUsed,
|
|
133
|
+
url: result.url,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const contentText = result.snapshot;
|
|
139
|
+
|
|
140
|
+
const screenshotLine = await router.captureScreenshotLine(tid);
|
|
141
|
+
|
|
142
|
+
// ---- Web Guide footer ----
|
|
143
|
+
const applicable = resolveApplicableGuides(
|
|
144
|
+
result.url,
|
|
145
|
+
result.dialogDetected ?? false,
|
|
146
|
+
result.botDetectionWarning ?? false,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
|
|
151
|
+
lines.push(
|
|
152
|
+
`Title: ${result.title || "(no title)"}`,
|
|
153
|
+
`URL: ${result.url}`,
|
|
154
|
+
`Backend: ${result.backendUsed}`,
|
|
155
|
+
result.elementCount !== undefined
|
|
156
|
+
? `Interactive elements: ${result.elementCount}`
|
|
157
|
+
: "",
|
|
158
|
+
result.profileMode !== undefined ? profileLine(result) : "",
|
|
159
|
+
result.botDetectionWarning
|
|
160
|
+
? "⚠ BOT DETECTION WARNING: This page appears to be protected by " +
|
|
161
|
+
"anti-automation. The content below may be incomplete or show " +
|
|
162
|
+
"a challenge page instead of the actual content."
|
|
163
|
+
: "",
|
|
164
|
+
screenshotLine,
|
|
165
|
+
"",
|
|
166
|
+
contentText,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Append guide footer after page content (only when guides apply)
|
|
170
|
+
const guideFooter = formatGuideFooter(applicable);
|
|
171
|
+
if (guideFooter) {
|
|
172
|
+
lines.push("", guideFooter);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: lines.filter(Boolean).join("\n") }],
|
|
177
|
+
details: {
|
|
178
|
+
title: result.title,
|
|
179
|
+
url: result.url,
|
|
180
|
+
backendUsed: result.backendUsed,
|
|
181
|
+
elementCount: result.elementCount,
|
|
182
|
+
profileMode: result.profileMode,
|
|
183
|
+
profileName: result.profileName,
|
|
184
|
+
...(applicable.length > 0 ? { guides: applicable } : {}),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
renderCall(args, theme, _context) {
|
|
190
|
+
const parts: string[] = [
|
|
191
|
+
theme.fg("toolTitle", theme.bold("browser-navigate ")),
|
|
192
|
+
];
|
|
193
|
+
parts.push(theme.fg("accent", `"${args.url}"`));
|
|
194
|
+
if (args.strategy && args.strategy !== "auto")
|
|
195
|
+
parts.push(theme.fg("dim", `via ${args.strategy}`));
|
|
196
|
+
return new Text(parts.join(" "), 0, 0);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
renderResult(result, { expanded, isPartial }, theme, _context) {
|
|
200
|
+
if (isPartial) return new Text(theme.fg("warning", "Navigating…"), 0, 0);
|
|
201
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
202
|
+
if (d?.error)
|
|
203
|
+
return new Text(
|
|
204
|
+
theme.fg(
|
|
205
|
+
"error",
|
|
206
|
+
`Failed: ${(result.content?.[0] as any)?.text ?? "?"}`,
|
|
207
|
+
),
|
|
208
|
+
0,
|
|
209
|
+
0,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const title = (d?.title as string) || "(no title)";
|
|
213
|
+
const backend = (d?.backendUsed as string) || "?";
|
|
214
|
+
const url = (d?.url as string) || "";
|
|
215
|
+
const ec = d?.elementCount as number | undefined;
|
|
216
|
+
const pm = d?.profileMode as string | undefined;
|
|
217
|
+
const pn = d?.profileName as string | undefined;
|
|
218
|
+
let text = theme.fg("accent", theme.bold(`🌐 ${title}`));
|
|
219
|
+
text += `\n${theme.fg("dim", url)}`;
|
|
220
|
+
text += `\n${theme.fg("muted", `via ${backend}`)}`;
|
|
221
|
+
if (ec !== undefined) text += ` · ${ec} elements`;
|
|
222
|
+
if (pn) {
|
|
223
|
+
const modeTag = pm === "session" ? "📋" : "👤";
|
|
224
|
+
text += ` ${theme.fg("dim", `${modeTag} ${pn}`)}`;
|
|
225
|
+
} else if (pm === "restored") {
|
|
226
|
+
text += ` ${theme.fg("accent", "↻ restored")}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Guide badge line (dedicated line below via status)
|
|
230
|
+
const guides = d?.guides as ApplicableGuide[] | undefined;
|
|
231
|
+
if (guides && guides.length > 0) {
|
|
232
|
+
const chips: string[] = [];
|
|
233
|
+
for (const g of guides) {
|
|
234
|
+
if (g.category === "site") {
|
|
235
|
+
chips.push(`${g.icon} guide avail: ${g.shortName}`);
|
|
236
|
+
} else {
|
|
237
|
+
chips.push(`${g.icon} ${g.shortName}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
text += `\n${theme.fg("muted", chips.join(" "))}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const content = (result.content?.[0] as any)?.text ?? "";
|
|
244
|
+
if (expanded) {
|
|
245
|
+
const preview = content.replace(/\n{3,}/g, "\n\n").slice(0, 500);
|
|
246
|
+
text += `\n\n${theme.fg("dim", preview)}`;
|
|
247
|
+
if (content.length > 500)
|
|
248
|
+
text += `\n${theme.fg("muted", `… ${content.length - 500} more chars`)}`;
|
|
249
|
+
} else {
|
|
250
|
+
text += `\n${theme.fg("muted", `${content.length} chars (expand)`)}`;
|
|
251
|
+
}
|
|
252
|
+
return new Text(text, 0, 0);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-press tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { executeInteractionTool } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export const browserPressTool = defineTool({
|
|
12
|
+
name: "browser-press",
|
|
13
|
+
label: "Press Key",
|
|
14
|
+
description:
|
|
15
|
+
'Press a keyboard key (e.g., "Enter", "Tab", "Escape", "ArrowDown", "ArrowUp"). ' +
|
|
16
|
+
"Useful for submitting forms, dismissing dialogs, or navigating dropdowns.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
key: Type.String({
|
|
19
|
+
description:
|
|
20
|
+
"Key to press (e.g., 'Enter', 'Tab', 'Escape', 'ArrowDown', 'ArrowUp')",
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
|
|
24
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
25
|
+
const { key } = params as { key: string };
|
|
26
|
+
return executeInteractionTool(
|
|
27
|
+
ctx,
|
|
28
|
+
"Press",
|
|
29
|
+
(tid) => router.press(tid, key),
|
|
30
|
+
(result) => ({
|
|
31
|
+
message: [
|
|
32
|
+
`Pressed "${key}"`,
|
|
33
|
+
result.newUrl ? `URL: ${result.newUrl}` : "",
|
|
34
|
+
result.newTitle ? `Title: ${result.newTitle}` : "",
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join("\n"),
|
|
38
|
+
details: {
|
|
39
|
+
key,
|
|
40
|
+
newUrl: result.newUrl,
|
|
41
|
+
newTitle: result.newTitle,
|
|
42
|
+
elementCount: result.elementCount,
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
renderCall(args, theme, _context) {
|
|
49
|
+
return new Text(
|
|
50
|
+
`${theme.fg("toolTitle", theme.bold("browser-press"))} ${theme.fg("accent", args.key)}`,
|
|
51
|
+
0,
|
|
52
|
+
0,
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
renderResult(result, _options, theme, _context) {
|
|
57
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
58
|
+
if (d?.error) return new Text(theme.fg("error", "Press failed"), 0, 0);
|
|
59
|
+
const ec = d?.elementCount as number | undefined;
|
|
60
|
+
const nu = d?.newUrl as string | undefined;
|
|
61
|
+
if (nu) {
|
|
62
|
+
return new Text(
|
|
63
|
+
theme.fg(
|
|
64
|
+
"success",
|
|
65
|
+
`✅ → ${nu}${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
66
|
+
),
|
|
67
|
+
0,
|
|
68
|
+
0,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return new Text(
|
|
72
|
+
theme.fg(
|
|
73
|
+
"dim",
|
|
74
|
+
`⌨ ${d?.key || ""}${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
75
|
+
),
|
|
76
|
+
0,
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-scroll tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type, StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as router from "../core/router.js";
|
|
9
|
+
import { executeInteractionTool } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export const browserScrollTool = defineTool({
|
|
12
|
+
name: "browser-scroll",
|
|
13
|
+
label: "Scroll Page",
|
|
14
|
+
description:
|
|
15
|
+
"Scroll the page up or down by approximately one viewport height.",
|
|
16
|
+
parameters: Type.Object({
|
|
17
|
+
direction: StringEnum(["up", "down"] as const, {
|
|
18
|
+
description: "Scroll direction",
|
|
19
|
+
}),
|
|
20
|
+
}),
|
|
21
|
+
|
|
22
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
23
|
+
const { direction } = params as { direction: "up" | "down" };
|
|
24
|
+
return executeInteractionTool(
|
|
25
|
+
ctx,
|
|
26
|
+
"Scroll",
|
|
27
|
+
(tid) => router.scroll(tid, direction),
|
|
28
|
+
(result) => ({
|
|
29
|
+
message: `Scrolled ${direction}`,
|
|
30
|
+
details: { direction, elementCount: result.elementCount },
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
renderCall(args, theme, _context) {
|
|
36
|
+
return new Text(
|
|
37
|
+
`${theme.fg("toolTitle", theme.bold("browser-scroll"))} ${theme.fg("dim", args.direction)}`,
|
|
38
|
+
0,
|
|
39
|
+
0,
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
renderResult(result, _options, theme, _context) {
|
|
44
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
45
|
+
if (d?.error) return new Text(theme.fg("error", "Scroll failed"), 0, 0);
|
|
46
|
+
const ec = d?.elementCount as number | undefined;
|
|
47
|
+
return new Text(
|
|
48
|
+
theme.fg(
|
|
49
|
+
"dim",
|
|
50
|
+
`↕ ${d?.direction || "?"}${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
51
|
+
),
|
|
52
|
+
0,
|
|
53
|
+
0,
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
});
|