effing-use 0.1.1 → 0.2.1
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/README.md +165 -26
- package/package.json +4 -3
- package/skills/effing-use/SKILL.md +38 -11
- package/src/browser/delta.ts +173 -0
- package/src/browser/engine.ts +581 -58
- package/src/browser/evidence.ts +208 -0
- package/src/browser/identity.ts +237 -0
- package/src/browser/macro.ts +118 -0
- package/src/browser/record.ts +149 -0
- package/src/browser/refs.ts +192 -7
- package/src/browser/session.ts +16 -0
- package/src/browser/state.ts +100 -0
- package/src/cli.ts +173 -0
- package/src/config.ts +10 -0
- package/src/http.ts +1 -0
- package/src/tools/act.ts +10 -7
- package/src/tools/extract.ts +1 -0
- package/src/tools/observe.ts +5 -1
package/src/browser/refs.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { Page, Locator } from "playwright";
|
|
2
|
+
import {
|
|
3
|
+
getFingerprint,
|
|
4
|
+
buildFingerprint,
|
|
5
|
+
chooseRebindIndex,
|
|
6
|
+
RAW_FP_SCRIPT,
|
|
7
|
+
type Fingerprint,
|
|
8
|
+
type RawFp,
|
|
9
|
+
type RebindCandidate,
|
|
10
|
+
} from "./identity.js";
|
|
2
11
|
|
|
3
12
|
export class EngineError extends Error {
|
|
4
13
|
code: string;
|
|
@@ -30,17 +39,52 @@ export function resolveRef(page: Page, ref: string): Locator {
|
|
|
30
39
|
export async function resolveLocator(
|
|
31
40
|
page: Page,
|
|
32
41
|
target: string,
|
|
42
|
+
sessionId?: string,
|
|
33
43
|
): Promise<Locator> {
|
|
34
44
|
const t = target.trim();
|
|
35
|
-
// 0. eNN ref
|
|
45
|
+
// 0. eNN ref — with identity validation when sessionId is known
|
|
36
46
|
if (/^e\d+$/i.test(t)) {
|
|
37
47
|
const loc = resolveRef(page, t);
|
|
38
|
-
if ((await loc.count())
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
if ((await loc.count()) === 0) {
|
|
49
|
+
throw new EngineError(
|
|
50
|
+
"E_NOT_FOUND",
|
|
51
|
+
`No element matches "${t}".`,
|
|
52
|
+
"Call browser_observe kind=snapshot first to refresh refs, then use a fresh e-ref.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (sessionId) {
|
|
56
|
+
const expected = getFingerprint(sessionId, t);
|
|
57
|
+
if (expected) {
|
|
58
|
+
// Validate against the SAME extractor that registered the snapshot
|
|
59
|
+
const raws = (await page.evaluate(RAW_FP_SCRIPT).catch(() => null)) as
|
|
60
|
+
| RawFp[]
|
|
61
|
+
| null;
|
|
62
|
+
const idx = Number(t.slice(1));
|
|
63
|
+
const actual = raws && raws[idx] ? buildFingerprint(raws[idx]) : null;
|
|
64
|
+
if (actual) {
|
|
65
|
+
const exact =
|
|
66
|
+
expected.role === actual.role &&
|
|
67
|
+
expected.accessibleName === actual.accessibleName &&
|
|
68
|
+
expected.textHash === actual.textHash;
|
|
69
|
+
if (!exact) {
|
|
70
|
+
// Conservative rebind — ambiguous → E_STALE, never guess
|
|
71
|
+
const rebound = await findRebound(page, expected, raws);
|
|
72
|
+
if (rebound) {
|
|
73
|
+
// Return rebound locator; caller can check rebound flag via session
|
|
74
|
+
(page as unknown as Record<string, unknown>).__rebound = true;
|
|
75
|
+
return rebound;
|
|
76
|
+
}
|
|
77
|
+
throw new EngineError(
|
|
78
|
+
"E_STALE",
|
|
79
|
+
`Ref ${t} is stale — element changed.`,
|
|
80
|
+
"Call browser_observe kind=snapshot to refresh refs.",
|
|
81
|
+
{ expected, actual },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return loc;
|
|
44
88
|
}
|
|
45
89
|
// 1. role= selector passes through
|
|
46
90
|
if (t.startsWith("role=")) return page.locator(t);
|
|
@@ -73,3 +117,144 @@ export async function resolveLocator(
|
|
|
73
117
|
"Call browser_observe kind=snapshot first, then use an e-ref, role= selector, or CSS selector.",
|
|
74
118
|
);
|
|
75
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find where the element `expected` used to describe lives now.
|
|
123
|
+
* Uses the shared extractor + conservative chooser: only an UNAMBIGUOUS match
|
|
124
|
+
* is returned; zero or several equally-good candidates → null → E_STALE
|
|
125
|
+
* upstream (v0.2 bug: first-match rebind clicked toggle-all).
|
|
126
|
+
*/
|
|
127
|
+
async function findRebound(
|
|
128
|
+
page: Page,
|
|
129
|
+
expected: Fingerprint,
|
|
130
|
+
raws: RawFp[] | null,
|
|
131
|
+
): Promise<Locator | null> {
|
|
132
|
+
const list =
|
|
133
|
+
raws ?? ((await page.evaluate(RAW_FP_SCRIPT).catch(() => [])) as RawFp[]);
|
|
134
|
+
const candidates: RebindCandidate[] = list.map((r, i) => ({
|
|
135
|
+
index: i,
|
|
136
|
+
fp: buildFingerprint(r),
|
|
137
|
+
}));
|
|
138
|
+
const pick = chooseRebindIndex(expected, candidates);
|
|
139
|
+
if (pick === null) return null;
|
|
140
|
+
return page.locator(INTERACTIVE_SELECTOR).nth(pick);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a portable, standalone selector for a target (plan §6.2 preference:
|
|
145
|
+
* id → name → ARIA label → stable data-* → placeholder → recorded text).
|
|
146
|
+
* Never returns nth-index or session-scoped e-refs; null when nothing unique
|
|
147
|
+
* can be found (caller falls back to the raw target).
|
|
148
|
+
*/
|
|
149
|
+
export async function stableSelectorFor(
|
|
150
|
+
page: Page,
|
|
151
|
+
target: string,
|
|
152
|
+
sessionId?: string,
|
|
153
|
+
): Promise<string | null> {
|
|
154
|
+
try {
|
|
155
|
+
const loc = await resolveLocator(page, target, sessionId);
|
|
156
|
+
const cands = await loc
|
|
157
|
+
.first()
|
|
158
|
+
.evaluate((el) => {
|
|
159
|
+
const out: string[] = [];
|
|
160
|
+
const tag = el.tagName.toLowerCase();
|
|
161
|
+
const esc = (s: string) => {
|
|
162
|
+
const c = (
|
|
163
|
+
globalThis as unknown as {
|
|
164
|
+
CSS?: { escape?: (x: string) => string };
|
|
165
|
+
}
|
|
166
|
+
).CSS;
|
|
167
|
+
return c && c.escape ? c.escape(s) : s;
|
|
168
|
+
};
|
|
169
|
+
const av = (s: string): string | null => {
|
|
170
|
+
const bs = String.fromCharCode(92);
|
|
171
|
+
if (s.indexOf('"') !== -1 || s.indexOf(bs) !== -1) return null;
|
|
172
|
+
return '"' + s + '"';
|
|
173
|
+
};
|
|
174
|
+
const uniq = (s: string): boolean => {
|
|
175
|
+
try {
|
|
176
|
+
return document.querySelectorAll(s).length === 1;
|
|
177
|
+
} catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
// 1. stable id (skip framework-generated/dynamic ids)
|
|
182
|
+
const id = el.getAttribute("id");
|
|
183
|
+
if (
|
|
184
|
+
id &&
|
|
185
|
+
!/^(bits|radix|headlessui|react-aria|ember|react-|:)/.test(id) &&
|
|
186
|
+
!/[-_]\d+$/.test(id) &&
|
|
187
|
+
uniq("#" + esc(id))
|
|
188
|
+
) {
|
|
189
|
+
out.push("#" + esc(id));
|
|
190
|
+
}
|
|
191
|
+
// 2. name attribute
|
|
192
|
+
const name = el.getAttribute("name");
|
|
193
|
+
if (name) {
|
|
194
|
+
const a = av(name);
|
|
195
|
+
if (a && uniq("[name=" + a + "]")) out.push("[name=" + a + "]");
|
|
196
|
+
}
|
|
197
|
+
// 3. ARIA label on the element itself
|
|
198
|
+
const aria = el.getAttribute("aria-label");
|
|
199
|
+
if (aria) {
|
|
200
|
+
const a = av(aria);
|
|
201
|
+
if (a && uniq(tag + "[aria-label=" + a + "]"))
|
|
202
|
+
out.push(tag + "[aria-label=" + a + "]");
|
|
203
|
+
}
|
|
204
|
+
// 4. stable data-* (test attrs preferred; state/animation attrs skipped)
|
|
205
|
+
const attrs = Array.prototype.slice.call(el.attributes) as Array<{
|
|
206
|
+
name: string;
|
|
207
|
+
value: string;
|
|
208
|
+
}>;
|
|
209
|
+
const dataAttrs = attrs.filter(
|
|
210
|
+
(a) =>
|
|
211
|
+
a.name.startsWith("data-") &&
|
|
212
|
+
!/^data-(state|orientation|index|selected|active|highlighted|value|radix|size|style|theme)/.test(
|
|
213
|
+
a.name,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
dataAttrs.sort((a, b) => {
|
|
217
|
+
const score = (n: string) =>
|
|
218
|
+
/^data-(test|testid|cy)/.test(n) ? 0 : 1;
|
|
219
|
+
return score(a.name) - score(b.name);
|
|
220
|
+
});
|
|
221
|
+
for (const a of dataAttrs) {
|
|
222
|
+
const v = av(a.value);
|
|
223
|
+
if (!v) continue;
|
|
224
|
+
const s = tag + "[" + a.name + "=" + v + "]";
|
|
225
|
+
if (uniq(s)) {
|
|
226
|
+
out.push(s);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// 5. placeholder (form controls with no name/id)
|
|
231
|
+
const ph = el.getAttribute("placeholder");
|
|
232
|
+
if (ph) {
|
|
233
|
+
const a = av(ph);
|
|
234
|
+
if (a && uniq(tag + "[placeholder=" + a + "]"))
|
|
235
|
+
out.push(tag + "[placeholder=" + a + "]");
|
|
236
|
+
}
|
|
237
|
+
// 6. recorded text (Playwright exact text engine)
|
|
238
|
+
const text = (el.textContent || "")
|
|
239
|
+
.trim()
|
|
240
|
+
.replace(/\s+/g, " ")
|
|
241
|
+
.slice(0, 60);
|
|
242
|
+
if (text && (tag === "button" || tag === "a")) {
|
|
243
|
+
const a = av(text);
|
|
244
|
+
if (a) out.push("text=" + a);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
})
|
|
248
|
+
.catch(() => [] as string[]);
|
|
249
|
+
for (const c of cands) {
|
|
250
|
+
const n = await page
|
|
251
|
+
.locator(c)
|
|
252
|
+
.count()
|
|
253
|
+
.catch(() => 0);
|
|
254
|
+
if (n === 1) return c;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/browser/session.ts
CHANGED
|
@@ -13,6 +13,7 @@ interface SessionEntry {
|
|
|
13
13
|
page: Page;
|
|
14
14
|
consoleLogs: Array<{ type: string; text: string; at: string }>;
|
|
15
15
|
networkLogs: Array<{ method: string; url: string; status: number }>;
|
|
16
|
+
mustObserve: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const sessions = new Map<string, SessionEntry>();
|
|
@@ -48,6 +49,7 @@ export async function getPage(
|
|
|
48
49
|
page,
|
|
49
50
|
consoleLogs: [],
|
|
50
51
|
networkLogs: [],
|
|
52
|
+
mustObserve: false,
|
|
51
53
|
};
|
|
52
54
|
page.on("console", (msg) => {
|
|
53
55
|
entry.consoleLogs.push({
|
|
@@ -91,6 +93,20 @@ export function getNetworkLogs(
|
|
|
91
93
|
return sessions.get(sessionId)?.networkLogs ?? [];
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
export function getMustObserve(sessionId = "default"): boolean {
|
|
97
|
+
return sessions.get(sessionId)?.mustObserve ?? false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function setMustObserve(sessionId = "default", v: boolean): void {
|
|
101
|
+
const s = sessions.get(sessionId);
|
|
102
|
+
if (s) s.mustObserve = v;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function clearMustObserve(sessionId = "default"): void {
|
|
106
|
+
const s = sessions.get(sessionId);
|
|
107
|
+
if (s) s.mustObserve = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
export async function closeSession(sessionId = "default"): Promise<void> {
|
|
95
111
|
const s = sessions.get(sessionId);
|
|
96
112
|
if (s) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Config } from "../config.js";
|
|
4
|
+
|
|
5
|
+
export type StateData = {
|
|
6
|
+
notes: string[];
|
|
7
|
+
lastActions: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const stateCache = new Map<string, StateData>();
|
|
11
|
+
|
|
12
|
+
function sanitizeSegment(s: string): string {
|
|
13
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "default";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function statePath(config: Config, sessionId: string): string {
|
|
17
|
+
return join(config.outputDir, "state", `${sanitizeSegment(sessionId)}.md`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getState(sessionId: string): StateData {
|
|
21
|
+
let s = stateCache.get(sessionId);
|
|
22
|
+
if (!s) {
|
|
23
|
+
s = { notes: [], lastActions: [] };
|
|
24
|
+
stateCache.set(sessionId, s);
|
|
25
|
+
}
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function appendNote(
|
|
30
|
+
config: Config,
|
|
31
|
+
sessionId: string,
|
|
32
|
+
note: string,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const s = getState(sessionId);
|
|
35
|
+
const line = note.slice(0, 300);
|
|
36
|
+
s.notes.push(line);
|
|
37
|
+
const max = config.stateMaxLines ?? 40;
|
|
38
|
+
while (s.notes.length > max) s.notes.shift();
|
|
39
|
+
await persist(config, sessionId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function appendAction(
|
|
43
|
+
config: Config,
|
|
44
|
+
sessionId: string,
|
|
45
|
+
entry: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const s = getState(sessionId);
|
|
48
|
+
s.lastActions.push(entry.slice(0, 200));
|
|
49
|
+
while (s.lastActions.length > 10) s.lastActions.shift();
|
|
50
|
+
await persist(config, sessionId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function persist(config: Config, sessionId: string): Promise<void> {
|
|
54
|
+
const s = getState(sessionId);
|
|
55
|
+
const path = statePath(config, sessionId);
|
|
56
|
+
await mkdir(join(config.outputDir, "state"), { recursive: true });
|
|
57
|
+
const content = [
|
|
58
|
+
`# state:${sessionId}`,
|
|
59
|
+
`## notes`,
|
|
60
|
+
...s.notes.map((n) => `- ${n}`),
|
|
61
|
+
`## lastActions`,
|
|
62
|
+
...s.lastActions.map((a) => `- ${a}`),
|
|
63
|
+
].join("\n");
|
|
64
|
+
await writeFile(path, content, "utf-8").catch(() => undefined);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readState(
|
|
68
|
+
config: Config,
|
|
69
|
+
sessionId: string,
|
|
70
|
+
): Promise<{ notes: string; lastActions: string[]; path: string }> {
|
|
71
|
+
const s = getState(sessionId);
|
|
72
|
+
// Try to load from disk if cache empty
|
|
73
|
+
if (s.notes.length === 0 && s.lastActions.length === 0) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = await readFile(statePath(config, sessionId), "utf-8");
|
|
76
|
+
const lines = raw.split("\n");
|
|
77
|
+
let section: "notes" | "actions" | null = null;
|
|
78
|
+
for (const l of lines) {
|
|
79
|
+
if (l.startsWith("## notes")) section = "notes";
|
|
80
|
+
else if (l.startsWith("## lastActions")) section = "actions";
|
|
81
|
+
else if (l.startsWith("- ") && section === "notes")
|
|
82
|
+
s.notes.push(l.slice(2));
|
|
83
|
+
else if (l.startsWith("- ") && section === "actions")
|
|
84
|
+
s.lastActions.push(l.slice(2));
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* no file yet */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const notes = s.notes.join("\n").slice(0, 1500);
|
|
91
|
+
return {
|
|
92
|
+
notes,
|
|
93
|
+
lastActions: [...s.lastActions],
|
|
94
|
+
path: statePath(config, sessionId),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clearState(sessionId: string): void {
|
|
99
|
+
stateCache.delete(sessionId);
|
|
100
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// CLI face — same engine via HTTP transport (model-agnostic).
|
|
3
|
+
// Usage: effing-use observe --kind snapshot --mode delta --session dev
|
|
4
|
+
// effing-use act --action click --target e5 --expect 'url~/dashboard'
|
|
5
|
+
// effing-use extract --kind state
|
|
6
|
+
|
|
7
|
+
const HELP = `effing-use CLI — same engine as MCP, over HTTP
|
|
8
|
+
Usage:
|
|
9
|
+
effing-use observe --kind <snapshot|screenshot|url|title|console|network|tabs|focused> [--mode full|delta] [--scope <css>] [--session <id>]
|
|
10
|
+
effing-use act --action <action> [--target <ref>] [--value <val>] [--expect <expr>] [--approve] [--session <id>]
|
|
11
|
+
effing-use act --action batch --file steps.json
|
|
12
|
+
effing-use extract --kind <text|html|table|query|pdf|trace_start|trace_stop|state> [--selector <css>] [--session <id>]
|
|
13
|
+
effing-use --help
|
|
14
|
+
|
|
15
|
+
Env: EFFING_USE_URL (default http://localhost:3123/mcp)
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
|
19
|
+
const out: Record<string, string | boolean> = {};
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a === "--help" || a === "-h") out.help = true;
|
|
23
|
+
else if (a.startsWith("--")) {
|
|
24
|
+
const k = a.slice(2);
|
|
25
|
+
const v = argv[i + 1];
|
|
26
|
+
if (v && !v.startsWith("--")) {
|
|
27
|
+
out[k] = v;
|
|
28
|
+
i++;
|
|
29
|
+
} else out[k] = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const [, , verb, ...rest] = process.argv;
|
|
37
|
+
const args = parseArgs(rest);
|
|
38
|
+
|
|
39
|
+
if (!verb || args.help || verb === "--help" || verb === "-h") {
|
|
40
|
+
process.stdout.write(HELP);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const url = process.env.EFFING_USE_URL ?? "http://localhost:3123/mcp";
|
|
45
|
+
// For CLI we call the HTTP MCP endpoint via JSON-RPC
|
|
46
|
+
// Minimal client: POST tools/call
|
|
47
|
+
const toolMap: Record<string, string> = {
|
|
48
|
+
observe: "browser_observe",
|
|
49
|
+
act: "browser_act",
|
|
50
|
+
extract: "browser_extract",
|
|
51
|
+
};
|
|
52
|
+
const tool = toolMap[verb];
|
|
53
|
+
if (!tool) {
|
|
54
|
+
process.stderr.write(`Unknown verb "${verb}". Use observe|act|extract.\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build arguments object for the tool
|
|
59
|
+
const toolArgs: Record<string, unknown> = {};
|
|
60
|
+
for (const [k, v] of Object.entries(args)) {
|
|
61
|
+
if (k === "help") continue;
|
|
62
|
+
// coerce
|
|
63
|
+
if (k === "approve") toolArgs[k] = v === true || v === "true";
|
|
64
|
+
else if (k === "limit") toolArgs[k] = Number(v);
|
|
65
|
+
else toolArgs[k] = v;
|
|
66
|
+
}
|
|
67
|
+
// Map CLI names to tool schema names
|
|
68
|
+
if (
|
|
69
|
+
toolArgs.kind === undefined &&
|
|
70
|
+
verb === "act" &&
|
|
71
|
+
toolArgs.action === undefined
|
|
72
|
+
) {
|
|
73
|
+
// allow --kind alias? no
|
|
74
|
+
}
|
|
75
|
+
// handle --file for batch
|
|
76
|
+
if (toolArgs.file) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await Bun.file(String(toolArgs.file)).text();
|
|
79
|
+
const steps = JSON.parse(raw);
|
|
80
|
+
toolArgs.steps = Array.isArray(steps) ? steps : (steps.steps ?? []);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
`Failed to read --file: ${e instanceof Error ? e.message : String(e)}\n`,
|
|
84
|
+
);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
delete toolArgs.file;
|
|
88
|
+
}
|
|
89
|
+
// sessionId alias
|
|
90
|
+
if (toolArgs.session && !toolArgs.sessionId) {
|
|
91
|
+
toolArgs.sessionId = toolArgs.session;
|
|
92
|
+
delete toolArgs.session;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = JSON.stringify({
|
|
96
|
+
jsonrpc: "2.0",
|
|
97
|
+
id: 1,
|
|
98
|
+
method: "tools/call",
|
|
99
|
+
params: { name: tool, arguments: toolArgs },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let res: Response;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
Accept: "application/json, text/event-stream",
|
|
109
|
+
},
|
|
110
|
+
body,
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "E_NO_SERVER",
|
|
117
|
+
message: `Cannot reach ${url}`,
|
|
118
|
+
hint: "start: bun src/http.ts",
|
|
119
|
+
}) + "\n",
|
|
120
|
+
);
|
|
121
|
+
process.exit(2);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!res.ok) {
|
|
125
|
+
// Try to parse SSE or JSON
|
|
126
|
+
const text = await res.text().catch(() => "");
|
|
127
|
+
// SSE format: data: {...}
|
|
128
|
+
const m = /data:\s*(\{.*\})/.exec(text);
|
|
129
|
+
const payload = m ? m[1] : text;
|
|
130
|
+
try {
|
|
131
|
+
const j = JSON.parse(payload);
|
|
132
|
+
const content =
|
|
133
|
+
j.result?.content?.[0]?.text ?? j.error?.message ?? payload;
|
|
134
|
+
// content is often JSON string of the tool result
|
|
135
|
+
try {
|
|
136
|
+
const inner = JSON.parse(content);
|
|
137
|
+
process.stdout.write(JSON.stringify(inner) + "\n");
|
|
138
|
+
process.exit(inner.ok === false ? 1 : 0);
|
|
139
|
+
} catch {
|
|
140
|
+
process.stdout.write(content + "\n");
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
process.stderr.write(text + "\n");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = await res.text();
|
|
150
|
+
// Handle SSE stream
|
|
151
|
+
const lines = text.split("\n").filter((l) => l.startsWith("data:"));
|
|
152
|
+
const last = lines[lines.length - 1]?.slice(5).trim() ?? text;
|
|
153
|
+
try {
|
|
154
|
+
const j = JSON.parse(last);
|
|
155
|
+
const content = j.result?.content?.[0]?.text ?? last;
|
|
156
|
+
try {
|
|
157
|
+
const inner = JSON.parse(content);
|
|
158
|
+
process.stdout.write(JSON.stringify(inner) + "\n");
|
|
159
|
+
process.exit(inner.ok === false ? 1 : 0);
|
|
160
|
+
} catch {
|
|
161
|
+
process.stdout.write(content + "\n");
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
process.stdout.write(text + "\n");
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
main().catch((e) => {
|
|
171
|
+
process.stderr.write(String(e) + "\n");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -15,6 +15,11 @@ const ConfigSchema = v.object({
|
|
|
15
15
|
outputDir: v.optional(v.string(), ".browser-use"),
|
|
16
16
|
outputMaxChars: v.optional(v.number(), 4000),
|
|
17
17
|
allowEval: v.optional(v.boolean(), false),
|
|
18
|
+
deltaDefault: v.optional(v.boolean(), true),
|
|
19
|
+
effectMaxChars: v.optional(v.number(), 800),
|
|
20
|
+
stateMaxLines: v.optional(v.number(), 40),
|
|
21
|
+
recordRedact: v.optional(v.boolean(), true),
|
|
22
|
+
effingUseUrl: v.optional(v.string(), "http://localhost:3123/mcp"),
|
|
18
23
|
});
|
|
19
24
|
|
|
20
25
|
export type Config = v.InferOutput<typeof ConfigSchema>;
|
|
@@ -28,5 +33,10 @@ export function loadConfig(): Config {
|
|
|
28
33
|
outputDir: process.env.OUTPUT_DIR ?? ".browser-use",
|
|
29
34
|
outputMaxChars: Number(process.env.OUTPUT_MAX_CHARS ?? 4000),
|
|
30
35
|
allowEval: process.env.ALLOW_EVAL === "true",
|
|
36
|
+
deltaDefault: process.env.DELTA_DEFAULT !== "false",
|
|
37
|
+
effectMaxChars: Number(process.env.EFFECT_MAX_CHARS ?? 800),
|
|
38
|
+
stateMaxLines: Number(process.env.STATE_MAX_LINES ?? 40),
|
|
39
|
+
recordRedact: process.env.RECORD_REDACT !== "false",
|
|
40
|
+
effingUseUrl: process.env.EFFING_USE_URL ?? "http://localhost:3123/mcp",
|
|
31
41
|
});
|
|
32
42
|
}
|
package/src/http.ts
CHANGED
|
@@ -11,6 +11,7 @@ const transport = new HttpTransport(server, { path: "/mcp" });
|
|
|
11
11
|
|
|
12
12
|
Bun.serve({
|
|
13
13
|
port,
|
|
14
|
+
hostname: "0.0.0.0",
|
|
14
15
|
// Bun closes idle connections after 10s by default (idleTimeout), and the
|
|
15
16
|
// timer applies even while a response is being streamed. The MCP
|
|
16
17
|
// Streamable-HTTP SSE notification stream sits idle between server->client
|
package/src/tools/act.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { tool } from "tmcp/utils";
|
|
|
3
3
|
import * as v from "valibot";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { getPage, getContext } from "../browser/session.js";
|
|
6
|
-
import { doAct, doBatch,
|
|
6
|
+
import { doAct, doBatch, type ActAction } from "../browser/engine.js";
|
|
7
7
|
import { EngineError } from "../browser/refs.js";
|
|
8
8
|
|
|
9
9
|
export const ACT_ACTIONS = [
|
|
@@ -34,6 +34,11 @@ export const ACT_ACTIONS = [
|
|
|
34
34
|
"tab_select",
|
|
35
35
|
"tab_close",
|
|
36
36
|
"resize",
|
|
37
|
+
"note",
|
|
38
|
+
"record_start",
|
|
39
|
+
"record_stop",
|
|
40
|
+
"compile",
|
|
41
|
+
"replay",
|
|
37
42
|
] as const;
|
|
38
43
|
|
|
39
44
|
const StepSchema = v.object({
|
|
@@ -53,6 +58,8 @@ export const actTool = defineTool(
|
|
|
53
58
|
value: v.optional(v.string()),
|
|
54
59
|
sessionId: v.optional(v.string()),
|
|
55
60
|
steps: v.optional(v.array(StepSchema)),
|
|
61
|
+
expect: v.optional(v.string()),
|
|
62
|
+
approve: v.optional(v.boolean()),
|
|
56
63
|
}),
|
|
57
64
|
annotations: {
|
|
58
65
|
readOnlyHint: false,
|
|
@@ -61,13 +68,13 @@ export const actTool = defineTool(
|
|
|
61
68
|
openWorldHint: true,
|
|
62
69
|
},
|
|
63
70
|
},
|
|
64
|
-
async ({ action, target, value, sessionId, steps }) => {
|
|
71
|
+
async ({ action, target, value, sessionId, steps, expect, approve }) => {
|
|
65
72
|
const config = loadConfig();
|
|
66
73
|
const sid = sessionId ?? "default";
|
|
67
74
|
try {
|
|
68
75
|
const page = await getPage(config, sid);
|
|
69
76
|
const context = await getContext(config, sid);
|
|
70
|
-
const opts = { context, sessionId: sid };
|
|
77
|
+
const opts = { context, sessionId: sid, expect, approve };
|
|
71
78
|
if (action === "batch") {
|
|
72
79
|
if (!steps || steps.length === 0)
|
|
73
80
|
return tool.text(
|
|
@@ -94,10 +101,6 @@ export const actTool = defineTool(
|
|
|
94
101
|
JSON.stringify({ ok: true, action, url, title, ...result }),
|
|
95
102
|
);
|
|
96
103
|
}
|
|
97
|
-
if (action === "goal") {
|
|
98
|
-
const result = await doGoal(page, config, value ?? target ?? "", opts);
|
|
99
|
-
return tool.text(JSON.stringify({ ok: true, action, ...result }));
|
|
100
|
-
}
|
|
101
104
|
const result = await doAct(
|
|
102
105
|
page,
|
|
103
106
|
config,
|
package/src/tools/extract.ts
CHANGED
package/src/tools/observe.ts
CHANGED
|
@@ -26,6 +26,8 @@ export const observeTool = defineTool(
|
|
|
26
26
|
limit: v.optional(v.number()),
|
|
27
27
|
sessionId: v.optional(v.string()),
|
|
28
28
|
inline: v.optional(v.boolean()),
|
|
29
|
+
mode: v.optional(v.picklist(["full", "delta"])),
|
|
30
|
+
scope: v.optional(v.string()),
|
|
29
31
|
}),
|
|
30
32
|
annotations: {
|
|
31
33
|
readOnlyHint: true,
|
|
@@ -34,7 +36,7 @@ export const observeTool = defineTool(
|
|
|
34
36
|
openWorldHint: false,
|
|
35
37
|
},
|
|
36
38
|
},
|
|
37
|
-
async ({ kind, target, limit, sessionId }) => {
|
|
39
|
+
async ({ kind, target, limit, sessionId, mode, scope }) => {
|
|
38
40
|
const config = loadConfig();
|
|
39
41
|
const sid = sessionId ?? "default";
|
|
40
42
|
try {
|
|
@@ -43,6 +45,8 @@ export const observeTool = defineTool(
|
|
|
43
45
|
const result = await doObserve(page, config, kind, target, limit, {
|
|
44
46
|
context,
|
|
45
47
|
sessionId: sid,
|
|
48
|
+
mode: mode as "full" | "delta" | undefined,
|
|
49
|
+
scope,
|
|
46
50
|
});
|
|
47
51
|
return tool.text(JSON.stringify({ ok: true, kind, ...result }));
|
|
48
52
|
} catch (e) {
|