pi-browser-harness 0.13.0 → 0.14.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/CHANGELOG.md +20 -0
- package/README.md +46 -0
- package/package.json +5 -3
- package/skills/pi-browser-harness/SKILL.md +32 -0
- package/src/cdp/commands.ts +5 -0
- package/src/cdp/events.ts +1 -0
- package/src/cdp/session.ts +40 -1
- package/src/client.ts +51 -11
- package/src/domains/ax-tree.ts +19 -0
- package/src/domains/js.ts +2 -2
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +4 -1
- package/src/domains/ref-resolve.ts +1 -1
- package/src/domains/search/google-serp.ts +77 -7
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +65 -7
- package/src/domains/snapshot.ts +11 -6
- package/src/index.ts +12 -1
- package/src/prompt.ts +2 -1
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +4 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/tool.ts +25 -3
|
@@ -2,6 +2,8 @@ export type SerpAnchor = {
|
|
|
2
2
|
readonly href: string;
|
|
3
3
|
readonly heading: string;
|
|
4
4
|
readonly snippet: string;
|
|
5
|
+
// Visible breadcrumb url ("https://host › path › seg"); the only place the target survives once Google wraps hrefs in /goto.
|
|
6
|
+
readonly cite?: string;
|
|
5
7
|
};
|
|
6
8
|
|
|
7
9
|
export type SerpExtraction = {
|
|
@@ -16,7 +18,7 @@ export type SearchResult = {
|
|
|
16
18
|
readonly rank: number;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
|
-
export type SerpVerdict = "ok" | "captcha" | "no_results";
|
|
21
|
+
export type SerpVerdict = "ok" | "captcha" | "no_results" | "extraction_mismatch";
|
|
20
22
|
|
|
21
23
|
const CAPTCHA_MARKERS: ReadonlyArray<string> = [
|
|
22
24
|
"unusual traffic",
|
|
@@ -36,12 +38,74 @@ const unwrapRedirect = (href: string): string => {
|
|
|
36
38
|
return target ?? href;
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
const CITE_SEPARATOR = /\s*›\s*/;
|
|
42
|
+
const TRUNCATION = /(?:\.\.\.|…)/;
|
|
43
|
+
|
|
44
|
+
// Rebuilds a url from Google's visible breadcrumb cite. Segments after a truncation marker are unknowable, so the
|
|
45
|
+
// path stops at the last complete segment rather than guessing.
|
|
46
|
+
export const urlFromCite = (cite: string): string | undefined => {
|
|
47
|
+
const segments = cite
|
|
48
|
+
.trim()
|
|
49
|
+
.split(CITE_SEPARATOR)
|
|
50
|
+
.map((segment) => segment.trim())
|
|
51
|
+
.filter((segment) => segment.length > 0);
|
|
52
|
+
const origin = segments[0];
|
|
53
|
+
if (origin === undefined || TRUNCATION.test(origin)) return undefined;
|
|
54
|
+
// A cite naming any other scheme (ftp:, mailto:) is not a web result; prefixing it would forge a bogus https url.
|
|
55
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(origin) && !/^https?:\/\//i.test(origin)) return undefined;
|
|
56
|
+
const withScheme = /^https?:\/\//i.test(origin) ? origin : `https://${origin}`;
|
|
57
|
+
const path: string[] = [];
|
|
58
|
+
for (const segment of segments.slice(1)) {
|
|
59
|
+
if (TRUNCATION.test(segment)) break;
|
|
60
|
+
path.push(segment);
|
|
61
|
+
}
|
|
62
|
+
const url = [withScheme.replace(/\/$/, ""), ...path].join("/");
|
|
63
|
+
return isExternalResult(url) ? url : undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Only a real Google /goto url counts: the daemon fetches these, so a look-alike host, userinfo, a port or http
|
|
67
|
+
// must never qualify. Hosts are google.com or www.google.<tld>, nothing else.
|
|
68
|
+
const GOOGLE_GOTO_HOST = /^(?:www\.)?google\.[a-z]{2,3}(?:\.[a-z]{2})?$/;
|
|
69
|
+
export const isGotoRedirect = (href: string): boolean => {
|
|
70
|
+
try {
|
|
71
|
+
const u = new URL(href);
|
|
72
|
+
return (
|
|
73
|
+
u.protocol === "https:" &&
|
|
74
|
+
u.username === "" &&
|
|
75
|
+
u.password === "" &&
|
|
76
|
+
u.port === "" &&
|
|
77
|
+
GOOGLE_GOTO_HOST.test(u.hostname.toLowerCase()) &&
|
|
78
|
+
u.pathname === "/goto"
|
|
79
|
+
);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type ResolvedTargets = ReadonlyMap<string, string>;
|
|
86
|
+
|
|
87
|
+
// Direct external hrefs win. A /goto wrapper is opaque: the server-resolved redirect target is exact, and the cite is
|
|
88
|
+
// a display breadcrumb that only approximates the path, so it is the fallback when no target was resolved.
|
|
89
|
+
const resolveResultUrl = (anchor: SerpAnchor, resolved: ResolvedTargets): string | undefined => {
|
|
90
|
+
if (!isGotoRedirect(anchor.href)) {
|
|
91
|
+
const unwrapped = unwrapRedirect(anchor.href);
|
|
92
|
+
return isExternalResult(unwrapped) ? unwrapped : undefined;
|
|
93
|
+
}
|
|
94
|
+
const target = resolved.get(anchor.href);
|
|
95
|
+
if (target !== undefined && isExternalResult(target)) return target;
|
|
96
|
+
if (anchor.cite !== undefined && anchor.cite.trim() !== "") return urlFromCite(anchor.cite);
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Any Google property, on any TLD (google.com, google.co.uk, consent.google.de, googleusercontent.com), is never a
|
|
101
|
+
// result: a sorry or consent page is where a rate-limited /goto redirect lands.
|
|
102
|
+
const GOOGLE_PROPERTY = /(?:^|\.)google(?:usercontent)?\.(?:com?\.[a-z]{2}|[a-z]{2,3})$/;
|
|
103
|
+
|
|
39
104
|
const isExternalResult = (url: string): boolean => {
|
|
40
105
|
if (!/^https?:\/\//i.test(url)) return false;
|
|
41
106
|
try {
|
|
42
107
|
const host = new URL(url).hostname.toLowerCase();
|
|
43
|
-
if (
|
|
44
|
-
if (host === "googleusercontent.com" || host.endsWith(".googleusercontent.com")) return false;
|
|
108
|
+
if (GOOGLE_PROPERTY.test(host)) return false;
|
|
45
109
|
return true;
|
|
46
110
|
} catch {
|
|
47
111
|
return false;
|
|
@@ -57,13 +121,17 @@ const dedupeKey = (url: string): string => {
|
|
|
57
121
|
}
|
|
58
122
|
};
|
|
59
123
|
|
|
60
|
-
export const parseGoogleSerp = (
|
|
124
|
+
export const parseGoogleSerp = (
|
|
125
|
+
anchors: ReadonlyArray<SerpAnchor>,
|
|
126
|
+
limit: number,
|
|
127
|
+
resolved: ResolvedTargets = new Map(),
|
|
128
|
+
): ReadonlyArray<SearchResult> => {
|
|
61
129
|
const seen = new Set<string>();
|
|
62
130
|
const results: SearchResult[] = [];
|
|
63
131
|
for (const anchor of anchors) {
|
|
64
132
|
if (results.length >= limit) break;
|
|
65
|
-
const url =
|
|
66
|
-
if (
|
|
133
|
+
const url = resolveResultUrl(anchor, resolved);
|
|
134
|
+
if (url === undefined) continue;
|
|
67
135
|
const key = dedupeKey(url);
|
|
68
136
|
if (seen.has(key)) continue;
|
|
69
137
|
seen.add(key);
|
|
@@ -77,9 +145,11 @@ export const parseGoogleSerp = (anchors: ReadonlyArray<SerpAnchor>, limit: numbe
|
|
|
77
145
|
return results;
|
|
78
146
|
};
|
|
79
147
|
|
|
80
|
-
export const classifySerp = (pageText: string, resultCount: number): SerpVerdict => {
|
|
148
|
+
export const classifySerp = (pageText: string, resultCount: number, anchorCount: number): SerpVerdict => {
|
|
81
149
|
if (resultCount > 0) return "ok";
|
|
82
150
|
const lower = pageText.toLowerCase();
|
|
83
151
|
if (CAPTCHA_MARKERS.some((marker) => lower.includes(marker))) return "captcha";
|
|
152
|
+
// Anchors were found but every one was filtered out: the markup changed, not the result set.
|
|
153
|
+
if (anchorCount > 0) return "extraction_mismatch";
|
|
84
154
|
return "no_results";
|
|
85
155
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Resolves one Google /goto?url=<opaque blob> link to its target by reading the server's redirect, without
|
|
2
|
+
// following it. The blob cannot be decoded client-side and the page's <cite> is only a display breadcrumb, so this
|
|
3
|
+
// is the one source of the exact url. Google answers the redirect to a cookie-less GET; HEAD returns 200 instead.
|
|
4
|
+
|
|
5
|
+
import { isGotoRedirect } from "./google-serp";
|
|
6
|
+
|
|
7
|
+
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
8
|
+
|
|
9
|
+
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36";
|
|
10
|
+
|
|
11
|
+
export type RedirectResolver = (href: string, signal: AbortSignal) => Promise<string | undefined>;
|
|
12
|
+
|
|
13
|
+
export type ResolveOptions = {
|
|
14
|
+
// Defence in depth: the parser already filters to /goto hrefs, and the resolver refuses anything else again so a
|
|
15
|
+
// caller bug cannot turn it into a generic url fetcher. Tests against a local server opt out explicitly.
|
|
16
|
+
readonly requireGoto?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const fetchRedirectTarget = async (
|
|
20
|
+
href: string,
|
|
21
|
+
signal: AbortSignal,
|
|
22
|
+
opts: ResolveOptions = {},
|
|
23
|
+
): Promise<string | undefined> => {
|
|
24
|
+
if ((opts.requireGoto ?? true) && !isGotoRedirect(href)) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(href, {
|
|
27
|
+
method: "GET",
|
|
28
|
+
redirect: "manual",
|
|
29
|
+
signal,
|
|
30
|
+
headers: { "user-agent": USER_AGENT, accept: "text/html" },
|
|
31
|
+
});
|
|
32
|
+
// The body is never read; cancelling it releases the socket without waiting for Google to finish sending.
|
|
33
|
+
await response.body?.cancel().catch(() => undefined);
|
|
34
|
+
if (!REDIRECT_STATUSES.has(response.status)) return undefined;
|
|
35
|
+
const location = response.headers.get("location");
|
|
36
|
+
if (location === null || location === "") return undefined;
|
|
37
|
+
return new URL(location, href).toString();
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -16,9 +16,47 @@ import { pageCallExpression } from "../page/inject";
|
|
|
16
16
|
import { extractSerp } from "../page/serp.page";
|
|
17
17
|
import type * as extracted from "../page/serp.page";
|
|
18
18
|
import { ExpandableTextSchema, renderExpandableText } from "../render";
|
|
19
|
-
import {
|
|
19
|
+
import { deadlineSignal, runLimited } from "../concurrency";
|
|
20
|
+
import {
|
|
21
|
+
classifySerp,
|
|
22
|
+
isGotoRedirect,
|
|
23
|
+
parseGoogleSerp,
|
|
24
|
+
type ResolvedTargets,
|
|
25
|
+
type SearchResult,
|
|
26
|
+
type SerpExtraction,
|
|
27
|
+
} from "./google-serp";
|
|
28
|
+
import { fetchRedirectTarget, type RedirectResolver } from "./redirect-resolver";
|
|
20
29
|
|
|
21
30
|
const LOAD_TIMEOUT_MS = 15_000;
|
|
31
|
+
const RESOLVE_BUDGET_MS = 5_000;
|
|
32
|
+
const RESOLVE_CONCURRENCY = 5;
|
|
33
|
+
|
|
34
|
+
export type WebSearchDeps = {
|
|
35
|
+
readonly resolveRedirect: RedirectResolver;
|
|
36
|
+
readonly resolveBudgetMs?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Resolves every /goto anchor in parallel under one time budget; a slot that fails or runs out of time is simply
|
|
40
|
+
// absent from the map, and the parser falls back to that anchor's cite.
|
|
41
|
+
const resolveGotoTargets = async (
|
|
42
|
+
anchors: ReadonlyArray<SerpExtraction["anchors"][number]>,
|
|
43
|
+
deps: WebSearchDeps,
|
|
44
|
+
signal: AbortSignal | undefined,
|
|
45
|
+
): Promise<ResolvedTargets> => {
|
|
46
|
+
const hrefs = anchors.map((a) => a.href).filter(isGotoRedirect);
|
|
47
|
+
if (hrefs.length === 0) return new Map();
|
|
48
|
+
const deadline = deadlineSignal(deps.resolveBudgetMs ?? RESOLVE_BUDGET_MS, signal);
|
|
49
|
+
const targets = await runLimited(hrefs, { limit: RESOLVE_CONCURRENCY, signal: deadline.signal }, (href, s) =>
|
|
50
|
+
deps.resolveRedirect(href, s),
|
|
51
|
+
);
|
|
52
|
+
deadline.clear();
|
|
53
|
+
const resolved = new Map<string, string>();
|
|
54
|
+
hrefs.forEach((href, i) => {
|
|
55
|
+
const target = targets[i];
|
|
56
|
+
if (target !== undefined) resolved.set(href, target);
|
|
57
|
+
});
|
|
58
|
+
return resolved;
|
|
59
|
+
};
|
|
22
60
|
|
|
23
61
|
const WebSearchArgs = Type.Object({
|
|
24
62
|
query: Type.String({ description: "The search query." }),
|
|
@@ -42,7 +80,12 @@ const buildSerpUrl = (query: string, limit: number): string =>
|
|
|
42
80
|
|
|
43
81
|
const SerpExtractionSchema = Type.Object({
|
|
44
82
|
anchors: Type.Array(
|
|
45
|
-
Type.Object({
|
|
83
|
+
Type.Object({
|
|
84
|
+
href: Type.String(),
|
|
85
|
+
heading: Type.String(),
|
|
86
|
+
snippet: Type.String(),
|
|
87
|
+
cite: Type.Optional(Type.String()),
|
|
88
|
+
}),
|
|
46
89
|
),
|
|
47
90
|
pageText: Type.String(),
|
|
48
91
|
});
|
|
@@ -74,7 +117,7 @@ const formatResults = (query: string, results: ReadonlyArray<SearchResult>): str
|
|
|
74
117
|
return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
|
|
75
118
|
};
|
|
76
119
|
|
|
77
|
-
export const
|
|
120
|
+
export const createWebSearchTool = (deps: WebSearchDeps) => defineBrowserTool({
|
|
78
121
|
name: "browser_web_search",
|
|
79
122
|
label: "Browser Web Search",
|
|
80
123
|
description:
|
|
@@ -83,6 +126,7 @@ export const webSearchTool = defineBrowserTool({
|
|
|
83
126
|
promptGuidelines: [
|
|
84
127
|
"On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.",
|
|
85
128
|
"On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
|
|
129
|
+
"If results were on the page but none could be parsed it fails with details.reason:'extraction_mismatch' (plus anchorCount) — Google's markup changed; report it rather than retrying.",
|
|
86
130
|
"Runs in its own isolated tab and never disturbs the user's current tab.",
|
|
87
131
|
],
|
|
88
132
|
parameters: WebSearchArgs,
|
|
@@ -112,15 +156,27 @@ export const webSearchTool = defineBrowserTool({
|
|
|
112
156
|
}
|
|
113
157
|
const extraction: SerpExtraction = parsedJson.data;
|
|
114
158
|
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
159
|
+
const resolved = await resolveGotoTargets(extraction.anchors, deps, signal);
|
|
160
|
+
const results = parseGoogleSerp(extraction.anchors, limit, resolved);
|
|
161
|
+
const anchorCount = extraction.anchors.length;
|
|
162
|
+
const verdict = classifySerp(extraction.pageText, results.length, anchorCount);
|
|
163
|
+
if (verdict === "captcha") {
|
|
118
164
|
return err({
|
|
119
165
|
kind: "invalid_state",
|
|
120
|
-
message:
|
|
166
|
+
message: "Google served a CAPTCHA / bot wall",
|
|
121
167
|
details: { reason: verdict, serpUrl },
|
|
122
168
|
});
|
|
123
169
|
}
|
|
170
|
+
if (verdict === "extraction_mismatch") {
|
|
171
|
+
return err({
|
|
172
|
+
kind: "invalid_state",
|
|
173
|
+
message: `found ${anchorCount} result anchor(s) but none parsed to an external url (Google markup may have changed)`,
|
|
174
|
+
details: { reason: verdict, anchorCount, serpUrl },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (verdict === "no_results") {
|
|
178
|
+
return err({ kind: "invalid_state", message: "no results found", details: { reason: verdict, serpUrl } });
|
|
179
|
+
}
|
|
124
180
|
|
|
125
181
|
const body = formatResults(args.query, results);
|
|
126
182
|
const truncated = await applyTruncation(body, "search");
|
|
@@ -146,3 +202,5 @@ export const webSearchTool = defineBrowserTool({
|
|
|
146
202
|
return renderExpandableText(result.details.render, expanded);
|
|
147
203
|
},
|
|
148
204
|
});
|
|
205
|
+
|
|
206
|
+
export const webSearchTool = createWebSearchTool({ resolveRedirect: fetchRedirectTarget });
|
package/src/domains/snapshot.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
buildTree,
|
|
13
13
|
collectInteractiveTargets,
|
|
14
14
|
countNodes,
|
|
15
|
+
firstNodes,
|
|
15
16
|
INTERACTIVE_ROLES,
|
|
17
|
+
keepOnly,
|
|
16
18
|
liveValue,
|
|
17
19
|
type SlimNode,
|
|
18
20
|
stripInternals,
|
|
@@ -136,12 +138,12 @@ export const snapshotTool = defineBrowserTool({
|
|
|
136
138
|
const pageUrl = piRes.data.kind === "dialog" ? "" : piRes.data.url;
|
|
137
139
|
const pageTitle = piRes.data.kind === "dialog" ? "" : piRes.data.title;
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// Every element gets a ref, so a tool or a recipe can reach one the shown outline cut off; maxNodes limits
|
|
142
|
+
// only what is shown.
|
|
143
|
+
const full = buildTree(rawNodes, { interestingOnly: args.interestingOnly ?? true, maxNodes: Number.POSITIVE_INFINITY });
|
|
144
|
+
const shown = firstNodes(full, args.maxNodes ?? 1000);
|
|
143
145
|
|
|
144
|
-
const targets = collectInteractiveTargets(
|
|
146
|
+
const targets = collectInteractiveTargets(full);
|
|
145
147
|
|
|
146
148
|
const refMap = new Map<string, number>();
|
|
147
149
|
targets.forEach(({ node, backendId }, i) => {
|
|
@@ -153,7 +155,8 @@ export const snapshotTool = defineBrowserTool({
|
|
|
153
155
|
if (targets.length > 0) {
|
|
154
156
|
// Geometry and live values are best-effort decoration: whatever lands inside the budget is kept, the rest is omitted.
|
|
155
157
|
const deadline = deadlineSignal(BOX_BUDGET_MS, signal);
|
|
156
|
-
|
|
158
|
+
const visible = targets.filter(({ node }) => shown.has(node));
|
|
159
|
+
await runLimited(visible, { limit: BOX_CONCURRENCY, signal: deadline.signal }, async ({ node, backendId }, sig) => {
|
|
157
160
|
const box = await boxOf(client, backendId);
|
|
158
161
|
if (!box.success || sig.aborted) return;
|
|
159
162
|
if (box.data.x < 0 || box.data.y < 0) return;
|
|
@@ -172,6 +175,8 @@ export const snapshotTool = defineBrowserTool({
|
|
|
172
175
|
}
|
|
173
176
|
session.setRefMap(refMap, refSig);
|
|
174
177
|
|
|
178
|
+
const slim = keepOnly(full, shown);
|
|
179
|
+
|
|
175
180
|
const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripInternals(slim), null, 2);
|
|
176
181
|
const trunc = await applyTruncation(text, "snapshot");
|
|
177
182
|
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,11 @@ import { createDaemonTransport } from "./daemon/transport";
|
|
|
12
12
|
import type { CdpTransport } from "./cdp/types";
|
|
13
13
|
import { setDebugClicks } from "./util/debug";
|
|
14
14
|
import { asBoolean, asString } from "./util/guards";
|
|
15
|
-
import type
|
|
15
|
+
import { type BrowserTool, setCallObserver } from "./util/tool";
|
|
16
16
|
import type { Result } from "./util/result";
|
|
17
|
+
import { activeJournal, createJournal, journalDir, setActiveJournal } from "./recipes/journal";
|
|
18
|
+
import { observeCall } from "./recipes/recordable";
|
|
19
|
+
import { registerRecipeCommand } from "./recipes/command";
|
|
17
20
|
|
|
18
21
|
export { defineBrowserTool } from "./util/tool";
|
|
19
22
|
export type {
|
|
@@ -178,9 +181,17 @@ export const createExtension = (deps: ExtensionDeps = {}): ((pi: ExtensionAPI) =
|
|
|
178
181
|
client.setProfilePin(profilePin);
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
setActiveJournal(createJournal(journalDir(), ctx.sessionManager.getSessionId()));
|
|
185
|
+
setCallObserver(observeCall);
|
|
186
|
+
|
|
181
187
|
harnessRegistry.register(pi, client);
|
|
182
188
|
registerSetupCommand(pi, client);
|
|
183
189
|
registerProfileCommand(pi, client);
|
|
190
|
+
registerRecipeCommand(pi, client);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
pi.on("agent_start", async () => {
|
|
194
|
+
await activeJournal()?.beginRun();
|
|
184
195
|
});
|
|
185
196
|
|
|
186
197
|
pi.on("session_shutdown", async () => {
|
package/src/prompt.ts
CHANGED
|
@@ -27,7 +27,8 @@ const joinShort = (names: ReadonlyArray<string>): string => names.map(shortToolN
|
|
|
27
27
|
export function getBrowserSystemPrompt(connected = true, tools: ReadonlyArray<PromptTool> = ALL_TOOLS): string {
|
|
28
28
|
const connectionNotice = connected
|
|
29
29
|
? ""
|
|
30
|
-
: "
|
|
30
|
+
: // "Not connected" alone read to some models as "no browser tools", and they fell back to curl.
|
|
31
|
+
"The browser_* tools are available in this session; the browser is just not connected yet. Call browser_setup first to connect (the user can also run /browser-setup), then use the browser_* tools. When the user asks for the browser, do not swap in curl, another fetch tool or another browser tool.\n\n";
|
|
31
32
|
const lanes = laneNames(tools);
|
|
32
33
|
return `
|
|
33
34
|
## Browser Control
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { BrowserClient } from "../client";
|
|
3
|
+
import { missingParams, runRecipe, unknownParams, unknownParamsMessage } from "./replay";
|
|
4
|
+
import { formatFailureHandoff, formatOutputs, listText } from "./run-tool";
|
|
5
|
+
import { findRecipe, listRecipes, recordRun } from "./store";
|
|
6
|
+
import { approveProjectRecipe, trustWritten } from "./trust";
|
|
7
|
+
|
|
8
|
+
const TOKEN = /([^\s=]+)="([^"]*)"|(\S+)/g;
|
|
9
|
+
|
|
10
|
+
// `NAME key=value key="two words"`: the first bare word is the name, a double-quoted value stays whole, and the
|
|
11
|
+
// bare words after an unquoted value join it, so `topic=Grace Hopper` is "Grace Hopper", not "Grace".
|
|
12
|
+
export const parseCommandArgs = (raw: string): { readonly name: string | undefined; readonly params: Readonly<Record<string, string>> } => {
|
|
13
|
+
let name: string | undefined;
|
|
14
|
+
let open: string | undefined;
|
|
15
|
+
const params: Record<string, string> = {};
|
|
16
|
+
for (const m of raw.matchAll(TOKEN)) {
|
|
17
|
+
const [, quotedKey, quotedValue, bare] = m;
|
|
18
|
+
if (quotedKey !== undefined && quotedValue !== undefined) {
|
|
19
|
+
params[quotedKey] = quotedValue;
|
|
20
|
+
open = undefined;
|
|
21
|
+
} else if (bare?.includes("=") === true) {
|
|
22
|
+
const at = bare.indexOf("=");
|
|
23
|
+
open = bare.slice(0, at);
|
|
24
|
+
params[open] = bare.slice(at + 1);
|
|
25
|
+
} else if (bare !== undefined && name === undefined) {
|
|
26
|
+
name = bare;
|
|
27
|
+
} else if (bare !== undefined && open !== undefined) {
|
|
28
|
+
params[open] = `${params[open] ?? ""} ${bare}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { name, params };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Runs the replay engine directly, so a command run never starts a model turn.
|
|
35
|
+
export const registerRecipeCommand = (pi: Pick<ExtensionAPI, "registerCommand" | "sendUserMessage" | "sendMessage">, client: BrowserClient): void => {
|
|
36
|
+
pi.registerCommand("browser-recipe", {
|
|
37
|
+
description: "Replay a saved browser recipe with no model calls: /browser-recipe NAME key=value. No name lists recipes.",
|
|
38
|
+
getArgumentCompletions: async (prefix) => {
|
|
39
|
+
const all = await listRecipes(process.cwd());
|
|
40
|
+
return all
|
|
41
|
+
.map((s) => s.recipe.name)
|
|
42
|
+
.filter((n) => n.startsWith(prefix))
|
|
43
|
+
.map((n) => ({ value: n, label: n }));
|
|
44
|
+
},
|
|
45
|
+
handler: async (raw, ctx) => {
|
|
46
|
+
const { name, params } = parseCommandArgs(raw);
|
|
47
|
+
if (name === undefined) {
|
|
48
|
+
ctx.ui.notify(await listText(ctx.cwd), "info");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const alive = await client.ensureAlive();
|
|
52
|
+
if (!alive.success) {
|
|
53
|
+
ctx.ui.notify(`Browser not connected: ${alive.error.message}. Run /browser-setup first.`, "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const stored = await findRecipe(ctx.cwd, name);
|
|
57
|
+
if (!stored.success) {
|
|
58
|
+
ctx.ui.notify(stored.error, "error");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const unknown = unknownParams(stored.data.recipe, params);
|
|
62
|
+
if (unknown.length > 0) {
|
|
63
|
+
ctx.ui.notify(unknownParamsMessage(stored.data.recipe, unknown), "error");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const missing = missingParams(stored.data.recipe, params);
|
|
67
|
+
if (missing.length > 0) {
|
|
68
|
+
ctx.ui.notify(`Recipe ${name} needs: ${missing.map((p) => `${p}=… (e.g. ${stored.data.recipe.params[p]?.example ?? ""})`).join(" ")}`, "error");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const approved = await approveProjectRecipe(ctx, stored.data, params);
|
|
72
|
+
if (!approved.success) {
|
|
73
|
+
ctx.ui.notify(approved.error, "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
ctx.ui.notify(`Running recipe ${name}…`, "info");
|
|
77
|
+
const outcome = await runRecipe({ client, extensionCtx: ctx, recipe: stored.data.recipe, params });
|
|
78
|
+
const at = new Date().toISOString();
|
|
79
|
+
if (outcome.ok) {
|
|
80
|
+
await recordRun(stored.data, { at, result: "ok" });
|
|
81
|
+
await trustWritten(stored.data, ctx.cwd);
|
|
82
|
+
ctx.ui.notify(`Recipe ${name} done: ${outcome.steps} steps ✓`, "info");
|
|
83
|
+
// Shown in the session without starting a model turn; the agent sees it with the user's next message.
|
|
84
|
+
const data = formatOutputs(outcome.outputs);
|
|
85
|
+
if (data !== "") pi.sendMessage({ customType: "browser-recipe-output", content: `/browser-recipe ${name}\n${data}`, display: true }, { triggerTurn: false });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await recordRun(stored.data, { at, result: "failed", step: outcome.failure.step });
|
|
89
|
+
await trustWritten(stored.data, ctx.cwd);
|
|
90
|
+
ctx.ui.notify(`Recipe ${name} failed at step ${outcome.failure.step}; handing it to the agent to finish the task and repair the recipe.`, "warning");
|
|
91
|
+
const report = `/browser-recipe ${name} failed. ${formatFailureHandoff(stored.data.recipe, params, outcome.failure)}`;
|
|
92
|
+
if (ctx.isIdle()) pi.sendUserMessage(report);
|
|
93
|
+
else pi.sendUserMessage(report, { deliverAs: "followUp" });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
};
|