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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-snapshot 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
|
+
import { updateFooterStatus } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
export const browserSnapshotTool = defineTool({
|
|
13
|
+
name: "browser-snapshot",
|
|
14
|
+
label: "Page Snapshot",
|
|
15
|
+
description:
|
|
16
|
+
"Get the current page's accessibility tree with @e1, @e2 element references. " +
|
|
17
|
+
"Also captures a screenshot to a temp file (see output for path). " +
|
|
18
|
+
"Use after browser-navigate to refresh the element list, or after page changes (click, scroll) to see the updated state.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
full: Type.Optional(
|
|
21
|
+
Type.Boolean({
|
|
22
|
+
description:
|
|
23
|
+
"If true, return complete tree instead of compact view (default: false)",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
29
|
+
const p = params as { full?: boolean; taskId?: string };
|
|
30
|
+
const tid = p?.taskId ?? taskId(ctx);
|
|
31
|
+
const full = p?.full ?? false;
|
|
32
|
+
const result = await router.snapshot(tid, full);
|
|
33
|
+
updateFooterStatus(ctx);
|
|
34
|
+
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: `Snapshot failed: ${result.error ?? "unknown"}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
details: { error: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const screenshotLine = await router.captureScreenshotLine(tid);
|
|
48
|
+
|
|
49
|
+
const parts = [screenshotLine, result.snapshot || "(empty page)"];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: parts.filter(Boolean).join("\n\n") }],
|
|
53
|
+
details: { elementCount: result.elementCount, full },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
renderCall(args, theme, _context) {
|
|
58
|
+
const label = args.full ? "full" : "compact";
|
|
59
|
+
return new Text(
|
|
60
|
+
`${theme.fg("toolTitle", theme.bold("browser-snapshot"))} ${theme.fg("dim", label)}`,
|
|
61
|
+
0,
|
|
62
|
+
0,
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
renderResult(result, { expanded, isPartial }, theme, _context) {
|
|
67
|
+
if (isPartial)
|
|
68
|
+
return new Text(theme.fg("warning", "Taking snapshot…"), 0, 0);
|
|
69
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
70
|
+
if (d?.error) return new Text(theme.fg("error", "Snapshot failed"), 0, 0);
|
|
71
|
+
const ec = (d?.elementCount as number) ?? 0;
|
|
72
|
+
const content = (result.content?.[0] as any)?.text ?? "";
|
|
73
|
+
const isFull = !!(d?.full as boolean);
|
|
74
|
+
if (expanded) {
|
|
75
|
+
const preview = content.slice(0, 400);
|
|
76
|
+
let text = theme.fg("accent", `📋 ${ec} elements`);
|
|
77
|
+
text += isFull ? "" : theme.fg("dim", " (compact)");
|
|
78
|
+
text += `\n${theme.fg("dim", preview)}`;
|
|
79
|
+
if (content.length > 400)
|
|
80
|
+
text += `\n${theme.fg("muted", `… ${content.length - 400} more chars`)}`;
|
|
81
|
+
return new Text(text, 0, 0);
|
|
82
|
+
}
|
|
83
|
+
const label = isFull ? " (full)" : " (compact)";
|
|
84
|
+
return new Text(
|
|
85
|
+
theme.fg("accent", `📋 ${ec} elements${label} (expand)`),
|
|
86
|
+
0,
|
|
87
|
+
0,
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser-type 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 browserTypeTool = defineTool({
|
|
12
|
+
name: "browser-type",
|
|
13
|
+
label: "Type Text",
|
|
14
|
+
description:
|
|
15
|
+
"Type text into an input element identified by its @e reference ID. " +
|
|
16
|
+
"Clears existing content before typing. Use after browser-navigate or browser-snapshot.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
ref: Type.String({
|
|
19
|
+
description:
|
|
20
|
+
"Element reference like @e5 (must be a textbox, searchbox, or combobox)",
|
|
21
|
+
}),
|
|
22
|
+
text: Type.String({ description: "Text to type into the element" }),
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
26
|
+
const { ref, text } = params as { ref: string; text: string };
|
|
27
|
+
return executeInteractionTool(
|
|
28
|
+
ctx,
|
|
29
|
+
"Type",
|
|
30
|
+
(tid) => router.type(tid, ref, text),
|
|
31
|
+
(result) => ({
|
|
32
|
+
message: `Typed "${text}" into ${ref}`,
|
|
33
|
+
details: { typed: true, ref, text, elementCount: result.elementCount },
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
renderCall(args, theme, _context) {
|
|
39
|
+
return new Text(
|
|
40
|
+
`${theme.fg("toolTitle", theme.bold("browser-type"))} ${theme.fg("accent", args.ref)} "${args.text}"`,
|
|
41
|
+
0,
|
|
42
|
+
0,
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
renderResult(result, _options, theme, _context) {
|
|
47
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
48
|
+
if (d?.error)
|
|
49
|
+
return new Text(theme.fg("error", `Type failed: ${d.error}`), 0, 0);
|
|
50
|
+
const ec = d?.elementCount as number | undefined;
|
|
51
|
+
return new Text(
|
|
52
|
+
theme.fg(
|
|
53
|
+
"success",
|
|
54
|
+
`📝 typed "${d?.text || "?"}"${ec !== undefined ? ` · ${ec} elements` : ""}`,
|
|
55
|
+
),
|
|
56
|
+
0,
|
|
57
|
+
0,
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
});
|
package/tools/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions barrel — exports all 12 tool definitions for registration.
|
|
3
|
+
*
|
|
4
|
+
* Each tool is defined in its own file under tools/ for maintainability.
|
|
5
|
+
* This barrel collects them for import by index.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { browserNavigateTool } from "./browser-navigate.js";
|
|
9
|
+
export { browserSnapshotTool } from "./browser-snapshot.js";
|
|
10
|
+
export { browserClickTool } from "./browser-click.js";
|
|
11
|
+
export { browserTypeTool } from "./browser-type.js";
|
|
12
|
+
export { browserScrollTool } from "./browser-scroll.js";
|
|
13
|
+
export { browserBackTool } from "./browser-back.js";
|
|
14
|
+
export { browserPressTool } from "./browser-press.js";
|
|
15
|
+
export { browserConsoleTool } from "./browser-console.js";
|
|
16
|
+
export { browserInspectTool } from "./browser-inspect.js";
|
|
17
|
+
export { webFetchTool } from "./web-fetch.js";
|
|
18
|
+
export { webGuideTool } from "./web-guide.js";
|
|
19
|
+
export { webLearnTool } from "./web-learn.js";
|
package/tools/utils.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the pi-lean-portal tool definitions.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.ts to avoid duplication across ~14 tool files.
|
|
5
|
+
* Includes taskId resolution, status bar updates, and profile line formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { getToggleState, getLearnState } from "../browser-toggle.js";
|
|
10
|
+
import { sessionManager } from "../core/shared/session-manager.js";
|
|
11
|
+
import { taskId } from "../core/shared/task-id.js";
|
|
12
|
+
|
|
13
|
+
// ─── Status bar ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Retained reference to the most recent extension context for use by
|
|
17
|
+
* the browser.profile event listener (which fires asynchronously from
|
|
18
|
+
* tool execution contexts). Updated on session_start and cleared on
|
|
19
|
+
* session_shutdown.
|
|
20
|
+
*/
|
|
21
|
+
let _lastCtx: {
|
|
22
|
+
ui: {
|
|
23
|
+
setStatus: (key: string, label: string) => void;
|
|
24
|
+
theme: { fg: (c: string, t: string) => string };
|
|
25
|
+
};
|
|
26
|
+
} | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Update the TUI status bar with the current browser state.
|
|
30
|
+
*/
|
|
31
|
+
export function updateFooterStatus(ctx: {
|
|
32
|
+
ui: {
|
|
33
|
+
setStatus: (key: string, label: string) => void;
|
|
34
|
+
theme: { fg: (c: string, t: string) => string };
|
|
35
|
+
};
|
|
36
|
+
}): void {
|
|
37
|
+
_lastCtx = ctx;
|
|
38
|
+
const toggleState = getToggleState();
|
|
39
|
+
if (toggleState === false) {
|
|
40
|
+
ctx.ui.setStatus("browser", "○ web off");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const body = sessionManager.getStatus();
|
|
44
|
+
const learn = getLearnState();
|
|
45
|
+
const dot = ctx.ui.theme.fg(learn ? "success" : "accent", "●");
|
|
46
|
+
ctx.ui.setStatus("browser", `${dot} ${body}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @internal — used by index.ts's profile event listener */
|
|
50
|
+
export function getLastCtx(): typeof _lastCtx {
|
|
51
|
+
return _lastCtx;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @internal — set by index.ts on startup/shutdown */
|
|
55
|
+
export function setLastCtx(ctx: typeof _lastCtx): void {
|
|
56
|
+
_lastCtx = ctx;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Profile line formatting ────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format the profile line for browser-navigate output.
|
|
63
|
+
*
|
|
64
|
+
* Examples:
|
|
65
|
+
* Profile: none
|
|
66
|
+
* Profile: session (this conversation)
|
|
67
|
+
* Profile: work (shared)
|
|
68
|
+
*/
|
|
69
|
+
export function profileLine(result: {
|
|
70
|
+
profileMode?: string;
|
|
71
|
+
profileName?: string;
|
|
72
|
+
}): string {
|
|
73
|
+
const mode = result.profileMode;
|
|
74
|
+
if (mode === "none") {
|
|
75
|
+
return "Profile: none";
|
|
76
|
+
}
|
|
77
|
+
if (mode === "session") {
|
|
78
|
+
return "Profile: session (this conversation)";
|
|
79
|
+
}
|
|
80
|
+
// Named profile
|
|
81
|
+
return `Profile: ${result.profileName ?? "unnamed"} (shared)`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Execute an interaction tool with standard boilerplate handling.
|
|
86
|
+
*
|
|
87
|
+
* Handles:
|
|
88
|
+
* - taskId resolution from context
|
|
89
|
+
* - Footer status update
|
|
90
|
+
* - Error formatting with consistent pattern
|
|
91
|
+
* - Snapshot and elementCount appending
|
|
92
|
+
*
|
|
93
|
+
* Each tool provides its router call and a formatSuccess callback that
|
|
94
|
+
* returns the success message and details object.
|
|
95
|
+
*
|
|
96
|
+
* @param ctx - Extension tool execution context
|
|
97
|
+
* @param actionName - Human-readable action name (e.g. "Click", "Type")
|
|
98
|
+
* @param action - Async function that performs the router call with a resolved taskId
|
|
99
|
+
* @param formatSuccess - Callback to produce the success message and details from the result
|
|
100
|
+
*/
|
|
101
|
+
export async function executeInteractionTool<
|
|
102
|
+
T extends {
|
|
103
|
+
success: boolean;
|
|
104
|
+
error?: string;
|
|
105
|
+
snapshot?: string;
|
|
106
|
+
elementCount?: number;
|
|
107
|
+
},
|
|
108
|
+
>(
|
|
109
|
+
ctx: {
|
|
110
|
+
ui: {
|
|
111
|
+
setStatus: (key: string, label: string) => void;
|
|
112
|
+
theme: { fg: (c: string, t: string) => string };
|
|
113
|
+
};
|
|
114
|
+
sessionManager?: { getSessionId?(): string };
|
|
115
|
+
},
|
|
116
|
+
actionName: string,
|
|
117
|
+
action: (tid: string) => Promise<T>,
|
|
118
|
+
formatSuccess: (result: T) => {
|
|
119
|
+
message: string;
|
|
120
|
+
details: Record<string, unknown>;
|
|
121
|
+
},
|
|
122
|
+
): Promise<{
|
|
123
|
+
content: Array<{ type: "text"; text: string }>;
|
|
124
|
+
details: Record<string, unknown>;
|
|
125
|
+
}> {
|
|
126
|
+
const tid = taskId(ctx);
|
|
127
|
+
const result = await action(tid);
|
|
128
|
+
updateFooterStatus(ctx);
|
|
129
|
+
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text: `${actionName} failed: ${result.error ?? "unknown"}`,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
details: { error: true },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { message, details } = formatSuccess(result);
|
|
143
|
+
let content = message;
|
|
144
|
+
if (result.snapshot) {
|
|
145
|
+
content += `\n\n${result.snapshot}`;
|
|
146
|
+
}
|
|
147
|
+
if (result.elementCount !== undefined) {
|
|
148
|
+
content += `\n\nInteractive elements: ${result.elementCount}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
content: [{ type: "text", text: content }],
|
|
153
|
+
details,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type { ExtensionAPI };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-fetch 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 { webFetch } from "../core/fetch-backend.js";
|
|
9
|
+
import { taskId } from "../core/shared/task-id.js";
|
|
10
|
+
|
|
11
|
+
export const webFetchTool = defineTool({
|
|
12
|
+
name: "web-fetch",
|
|
13
|
+
label: "Web Fetch",
|
|
14
|
+
description:
|
|
15
|
+
"Perform a stateless HTTP fetch of a URL and return its content as Markdown. " +
|
|
16
|
+
"Auto-detects JS-only shells and bot challenge pages. " +
|
|
17
|
+
"For interactive browsing with @e1/@e2 element references, use browser-navigate instead.",
|
|
18
|
+
promptSnippet: "Fetch a URL via stateless HTTP and get Markdown content",
|
|
19
|
+
promptGuidelines: [
|
|
20
|
+
"Use web-fetch for quick, stateless page retrieval when you don't need JavaScript or interactive elements.",
|
|
21
|
+
"The tool returns page content as Markdown, truncated to ~4K chars inline.",
|
|
22
|
+
"If the result mentions a temp file with full content, use the read tool with offset/limit to access specific sections.",
|
|
23
|
+
"If the result indicates the page needs JavaScript, switch to browser-navigate (try strategy='chromium' or strategy='firefox').",
|
|
24
|
+
"If bot detection is triggered, the page may be blocked — try browser-navigate instead.",
|
|
25
|
+
"This tool does NOT create a browser session — it's a simple HTTP fetch.",
|
|
26
|
+
],
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
url: Type.String({ description: "The URL to fetch" }),
|
|
29
|
+
timeout: Type.Optional(
|
|
30
|
+
Type.Number({
|
|
31
|
+
description: "Timeout in seconds (default: 30, max: 120)",
|
|
32
|
+
minimum: 1,
|
|
33
|
+
maximum: 120,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
39
|
+
const { url, timeout = 30 } = params as {
|
|
40
|
+
url: string;
|
|
41
|
+
timeout?: number;
|
|
42
|
+
};
|
|
43
|
+
const tid = taskId(ctx);
|
|
44
|
+
|
|
45
|
+
const fetchOptions: {
|
|
46
|
+
url: string;
|
|
47
|
+
timeout: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
taskId?: string;
|
|
50
|
+
} = {
|
|
51
|
+
url,
|
|
52
|
+
timeout,
|
|
53
|
+
taskId: tid,
|
|
54
|
+
};
|
|
55
|
+
if (signal) fetchOptions.signal = signal;
|
|
56
|
+
|
|
57
|
+
const result = await webFetch(fetchOptions);
|
|
58
|
+
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
return {
|
|
61
|
+
content: [
|
|
62
|
+
{
|
|
63
|
+
type: "text",
|
|
64
|
+
text: `Fetch failed: ${result.error ?? "unknown error"}`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
details: {
|
|
68
|
+
error: true,
|
|
69
|
+
url: result.url,
|
|
70
|
+
statusCode: result.statusCode,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = [
|
|
76
|
+
result.title ? `Title: ${result.title}` : "",
|
|
77
|
+
`URL: ${result.url}`,
|
|
78
|
+
`Backend: ${result.backendUsed}`,
|
|
79
|
+
result.statusCode ? `HTTP ${result.statusCode}` : "",
|
|
80
|
+
result.needsJavaScript
|
|
81
|
+
? "⚠ This page appears to need JavaScript for full rendering."
|
|
82
|
+
: "",
|
|
83
|
+
result.botDetected
|
|
84
|
+
? "⚠ Bot detection triggered — the page may be blocking automation. Try browser-navigate with a stealth backend instead if one is configured."
|
|
85
|
+
: "",
|
|
86
|
+
"",
|
|
87
|
+
result.content,
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text", text: lines.filter(Boolean).join("\n") }],
|
|
92
|
+
details: {
|
|
93
|
+
title: result.title,
|
|
94
|
+
url: result.url,
|
|
95
|
+
backendUsed: result.backendUsed,
|
|
96
|
+
statusCode: result.statusCode,
|
|
97
|
+
needsJavaScript: result.needsJavaScript,
|
|
98
|
+
botDetected: result.botDetected,
|
|
99
|
+
...(result.filePath ? { filePath: result.filePath } : {}),
|
|
100
|
+
...(result.totalChars ? { totalChars: result.totalChars } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
renderCall(args, theme, _context) {
|
|
106
|
+
const parts: string[] = [theme.fg("toolTitle", theme.bold("web-fetch "))];
|
|
107
|
+
parts.push(theme.fg("accent", `"${args.url}"`));
|
|
108
|
+
return new Text(parts.join(" "), 0, 0);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
renderResult(result, { expanded, isPartial }, theme, _context) {
|
|
112
|
+
if (isPartial) return new Text(theme.fg("warning", "Fetching…"), 0, 0);
|
|
113
|
+
const d = result.details as Record<string, unknown> | undefined;
|
|
114
|
+
if (d?.error)
|
|
115
|
+
return new Text(
|
|
116
|
+
theme.fg(
|
|
117
|
+
"error",
|
|
118
|
+
`Fetch failed: ${(result.content?.[0] as any)?.text ?? "?"}`,
|
|
119
|
+
),
|
|
120
|
+
0,
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const title = (d?.title as string) || "(no title)";
|
|
125
|
+
const url = (d?.url as string) || "";
|
|
126
|
+
const statusCode = d?.statusCode as number | undefined;
|
|
127
|
+
const needsJS = d?.needsJavaScript as boolean | undefined;
|
|
128
|
+
const botDetected = d?.botDetected as boolean | undefined;
|
|
129
|
+
|
|
130
|
+
let text = theme.fg("accent", theme.bold(`📡 ${title}`));
|
|
131
|
+
text += `\n${theme.fg("dim", url)}`;
|
|
132
|
+
if (statusCode) text += ` · HTTP ${statusCode}`;
|
|
133
|
+
if (needsJS) text += ` ${theme.fg("warning", "⚠ needs JS")}`;
|
|
134
|
+
if (botDetected) text += ` ${theme.fg("warning", "⚠ bot detected")}`;
|
|
135
|
+
|
|
136
|
+
const content = (result.content?.[0] as any)?.text ?? "";
|
|
137
|
+
if (expanded) {
|
|
138
|
+
const preview = content.replace(/\n{3,}/g, "\n\n").slice(0, 500);
|
|
139
|
+
text += `\n\n${theme.fg("dim", preview)}`;
|
|
140
|
+
if (content.length > 500)
|
|
141
|
+
text += `\n${theme.fg("muted", `… ${content.length - 500} more chars`)}`;
|
|
142
|
+
} else {
|
|
143
|
+
text += `\n${theme.fg("muted", `${content.length} chars (expand)`)}`;
|
|
144
|
+
}
|
|
145
|
+
return new Text(text, 0, 0);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-guide tool definition.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { Guide } from "../core/guides.js";
|
|
8
|
+
import { formatGuideList, getGuideContent } from "../core/guides.js";
|
|
9
|
+
|
|
10
|
+
export const webGuideTool = defineTool({
|
|
11
|
+
name: "web-guide",
|
|
12
|
+
label: "Web Navigation Guide",
|
|
13
|
+
description:
|
|
14
|
+
"Get navigation guidance for a site or pattern. " +
|
|
15
|
+
"Call with a guide name (e.g. 'reddit', 'cookie-consent', 'bot-detection') " +
|
|
16
|
+
"for guidance, or with no parameter to list all available guides.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
guide: Type.Optional(
|
|
19
|
+
Type.String({
|
|
20
|
+
description:
|
|
21
|
+
"Guide name (e.g. 'reddit', 'cookie-consent', 'bot-detection'). Omit to list all guides.",
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
27
|
+
const { guide } = params as { guide?: string };
|
|
28
|
+
if (!guide) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: formatGuideList() }],
|
|
31
|
+
details: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const entry: Guide | undefined = getGuideContent()[guide];
|
|
35
|
+
if (!entry) {
|
|
36
|
+
return {
|
|
37
|
+
content: [
|
|
38
|
+
{
|
|
39
|
+
type: "text",
|
|
40
|
+
text: `No guide for '${guide}'. Available: ${Object.keys(getGuideContent()).join(", ")}`,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
details: {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const currentDate = new Date().toISOString().slice(0, 10);
|
|
47
|
+
const text =
|
|
48
|
+
entry.content +
|
|
49
|
+
`\n\n_Guide updated: ${entry.updated} · Current date: ${currentDate} · Source: ${entry.source}_`;
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text }],
|
|
52
|
+
details: {},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web-learn 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 { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { USER_GUIDES_DIR, invalidateGuideContent } from "../core/guides.js";
|
|
11
|
+
|
|
12
|
+
export const webLearnTool = defineTool({
|
|
13
|
+
name: "web-learn",
|
|
14
|
+
label: "Learn Navigation Patterns",
|
|
15
|
+
description:
|
|
16
|
+
"Save or update a navigation guide for a site. " +
|
|
17
|
+
"Creates a new guide file for the given domain, or updates an existing one with new content and date. " +
|
|
18
|
+
"The guide becomes available immediately via web-guide and appears in the guide footer on future navigations. " +
|
|
19
|
+
"Requires /web learn to be active.",
|
|
20
|
+
parameters: Type.Object({
|
|
21
|
+
domain: Type.String({
|
|
22
|
+
description: "Primary domain (e.g. 'reddit.com'). Used as the filename.",
|
|
23
|
+
}),
|
|
24
|
+
content: Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"Markdown guidance content describing the site's navigation patterns, page structure, consent dialogs, and known quirks.",
|
|
27
|
+
}),
|
|
28
|
+
domains: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"Comma-separated additional domains (e.g. 'www.reddit.com, old.reddit.com'). Omit if only the primary domain applies.",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
}),
|
|
35
|
+
|
|
36
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
37
|
+
const { domain, content, domains } = params as {
|
|
38
|
+
domain: string;
|
|
39
|
+
content: string;
|
|
40
|
+
domains?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── Validation ────────────────────────────────────────────
|
|
44
|
+
if (!domain.includes(".")) {
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: `'${domain}' doesn't look like a domain. Use the full domain (e.g. 'reddit.com').`,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
details: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!content || content.trim().length < 20) {
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: "Content too short. Provide at least a few sentences of guidance.",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
details: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── File path ─────────────────────────────────────────────
|
|
69
|
+
mkdirSync(USER_GUIDES_DIR, { recursive: true });
|
|
70
|
+
const filename = `${domain}.md`;
|
|
71
|
+
const filepath = join(USER_GUIDES_DIR, filename);
|
|
72
|
+
|
|
73
|
+
// ── Build frontmatter ─────────────────────────────────────
|
|
74
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
75
|
+
const allDomains = [domain];
|
|
76
|
+
if (domains) {
|
|
77
|
+
for (const d of domains
|
|
78
|
+
.split(",")
|
|
79
|
+
.map((s) => s.trim())
|
|
80
|
+
.filter(Boolean)) {
|
|
81
|
+
if (!allDomains.includes(d)) allDomains.push(d);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const frontmatter = [
|
|
86
|
+
"---",
|
|
87
|
+
"category: site",
|
|
88
|
+
`domains: ${allDomains.join(", ")}`,
|
|
89
|
+
`updated: ${today}`,
|
|
90
|
+
"---",
|
|
91
|
+
].join("\n");
|
|
92
|
+
|
|
93
|
+
const fileContent = frontmatter + "\n\n" + content.trim() + "\n";
|
|
94
|
+
|
|
95
|
+
// ── Detect whether this is a create or update ──────────────
|
|
96
|
+
const isUpdate = existsSync(filepath);
|
|
97
|
+
writeFileSync(filepath, fileContent, "utf-8");
|
|
98
|
+
|
|
99
|
+
// ── Invalidate cache ────────────────────────────────────
|
|
100
|
+
invalidateGuideContent();
|
|
101
|
+
|
|
102
|
+
const verb = isUpdate ? "Updated" : "Created";
|
|
103
|
+
return {
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: "text",
|
|
107
|
+
text:
|
|
108
|
+
`📖 ${verb} guide at ~/.pi/agent/pi-lean-portal/web-guides/${filename}\n` +
|
|
109
|
+
` Domains: ${allDomains.join(", ")}\n` +
|
|
110
|
+
` Call web-guide guide="${domain}" to view it.\n` +
|
|
111
|
+
` A guide badge and footer will appear when navigating to ${allDomains[0]}.`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
details: {
|
|
115
|
+
filePath: filepath,
|
|
116
|
+
domains: allDomains,
|
|
117
|
+
guideName: domain,
|
|
118
|
+
isUpdate,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
renderCall(args, theme, _context) {
|
|
124
|
+
const parts: string[] = [theme.fg("toolTitle", theme.bold("web-learn "))];
|
|
125
|
+
parts.push(theme.fg("accent", `"${args.domain}"`));
|
|
126
|
+
return new Text(parts.join(" "), 0, 0);
|
|
127
|
+
},
|
|
128
|
+
});
|