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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { type Result, err, ok } from "../util/result";
|
|
2
|
+
import type { JsonObject } from "../util/tool";
|
|
3
|
+
import type { JournalEntry, Locator } from "./journal";
|
|
4
|
+
import { PARAM_FORMS, RECIPE_SITE, type RecipeStep, type StepExpect } from "./recipe";
|
|
5
|
+
import { MESSAGE_ROLES } from "./recordable";
|
|
6
|
+
import { messagePrefix } from "./replay";
|
|
7
|
+
|
|
8
|
+
const MAX_EXPECT_APPEARED = 3;
|
|
9
|
+
|
|
10
|
+
const shortName = (tool: string): string => (tool.startsWith("browser_") ? tool.slice("browser_".length) : tool);
|
|
11
|
+
|
|
12
|
+
// The agent's most recent browser task: every agent entry from the highest run that has one.
|
|
13
|
+
export const latestTask = (entries: ReadonlyArray<JournalEntry>): ReadonlyArray<JournalEntry> => {
|
|
14
|
+
const agent = entries.filter((e) => e.source === "agent");
|
|
15
|
+
const last = agent.reduce((max, e) => Math.max(max, e.run), 0);
|
|
16
|
+
return agent.filter((e) => e.run === last);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Calls whose effect is the page they land on; the elements of that page say nothing about the step.
|
|
20
|
+
const LOADS_PAGE: ReadonlySet<string> = new Set(["browser_navigate", "browser_new_tab", "browser_go_back", "browser_go_forward", "browser_reload"]);
|
|
21
|
+
|
|
22
|
+
// What a replay should see after the step. A new URL, a dialog, a download and a status or alert message are each
|
|
23
|
+
// a direct sign the step worked; elements that appeared are used only when there is none of these, since they are
|
|
24
|
+
// often whatever else the page happened to render.
|
|
25
|
+
// A wait changes nothing; whatever appeared while it waited is the page's own doing, and its success is the check.
|
|
26
|
+
const WAITS: ReadonlySet<string> = new Set(["browser_wait_for", "browser_wait_for_load"]);
|
|
27
|
+
|
|
28
|
+
// Replay compares only a URL's origin and path, so that is all a step keeps: a query or fragment can hold a
|
|
29
|
+
// one-time token (Cloudflare's __cf_chl_rt_tk, a session id) that has no place on disk and means nothing next run.
|
|
30
|
+
export const pageKey = (url: string): string => {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
return u.origin === "null" ? url : `${u.origin}${u.pathname}`;
|
|
34
|
+
} catch {
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const expectOf = (e: JournalEntry): StepExpect | undefined => {
|
|
40
|
+
const c = e.change;
|
|
41
|
+
if (c === undefined || WAITS.has(e.tool)) return undefined;
|
|
42
|
+
const url = c.url !== undefined && pageKey(c.url) !== pageKey(e.url) ? pageKey(c.url) : undefined;
|
|
43
|
+
// A page load brings its banners and counters with it, so its messages say nothing about the step. A message
|
|
44
|
+
// with no words before its first number ("1,151") would match any message of its role, so it is no check.
|
|
45
|
+
const messages = LOADS_PAGE.has(e.tool) ? [] : c.appeared.filter((a) => MESSAGE_ROLES.has(a.role) && messagePrefix(a.name) !== "");
|
|
46
|
+
const strong = url !== undefined || c.dialog !== undefined || c.download === true || LOADS_PAGE.has(e.tool);
|
|
47
|
+
const elements = c.appeared.filter((a) => !MESSAGE_ROLES.has(a.role));
|
|
48
|
+
const appeared = (messages.length > 0 ? messages : strong ? [] : elements).slice(0, MAX_EXPECT_APPEARED);
|
|
49
|
+
const expect: StepExpect = {
|
|
50
|
+
...(url !== undefined ? { url } : {}),
|
|
51
|
+
...(appeared.length > 0 ? { appeared } : {}),
|
|
52
|
+
...(c.dialog !== undefined ? { dialog: c.dialog } : {}),
|
|
53
|
+
...(c.download === true ? { download: true } : {}),
|
|
54
|
+
};
|
|
55
|
+
return Object.keys(expect).length > 0 ? expect : undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isPoint = (l: Locator | undefined): boolean => l !== undefined && "x" in l;
|
|
59
|
+
|
|
60
|
+
// Browser settings, not page actions: a replayed download folder would move the user's Chrome downloads for good.
|
|
61
|
+
const SETTINGS: ReadonlySet<string> = new Set(["browser_download"]);
|
|
62
|
+
|
|
63
|
+
// A script the journal measured to change nothing (see JournalEntry.readOnly): the agent only read the page with it,
|
|
64
|
+
// so replay does not need it and it is left out rather than refusing the save.
|
|
65
|
+
export const isNoEffectScript = (e: JournalEntry): boolean => e.ok && e.tool === "browser_execute_js" && e.readOnly === true;
|
|
66
|
+
|
|
67
|
+
// `openBefore` counts the tabs that steps before these, kept from a recipe being repaired, opened and left open.
|
|
68
|
+
// A read whose args could not be kept (request headers, a tab id) is left out; so is a read the task repeated
|
|
69
|
+
// exactly later, which the later copy covers.
|
|
70
|
+
export const isLeftOutRead = (e: JournalEntry, i: number, entries: ReadonlyArray<JournalEntry>): boolean =>
|
|
71
|
+
e.read === true &&
|
|
72
|
+
(!e.replayable || entries.slice(i + 1).some((l) => l.ok && l.read === true && l.tool === e.tool && JSON.stringify(l.args) === JSON.stringify(e.args)));
|
|
73
|
+
|
|
74
|
+
export const compileSteps = (entries: ReadonlyArray<JournalEntry>, openBefore = 0): Result<ReadonlyArray<RecipeStep>, string> => {
|
|
75
|
+
const steps: RecipeStep[] = [];
|
|
76
|
+
let openTabs = openBefore;
|
|
77
|
+
for (const [i, e] of entries.entries()) {
|
|
78
|
+
if (!e.ok || SETTINGS.has(e.tool) || isNoEffectScript(e) || isLeftOutRead(e, i, entries)) continue;
|
|
79
|
+
if (e.password) return err(`${e.tool} typed into a password field; recipes never type credentials, so this task cannot be saved`);
|
|
80
|
+
if (!e.replayable) return err(`${e.tool} cannot be replayed; redo the task without it, or leave it out of the recipe`);
|
|
81
|
+
if (e.tool === "browser_new_tab") openTabs += 1;
|
|
82
|
+
if (e.tool === "browser_close_tab") {
|
|
83
|
+
if (openTabs === 0) {
|
|
84
|
+
return err("browser_close_tab closes a tab the task did not open, so a replay would close the user's tab; leave the tab open, or start the task with browser_new_tab");
|
|
85
|
+
}
|
|
86
|
+
openTabs -= 1;
|
|
87
|
+
}
|
|
88
|
+
const expect = expectOf(e);
|
|
89
|
+
if (e.tool === "browser_fill_form") {
|
|
90
|
+
for (const f of e.fields ?? []) {
|
|
91
|
+
steps.push({ action: "fill", args: { value: f.value }, ...(f.locator !== undefined ? { locator: f.locator } : {}) });
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const fragile = isPoint(e.locator) || e.tool === "browser_drag_and_drop";
|
|
96
|
+
steps.push({
|
|
97
|
+
action: shortName(e.tool),
|
|
98
|
+
args: e.args,
|
|
99
|
+
...(e.locator !== undefined ? { locator: e.locator } : {}),
|
|
100
|
+
...(expect !== undefined ? { expect } : {}),
|
|
101
|
+
...(fragile ? { fragile: true } : {}),
|
|
102
|
+
...(e.wrote === true ? { writes: true } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (steps.length === 0) return err("the agent's last browser task made no page-changing calls to save");
|
|
106
|
+
return ok(steps);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// The value as typed becomes {{name}}; its URL forms ("Alan_Turing", "Alan+Turing", "Alan%20Turing") become
|
|
110
|
+
// {{name|form}}, so a replay writes the new value the same way.
|
|
111
|
+
const template = (s: string, pairs: ReadonlyArray<readonly [string, string]>): string =>
|
|
112
|
+
pairs.reduce((acc, [name, value]) => {
|
|
113
|
+
let out = acc.split(value).join(`{{${name}}}`);
|
|
114
|
+
for (const [form, apply] of PARAM_FORMS) {
|
|
115
|
+
const shaped = apply(value);
|
|
116
|
+
if (shaped !== value) out = out.split(shaped).join(`{{${name}|${form}}}`);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}, s);
|
|
120
|
+
|
|
121
|
+
const templateArgs = (args: JsonObject, pairs: ReadonlyArray<readonly [string, string]>): JsonObject => {
|
|
122
|
+
const out: Record<string, JsonObject[string]> = {};
|
|
123
|
+
for (const [k, v] of Object.entries(args)) out[k] = typeof v === "string" ? template(v, pairs) : v;
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const templateLocator = (l: Locator, pairs: ReadonlyArray<readonly [string, string]>): Locator =>
|
|
128
|
+
"role" in l ? { ...l, name: template(l.name, pairs) } : l;
|
|
129
|
+
|
|
130
|
+
// The page a step lands on carries the param too: a repo's URL, an account's name in a heading.
|
|
131
|
+
const templateExpect = (e: StepExpect, pairs: ReadonlyArray<readonly [string, string]>): StepExpect => ({
|
|
132
|
+
...e,
|
|
133
|
+
...(e.url !== undefined ? { url: template(e.url, pairs) } : {}),
|
|
134
|
+
...(e.appeared !== undefined ? { appeared: e.appeared.map((a) => ({ ...a, name: template(a.name, pairs) })) } : {}),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const MAX_LISTED_VALUES = 12;
|
|
138
|
+
const MAX_VALUE_CHARS = 100;
|
|
139
|
+
|
|
140
|
+
// The values a param could be taken from, step by step, so a refused param can be fixed without guessing.
|
|
141
|
+
const valuesIn = (steps: ReadonlyArray<RecipeStep>): string => {
|
|
142
|
+
const cut = (v: string): string => JSON.stringify(v.length > MAX_VALUE_CHARS ? `${v.slice(0, MAX_VALUE_CHARS)}…` : v);
|
|
143
|
+
const lines = steps.flatMap((s, i) => [
|
|
144
|
+
...Object.entries(s.args).flatMap(([k, v]) => (typeof v === "string" && v !== "" ? [` step ${i + 1} ${s.action} ${k}: ${cut(v)}`] : [])),
|
|
145
|
+
...(s.locator !== undefined && "name" in s.locator ? [` step ${i + 1} ${s.action} target: ${cut(s.locator.name)}`] : []),
|
|
146
|
+
]);
|
|
147
|
+
if (lines.length === 0) return "No step holds a typed value or URL.";
|
|
148
|
+
const more = lines.length > MAX_LISTED_VALUES ? [` … and ${lines.length - MAX_LISTED_VALUES} more`] : [];
|
|
149
|
+
return [
|
|
150
|
+
"A param value must appear, exactly as written, in one of these:",
|
|
151
|
+
...lines.slice(0, MAX_LISTED_VALUES),
|
|
152
|
+
...more,
|
|
153
|
+
].join("\n");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Every occurrence of a param's value becomes {{name}}; longer values go first so "2026-09-01" is not split by "2026-09".
|
|
157
|
+
export const applyParams = (
|
|
158
|
+
steps: ReadonlyArray<RecipeStep>,
|
|
159
|
+
params: Readonly<Record<string, string>>,
|
|
160
|
+
): Result<ReadonlyArray<RecipeStep>, string> => {
|
|
161
|
+
const pairs = [...Object.entries(params)].sort((a, b) => b[1].length - a[1].length);
|
|
162
|
+
const empty = pairs.find(([, v]) => v === "");
|
|
163
|
+
if (empty !== undefined) return err(`param ${empty[0]} has an empty value`);
|
|
164
|
+
const out = steps.map((s) => ({
|
|
165
|
+
...s,
|
|
166
|
+
args: templateArgs(s.args, pairs),
|
|
167
|
+
...(s.locator !== undefined ? { locator: templateLocator(s.locator, pairs) } : {}),
|
|
168
|
+
...(s.expect !== undefined ? { expect: templateExpect(s.expect, pairs) } : {}),
|
|
169
|
+
}));
|
|
170
|
+
for (const [name, value] of pairs) {
|
|
171
|
+
const placeholder = new RegExp(`\\{\\{${name}(\\|[a-z]+)?\\}\\}`);
|
|
172
|
+
const used = out.some((s) => placeholder.test(JSON.stringify(s)));
|
|
173
|
+
if (!used) return err(`param ${name}: the value "${value}" does not appear in the task's steps. ${valuesIn(steps)}`);
|
|
174
|
+
}
|
|
175
|
+
return ok(out);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const OPENS_URL: ReadonlySet<string> = new Set(["browser_navigate", "browser_new_tab", "browser_http_get", "browser_read_page"]);
|
|
179
|
+
|
|
180
|
+
// A call that opens or reads a URL is about that URL; its e.url is the tab it started from, which may be another site.
|
|
181
|
+
const pageOf = (e: JournalEntry): string => {
|
|
182
|
+
if (!OPENS_URL.has(e.tool)) return e.url;
|
|
183
|
+
const url = e.args["url"];
|
|
184
|
+
return typeof url === "string" ? url : (e.change?.url ?? "");
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const siteOf = (entries: ReadonlyArray<JournalEntry>): string => {
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
const url = pageOf(e);
|
|
190
|
+
try {
|
|
191
|
+
const host = new URL(url).hostname;
|
|
192
|
+
if (RECIPE_SITE.test(host)) return host;
|
|
193
|
+
} catch {
|
|
194
|
+
// An unparseable URL names no site; try the next entry.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return "unknown-site";
|
|
198
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
import { refreshRefs } from "../domains/ref-resolve";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { sleep } from "../util/sleep";
|
|
5
|
+
import type { Locator } from "./journal";
|
|
6
|
+
|
|
7
|
+
export type TargetArgs = { readonly ref: string } | { readonly selector: string } | { readonly x: number; readonly y: number };
|
|
8
|
+
|
|
9
|
+
export const FIND_TIMEOUT_MS = 5_000;
|
|
10
|
+
const POLL_MS = 250;
|
|
11
|
+
|
|
12
|
+
// Chrome's accessible names differ in spacing from one load to the next ("Homepage (g then d)" against
|
|
13
|
+
// "Homepage ( g then d )"), so names are compared with runs of whitespace collapsed and none around brackets.
|
|
14
|
+
const normalName = (name: string): string =>
|
|
15
|
+
name
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.replace(/\s*([()[\]{}])\s*/g, "$1")
|
|
18
|
+
.trim();
|
|
19
|
+
|
|
20
|
+
export const sameName = (a: string, b: string): boolean => normalName(a) === normalName(b);
|
|
21
|
+
|
|
22
|
+
const nthMatch = (client: BrowserClient, role: string, name: string, nth: number): string | undefined => {
|
|
23
|
+
let seen = 0;
|
|
24
|
+
for (const [ref, sig] of client.session().refSignatures()) {
|
|
25
|
+
if (sig.role !== role || !sameName(sig.name, name)) continue;
|
|
26
|
+
if (seen === nth) return ref;
|
|
27
|
+
seen += 1;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// A role+name locator is retried until the element shows up, which replaces the sleeps a recording would need.
|
|
33
|
+
// A css locator or a point passes through: the tools resolve those themselves.
|
|
34
|
+
export const findTarget = async (client: BrowserClient, locator: Locator, signal?: AbortSignal): Promise<Result<TargetArgs, string>> => {
|
|
35
|
+
if ("css" in locator) return ok({ selector: locator.css });
|
|
36
|
+
if ("x" in locator) return ok({ x: locator.x, y: locator.y });
|
|
37
|
+
const deadline = Date.now() + FIND_TIMEOUT_MS;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const refreshed = await refreshRefs(client);
|
|
40
|
+
if (refreshed.success) {
|
|
41
|
+
const ref = nthMatch(client, locator.role, locator.name, locator.nth);
|
|
42
|
+
if (ref !== undefined) return ok({ ref });
|
|
43
|
+
}
|
|
44
|
+
if (Date.now() >= deadline || signal?.aborted) break;
|
|
45
|
+
await sleep(POLL_MS, signal);
|
|
46
|
+
}
|
|
47
|
+
const which = locator.nth > 0 ? ` (match ${locator.nth + 1})` : "";
|
|
48
|
+
return err(`no ${locator.role} named "${locator.name}"${which} on the page after ${FIND_TIMEOUT_MS / 1000}s`);
|
|
49
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { agentDir } from "../profile/paths";
|
|
4
|
+
import type { JsonObject } from "../util/tool";
|
|
5
|
+
|
|
6
|
+
export type Locator =
|
|
7
|
+
| { readonly role: string; readonly name: string; readonly nth: number }
|
|
8
|
+
| { readonly css: string }
|
|
9
|
+
| { readonly x: number; readonly y: number };
|
|
10
|
+
|
|
11
|
+
export type ObservedChange = {
|
|
12
|
+
readonly url?: string;
|
|
13
|
+
readonly appeared: ReadonlyArray<{ readonly role: string; readonly name: string }>;
|
|
14
|
+
readonly dialog?: string;
|
|
15
|
+
readonly download?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type JournalFieldEntry = { readonly locator?: Locator; readonly value: string; readonly password: boolean };
|
|
19
|
+
|
|
20
|
+
// One page-changing call. `args` holds the tool's arguments minus the element handle (ref, selector, x/y),
|
|
21
|
+
// which lives in `locator`; a password value is replaced by "" and flagged, never stored.
|
|
22
|
+
export type JournalEntry = {
|
|
23
|
+
readonly v: 1;
|
|
24
|
+
readonly at: string;
|
|
25
|
+
readonly run: number;
|
|
26
|
+
readonly source: "agent" | "replay";
|
|
27
|
+
readonly tool: string;
|
|
28
|
+
readonly replayable: boolean;
|
|
29
|
+
readonly args: JsonObject;
|
|
30
|
+
readonly locator?: Locator;
|
|
31
|
+
readonly fields?: ReadonlyArray<JournalFieldEntry>;
|
|
32
|
+
readonly password: boolean;
|
|
33
|
+
readonly url: string;
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
readonly error?: string;
|
|
36
|
+
readonly change?: ObservedChange;
|
|
37
|
+
// Set on a browser_execute_js that was measured to change nothing: same page, form values, storage and URL, and
|
|
38
|
+
// no request that could change server state. Saving leaves such a call out.
|
|
39
|
+
readonly readOnly?: boolean;
|
|
40
|
+
// A call that only read data for the agent (see READS in recordable.ts); replayable when its args could be kept.
|
|
41
|
+
readonly read?: boolean;
|
|
42
|
+
// The tab sent a request that can change server state (a POST, PUT, DELETE...) while the call ran.
|
|
43
|
+
readonly wrote?: boolean;
|
|
44
|
+
readonly recipe?: { readonly name: string; readonly step: number };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type NewEntry = Omit<JournalEntry, "v" | "at" | "run">;
|
|
48
|
+
|
|
49
|
+
export type Journal = {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
readonly beginRun: () => Promise<number>;
|
|
52
|
+
readonly append: (e: NewEntry) => Promise<void>;
|
|
53
|
+
readonly entries: () => Promise<ReadonlyArray<JournalEntry>>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
export const journalDir = (): string => join(agentDir(), "browser-harness", "journal");
|
|
59
|
+
|
|
60
|
+
const isEntry = (v: unknown): v is JournalEntry =>
|
|
61
|
+
typeof v === "object" && v !== null && "v" in v && v.v === 1 && "run" in v && typeof v.run === "number";
|
|
62
|
+
|
|
63
|
+
const readEntries = async (path: string): Promise<ReadonlyArray<JournalEntry>> => {
|
|
64
|
+
let raw: string;
|
|
65
|
+
try {
|
|
66
|
+
raw = await readFile(path, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const out: JournalEntry[] = [];
|
|
71
|
+
for (const line of raw.split("\n")) {
|
|
72
|
+
if (line.trim() === "") continue;
|
|
73
|
+
try {
|
|
74
|
+
const parsed: unknown = JSON.parse(line);
|
|
75
|
+
if (isEntry(parsed)) out.push(parsed);
|
|
76
|
+
} catch {
|
|
77
|
+
// A torn last line from a crash mid-append is skipped, not fatal.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const pruneOld = async (dir: string, now: number): Promise<void> => {
|
|
84
|
+
let names: string[];
|
|
85
|
+
try {
|
|
86
|
+
names = await readdir(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (const name of names) {
|
|
91
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
92
|
+
const file = join(dir, name);
|
|
93
|
+
const info = await stat(file).catch(() => undefined);
|
|
94
|
+
if (info && now - info.mtimeMs > RETENTION_MS) await unlink(file).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const createJournal = (dir: string, sessionId: string, now: () => number = Date.now): Journal => {
|
|
99
|
+
const path = join(dir, `${sessionId}.jsonl`);
|
|
100
|
+
let run: number | undefined;
|
|
101
|
+
void pruneOld(dir, now());
|
|
102
|
+
return {
|
|
103
|
+
path,
|
|
104
|
+
beginRun: async () => {
|
|
105
|
+
if (run === undefined) {
|
|
106
|
+
const existing = await readEntries(path);
|
|
107
|
+
run = existing.reduce((max, e) => Math.max(max, e.run), 0);
|
|
108
|
+
}
|
|
109
|
+
run += 1;
|
|
110
|
+
return run;
|
|
111
|
+
},
|
|
112
|
+
append: async (e) => {
|
|
113
|
+
const entry: JournalEntry = { v: 1, at: new Date(now()).toISOString(), run: run ?? 0, ...e };
|
|
114
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
115
|
+
await appendFile(path, `${JSON.stringify(entry)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
116
|
+
},
|
|
117
|
+
entries: () => readEntries(path),
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let active: Journal | undefined;
|
|
122
|
+
export const activeJournal = (): Journal | undefined => active;
|
|
123
|
+
export const setActiveJournal = (j: Journal | undefined): void => {
|
|
124
|
+
active = j;
|
|
125
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { parse, stringify } from "yaml";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Compile } from "typebox/compile";
|
|
4
|
+
import { type Result, err, ok } from "../util/result";
|
|
5
|
+
import { isRecord } from "../util/guards";
|
|
6
|
+
import type { JsonObject, JsonValue } from "../util/tool";
|
|
7
|
+
import type { Locator } from "./journal";
|
|
8
|
+
import { REPLAYABLE } from "./recordable";
|
|
9
|
+
|
|
10
|
+
export type StepExpect = {
|
|
11
|
+
readonly url?: string;
|
|
12
|
+
readonly appeared?: ReadonlyArray<{ readonly role: string; readonly name: string }>;
|
|
13
|
+
readonly dialog?: string;
|
|
14
|
+
readonly download?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type RecipeStep = {
|
|
18
|
+
readonly action: string;
|
|
19
|
+
readonly args: JsonObject;
|
|
20
|
+
readonly locator?: Locator;
|
|
21
|
+
readonly expect?: StepExpect;
|
|
22
|
+
readonly fragile?: boolean;
|
|
23
|
+
// The step sent data to the site when it was recorded, so running it again may repeat that (an order, a message).
|
|
24
|
+
readonly writes?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type LastRun = { readonly at: string; readonly result: "ok" | "failed"; readonly step?: number; readonly repairedStep?: number };
|
|
28
|
+
|
|
29
|
+
export type Recipe = {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly site: string;
|
|
32
|
+
readonly version: number;
|
|
33
|
+
readonly params: Readonly<Record<string, { readonly type: "string"; readonly example: string }>>;
|
|
34
|
+
readonly steps: ReadonlyArray<RecipeStep>;
|
|
35
|
+
readonly recorded: { readonly at: string; readonly session: string };
|
|
36
|
+
readonly lastRun?: LastRun;
|
|
37
|
+
// Saved by a version that marks each step that sent data (`writes`). Without it, an unmarked step may have.
|
|
38
|
+
readonly writesTracked?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const RECIPE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
42
|
+
// A host (name, IPv4, bracketed IPv6) or unknown-site. The site is a directory under the recipe root, so it may
|
|
43
|
+
// never hold a path separator or be "." or "..".
|
|
44
|
+
export const RECIPE_SITE = /^(?!\.{1,2}$)[a-z0-9._\-[\]:]{1,253}$/;
|
|
45
|
+
// The forms a param's value takes in a URL, by the name a recipe writes after "|": {{topic|underscore}} fills
|
|
46
|
+
// "Grace Hopper" as "Grace_Hopper". A plain {{topic}} is the value as typed.
|
|
47
|
+
export const PARAM_FORMS: ReadonlyMap<string, (value: string) => string> = new Map([
|
|
48
|
+
["url", (v: string) => encodeURIComponent(v)],
|
|
49
|
+
["plus", (v: string) => encodeURIComponent(v).replace(/%20/g, "+")],
|
|
50
|
+
["underscore", (v: string) => v.replace(/ /g, "_")],
|
|
51
|
+
]);
|
|
52
|
+
const PARAM_REF = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?:\|([a-z]+))?\}\}/g;
|
|
53
|
+
const LOCATOR_KEYS: ReadonlySet<string> = new Set(["role", "name", "nth", "css", "x", "y"]);
|
|
54
|
+
const STEP_META_KEYS: ReadonlySet<string> = new Set(["expect", "fragile", "writes"]);
|
|
55
|
+
// For these actions x/y are the tool's own coordinates, not where an element is.
|
|
56
|
+
const COORDINATE_ACTIONS: ReadonlySet<string> = new Set(["scroll", "drag_and_drop"]);
|
|
57
|
+
|
|
58
|
+
// Short action names a recipe may use: every replayable tool except fill_form, which save expands into fills.
|
|
59
|
+
const ACTIONS: ReadonlySet<string> = new Set(
|
|
60
|
+
[...REPLAYABLE.keys()].filter((t) => t !== "browser_fill_form").map((t) => t.slice("browser_".length)),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const FileSchema = Type.Object({
|
|
64
|
+
name: Type.String({ pattern: RECIPE_NAME.source }),
|
|
65
|
+
site: Type.String({ pattern: RECIPE_SITE.source }),
|
|
66
|
+
version: Type.Integer({ minimum: 1 }),
|
|
67
|
+
params: Type.Record(Type.String(), Type.Object({ type: Type.Literal("string"), example: Type.String() })),
|
|
68
|
+
steps: Type.Array(Type.Record(Type.String(), Type.Unknown()), { minItems: 1 }),
|
|
69
|
+
recorded: Type.Object({ at: Type.String(), session: Type.String() }),
|
|
70
|
+
writes_tracked: Type.Optional(Type.Boolean()),
|
|
71
|
+
last_run: Type.Optional(
|
|
72
|
+
Type.Object({
|
|
73
|
+
at: Type.String(),
|
|
74
|
+
result: Type.Union([Type.Literal("ok"), Type.Literal("failed")]),
|
|
75
|
+
step: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
76
|
+
repaired_step: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
const fileValidator = Compile(FileSchema);
|
|
81
|
+
|
|
82
|
+
export const paramRefs = (value: string): ReadonlyArray<string> => [...value.matchAll(PARAM_REF)].map((m) => m[1] ?? "");
|
|
83
|
+
|
|
84
|
+
const unknownForm = (value: string): string | undefined =>
|
|
85
|
+
[...value.matchAll(PARAM_REF)].map((m) => m[2]).find((f) => f !== undefined && !PARAM_FORMS.has(f));
|
|
86
|
+
|
|
87
|
+
export const fillParams = (s: string, params: Readonly<Record<string, string>>): string =>
|
|
88
|
+
s.replace(PARAM_REF, (whole, name: string, form: string | undefined) => {
|
|
89
|
+
const value = params[name];
|
|
90
|
+
if (value === undefined) return whole;
|
|
91
|
+
if (form === undefined) return value;
|
|
92
|
+
return PARAM_FORMS.get(form)?.(value) ?? whole;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const isJsonScalar = (v: unknown): v is string | number | boolean =>
|
|
96
|
+
typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
97
|
+
|
|
98
|
+
const locatorOf = (body: Readonly<Record<string, unknown>>): Locator | undefined => {
|
|
99
|
+
const { role, name, nth, css, x, y } = body;
|
|
100
|
+
if (typeof role === "string" && typeof name === "string") return { role, name, nth: typeof nth === "number" ? nth : 0 };
|
|
101
|
+
if (typeof css === "string") return { css };
|
|
102
|
+
if (typeof x === "number" && typeof y === "number") return { x, y };
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const expectOf = (v: unknown): StepExpect | undefined => {
|
|
107
|
+
if (!isRecord(v)) return undefined;
|
|
108
|
+
const appeared = Array.isArray(v["appeared"])
|
|
109
|
+
? v["appeared"].flatMap((a) => (isRecord(a) && typeof a["role"] === "string" && typeof a["name"] === "string" ? [{ role: a["role"], name: a["name"] }] : []))
|
|
110
|
+
: undefined;
|
|
111
|
+
return {
|
|
112
|
+
...(typeof v["url"] === "string" ? { url: v["url"] } : {}),
|
|
113
|
+
...(appeared !== undefined && appeared.length > 0 ? { appeared } : {}),
|
|
114
|
+
...(typeof v["dialog"] === "string" ? { dialog: v["dialog"] } : {}),
|
|
115
|
+
...(v["download"] === true ? { download: true } : {}),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const decodeStep = (raw: Readonly<Record<string, unknown>>, n: number, declared: ReadonlySet<string>): Result<RecipeStep, string> => {
|
|
120
|
+
const actionKeys = Object.keys(raw).filter((k) => !STEP_META_KEYS.has(k));
|
|
121
|
+
if (actionKeys.length !== 1) return err(`step ${n}: needs exactly one action, found ${actionKeys.length}`);
|
|
122
|
+
const action = actionKeys[0] ?? "";
|
|
123
|
+
if (!ACTIONS.has(action)) return err(`step ${n}: "${action}" is not a replayable action`);
|
|
124
|
+
const body = raw[action] ?? {};
|
|
125
|
+
if (!isRecord(body)) return err(`step ${n}: "${action}" must be a map`);
|
|
126
|
+
const coordinates = COORDINATE_ACTIONS.has(action);
|
|
127
|
+
const args: Record<string, string | number | boolean> = {};
|
|
128
|
+
for (const [k, v] of Object.entries(body)) {
|
|
129
|
+
if (LOCATOR_KEYS.has(k) && !coordinates) continue;
|
|
130
|
+
if (!isJsonScalar(v)) return err(`step ${n}: "${action}.${k}" must be a string, number or boolean`);
|
|
131
|
+
args[k] = v;
|
|
132
|
+
}
|
|
133
|
+
for (const v of Object.values(body)) {
|
|
134
|
+
if (typeof v !== "string") continue;
|
|
135
|
+
const unknown = paramRefs(v).find((p) => !declared.has(p));
|
|
136
|
+
if (unknown !== undefined) return err(`step ${n}: {{${unknown}}} is not a declared param`);
|
|
137
|
+
const form = unknownForm(v);
|
|
138
|
+
if (form !== undefined) return err(`step ${n}: "|${form}" is not a param form; use one of ${[...PARAM_FORMS.keys()].join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
const locator = coordinates ? undefined : locatorOf(body);
|
|
141
|
+
const expect = expectOf(raw["expect"]);
|
|
142
|
+
return ok({
|
|
143
|
+
action,
|
|
144
|
+
args,
|
|
145
|
+
...(locator !== undefined ? { locator } : {}),
|
|
146
|
+
...(expect !== undefined ? { expect } : {}),
|
|
147
|
+
...(raw["fragile"] === true ? { fragile: true } : {}),
|
|
148
|
+
...(raw["writes"] === true ? { writes: true } : {}),
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Validates the whole file before anything runs; the first problem is reported with its step number.
|
|
153
|
+
export const parseRecipe = (text: string): Result<Recipe, string> => {
|
|
154
|
+
let raw: unknown;
|
|
155
|
+
try {
|
|
156
|
+
raw = parse(text);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return err(`not valid YAML: ${e instanceof Error ? e.message : String(e)}`);
|
|
159
|
+
}
|
|
160
|
+
if (!fileValidator.Check(raw)) {
|
|
161
|
+
const messages = fileValidator.Errors(raw).map((x) => x.message).join("; ");
|
|
162
|
+
return err(`invalid recipe: ${messages.length > 0 ? messages : "unknown shape"}`);
|
|
163
|
+
}
|
|
164
|
+
const declared = new Set(Object.keys(raw.params));
|
|
165
|
+
const steps: RecipeStep[] = [];
|
|
166
|
+
for (const [i, s] of raw.steps.entries()) {
|
|
167
|
+
const decoded = decodeStep(s, i + 1, declared);
|
|
168
|
+
if (!decoded.success) return decoded;
|
|
169
|
+
steps.push(decoded.data);
|
|
170
|
+
}
|
|
171
|
+
const lr = raw.last_run;
|
|
172
|
+
return ok({
|
|
173
|
+
name: raw.name,
|
|
174
|
+
site: raw.site,
|
|
175
|
+
version: raw.version,
|
|
176
|
+
params: raw.params,
|
|
177
|
+
steps,
|
|
178
|
+
recorded: raw.recorded,
|
|
179
|
+
...(raw.writes_tracked === true ? { writesTracked: true } : {}),
|
|
180
|
+
...(lr !== undefined
|
|
181
|
+
? { lastRun: { at: lr.at, result: lr.result, ...(lr.step !== undefined ? { step: lr.step } : {}), ...(lr.repaired_step !== undefined ? { repairedStep: lr.repaired_step } : {}) } }
|
|
182
|
+
: {}),
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const encodeStep = (s: RecipeStep): Record<string, JsonValue> => ({
|
|
187
|
+
[s.action]: { ...(s.locator ?? {}), ...s.args },
|
|
188
|
+
...(s.expect !== undefined && Object.keys(s.expect).length > 0 ? { expect: s.expect } : {}),
|
|
189
|
+
...(s.fragile === true ? { fragile: true } : {}),
|
|
190
|
+
...(s.writes === true ? { writes: true } : {}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
export const stringifyRecipe = (r: Recipe): string =>
|
|
194
|
+
stringify(
|
|
195
|
+
{
|
|
196
|
+
name: r.name,
|
|
197
|
+
site: r.site,
|
|
198
|
+
version: r.version,
|
|
199
|
+
params: r.params,
|
|
200
|
+
steps: r.steps.map(encodeStep),
|
|
201
|
+
recorded: r.recorded,
|
|
202
|
+
...(r.writesTracked === true ? { writes_tracked: true } : {}),
|
|
203
|
+
...(r.lastRun !== undefined
|
|
204
|
+
? {
|
|
205
|
+
last_run: {
|
|
206
|
+
at: r.lastRun.at,
|
|
207
|
+
result: r.lastRun.result,
|
|
208
|
+
...(r.lastRun.step !== undefined ? { step: r.lastRun.step } : {}),
|
|
209
|
+
...(r.lastRun.repairedStep !== undefined ? { repaired_step: r.lastRun.repairedStep } : {}),
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
213
|
+
},
|
|
214
|
+
{ lineWidth: 0 },
|
|
215
|
+
);
|