effing-use 0.1.1 → 0.2.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.
@@ -2,7 +2,35 @@ import type { Page, BrowserContext } from "playwright";
2
2
  import type { Config } from "../config.js";
3
3
  import { EngineError, resolveLocator } from "./refs.js";
4
4
  import { cap, saveText, stamp } from "./output.js";
5
- import { getConsoleLogs, getNetworkLogs, closeSession } from "./session.js";
5
+ import {
6
+ getConsoleLogs,
7
+ getNetworkLogs,
8
+ closeSession,
9
+ getMustObserve,
10
+ setMustObserve,
11
+ clearMustObserve,
12
+ } from "./session.js";
13
+ import { collectEffect, evaluateExpect, parseExpect } from "./evidence.js";
14
+ import {
15
+ getBaseline,
16
+ setBaseline,
17
+ computeDelta,
18
+ injectDirtyObserver,
19
+ checkDirty,
20
+ clearDirtyFlag,
21
+ ensureState as ensureDeltaState,
22
+ } from "./delta.js";
23
+ import { appendAction, appendNote, readState } from "./state.js";
24
+ import {
25
+ isRecording,
26
+ captureStep,
27
+ saveRecording,
28
+ loadRecording,
29
+ startRecording,
30
+ stopRecording,
31
+ } from "./record.js";
32
+ import { compileMacro, isIrreversible } from "./macro.js";
33
+ import { registerFingerprints, clearRegistry } from "./identity.js";
6
34
 
7
35
  export type ActAction =
8
36
  | "open"
@@ -31,26 +59,32 @@ export type ActAction =
31
59
  | "tab_new"
32
60
  | "tab_select"
33
61
  | "tab_close"
34
- | "resize";
62
+ | "resize"
63
+ | "note"
64
+ | "record_start"
65
+ | "record_stop"
66
+ | "compile"
67
+ | "replay";
35
68
 
36
69
  export interface ActOpts {
37
70
  context?: BrowserContext;
38
71
  sessionId?: string;
72
+ expect?: string;
73
+ approve?: boolean;
39
74
  }
40
-
41
75
  export interface ObserveOpts {
42
76
  context?: BrowserContext;
43
77
  sessionId?: string;
78
+ mode?: "full" | "delta";
79
+ scope?: string;
44
80
  }
45
81
 
46
82
  function timeoutOf(config: Config): number {
47
83
  return config.timeoutMs;
48
84
  }
49
-
50
85
  async function liteState(page: Page): Promise<{ url: string; title: string }> {
51
86
  return { url: page.url(), title: await page.title().catch(() => "") };
52
87
  }
53
-
54
88
  function toEngineError(e: unknown, fallbackHint: string): EngineError {
55
89
  if (e instanceof EngineError) return e;
56
90
  const msg = e instanceof Error ? e.message : String(e);
@@ -63,9 +97,34 @@ function toEngineError(e: unknown, fallbackHint: string): EngineError {
63
97
  fallbackHint,
64
98
  );
65
99
  }
100
+ const MUTATING = new Set<string>([
101
+ "click",
102
+ "dblclick",
103
+ "fill",
104
+ "type",
105
+ "press",
106
+ "select",
107
+ "check",
108
+ "uncheck",
109
+ "hover",
110
+ "drag",
111
+ "upload",
112
+ "scroll",
113
+ "back",
114
+ "forward",
115
+ "reload",
116
+ "open",
117
+ "goto",
118
+ "resize",
119
+ "tab_new",
120
+ "tab_select",
121
+ "tab_close",
122
+ ]);
123
+ function isMutating(a: string): boolean {
124
+ return MUTATING.has(a);
125
+ }
66
126
 
67
127
  // ---------------------------------------------------------------- act
68
-
69
128
  export async function doAct(
70
129
  page: Page,
71
130
  config: Config,
@@ -75,6 +134,158 @@ export async function doAct(
75
134
  opts?: ActOpts,
76
135
  ): Promise<Record<string, unknown>> {
77
136
  const timeout = timeoutOf(config);
137
+ const sid = opts?.sessionId ?? "default";
138
+
139
+ // Non-page meta actions (no mustObserve guard, no evidence)
140
+ if (action === "note") {
141
+ if (!value && !target)
142
+ throw new EngineError(
143
+ "E_BAD_INPUT",
144
+ "Missing note text.",
145
+ "Pass value with the note.",
146
+ );
147
+ const note = value ?? target ?? "";
148
+ await appendNote(config, sid, note);
149
+ return { noted: true, note: note.slice(0, 300) };
150
+ }
151
+ if (action === "record_start") {
152
+ const name = (value ?? target ?? "recording").trim() || "recording";
153
+ startRecording(sid, name);
154
+ return { recording: true, name };
155
+ }
156
+ if (action === "record_stop") {
157
+ const rec = stopRecording(sid);
158
+ if (!rec)
159
+ throw new EngineError(
160
+ "E_BAD_INPUT",
161
+ "No active recording.",
162
+ "Call record_start first.",
163
+ );
164
+ const path = await saveRecording(config, rec.name, rec.steps as any);
165
+ return { recording: false, name: rec.name, steps: rec.steps.length, path };
166
+ }
167
+ if (action === "compile") {
168
+ const name = (value ?? target ?? "").trim();
169
+ if (!name)
170
+ throw new EngineError(
171
+ "E_BAD_INPUT",
172
+ "Missing macro name.",
173
+ "Pass value with the recording name.",
174
+ );
175
+ const rec = await loadRecording(config, name).catch(() => {
176
+ throw new EngineError(
177
+ "E_NOT_FOUND",
178
+ `No recording "${name}".`,
179
+ "Call record_start/stop first.",
180
+ );
181
+ });
182
+ const { tsPath, mdPath, warnings } = await compileMacro(
183
+ config,
184
+ name,
185
+ rec.steps as any,
186
+ );
187
+ return {
188
+ compiled: true,
189
+ name,
190
+ steps: rec.steps.length,
191
+ tsPath,
192
+ mdPath,
193
+ warnings,
194
+ };
195
+ }
196
+ if (action === "replay") {
197
+ const name = (value ?? target ?? "").trim();
198
+ if (!name)
199
+ throw new EngineError(
200
+ "E_BAD_INPUT",
201
+ "Missing macro name.",
202
+ "Pass value with the macro name.",
203
+ );
204
+ const rec = await loadRecording(config, name).catch(() => {
205
+ throw new EngineError(
206
+ "E_NOT_FOUND",
207
+ `No recording "${name}".`,
208
+ "Compile first or check name.",
209
+ );
210
+ });
211
+ const approve = opts?.approve === true;
212
+ // Check approval gate
213
+ const needsApproval = (rec.steps as any[]).filter((s: any) =>
214
+ isIrreversible(s),
215
+ );
216
+ if (needsApproval.length > 0 && !approve) {
217
+ const first = needsApproval[0];
218
+ return {
219
+ ok: false,
220
+ code: "E_APPROVAL_REQUIRED",
221
+ message: `Step ${first.seq} requires approval (${first.op}).`,
222
+ hint: "Re-run with approve:true.",
223
+ step: first.seq,
224
+ description: `${first.op} ${first.target ?? ""}`,
225
+ } as any;
226
+ }
227
+ // Deterministic replay
228
+ const results: any[] = [];
229
+ for (const s of rec.steps as any[]) {
230
+ try {
231
+ const r = await doAct(
232
+ page,
233
+ config,
234
+ s.op as ActAction,
235
+ s.target,
236
+ s.value === "«redacted»" ? undefined : s.value,
237
+ { ...opts, expect: s.expect },
238
+ );
239
+ results.push({ ok: true, seq: s.seq, ...r });
240
+ } catch (e) {
241
+ const err = toEngineError(e, "Replay failed.");
242
+ results.push({
243
+ ok: false,
244
+ seq: s.seq,
245
+ code: err.code,
246
+ message: err.message,
247
+ });
248
+ return { replayed: false, name, failSeq: s.seq, results } as any;
249
+ }
250
+ }
251
+ return { replayed: true, name, steps: results.length, results } as any;
252
+ }
253
+
254
+ // Failure contract guard
255
+ if (isMutating(action) && getMustObserve(sid)) {
256
+ throw new EngineError(
257
+ "E_MUST_OBSERVE",
258
+ "Must observe before next mutation.",
259
+ "Call browser_observe kind=snapshot first.",
260
+ { mustObserve: true } as any,
261
+ );
262
+ }
263
+
264
+ // Validate expect syntax early
265
+ if (opts?.expect) {
266
+ const parsed = parseExpect(opts.expect);
267
+ if (!parsed)
268
+ throw new EngineError(
269
+ "E_BAD_EXPECT",
270
+ `Bad expect "${opts.expect}".`,
271
+ "Use url~<regex> | text~<regex> | visible=<css> | gone=<css>",
272
+ );
273
+ }
274
+
275
+ const urlBefore = page.url();
276
+ let domBefore: string | null = null;
277
+ try {
278
+ domBefore = await page.evaluate(
279
+ () => document.body?.innerText?.slice(0, 200) ?? "",
280
+ );
281
+ } catch {
282
+ domBefore = null;
283
+ }
284
+ const consoleBefore = getConsoleLogs(sid).length;
285
+ const networkBefore = getNetworkLogs(sid).length;
286
+
287
+ let result: Record<string, unknown>;
288
+ let isTimeout = false;
78
289
  try {
79
290
  switch (action) {
80
291
  case "open":
@@ -87,7 +298,8 @@ export async function doAct(
87
298
  "Pass the URL in value (or target).",
88
299
  );
89
300
  await page.goto(url, { waitUntil: "domcontentloaded", timeout });
90
- return { ...(await liteState(page)) };
301
+ result = { ...(await liteState(page)) };
302
+ break;
91
303
  }
92
304
  case "click": {
93
305
  if (!target)
@@ -96,11 +308,16 @@ export async function doAct(
96
308
  "Missing target.",
97
309
  "Pass an e-ref, role= selector, or CSS.",
98
310
  );
99
- const loc = await resolveLocator(page, target);
311
+ const loc = await resolveLocator(page, target, sid);
100
312
  const button =
101
313
  (value as "left" | "middle" | "right" | undefined) ?? "left";
102
314
  await loc.click({ button, timeout });
103
- return { ...(await liteState(page)) };
315
+ result = { ...(await liteState(page)) };
316
+ if ((page as any).__rebound) {
317
+ (result as any).rebound = true;
318
+ delete (page as any).__rebound;
319
+ }
320
+ break;
104
321
  }
105
322
  case "dblclick": {
106
323
  if (!target)
@@ -109,9 +326,10 @@ export async function doAct(
109
326
  "Missing target.",
110
327
  "Pass an e-ref, role= selector, or CSS.",
111
328
  );
112
- const loc = await resolveLocator(page, target);
329
+ const loc = await resolveLocator(page, target, sid);
113
330
  await loc.dblclick({ timeout });
114
- return { ...(await liteState(page)) };
331
+ result = { ...(await liteState(page)) };
332
+ break;
115
333
  }
116
334
  case "fill": {
117
335
  if (!target)
@@ -120,13 +338,15 @@ export async function doAct(
120
338
  "Missing target.",
121
339
  "Pass an e-ref, role= selector, or CSS.",
122
340
  );
123
- const loc = await resolveLocator(page, target);
341
+ const loc = await resolveLocator(page, target, sid);
124
342
  await loc.fill(value ?? "", { timeout });
125
- return { ...(await liteState(page)) };
343
+ result = { ...(await liteState(page)) };
344
+ break;
126
345
  }
127
346
  case "type": {
128
347
  await page.keyboard.type(value ?? "", { delay: 0 });
129
- return { ...(await liteState(page)) };
348
+ result = { ...(await liteState(page)) };
349
+ break;
130
350
  }
131
351
  case "press": {
132
352
  if (!value)
@@ -136,7 +356,8 @@ export async function doAct(
136
356
  "Pass a key like Enter, Tab, Escape in value.",
137
357
  );
138
358
  await page.keyboard.press(value);
139
- return { ...(await liteState(page)) };
359
+ result = { ...(await liteState(page)) };
360
+ break;
140
361
  }
141
362
  case "select": {
142
363
  if (!target)
@@ -145,9 +366,10 @@ export async function doAct(
145
366
  "Missing target.",
146
367
  "Pass a select element ref in target.",
147
368
  );
148
- const loc = await resolveLocator(page, target);
369
+ const loc = await resolveLocator(page, target, sid);
149
370
  await loc.selectOption(value ?? "", { timeout });
150
- return { ...(await liteState(page)) };
371
+ result = { ...(await liteState(page)) };
372
+ break;
151
373
  }
152
374
  case "check": {
153
375
  if (!target)
@@ -156,9 +378,10 @@ export async function doAct(
156
378
  "Missing target.",
157
379
  "Pass a checkbox ref in target.",
158
380
  );
159
- const loc = await resolveLocator(page, target);
381
+ const loc = await resolveLocator(page, target, sid);
160
382
  await loc.check({ timeout });
161
- return { ...(await liteState(page)) };
383
+ result = { ...(await liteState(page)) };
384
+ break;
162
385
  }
163
386
  case "uncheck": {
164
387
  if (!target)
@@ -167,9 +390,10 @@ export async function doAct(
167
390
  "Missing target.",
168
391
  "Pass a checkbox ref in target.",
169
392
  );
170
- const loc = await resolveLocator(page, target);
393
+ const loc = await resolveLocator(page, target, sid);
171
394
  await loc.uncheck({ timeout });
172
- return { ...(await liteState(page)) };
395
+ result = { ...(await liteState(page)) };
396
+ break;
173
397
  }
174
398
  case "hover": {
175
399
  if (!target)
@@ -178,9 +402,10 @@ export async function doAct(
178
402
  "Missing target.",
179
403
  "Pass an e-ref, role= selector, or CSS.",
180
404
  );
181
- const loc = await resolveLocator(page, target);
405
+ const loc = await resolveLocator(page, target, sid);
182
406
  await loc.hover({ timeout });
183
- return { ...(await liteState(page)) };
407
+ result = { ...(await liteState(page)) };
408
+ break;
184
409
  }
185
410
  case "drag": {
186
411
  if (!target || !value)
@@ -189,10 +414,11 @@ export async function doAct(
189
414
  "Missing drag endpoints.",
190
415
  "Pass start ref in target and end ref in value.",
191
416
  );
192
- const start = await resolveLocator(page, target);
193
- const end = await resolveLocator(page, value);
417
+ const start = await resolveLocator(page, target, sid);
418
+ const end = await resolveLocator(page, value, sid);
194
419
  await start.dragTo(end, { timeout });
195
- return { ...(await liteState(page)) };
420
+ result = { ...(await liteState(page)) };
421
+ break;
196
422
  }
197
423
  case "upload": {
198
424
  const files = (value ?? "")
@@ -206,7 +432,8 @@ export async function doAct(
206
432
  "Pass comma-separated file paths in value.",
207
433
  );
208
434
  await page.setInputFiles("input[type=file]", files, { timeout });
209
- return { ...(await liteState(page)), files };
435
+ result = { ...(await liteState(page)), files };
436
+ break;
210
437
  }
211
438
  case "scroll": {
212
439
  const dir = (target ?? "").toLowerCase();
@@ -219,52 +446,60 @@ export async function doAct(
219
446
  window.scrollTo(0, document.body.scrollHeight),
220
447
  );
221
448
  else if (target) {
222
- const loc = await resolveLocator(page, target);
449
+ const loc = await resolveLocator(page, target, sid);
223
450
  await loc.scrollIntoViewIfNeeded({ timeout });
224
- } else {
225
- await page.mouse.wheel(0, 500);
226
- }
227
- return { ...(await liteState(page)) };
451
+ } else await page.mouse.wheel(0, 500);
452
+ result = { ...(await liteState(page)) };
453
+ break;
228
454
  }
229
455
  case "back":
230
456
  await page
231
457
  .goBack({ waitUntil: "domcontentloaded", timeout })
232
458
  .catch(() => null);
233
- return { ...(await liteState(page)) };
459
+ result = { ...(await liteState(page)) };
460
+ break;
234
461
  case "forward":
235
462
  await page
236
463
  .goForward({ waitUntil: "domcontentloaded", timeout })
237
464
  .catch(() => null);
238
- return { ...(await liteState(page)) };
465
+ result = { ...(await liteState(page)) };
466
+ break;
239
467
  case "reload":
240
468
  await page.reload({ waitUntil: "domcontentloaded", timeout });
241
- return { ...(await liteState(page)) };
469
+ result = { ...(await liteState(page)) };
470
+ break;
242
471
  case "wait": {
243
472
  const t = target ?? "";
244
473
  const msMatch = /^ms:(\d+)$/.exec(t);
245
474
  if (msMatch) {
246
475
  await page.waitForTimeout(Number(msMatch[1]));
247
- return { ...(await liteState(page)), waited: t };
476
+ result = { ...(await liteState(page)), waited: t };
477
+ break;
248
478
  }
249
479
  const textMatch = /^text:(.+)$/.exec(t);
250
480
  if (textMatch) {
251
481
  await page.getByText(textMatch[1]).first().waitFor({ timeout });
252
- return { ...(await liteState(page)), waited: t };
482
+ result = { ...(await liteState(page)), waited: t };
483
+ break;
253
484
  }
254
485
  if (t) {
255
- const loc = await resolveLocator(page, t);
486
+ const loc = await resolveLocator(page, t, sid);
256
487
  await loc.waitFor({ timeout });
257
- return { ...(await liteState(page)), waited: t };
488
+ result = { ...(await liteState(page)), waited: t };
489
+ break;
258
490
  }
259
491
  await page.waitForTimeout(500);
260
- return { ...(await liteState(page)) };
492
+ result = { ...(await liteState(page)) };
493
+ break;
261
494
  }
262
495
  case "dialog_accept":
263
496
  page.once("dialog", (d) => void d.accept(value));
264
- return { armed: true };
497
+ result = { armed: true };
498
+ break;
265
499
  case "dialog_dismiss":
266
500
  page.once("dialog", (d) => void d.dismiss());
267
- return { armed: true };
501
+ result = { armed: true };
502
+ break;
268
503
  case "resize": {
269
504
  const m = /^(\d+)x(\d+)$/.exec(value ?? "");
270
505
  if (!m)
@@ -277,7 +512,8 @@ export async function doAct(
277
512
  width: Number(m[1]),
278
513
  height: Number(m[2]),
279
514
  });
280
- return { ...(await liteState(page)), viewport: value };
515
+ result = { ...(await liteState(page)), viewport: value };
516
+ break;
281
517
  }
282
518
  case "tab_new": {
283
519
  if (!opts?.context)
@@ -292,7 +528,11 @@ export async function doAct(
292
528
  waitUntil: "domcontentloaded",
293
529
  timeout,
294
530
  });
295
- return { url: p.url(), tabs: opts.context.pages().map((x) => x.url()) };
531
+ result = {
532
+ url: p.url(),
533
+ tabs: opts.context.pages().map((x) => x.url()),
534
+ };
535
+ break;
296
536
  }
297
537
  case "tab_select": {
298
538
  if (!opts?.context)
@@ -310,7 +550,8 @@ export async function doAct(
310
550
  "Call browser_observe kind=tabs for the tab list.",
311
551
  );
312
552
  await pages[i].bringToFront();
313
- return { url: pages[i].url() };
553
+ result = { url: pages[i].url() };
554
+ break;
314
555
  }
315
556
  case "tab_close": {
316
557
  if (!opts?.context)
@@ -329,11 +570,14 @@ export async function doAct(
329
570
  "Call browser_observe kind=tabs for the tab list.",
330
571
  );
331
572
  await pages[i].close();
332
- return { closed: i, tabs: opts.context.pages().map((x) => x.url()) };
573
+ result = { closed: i, tabs: opts.context.pages().map((x) => x.url()) };
574
+ break;
333
575
  }
334
576
  case "close":
335
- await closeSession(opts?.sessionId ?? "default");
336
- return { closed: true };
577
+ await closeSession(sid);
578
+ clearRegistry(sid);
579
+ result = { closed: true };
580
+ break;
337
581
  case "goal":
338
582
  return doGoal(page, config, value ?? target ?? "", opts);
339
583
  case "batch":
@@ -350,11 +594,85 @@ export async function doAct(
350
594
  );
351
595
  }
352
596
  } catch (e) {
353
- throw toEngineError(
597
+ const err = toEngineError(
354
598
  e,
355
599
  "Retry with a fresh snapshot ref, or re-observe state.",
356
600
  );
601
+ if (err.code === "E_TIMEOUT") {
602
+ isTimeout = true;
603
+ setMustObserve(sid, true);
604
+ }
605
+ throw err;
357
606
  }
607
+
608
+ // Post-action: evidence, expect, state, dirty, recording
609
+ let effect: any = undefined;
610
+ if (isMutating(action)) {
611
+ effect = await collectEffect(
612
+ page,
613
+ sid,
614
+ urlBefore,
615
+ domBefore,
616
+ consoleBefore,
617
+ networkBefore,
618
+ config.effectMaxChars,
619
+ );
620
+ // Failure contract: timeout with no DOM/URL change => mustObserve
621
+ if (isTimeout && !effect.urlChanged && effect.domChanged.length === 0)
622
+ setMustObserve(sid, true);
623
+ // Expect evaluation
624
+ if (opts?.expect) {
625
+ const ev = await evaluateExpect(page, opts.expect);
626
+ if (!ev.ok) {
627
+ effect.mustObserve = false;
628
+ throw new EngineError(
629
+ "E_EXPECT",
630
+ ev.message ?? "Expect failed.",
631
+ "Check effect and re-plan.",
632
+ { effect, expect: opts.expect } as any,
633
+ );
634
+ }
635
+ (result as any).expect = "pass";
636
+ }
637
+ if (effect) {
638
+ (result as any).effect = effect;
639
+ if (effect.mustObserve) (result as any).mustObserve = true;
640
+ }
641
+ // Mark dirty for delta
642
+ ensureDeltaState(sid).dirty = true;
643
+ try {
644
+ await page.evaluate(() => {
645
+ (window as any).__effDirty = true;
646
+ });
647
+ } catch {}
648
+ }
649
+
650
+ // Append to state ring
651
+ const actionLine = `${action} ${target ?? ""} ${value ?? ""}`
652
+ .trim()
653
+ .slice(0, 120);
654
+ await appendAction(
655
+ config,
656
+ sid,
657
+ `${actionLine} -> ${effect ? JSON.stringify(effect).slice(0, 80) : "ok"}`,
658
+ );
659
+
660
+ // Recording capture
661
+ if (isRecording(sid) && isMutating(action)) {
662
+ captureStep(
663
+ sid,
664
+ {
665
+ op: action,
666
+ target,
667
+ value,
668
+ expect: opts?.expect,
669
+ resolvedSelector: target,
670
+ },
671
+ config,
672
+ );
673
+ }
674
+
675
+ return result;
358
676
  }
359
677
 
360
678
  // ---------------------------------------------------------------- goal
@@ -473,15 +791,109 @@ export async function doObserve(
473
791
  case "title":
474
792
  return { title: await page.title().catch(() => ""), url: page.url() };
475
793
  case "snapshot": {
476
- const yaml = await buildSnapshot(page);
794
+ const mode = (opts?.mode ?? (config.deltaDefault ? "delta" : "full")) as
795
+ | "full"
796
+ | "delta";
797
+ const scope = opts?.scope;
798
+ // Scoped snapshot: only within selector
799
+ let yaml: string;
800
+ if (scope) {
801
+ yaml = await buildSnapshot(page, scope);
802
+ const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
803
+ const { text, truncated } = cap(yaml, config.outputMaxChars);
804
+ clearMustObserve(sessionId);
805
+ return {
806
+ path,
807
+ summary: text,
808
+ truncated,
809
+ url: page.url(),
810
+ title: await page.title().catch(() => ""),
811
+ scope,
812
+ mode: "scoped",
813
+ };
814
+ }
815
+ // Delta path
816
+ if (mode === "delta") {
817
+ const baseline = getBaseline(sessionId);
818
+ // If no baseline, do full
819
+ if (!baseline) {
820
+ yaml = await buildSnapshot(page);
821
+ setBaseline(sessionId, yaml, page.url());
822
+ await injectDirtyObserver(page, sessionId);
823
+ // register fingerprints
824
+ const fps = await collectFingerprints(page);
825
+ registerFingerprints(sessionId, fps);
826
+ const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
827
+ const { text, truncated } = cap(yaml, config.outputMaxChars);
828
+ clearMustObserve(sessionId);
829
+ return {
830
+ path,
831
+ summary: text,
832
+ truncated,
833
+ url: page.url(),
834
+ title: await page.title().catch(() => ""),
835
+ mode: "full",
836
+ };
837
+ }
838
+ // Check dirty flag
839
+ const dirty = await checkDirty(page).catch(() => true);
840
+ if (!dirty) {
841
+ clearMustObserve(sessionId);
842
+ return {
843
+ unchanged: true,
844
+ url: page.url(),
845
+ title: await page.title().catch(() => ""),
846
+ hint: "last snapshot still valid",
847
+ mode: "delta",
848
+ };
849
+ }
850
+ yaml = await buildSnapshot(page);
851
+ const { delta, unchanged } = computeDelta(baseline, yaml);
852
+ if (unchanged) {
853
+ await clearDirtyFlag(page);
854
+ clearMustObserve(sessionId);
855
+ return {
856
+ unchanged: true,
857
+ url: page.url(),
858
+ title: await page.title().catch(() => ""),
859
+ hint: "no changes",
860
+ mode: "delta",
861
+ };
862
+ }
863
+ setBaseline(sessionId, yaml, page.url());
864
+ await clearDirtyFlag(page);
865
+ await injectDirtyObserver(page, sessionId);
866
+ const fps = await collectFingerprints(page);
867
+ registerFingerprints(sessionId, fps);
868
+ const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
869
+ const { text: dtext, truncated } = cap(delta, config.outputMaxChars);
870
+ clearMustObserve(sessionId);
871
+ return {
872
+ path,
873
+ delta: dtext,
874
+ truncated,
875
+ url: page.url(),
876
+ title: await page.title().catch(() => ""),
877
+ mode: "delta",
878
+ fullPath: path,
879
+ };
880
+ }
881
+ // Full mode
882
+ yaml = await buildSnapshot(page);
883
+ setBaseline(sessionId, yaml, page.url());
884
+ await injectDirtyObserver(page, sessionId);
885
+ const fps2 = await collectFingerprints(page);
886
+ registerFingerprints(sessionId, fps2);
477
887
  const path = await saveText(config, stamp("snapshot", "yaml"), yaml);
478
888
  const { text, truncated } = cap(yaml, config.outputMaxChars);
889
+ clearMustObserve(sessionId);
479
890
  return {
480
891
  path,
481
892
  summary: text,
482
893
  truncated,
483
894
  url: page.url(),
484
895
  title: await page.title().catch(() => ""),
896
+ mode: "full",
485
897
  };
486
898
  }
487
899
  case "screenshot": {
@@ -524,17 +936,28 @@ export async function doObserve(
524
936
  }
525
937
  }
526
938
 
527
- async function buildSnapshot(page: Page): Promise<string> {
939
+ async function buildSnapshot(page: Page, scope?: string): Promise<string> {
528
940
  const header = [
529
941
  "# Snapshot — refs are nth-match: eN = Nth match of (button, a, input, select, textarea, [role=button], [tabindex]) in DOM order.",
530
942
  `# url: ${page.url()}`,
531
943
  ].join("\n");
532
- const items = await page.evaluate(() => {
944
+ const items = await page.evaluate((scopeSel) => {
945
+ const root = scopeSel ? document.querySelector(scopeSel) : document;
946
+ if (!root) return [] as string[];
533
947
  const els = [
534
- ...document.querySelectorAll(
948
+ ...(root as Element).querySelectorAll(
535
949
  "button, a, input, select, textarea, [role=button], [tabindex]",
536
950
  ),
537
951
  ];
952
+ // also include root itself if it matches
953
+ if (
954
+ scopeSel &&
955
+ (root as Element).matches?.(
956
+ "button, a, input, select, textarea, [role=button], [tabindex]",
957
+ )
958
+ ) {
959
+ els.unshift(root as Element);
960
+ }
538
961
  return els.slice(0, 200).map((el) => {
539
962
  const tag = el.tagName.toLowerCase();
540
963
  const text = (el.textContent ?? "")
@@ -553,11 +976,62 @@ async function buildSnapshot(page: Page): Promise<string> {
553
976
  .join(" ");
554
977
  return `${tag} "${text}"${extra ? " " + extra : ""}`;
555
978
  });
556
- });
979
+ }, scope ?? null);
557
980
  const lines = items.map((line, i) => `[e${i}] ${line}`);
558
981
  return `${header}\n${lines.join("\n") || "(no interactive elements)"}`;
559
982
  }
560
983
 
984
+ async function collectFingerprints(
985
+ page: Page,
986
+ ): Promise<Array<{ ref: string; fp: import("./identity.js").Fingerprint }>> {
987
+ const raw = await page
988
+ .evaluate(() => {
989
+ const els = [
990
+ ...document.querySelectorAll(
991
+ "button, a, input, select, textarea, [role=button], [tabindex]",
992
+ ),
993
+ ].slice(0, 200);
994
+ return els.map((el) => {
995
+ const text = (el.textContent ?? "")
996
+ .trim()
997
+ .replace(/\s+/g, " ")
998
+ .slice(0, 80);
999
+ const aria = el.getAttribute("aria-label") ?? "";
1000
+ const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
1001
+ let cur: Element | null = el;
1002
+ const parts: string[] = [];
1003
+ while (cur && parts.length < 6) {
1004
+ parts.push(cur.tagName.toLowerCase());
1005
+ cur = cur.parentElement;
1006
+ }
1007
+ const rect = el.getBoundingClientRect();
1008
+ return {
1009
+ role,
1010
+ accessibleName: aria,
1011
+ text,
1012
+ pathHash: parts.join(">"),
1013
+ box: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
1014
+ };
1015
+ });
1016
+ })
1017
+ .catch(() => [] as any[]);
1018
+ return raw.map((r: any, i: number) => ({
1019
+ ref: `e${i}`,
1020
+ fp: {
1021
+ role: r.role || "generic",
1022
+ accessibleName: (r.accessibleName || "").trim().slice(0, 80),
1023
+ textHash: (r.text || "").trim().replace(/\s+/g, " ").slice(0, 80),
1024
+ box: {
1025
+ x: Math.round(r.box.x),
1026
+ y: Math.round(r.box.y),
1027
+ w: Math.round(r.box.w),
1028
+ h: Math.round(r.box.h),
1029
+ },
1030
+ pathHash: r.pathHash || "",
1031
+ },
1032
+ }));
1033
+ }
1034
+
561
1035
  // ---------------------------------------------------------------- extract
562
1036
 
563
1037
  export async function doExtract(
@@ -657,11 +1131,23 @@ export async function doExtract(
657
1131
  await ctx.tracing.stop({ path: saved });
658
1132
  return { path: saved };
659
1133
  }
1134
+ case "state": {
1135
+ const sid = opts?.sessionId ?? "default";
1136
+ const st = await readState(config, sid);
1137
+ const dirty = await checkDirty(page).catch(() => false);
1138
+ return {
1139
+ notes: st.notes,
1140
+ lastActions: st.lastActions,
1141
+ path: st.path,
1142
+ url: page.url(),
1143
+ dirty,
1144
+ };
1145
+ }
660
1146
  default:
661
1147
  throw new EngineError(
662
1148
  "E_BAD_INPUT",
663
1149
  `Unknown extract kind "${kind}".`,
664
- "Valid: text, html, table, query, pdf, trace_start, trace_stop.",
1150
+ "Valid: text, html, table, query, pdf, trace_start, trace_stop, state.",
665
1151
  );
666
1152
  }
667
1153
  }