@dikshanty94/webnav 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.
Files changed (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,401 @@
1
+ // The replay engine: plays back a recorded StoredActionEffect[] against a live adapter,
2
+ // auto-paced or step-by-step, pausing for missing creds / commit confirmation / drift.
3
+ // Deps injected (adapter/creds/sleep) so tests drive it with fakes — mirrors live-record.ts.
4
+ import { mkdirSync, copyFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { parseSnapshot } from '../playwright/snapshot.js';
7
+ import { resolveByFingerprint } from '../playwright/fingerprint.js';
8
+ import { didNavigate } from '../explorer/diff.js';
9
+ import { COMMIT_WORDS } from '../explorer/draft.js';
10
+ import { resolveEvent, descriptorName } from './live.js';
11
+ export class ReplayController {
12
+ state;
13
+ nextResolve = null;
14
+ waitResolve = null;
15
+ aborted = false;
16
+ constructor(session, labels) {
17
+ this.state = { session, running: true, mode: 'auto', waiting: null, done: false,
18
+ steps: labels.map((l) => ({ seq: l.seq, label: l.label, status: 'pending', shot: null })) };
19
+ }
20
+ control(action) {
21
+ if (action === 'pause') {
22
+ this.state.mode = 'step';
23
+ return true;
24
+ }
25
+ if (action === 'resume') {
26
+ this.state.mode = 'auto';
27
+ this.nextResolve?.();
28
+ this.nextResolve = null;
29
+ return true;
30
+ }
31
+ if (action === 'next') {
32
+ this.nextResolve?.();
33
+ this.nextResolve = null;
34
+ return true;
35
+ }
36
+ if (action === 'abort') {
37
+ this.aborted = true;
38
+ this.state.waiting = null;
39
+ this.state.waitingLabel = undefined;
40
+ this.nextResolve?.();
41
+ this.nextResolve = null;
42
+ this.waitResolve?.('abort');
43
+ this.waitResolve = null;
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ supply(value, save) {
49
+ if (this.state.waiting !== 'value' || !this.waitResolve)
50
+ return false;
51
+ this.state.waiting = null;
52
+ const r = this.waitResolve;
53
+ this.waitResolve = null;
54
+ r({ value, save });
55
+ return true;
56
+ }
57
+ confirm(fire) {
58
+ if (this.state.waiting !== 'confirm' || !this.waitResolve)
59
+ return false;
60
+ this.state.waiting = null;
61
+ const r = this.waitResolve;
62
+ this.waitResolve = null;
63
+ r({ fire });
64
+ return true;
65
+ }
66
+ async gate(sleep, paceMs) {
67
+ if (this.aborted)
68
+ return 'abort';
69
+ if (this.state.mode === 'auto') {
70
+ await sleep(paceMs);
71
+ return this.aborted ? 'abort' : 'go';
72
+ }
73
+ await new Promise((r) => { this.nextResolve = r; });
74
+ return this.aborted ? 'abort' : 'go';
75
+ }
76
+ waitFor(kind, label) {
77
+ this.state.waiting = kind;
78
+ this.state.waitingLabel = label;
79
+ return new Promise((r) => { this.waitResolve = r; });
80
+ }
81
+ }
82
+ const normName = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
83
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ /** Best-effort screenshot copy — decoration only, never fails the step. */
85
+ function saveShot(shotsDir, src, seq) {
86
+ const name = `step-${seq}.png`;
87
+ try {
88
+ mkdirSync(shotsDir, { recursive: true });
89
+ copyFileSync(src, join(shotsDir, name));
90
+ return name;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ export async function runReplay(effects, ctl, deps) {
97
+ const sleep = deps.sleep ?? realSleep;
98
+ const paceMs = deps.paceMs ?? 1500;
99
+ const st = ctl.state;
100
+ const skipRest = (from) => {
101
+ for (let j = from; j < st.steps.length; j++) {
102
+ if (st.steps[j].status === 'running' || st.steps[j].status === 'pending')
103
+ st.steps[j].status = 'skipped';
104
+ }
105
+ };
106
+ try {
107
+ if (effects.length === 0) {
108
+ st.done = true;
109
+ st.running = false;
110
+ return st;
111
+ }
112
+ await deps.adapter.open(effects[0].fromUrl);
113
+ for (let i = 0; i < effects.length; i++) {
114
+ const e = effects[i];
115
+ const step = st.steps[i];
116
+ step.status = 'running';
117
+ const g = await ctl.gate(sleep, paceMs);
118
+ if (g === 'abort') {
119
+ skipRest(i);
120
+ break;
121
+ }
122
+ if (e.action === null) {
123
+ if (e.navigated) {
124
+ await deps.adapter.goto(e.toUrl);
125
+ step.status = 'jumped';
126
+ }
127
+ else {
128
+ step.status = 'skipped';
129
+ }
130
+ continue; // no gate/screenshot for skipped/jumped null-action steps
131
+ }
132
+ const action = e.action;
133
+ let ref = action.ref ?? null;
134
+ let abortedHere = false;
135
+ if (action.elementFp) {
136
+ // Resolve with ONE human-assisted retry (plan semantics): the first miss pauses
137
+ // (step mode) so the human can fix the live page — dismiss a popup, let a render
138
+ // settle — and hit Next, which re-resolves THIS step. A second miss is final.
139
+ for (let attempt = 0; attempt < 2; attempt++) {
140
+ const nodes = parseSnapshot(await deps.adapter.snapshot());
141
+ ref = resolveByFingerprint(action.elementFp, nodes);
142
+ if (ref || attempt === 1)
143
+ break;
144
+ step.status = 'fail';
145
+ step.note = 'element not found — Next retries once';
146
+ ctl.control('pause');
147
+ const g2 = await ctl.gate(sleep, paceMs);
148
+ if (g2 === 'abort') {
149
+ abortedHere = true;
150
+ break;
151
+ }
152
+ step.status = 'running';
153
+ step.note = undefined;
154
+ }
155
+ }
156
+ if (abortedHere) {
157
+ skipRest(i);
158
+ break;
159
+ }
160
+ if (!ref) {
161
+ step.status = 'fail';
162
+ step.note = 'element not found';
163
+ ctl.control('pause');
164
+ continue;
165
+ }
166
+ if (action.role === 'textbox') {
167
+ const wantKey = normName(action.name ?? '');
168
+ const creds = deps.creds.get(deps.site);
169
+ let value;
170
+ for (const [k, v] of Object.entries(creds)) {
171
+ if (normName(k) === wantKey) {
172
+ value = v;
173
+ break;
174
+ }
175
+ }
176
+ // the RECORDED value is the flow's variable — use it when no stored cred
177
+ // overrides (creds are the operator's variable store; secrets were never
178
+ // recorded, so a password still asks unless creds cover it).
179
+ if (value === undefined && typeof action.value === 'string')
180
+ value = action.value;
181
+ if (value === undefined) {
182
+ const answer = await ctl.waitFor('value', action.name ?? step.label);
183
+ if (answer === 'abort') {
184
+ skipRest(i);
185
+ break;
186
+ }
187
+ value = answer.value ?? '';
188
+ if (answer.save)
189
+ deps.creds.set(deps.site, { [wantKey]: value });
190
+ }
191
+ await deps.adapter.fill(ref, value);
192
+ }
193
+ else {
194
+ const label = action.name ?? step.label;
195
+ if (COMMIT_WORDS.test(label)) {
196
+ const answer = await ctl.waitFor('confirm', label);
197
+ if (answer === 'abort') {
198
+ skipRest(i);
199
+ break;
200
+ }
201
+ if (!answer.fire) {
202
+ step.status = 'skipped';
203
+ continue;
204
+ }
205
+ }
206
+ await deps.adapter.click(ref);
207
+ }
208
+ if (e.navigated) {
209
+ const cur = await deps.adapter.currentUrl();
210
+ if (didNavigate(cur, e.toUrl)) {
211
+ step.status = 'fail';
212
+ step.note = 'landed elsewhere';
213
+ ctl.control('pause');
214
+ continue;
215
+ }
216
+ }
217
+ if (deps.shotsDir) {
218
+ const src = await deps.adapter.screenshot();
219
+ if (src)
220
+ step.shot = saveShot(deps.shotsDir, src, e.seq);
221
+ }
222
+ step.status = 'ok';
223
+ }
224
+ }
225
+ catch (e) {
226
+ // Engine failure (browser would not open, adapter died mid-run): resolve with a
227
+ // terminal error state — a REJECTED replay promise killed the whole dashboard
228
+ // process once (unhandled rejection; live crash 2026-07-15). Never rethrow.
229
+ st.error = String(e.message ?? e);
230
+ skipRest(0);
231
+ }
232
+ finally {
233
+ st.done = true;
234
+ st.running = false;
235
+ await deps.adapter.close().catch(() => { }); // close on a never-opened session throws too
236
+ }
237
+ return st;
238
+ }
239
+ // --- Ledger replay: play the RAW event stream (including what assembly dropped) ---
240
+ // "Exactly what was done, again": resolution is the SAME deterministic descriptor
241
+ // matching recording used (resolveEvent) — never a stored selector, never a guess.
242
+ // A capture miss replays as a visible failed step, so this runner doubles as a
243
+ // capture-fidelity test (spec 2026-07-16).
244
+ /** A ledger row as a resolvable LiveEvent: human rows ARE LiveEvents; agent rows
245
+ * synthesize one from {role,name} (descriptorName reads ariaLabel first). */
246
+ function asLiveEvent(e) {
247
+ const d = e.descriptor;
248
+ if (e.source === 'human')
249
+ return d;
250
+ return { seq: e.seq, kind: e.kind === 'type' ? 'input' : 'click', tagName: '',
251
+ url: String(d.url ?? ''), role: d.role ?? null,
252
+ ariaLabel: d.name ?? null, leafText: null, href: null,
253
+ placeholder: null, nameAttr: null, inputType: null };
254
+ }
255
+ /** The page URL an event acted on (agent navigate rows act FROM fromUrl). */
256
+ function pageUrlOf(e) {
257
+ const d = e.descriptor;
258
+ return String((e.source === 'agent' && e.kind === 'navigate' ? d.fromUrl : d.url) ?? '');
259
+ }
260
+ export async function runLedgerReplay(events, ctl, deps) {
261
+ const sleep = deps.sleep ?? realSleep;
262
+ const paceMs = deps.paceMs ?? 1500;
263
+ const st = ctl.state;
264
+ const skipRest = (from) => {
265
+ for (let j = from; j < st.steps.length; j++) {
266
+ if (st.steps[j].status === 'running' || st.steps[j].status === 'pending')
267
+ st.steps[j].status = 'skipped';
268
+ }
269
+ };
270
+ try {
271
+ if (events.length === 0) {
272
+ return st;
273
+ }
274
+ const first = events[0];
275
+ await deps.adapter.open(first.source === 'agent' && first.kind === 'navigate'
276
+ ? String(first.descriptor.url) : pageUrlOf(first));
277
+ for (let i = 0; i < events.length; i++) {
278
+ const e = events[i];
279
+ const d = e.descriptor;
280
+ const step = st.steps[i];
281
+ step.status = 'running';
282
+ const g = await ctl.gate(sleep, paceMs);
283
+ if (g === 'abort') {
284
+ skipRest(i);
285
+ break;
286
+ }
287
+ // expected landing = the page the NEXT event acted on (judgment-free — the
288
+ // ledger's own urls encode the journey)
289
+ const nextUrl = i + 1 < events.length ? pageUrlOf(events[i + 1]) : null;
290
+ if (e.source === 'agent' && e.kind === 'navigate') {
291
+ await deps.adapter.goto(String(d.url));
292
+ step.status = 'jumped';
293
+ continue;
294
+ }
295
+ const lev = asLiveEvent(e);
296
+ // resolve with ONE human-assisted retry (same semantics as steps replay)
297
+ let ref = null;
298
+ let abortedHere = false;
299
+ for (let attempt = 0; attempt < 2; attempt++) {
300
+ const res = resolveEvent(lev, parseSnapshot(await deps.adapter.snapshot()));
301
+ ref = res && 'ref' in res ? res.ref : null; // candidates = ambiguous = no guess
302
+ if (ref || attempt === 1)
303
+ break;
304
+ step.status = 'fail';
305
+ step.note = 'element not found — Next retries once';
306
+ ctl.control('pause');
307
+ const g2 = await ctl.gate(sleep, paceMs);
308
+ if (g2 === 'abort') {
309
+ abortedHere = true;
310
+ break;
311
+ }
312
+ step.status = 'running';
313
+ step.note = undefined;
314
+ }
315
+ if (abortedHere) {
316
+ skipRest(i);
317
+ break;
318
+ }
319
+ if (!ref) {
320
+ step.status = 'fail';
321
+ step.note = 'element not found';
322
+ ctl.control('pause');
323
+ continue;
324
+ }
325
+ const label = descriptorName(lev) ?? step.label;
326
+ if (e.kind === 'input' || e.kind === 'type') {
327
+ let value = typeof d.value === 'string' ? d.value : undefined; // human non-secret variable
328
+ if (value === undefined) {
329
+ const wantKey = normName(label);
330
+ for (const [k, v] of Object.entries(deps.creds.get(deps.site))) {
331
+ if (normName(k) === wantKey) {
332
+ value = v;
333
+ break;
334
+ }
335
+ }
336
+ }
337
+ if (value === undefined) {
338
+ const answer = await ctl.waitFor('value', label);
339
+ if (answer === 'abort') {
340
+ skipRest(i);
341
+ break;
342
+ }
343
+ value = answer.value ?? '';
344
+ if (answer.save)
345
+ deps.creds.set(deps.site, { [normName(label)]: value });
346
+ }
347
+ await deps.adapter.fill(ref, value);
348
+ }
349
+ else if (e.kind === 'hover') {
350
+ await deps.adapter.hover(ref);
351
+ }
352
+ else {
353
+ if (COMMIT_WORDS.test(label)) {
354
+ const answer = await ctl.waitFor('confirm', label);
355
+ if (answer === 'abort') {
356
+ skipRest(i);
357
+ break;
358
+ }
359
+ if (!answer.fire) {
360
+ step.status = 'skipped';
361
+ continue;
362
+ }
363
+ }
364
+ await deps.adapter.click(ref);
365
+ }
366
+ // inputs/hovers never navigate (assembleEffect's guarantee for input; hover is a
367
+ // same-page reveal) — a page change after one of these belongs to the FOLLOWING
368
+ // event, so skip the landing check here rather than false-failing this step.
369
+ if (nextUrl && e.kind !== 'input' && e.kind !== 'type' && e.kind !== 'hover' && didNavigate(pageUrlOf(e), nextUrl)) {
370
+ // bounded settle: a client-side redirect needs a beat before the url is real
371
+ let cur = await deps.adapter.currentUrl();
372
+ for (let w = 0; w < 3 && didNavigate(cur, nextUrl); w++) {
373
+ await sleep(700);
374
+ cur = await deps.adapter.currentUrl();
375
+ }
376
+ if (didNavigate(cur, nextUrl)) {
377
+ step.status = 'fail';
378
+ step.note = 'landed elsewhere';
379
+ ctl.control('pause');
380
+ continue;
381
+ }
382
+ }
383
+ if (deps.shotsDir) {
384
+ const src = await deps.adapter.screenshot();
385
+ if (src)
386
+ step.shot = saveShot(deps.shotsDir, src, e.seq);
387
+ }
388
+ step.status = 'ok';
389
+ }
390
+ }
391
+ catch (e) {
392
+ st.error = String(e.message ?? e); // same never-reject contract as runReplay
393
+ skipRest(0);
394
+ }
395
+ finally {
396
+ st.done = true;
397
+ st.running = false;
398
+ await deps.adapter.close().catch(() => { });
399
+ }
400
+ return st;
401
+ }
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import type { Coverage, LandingStructure } from './coverage.js';
3
+ declare const run: typeof execFile.__promisify__;
4
+ export interface ReviewStepInfo {
5
+ seq: number;
6
+ kind: string;
7
+ label: string;
8
+ value?: string;
9
+ capturedAt: number;
10
+ }
11
+ export interface ReviewFrame {
12
+ path: string;
13
+ atMs: number;
14
+ }
15
+ /** Parse ffmpeg showinfo stderr → the pts seconds of each selected frame (in order). */
16
+ export declare function parseShowinfoTimes(stderr: string): number[];
17
+ /** The audit task — shown to the operator in the dashboard and editable per run.
18
+ * This is the GOAL: compare the video (frames) against the captured steps and
19
+ * surface CAPTURE GAPS — visible changes with no recorded step. */
20
+ export declare const DEFAULT_INSTRUCTIONS = "Your job \u2014 compare what the video SHOWS against what was CAPTURED:\n1. For each frame, say what user action most likely produced that screen state.\n2. CAPTURE GAPS: visible changes in the frames with NO captured step near that\n timestamp (\u00B15s). These are recorder misses \u2014 the deliverable. Be specific:\n what happened on screen, when, and what kind of event the recorder should\n have caught (click / input / navigation / scroll / hover-menu ...).\n3. Steps with no visual correlate in any frame (possible over-capture or noise).\n4. A short verdict: is this recording complete enough to replay the user's\n journey? What single capture improvement would help most?\n\nFormat as markdown with sections: Frames, Capture gaps, Uncorrelated steps, Verdict.\nBe concrete and terse. If the evidence is thin (few frames/steps), say so honestly.";
21
+ export interface CaptureGap {
22
+ atMs?: number;
23
+ kind?: string;
24
+ whatHappened?: string;
25
+ shouldHaveCaptured?: string;
26
+ }
27
+ /** Tolerant extraction of the gap JSON from a review reply (may be wrapped in
28
+ * prose/markdown). Returns [] on absence/parse failure (never throws). */
29
+ export declare function parseGaps(text: string): CaptureGap[];
30
+ /** The audit prompt. Pure — unit-tested; the spawn stays thin. */
31
+ export declare function buildReviewPrompt(session: string, steps: ReviewStepInfo[], logLines: {
32
+ t: number;
33
+ line: string;
34
+ }[], frames: ReviewFrame[], instructions?: string, structured?: boolean, knownDrops?: Coverage['dropped'], structure?: LandingStructure[]): string;
35
+ export interface ReviewDeps {
36
+ videosDir: string;
37
+ outDir: string;
38
+ steps: ReviewStepInfo[];
39
+ logs: {
40
+ t: number;
41
+ line: string;
42
+ }[];
43
+ log: (line: string) => void;
44
+ claudeModel?: string;
45
+ instructions?: string;
46
+ maxFrames?: number;
47
+ structured?: boolean;
48
+ knownDrops?: Coverage['dropped'];
49
+ coverage?: Coverage;
50
+ structure?: LandingStructure[];
51
+ exec?: typeof run;
52
+ }
53
+ /** Extract scene-change frames from one take. Returns frames with ABSOLUTE wall-clock
54
+ * times. The ts in take-<ts>.webm is the STOP/SAVE time (Date.now() at videoStop),
55
+ * NOT the start — so the true start is reconstructed as stop − ffprobe duration,
56
+ * and each frame's time is that true start + its pts offset. */
57
+ /** The frame-selection expression. Web-UI tuned (live finding: a 10-min browsing
58
+ * take yielded 4 frames at scene>0.08 — movie-cut thresholds miss typing/menus/
59
+ * scroll): >2% change counts, a min-gap rate-limits bursts (a playing video would
60
+ * otherwise eat the whole frame budget in seconds), and a HEARTBEAT guarantees
61
+ * one frame per interval across the entire timeline even when nothing trips the
62
+ * threshold — full coverage for the gap audit. Pure: unit-tested. */
63
+ export declare function frameSelectExpr(durationS: number, maxFrames: number): {
64
+ expr: string;
65
+ minGapS: number;
66
+ heartbeatS: number;
67
+ };
68
+ export declare function extractFrames(takePath: string, takeStopMs: number, framesDir: string, maxFrames: number, exec: typeof run): Promise<ReviewFrame[]>;
69
+ export declare function runSessionReview(session: string, deps: ReviewDeps): Promise<string | {
70
+ report: string;
71
+ gaps: CaptureGap[];
72
+ }>;
73
+ export {};
@@ -0,0 +1,171 @@
1
+ // Session review: audit what the recorder CAPTURED against what the video SHOWS.
2
+ // Pipeline: ffmpeg scene-detection extracts a compact FRAME STRIP from each video
3
+ // take (Claude cannot ingest video — stills at change-moments are the consumable
4
+ // form, and better for gap-finding since each frame carries a timestamp to match
5
+ // against step times) → a headless `claude -p` (Sonnet, per user decision; this is
6
+ // a judgment task OUTSIDE webnav's zero-LLM runtime — it audits the tool, it is
7
+ // not part of navigation) reads steps+logs+frames and reports capture gaps.
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ const run = promisify(execFile);
13
+ /** Parse ffmpeg showinfo stderr → the pts seconds of each selected frame (in order). */
14
+ export function parseShowinfoTimes(stderr) {
15
+ const out = [];
16
+ for (const m of stderr.matchAll(/pts_time:([\d.]+)/g))
17
+ out.push(Number(m[1]));
18
+ return out;
19
+ }
20
+ /** The audit task — shown to the operator in the dashboard and editable per run.
21
+ * This is the GOAL: compare the video (frames) against the captured steps and
22
+ * surface CAPTURE GAPS — visible changes with no recorded step. */
23
+ export const DEFAULT_INSTRUCTIONS = `Your job — compare what the video SHOWS against what was CAPTURED:
24
+ 1. For each frame, say what user action most likely produced that screen state.
25
+ 2. CAPTURE GAPS: visible changes in the frames with NO captured step near that
26
+ timestamp (±5s). These are recorder misses — the deliverable. Be specific:
27
+ what happened on screen, when, and what kind of event the recorder should
28
+ have caught (click / input / navigation / scroll / hover-menu ...).
29
+ 3. Steps with no visual correlate in any frame (possible over-capture or noise).
30
+ 4. A short verdict: is this recording complete enough to replay the user's
31
+ journey? What single capture improvement would help most?
32
+
33
+ Format as markdown with sections: Frames, Capture gaps, Uncorrelated steps, Verdict.
34
+ Be concrete and terse. If the evidence is thin (few frames/steps), say so honestly.`;
35
+ /** Append the structured-output instruction: return a JSON gap list the capture
36
+ * loop can parse to judge convergence (zero gaps = complete). */
37
+ const STRUCTURED_TAIL = `
38
+
39
+ OUTPUT FORMAT (STRICT): after any brief reasoning, end your reply with ONE JSON
40
+ object on its own, exactly:
41
+ {"gaps":[{"atMs":<frame time ms>,"kind":"click|input|navigation|scroll|hover-menu|other","whatHappened":"...","shouldHaveCaptured":"..."}],"verdict":"..."}
42
+ A gap = a visible change in the frames with NO captured step within ±5s. If capture
43
+ is complete, return {"gaps":[],"verdict":"complete"}. Emit NOTHING after the JSON.`;
44
+ /** Tolerant extraction of the gap JSON from a review reply (may be wrapped in
45
+ * prose/markdown). Returns [] on absence/parse failure (never throws). */
46
+ export function parseGaps(text) {
47
+ // last {...} block that parses and has a `gaps` array wins (the model ends with it)
48
+ const matches = text.match(/\{[\s\S]*\}/g);
49
+ if (!matches)
50
+ return [];
51
+ for (let i = matches.length - 1; i >= 0; i--) {
52
+ try {
53
+ const o = JSON.parse(matches[i]);
54
+ if (Array.isArray(o.gaps))
55
+ return o.gaps;
56
+ }
57
+ catch { /* try the next candidate */ }
58
+ }
59
+ return [];
60
+ }
61
+ /** The audit prompt. Pure — unit-tested; the spawn stays thin. */
62
+ export function buildReviewPrompt(session, steps, logLines, frames, instructions, structured, knownDrops, structure) {
63
+ const t = (ms) => new Date(ms).toLocaleTimeString();
64
+ const stepTxt = steps.length
65
+ ? steps.map((s) => `- [${t(s.capturedAt)}] seq ${s.seq} ${s.kind}: ${s.label}${s.value !== undefined ? ` = "${s.value}"` : ''}`).join('\n')
66
+ : '(no steps captured)';
67
+ const logTxt = logLines.map((l) => `- [${t(l.t)}] ${l.line}`).join('\n') || '(no logs)';
68
+ const frameTxt = frames.map((f, i) => `- frame ${i + 1} at ${t(f.atMs)}: ${f.path}`).join('\n') || '(no frames extracted)';
69
+ const dropTxt = knownDrops?.length
70
+ ? `\nASSEMBLY DROPS (already known — measured deterministically; do NOT re-report these as gaps):\n${knownDrops.map((d) => `- seq ${d.seq} ${d.kind}: ${d.label ?? '(unlabeled)'} — ${d.reason}`).join('\n')}\n`
71
+ : '';
72
+ const structureTxt = structure?.length
73
+ ? `\nLANDING STRUCTURE (named vs NAMELESS interactive controls per page — many nameless controls = a sensor gap; compare against what the frames show):\n${structure.map((s) => `- ${s.url} — named: ${s.named}, nameless: ${s.nameless}`).join('\n')}\n`
74
+ : '';
75
+ return `You are auditing a browser-session RECORDER for capture gaps. A human browsed a website
76
+ while our tool recorded their actions as "steps". We also have a screen video of the same
77
+ session, from which scene-change frames were extracted (one image per visible change,
78
+ each with its wall-clock timestamp).
79
+
80
+ Session: ${session}
81
+
82
+ CAPTURED STEPS (what our recorder saved):
83
+ ${stepTxt}
84
+
85
+ RECORDER LOGS (including honest skips):
86
+ ${logTxt}
87
+
88
+ VIDEO FRAMES (visible changes; READ each image file with the Read tool):
89
+ ${frameTxt}
90
+ ${dropTxt}${structureTxt}
91
+ ${instructions ?? DEFAULT_INSTRUCTIONS}${structured ? STRUCTURED_TAIL : ''}`;
92
+ }
93
+ /** Extract scene-change frames from one take. Returns frames with ABSOLUTE wall-clock
94
+ * times. The ts in take-<ts>.webm is the STOP/SAVE time (Date.now() at videoStop),
95
+ * NOT the start — so the true start is reconstructed as stop − ffprobe duration,
96
+ * and each frame's time is that true start + its pts offset. */
97
+ /** The frame-selection expression. Web-UI tuned (live finding: a 10-min browsing
98
+ * take yielded 4 frames at scene>0.08 — movie-cut thresholds miss typing/menus/
99
+ * scroll): >2% change counts, a min-gap rate-limits bursts (a playing video would
100
+ * otherwise eat the whole frame budget in seconds), and a HEARTBEAT guarantees
101
+ * one frame per interval across the entire timeline even when nothing trips the
102
+ * threshold — full coverage for the gap audit. Pure: unit-tested. */
103
+ export function frameSelectExpr(durationS, maxFrames) {
104
+ // budget ~25% of frames for scene-changes so bursts can't cut off tail coverage
105
+ const heartbeatS = Math.max(5, Math.ceil(durationS / Math.max(1, Math.floor(maxFrames * 0.75))));
106
+ const minGapS = Math.max(2, Math.floor(heartbeatS / 3));
107
+ const expr = `isnan(prev_selected_t)+(gt(scene\,0.02)+gte(t-prev_selected_t\,${heartbeatS}))*gte(t-prev_selected_t\,${minGapS})`;
108
+ return { expr, minGapS, heartbeatS };
109
+ }
110
+ export async function extractFrames(takePath, takeStopMs, framesDir, maxFrames, exec) {
111
+ mkdirSync(framesDir, { recursive: true });
112
+ let durationS = 0;
113
+ try {
114
+ const pr = await exec('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', takePath]);
115
+ durationS = Number(String(pr.stdout).trim()) || 0;
116
+ }
117
+ catch { /* unknown duration → trueStartMs falls back to takeStopMs (uncorrected, no worse than before) */ }
118
+ const trueStartMs = takeStopMs - Math.round(durationS * 1000);
119
+ const { expr } = frameSelectExpr(durationS, maxFrames);
120
+ const vf = `select='${expr}',showinfo,scale=800:-2`;
121
+ let stderr = '';
122
+ try {
123
+ const r = await exec('ffmpeg', ['-y', '-i', takePath, '-vf', vf, '-fps_mode', 'vfr',
124
+ '-frames:v', String(maxFrames), join(framesDir, 'f-%03d.png')], { maxBuffer: 32 * 1024 * 1024 });
125
+ stderr = r.stderr ?? '';
126
+ }
127
+ catch (e) {
128
+ // ffmpeg exits non-zero on some streams even after writing frames — keep what landed
129
+ stderr = e.stderr ?? '';
130
+ }
131
+ const times = parseShowinfoTimes(stderr);
132
+ const files = readdirSync(framesDir).filter((f) => f.endsWith('.png')).sort();
133
+ return files.map((f, i) => ({ path: join(framesDir, f), atMs: trueStartMs + Math.round((times[i] ?? 0) * 1000) }));
134
+ }
135
+ export async function runSessionReview(session, deps) {
136
+ const exec = deps.exec ?? run;
137
+ const maxFrames = deps.maxFrames ?? 20;
138
+ deps.log(`review: extracting frames for ${session}…`);
139
+ let takes = [];
140
+ try {
141
+ takes = readdirSync(deps.videosDir).filter((f) => f.endsWith('.webm')).sort();
142
+ }
143
+ catch { /* no videos */ }
144
+ const frames = [];
145
+ for (const take of takes) {
146
+ const stopMs = Number((/take-(\d+)\.webm/.exec(take) ?? [])[1] ?? 0);
147
+ const dir = join(deps.outDir, 'frames-' + take.replace(/\.webm$/, ''));
148
+ const got = await extractFrames(join(deps.videosDir, take), stopMs, dir, maxFrames, exec);
149
+ frames.push(...got);
150
+ deps.log(`review: ${got.length} change-frames from ${take}`);
151
+ }
152
+ const prompt = buildReviewPrompt(session, deps.steps, deps.logs, frames, deps.instructions, deps.structured, deps.knownDrops, deps.structure);
153
+ deps.log(`review: asking Claude (${deps.claudeModel ?? 'sonnet'}) — ${frames.length} frames, ${deps.steps.length} steps…`);
154
+ let report;
155
+ try {
156
+ const { stdout } = await exec('claude', ['-p', prompt, '--model', deps.claudeModel ?? 'sonnet', '--allowedTools', 'Read'], { maxBuffer: 32 * 1024 * 1024, timeout: 5 * 60_000 });
157
+ report = stdout.trim() || '(claude returned no output)';
158
+ }
159
+ catch (e) {
160
+ report = 'REVIEW FAILED: ' + String(e.message ?? e);
161
+ }
162
+ mkdirSync(deps.outDir, { recursive: true });
163
+ writeFileSync(join(deps.outDir, 'review.md'), report);
164
+ deps.log('review: done — report saved');
165
+ if (deps.structured) {
166
+ const gaps = parseGaps(report);
167
+ writeFileSync(join(deps.outDir, 'review.json'), JSON.stringify({ gaps, coverage: deps.coverage ?? null, structure: deps.structure ?? null }, null, 2));
168
+ return { report, gaps };
169
+ }
170
+ return report;
171
+ }
@@ -0,0 +1,7 @@
1
+ export interface SerializableNode {
2
+ role: string;
3
+ name: string | null;
4
+ url?: string | null;
5
+ children?: SerializableNode[];
6
+ }
7
+ export declare function serializeSnapshot(root: SerializableNode): string;
@@ -0,0 +1,25 @@
1
+ // src/recorder/snapshot-dom.ts
2
+ // Serialize an accessibility tree to the playwright-parity snapshot text that
3
+ // src/playwright/snapshot.ts::parseSnapshot reads. Pure + deterministic; the
4
+ // in-repo test oracle for the extension's browser-side twin.
5
+ // TWIN of webnav-extension/serialize.ts::serialize — MUST stay in sync (same
6
+ // ~15-line algorithm, duplicated deliberately; see the ponytail note there).
7
+ // Assign a sequential synthetic ref (e1, e2, …) to EVERY node in pre-order, so
8
+ // even nameless icon nodes carry a bracketed attr and survive parseSnapshot
9
+ // (which drops a line that has neither a quoted name nor a [attr]).
10
+ export function serializeSnapshot(root) {
11
+ const lines = [];
12
+ let counter = 0;
13
+ const walk = (node, depth) => {
14
+ const ref = `e${++counter}`;
15
+ const indent = ' '.repeat(depth);
16
+ const namePart = node.name !== null ? ` "${node.name}"` : '';
17
+ lines.push(`${indent}${node.role}${namePart} [ref=${ref}]`);
18
+ if (node.url)
19
+ lines.push(`${indent} /url: ${node.url}`);
20
+ for (const child of node.children ?? [])
21
+ walk(child, depth + 1);
22
+ };
23
+ walk(root, 0);
24
+ return lines.join('\n');
25
+ }