@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,13 @@
1
+ export interface VideoAdapter {
2
+ videoStart(): Promise<void>;
3
+ videoStop(filename: string): Promise<boolean>;
4
+ }
5
+ export interface VideoSyncDeps {
6
+ videosRoot: string;
7
+ log: (line: string) => void;
8
+ onSaved?: () => void;
9
+ now?: () => number;
10
+ }
11
+ /** Make a stateful videoSync(session, recording) bound to one adapter+deps.
12
+ * Returns the closure the caller invokes on each record-state change. */
13
+ export declare function makeVideoSync(getAdapter: () => VideoAdapter | null, deps: VideoSyncDeps): (session: string, recording: boolean) => void;
@@ -0,0 +1,32 @@
1
+ // Per-recording video capture, shared by BOTH the human live-record loop and the
2
+ // agent `use navigate` path. A "take" is one Record→Stop span → one
3
+ // ~/.webnav/recordings/<session>/take-<ts>.webm. Idempotent per span (a start
4
+ // while already recording, or a stop while already stopped, is a no-op).
5
+ import { mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ /** Make a stateful videoSync(session, recording) bound to one adapter+deps.
8
+ * Returns the closure the caller invokes on each record-state change. */
9
+ export function makeVideoSync(getAdapter, deps) {
10
+ const now = deps.now ?? (() => Date.now());
11
+ let videoOn = false;
12
+ return (session, recording) => {
13
+ const adapter = getAdapter();
14
+ if (recording && !videoOn && adapter) {
15
+ videoOn = true;
16
+ void adapter.videoStart().then(() => deps.log('video: recording started'), () => deps.log('video: START FAILED'));
17
+ }
18
+ else if (!recording && videoOn && adapter) {
19
+ videoOn = false;
20
+ const dir = join(deps.videosRoot, session);
21
+ try {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+ catch { /* decoration */ }
25
+ const file = join(dir, 'take-' + now() + '.webm');
26
+ void adapter.videoStop(file).then((ok) => {
27
+ deps.log(ok ? 'video: saved ' + file : 'video: SAVE FAILED (' + file + ')');
28
+ deps.onSaved?.();
29
+ });
30
+ }
31
+ };
32
+ }
@@ -0,0 +1,49 @@
1
+ import type { TokenSavings } from './router/tokens.js';
2
+ import type { Affordance } from './mapstore/types.js';
3
+ export interface Candidate {
4
+ id: string;
5
+ url: string;
6
+ signals: Record<string, unknown>;
7
+ }
8
+ export interface EvidenceBundle {
9
+ goal: string;
10
+ query: string;
11
+ candidates: Candidate[];
12
+ cost: {
13
+ playwright_calls: number;
14
+ savings: TokenSavings;
15
+ };
16
+ snapshot?: string;
17
+ repertoire?: Affordance[];
18
+ }
19
+ export type RecallResponse = {
20
+ status: 'done';
21
+ evidence: EvidenceBundle;
22
+ halted?: 'commit-point';
23
+ } | {
24
+ status: 'needs-navigation';
25
+ at: number;
26
+ semanticStep: string;
27
+ snapshot: string;
28
+ question: string;
29
+ } | {
30
+ status: 'needs-classification';
31
+ action: string;
32
+ snapshot: string;
33
+ at?: number;
34
+ } | {
35
+ status: 'needs-auth';
36
+ at: number;
37
+ profile: string;
38
+ site: string;
39
+ loginUrl: string;
40
+ } | {
41
+ status: 'checkpoint';
42
+ at: number;
43
+ state: string;
44
+ snapshot: string;
45
+ repertoire: Affordance[];
46
+ } | {
47
+ status: 'failed';
48
+ reason: string;
49
+ };
@@ -0,0 +1,3 @@
1
+ // The call-and-response protocol between the calling agent and webnav.
2
+ // webnav NEVER reasons; whenever a decision is needed, it hands back to the agent.
3
+ export {};
@@ -0,0 +1,59 @@
1
+ import type { ActionEffect } from '../mapstore/record.js';
2
+ export declare const OVERLAY_ON_JS: string;
3
+ export interface AgentSessionCmd {
4
+ cmd: 'navigate' | 'snapshot' | 'click' | 'type' | 'hover' | 'eval' | 'quit';
5
+ url?: string;
6
+ ref?: string;
7
+ text?: string;
8
+ js?: string;
9
+ }
10
+ export declare const NAME_PROBE_JS = "(el) => {\n // Defense in depth: even if a page's a11y snapshot exposes our own REC-overlay\n // pill (a focusable element inside an aria-hidden container isn't reliably\n // suppressed), never let the probe mint a name for it \u2014 that could mint a\n // bogus overlay affordance into the map (wrong-map class).\n if (el.closest && el.closest('#__webnav_rec_badge')) return '';\n const ATTRS = ['aria-label','title','data-tooltip-content','data-tooltip','data-title','data-original-title','data-tip','aria-description'];\n const g = (n) => { if (!n || !n.getAttribute) return ''; for (const a of ATTRS) { const v = n.getAttribute(a); if (v && v.trim()) return v.trim(); } return ''; };\n let s = g(el);\n if (!s) { const t = el.querySelector && el.querySelector('svg title, title'); if (t) s = (t.textContent || '').trim(); }\n if (!s && el.getAttribute) { const lb = el.getAttribute('aria-labelledby'); if (lb) { const r = el.ownerDocument.getElementById(lb); if (r) s = (r.textContent || '').trim(); } }\n if (!s) { let n = el.parentElement, d = 0; while (n && d < 2 && !s) { s = g(n); n = n.parentElement; d++; } }\n // Last resort \u2014 the element's OWN text content. A label-less control whose text IS its\n // label: a sortable column header 'Name', or a date-range button reading 'Last 7 Days\n // (CD) : 02 Jul 2026 - 08 Jul 2026 UTC'. SINGLE-LINE only (no newline) so we never scrape\n // a whole multi-row card (the leaf-vs-container rule from live.ts); the length cap is\n // generous (120) because real button labels can be long \u2014 the final slice(0,80) still\n // bounds what we store.\n if (!s) { const own = (el.textContent || '').trim(); if (own && own.length <= 120 && !/\\n/.test(own)) s = own; }\n return (s || '').trim().slice(0, 80);\n}";
11
+ /** Pick the best label for a recorded action: the recovered accessible name if present,
12
+ * else the DOM-probed attribute label (tooltip/aria), else null. REJECTS a probe result
13
+ * that's actually a playwright-cli error blob (a stale-ref eval returns "### Error …" as
14
+ * a string — that must never be stored as the element's name). Pure — unit-tested. */
15
+ export declare function enrichName(recoveredName: string | null, probed: string | null | undefined): string | null;
16
+ export interface AgentSessionDeps {
17
+ sessionId: string;
18
+ adapter: {
19
+ open(url: string): Promise<unknown>;
20
+ goto(url: string): Promise<unknown>;
21
+ snapshot(): Promise<string>;
22
+ currentUrl(): Promise<string>;
23
+ fill(ref: string, text: string): Promise<unknown>;
24
+ act(ref: string): Promise<unknown>;
25
+ hover(ref: string): Promise<unknown>;
26
+ evalJs(js: string, ref?: string): Promise<string>;
27
+ close(): Promise<unknown>;
28
+ };
29
+ store: {
30
+ isActive(s: string): boolean;
31
+ appendActionEffect(s: string, fx: ActionEffect): number | null;
32
+ appendEvent(s: string, ev: {
33
+ t?: number;
34
+ source: 'human' | 'agent';
35
+ kind: string;
36
+ descriptor: Record<string, unknown>;
37
+ }): number | null;
38
+ stampEvent(s: string, seq: number, disposition: string): void;
39
+ };
40
+ recover: (snapshot: string, ref: string) => {
41
+ action: {
42
+ role: string;
43
+ name: string | null;
44
+ ref: string;
45
+ elementFp?: unknown;
46
+ };
47
+ };
48
+ readLine: () => Promise<string | null>;
49
+ write: (line: string) => void;
50
+ notify: (kind: 'step' | 'sessions' | 'log', line?: string) => void;
51
+ startVideo: () => Promise<void>;
52
+ stopVideo: () => Promise<string | null>;
53
+ startUrl: string;
54
+ }
55
+ /** Run the interactive loop until quit/EOF. Returns a summary. */
56
+ export declare function runAgentSession(deps: AgentSessionDeps): Promise<{
57
+ steps: number;
58
+ video: string | null;
59
+ }>;
@@ -0,0 +1,230 @@
1
+ // The interactive long-lived agent session loop. ONE process owns the browser
2
+ // start-to-finish: it opens, starts video + recording, then reads JSON-line
3
+ // commands on stdin, executes each against the SAME open browser, and writes one
4
+ // JSON result line per command. On quit/EOF it stops video (saves the take),
5
+ // stops recording, and closes the browser. This is the ONLY shape that lets video
6
+ // span the whole session (playwright-cli video is bound to one process) AND leaks
7
+ // nothing (the process closes its own browser on exit).
8
+ //
9
+ // Pure core: deps injected (adapter, store, io, notify, video) so it's unit-tested
10
+ // with scripted stdin + a fake adapter — no real browser.
11
+ import { runActionRecorded, parseEvalResult, settleSnapshot, probeLanding } from '../router/browse.js';
12
+ import { diffSnapshots } from '../explorer/diff.js';
13
+ import { parseSnapshot } from '../playwright/snapshot.js';
14
+ import { INSTALLER_JS, MODE_JS } from './live.js';
15
+ // Paint the same REC overlay the human live-recorder uses, so an agent session's
16
+ // video also shows a recording indicator + click ripples (agent clicks go through
17
+ // playwright's real .click(), which dispatches a trusted DOM click event — that
18
+ // bubbles to INSTALLER_JS's document click listener exactly like a human click, so
19
+ // the existing ripple painter fires with NO extra eval needed here). Composed as one
20
+ // eval (install-if-missing, then paint recording=on) — mirrors TICK_JS in live.ts.
21
+ // Best-effort: a CSP page that blocks eval must not break navigation/recording.
22
+ export const OVERLAY_ON_JS = `() => { (${INSTALLER_JS})(); return (${MODE_JS(true)})(); }`;
23
+ // A ref-scoped DOM read (playwright-cli `eval "(element)=>…" <ref>`) to recover a
24
+ // human-meaningful label for a NAMELESS control (icon-only buttons: expand chevron,
25
+ // favorite star, duplicate — their label lives in a tooltip/aria attribute, NOT in the
26
+ // a11y snapshot's accessible name). Zero-LLM, observe-only. Runs ONLY when the recovered
27
+ // name is empty (no cost on named elements). Best-effort: a CSP page that blocks eval
28
+ // just leaves name null. TOOLTIP-ATTR LIST is broad on purpose — libraries name the
29
+ // attribute differently (title/aria-label, data-tooltip, data-tooltip-content [react-
30
+ // tooltip], data-title, data-original-title [bootstrap]).
31
+ export const NAME_PROBE_JS = `(el) => {
32
+ // Defense in depth: even if a page's a11y snapshot exposes our own REC-overlay
33
+ // pill (a focusable element inside an aria-hidden container isn't reliably
34
+ // suppressed), never let the probe mint a name for it — that could mint a
35
+ // bogus overlay affordance into the map (wrong-map class).
36
+ if (el.closest && el.closest('#__webnav_rec_badge')) return '';
37
+ const ATTRS = ['aria-label','title','data-tooltip-content','data-tooltip','data-title','data-original-title','data-tip','aria-description'];
38
+ const g = (n) => { if (!n || !n.getAttribute) return ''; for (const a of ATTRS) { const v = n.getAttribute(a); if (v && v.trim()) return v.trim(); } return ''; };
39
+ let s = g(el);
40
+ if (!s) { const t = el.querySelector && el.querySelector('svg title, title'); if (t) s = (t.textContent || '').trim(); }
41
+ if (!s && el.getAttribute) { const lb = el.getAttribute('aria-labelledby'); if (lb) { const r = el.ownerDocument.getElementById(lb); if (r) s = (r.textContent || '').trim(); } }
42
+ if (!s) { let n = el.parentElement, d = 0; while (n && d < 2 && !s) { s = g(n); n = n.parentElement; d++; } }
43
+ // Last resort — the element's OWN text content. A label-less control whose text IS its
44
+ // label: a sortable column header 'Name', or a date-range button reading 'Last 7 Days
45
+ // (CD) : 02 Jul 2026 - 08 Jul 2026 UTC'. SINGLE-LINE only (no newline) so we never scrape
46
+ // a whole multi-row card (the leaf-vs-container rule from live.ts); the length cap is
47
+ // generous (120) because real button labels can be long — the final slice(0,80) still
48
+ // bounds what we store.
49
+ if (!s) { const own = (el.textContent || '').trim(); if (own && own.length <= 120 && !/\\n/.test(own)) s = own; }
50
+ return (s || '').trim().slice(0, 80);
51
+ }`;
52
+ /** Pick the best label for a recorded action: the recovered accessible name if present,
53
+ * else the DOM-probed attribute label (tooltip/aria), else null. REJECTS a probe result
54
+ * that's actually a playwright-cli error blob (a stale-ref eval returns "### Error …" as
55
+ * a string — that must never be stored as the element's name). Pure — unit-tested. */
56
+ export function enrichName(recoveredName, probed) {
57
+ const r = (recoveredName ?? '').trim();
58
+ if (r)
59
+ return r;
60
+ const p = (probed ?? '').trim();
61
+ if (!p || p.startsWith('### Error') || /^Error:/.test(p))
62
+ return null; // eval failed → no name, not the error text
63
+ return p;
64
+ }
65
+ /** Run the interactive loop until quit/EOF. Returns a summary. */
66
+ export async function runAgentSession(deps) {
67
+ let steps = 0;
68
+ let video = null;
69
+ const out = (obj) => deps.write(JSON.stringify(obj));
70
+ // open + video + first navigate is the caller's job (it built the adapter); here we
71
+ // assume the browser is open at startUrl. Start video now so it spans everything.
72
+ await deps.startVideo().catch(() => { });
73
+ deps.notify('sessions', 'agent session started: ' + deps.sessionId);
74
+ out({ ok: true, ready: true, session: deps.sessionId, url: deps.startUrl });
75
+ try {
76
+ for (;;) {
77
+ const line = await deps.readLine();
78
+ if (line === null)
79
+ break; // EOF → teardown
80
+ const trimmed = line.trim();
81
+ if (!trimmed)
82
+ continue;
83
+ let c;
84
+ try {
85
+ c = JSON.parse(trimmed);
86
+ }
87
+ catch {
88
+ out({ ok: false, error: 'invalid JSON command' });
89
+ continue;
90
+ }
91
+ if (c.cmd === 'quit')
92
+ break;
93
+ let pendingLedger = null; // reset per command; stamped by this command or the catch below
94
+ try {
95
+ if (c.cmd === 'navigate') {
96
+ if (!c.url) {
97
+ out({ ok: false, error: 'navigate needs url' });
98
+ continue;
99
+ }
100
+ const fromUrl = await deps.adapter.currentUrl().catch(() => '');
101
+ const fromSnapshot = fromUrl ? await deps.adapter.snapshot().catch(() => '') : '';
102
+ pendingLedger = deps.store.appendEvent(deps.sessionId, {
103
+ source: 'agent', kind: 'navigate', descriptor: { cmd: 'navigate', url: c.url, fromUrl: fromUrl || c.url }
104
+ });
105
+ await deps.adapter.goto(c.url);
106
+ await deps.adapter.evalJs(OVERLAY_ON_JS).catch(() => { }); // best-effort: video overlay
107
+ // SETTLE before reading url+snapshot: a client-side redirect/late render otherwise
108
+ // records a transient URL as a page (the ghost-state class of bugs). Bounded retry.
109
+ const toSnapshot = await settleSnapshot(() => deps.adapter.snapshot());
110
+ const toUrl = await deps.adapter.currentUrl();
111
+ // X6: probe the SETTLED landing's nameless icon controls (title/aria/tooltip) before
112
+ // appending — fires only when nameless interactive nodes exist. Best-effort.
113
+ const nameHints = await probeLanding(deps.adapter, toSnapshot).catch(() => undefined);
114
+ if (deps.store.isActive(deps.sessionId)) {
115
+ const stepSeq = deps.store.appendActionEffect(deps.sessionId, {
116
+ fromUrl: fromUrl || c.url, fromSnapshot, action: null,
117
+ toUrl, toSnapshot, navigated: true, diff: { added: [], removed: [] },
118
+ requestedUrl: c.url, nameHints,
119
+ });
120
+ if (pendingLedger != null && stepSeq != null)
121
+ deps.store.stampEvent(deps.sessionId, pendingLedger, 'step:' + stepSeq);
122
+ pendingLedger = null;
123
+ steps++;
124
+ deps.notify('step', 'agent nav: ' + toUrl);
125
+ }
126
+ out({ ok: true, url: toUrl });
127
+ }
128
+ else if (c.cmd === 'snapshot') {
129
+ out({ ok: true, snapshot: await deps.adapter.snapshot() });
130
+ }
131
+ else if (c.cmd === 'click' || c.cmd === 'type') {
132
+ if (!c.ref) {
133
+ out({ ok: false, error: c.cmd + ' needs ref' });
134
+ continue;
135
+ }
136
+ const fromSnapshot = await deps.adapter.snapshot();
137
+ const fromUrl = await deps.adapter.currentUrl();
138
+ const { action } = deps.recover(fromSnapshot, c.ref);
139
+ // NAMELESS icon controls (expand/favorite/duplicate) have no a11y name → the
140
+ // step would log as the page URL. Probe the element's own attributes for a
141
+ // human label BEFORE acting (title/aria-label/svg-title/tooltip). No cost on
142
+ // named elements. Best-effort: eval failure leaves the name as-is.
143
+ if (!(action.name ?? '').trim()) {
144
+ const probed = parseEvalResult(await deps.adapter.evalJs(NAME_PROBE_JS, c.ref).catch(() => ''));
145
+ action.name = enrichName(action.name, probed);
146
+ }
147
+ const r = await runActionRecorded({
148
+ sessionId: deps.sessionId, recordStore: deps.store,
149
+ fromUrl, fromSnapshot, action: action,
150
+ text: c.cmd === 'type' ? c.text : undefined,
151
+ adapter: deps.adapter,
152
+ });
153
+ if (r.status === 'failed') {
154
+ out({ ok: false, error: r.reason });
155
+ continue;
156
+ }
157
+ if (r.recorded) {
158
+ steps++;
159
+ deps.notify('step', 'agent ' + c.cmd + ': ' + (action.name ?? c.ref));
160
+ }
161
+ if (r.navigated)
162
+ await deps.adapter.evalJs(OVERLAY_ON_JS).catch(() => { }); // best-effort: video overlay (fresh page from in-page nav)
163
+ out({ ok: true, navigated: r.navigated, name: action.name });
164
+ }
165
+ else if (c.cmd === 'hover') {
166
+ // HOVER = reveal-on-hover menus/tooltips. A same-page action (never navigates):
167
+ // hover the ref, snapshot the revealed state, record an ActionEffect whose diff
168
+ // shows what the hover exposed. Marked action.hover so the kind is distinguishable.
169
+ if (!c.ref) {
170
+ out({ ok: false, error: 'hover needs ref' });
171
+ continue;
172
+ }
173
+ const fromSnapshot = await deps.adapter.snapshot();
174
+ const fromUrl = await deps.adapter.currentUrl();
175
+ const { action } = deps.recover(fromSnapshot, c.ref);
176
+ if (!(action.name ?? '').trim()) {
177
+ const probed = parseEvalResult(await deps.adapter.evalJs(NAME_PROBE_JS, c.ref).catch(() => ''));
178
+ action.name = enrichName(action.name, probed);
179
+ }
180
+ pendingLedger = deps.store.appendEvent(deps.sessionId, {
181
+ source: 'agent', kind: 'hover',
182
+ descriptor: { cmd: 'hover', ref: c.ref, role: action.role, name: action.name, url: fromUrl }
183
+ });
184
+ await deps.adapter.hover(c.ref);
185
+ const toSnapshot = await deps.adapter.snapshot();
186
+ const toUrl = await deps.adapter.currentUrl();
187
+ if (deps.store.isActive(deps.sessionId)) {
188
+ const stepSeq = deps.store.appendActionEffect(deps.sessionId, {
189
+ fromUrl, fromSnapshot, action: { ...action, hover: true },
190
+ toUrl, toSnapshot, navigated: false,
191
+ diff: diffSnapshots(parseSnapshot(fromSnapshot), parseSnapshot(toSnapshot)),
192
+ });
193
+ if (pendingLedger != null && stepSeq != null)
194
+ deps.store.stampEvent(deps.sessionId, pendingLedger, 'step:' + stepSeq);
195
+ pendingLedger = null;
196
+ steps++;
197
+ deps.notify('step', 'agent hover: ' + (action.name ?? c.ref));
198
+ }
199
+ out({ ok: true, name: action.name, revealed: parseSnapshot(toSnapshot).length - parseSnapshot(fromSnapshot).length });
200
+ }
201
+ else if (c.cmd === 'eval') {
202
+ if (!c.js) {
203
+ out({ ok: false, error: 'eval needs js' });
204
+ continue;
205
+ }
206
+ out({ ok: true, result: await deps.adapter.evalJs(c.js) });
207
+ }
208
+ else {
209
+ out({ ok: false, error: 'unknown cmd: ' + String(c.cmd) });
210
+ }
211
+ }
212
+ catch (e) {
213
+ if (pendingLedger != null) {
214
+ deps.store.stampEvent(deps.sessionId, pendingLedger, 'dropped:failed:' + String(e.message ?? e).slice(0, 120));
215
+ pendingLedger = null;
216
+ }
217
+ out({ ok: false, error: String(e.message ?? e) });
218
+ }
219
+ }
220
+ }
221
+ finally {
222
+ // teardown IN THIS PROCESS: stop video (must happen while the session is open),
223
+ // then close the browser. Order matters — video-stop after close saves nothing.
224
+ video = await deps.stopVideo().catch(() => null);
225
+ await deps.adapter.close().catch(() => { });
226
+ deps.notify('sessions', 'agent session ended: ' + deps.sessionId + (video ? ' (video saved)' : ''));
227
+ }
228
+ out({ ok: true, done: true, steps, video });
229
+ return { steps, video };
230
+ }
@@ -0,0 +1,19 @@
1
+ import type { CaptureGap } from './review.js';
2
+ export interface CaptureLoopDeps {
3
+ objective: string;
4
+ maxRounds?: number;
5
+ explore: (round: number) => Promise<string | null>;
6
+ review: (sessionId: string) => Promise<CaptureGap[]>;
7
+ log: (line: string) => void;
8
+ }
9
+ export interface CaptureRound {
10
+ round: number;
11
+ session: string | null;
12
+ gaps: CaptureGap[];
13
+ }
14
+ export interface CaptureLoopResult {
15
+ status: 'clean' | 'needs-fix' | 'max-rounds';
16
+ rounds: CaptureRound[];
17
+ gaps: CaptureGap[];
18
+ }
19
+ export declare function runCaptureLoop(deps: CaptureLoopDeps): Promise<CaptureLoopResult>;
@@ -0,0 +1,40 @@
1
+ /** Signature of a gap for thrash-detection: a NEW gap type is one not seen before. */
2
+ function gapKind(g) { return (g.kind ?? 'other') + ':' + (g.shouldHaveCaptured ?? g.whatHappened ?? ''); }
3
+ export async function runCaptureLoop(deps) {
4
+ const maxRounds = deps.maxRounds ?? 5;
5
+ const rounds = [];
6
+ const seenKinds = new Set();
7
+ let noNewGapStreak = 0;
8
+ for (let round = 1; round <= maxRounds; round++) {
9
+ deps.log(`capture-loop round ${round}/${maxRounds}: exploring "${deps.objective}"…`);
10
+ const session = await deps.explore(round);
11
+ if (!session) {
12
+ deps.log(`round ${round}: exploration failed (no session) — stopping`);
13
+ rounds.push({ round, session: null, gaps: [] });
14
+ return { status: 'needs-fix', rounds, gaps: [] };
15
+ }
16
+ const gaps = await deps.review(session);
17
+ rounds.push({ round, session, gaps });
18
+ deps.log(`round ${round}: ${gaps.length} capture gap(s)`);
19
+ if (gaps.length === 0) {
20
+ deps.log(`round ${round}: CLEAN — capture is complete for this objective`);
21
+ return { status: 'clean', rounds, gaps: [] };
22
+ }
23
+ // thrash guard: if a whole round surfaced no NEW gap TYPE, we're not converging
24
+ // via re-exploration alone → the gaps need code fixes; stop and hand them over.
25
+ const fresh = gaps.filter((g) => !seenKinds.has(gapKind(g)));
26
+ fresh.forEach((g) => seenKinds.add(gapKind(g)));
27
+ if (fresh.length === 0) {
28
+ if (++noNewGapStreak >= 2) {
29
+ deps.log(`round ${round}: no new gap types in 2 rounds — needs code fixes`);
30
+ return { status: 'needs-fix', rounds, gaps };
31
+ }
32
+ }
33
+ else {
34
+ noNewGapStreak = 0;
35
+ }
36
+ deps.log(`round ${round}: gaps remain → (fix recorder code, then next round)`);
37
+ }
38
+ deps.log(`capture-loop: hit max rounds (${maxRounds}) with gaps remaining`);
39
+ return { status: 'max-rounds', rounds, gaps: rounds[rounds.length - 1]?.gaps ?? [] };
40
+ }
@@ -0,0 +1,22 @@
1
+ import type { StoredLedgerEvent } from '../mapstore/record.js';
2
+ import type { StoredActionEffect } from '../mapstore/record.js';
3
+ export interface Coverage {
4
+ total: number;
5
+ captured: number;
6
+ dropped: {
7
+ seq: number;
8
+ kind: string;
9
+ label: string | null;
10
+ reason: string;
11
+ }[];
12
+ }
13
+ export declare function coverage(events: StoredLedgerEvent[]): Coverage;
14
+ export interface LandingStructure {
15
+ url: string;
16
+ named: number;
17
+ nameless: number;
18
+ }
19
+ /** Structure audit (fidelity roadmap 1c/gap-3): per distinct landing, how many
20
+ * interactive nodes are named vs NAMELESS. A page thick with nameless controls
21
+ * is a sensor gap the review prompt should flag for a frame-by-frame compare. */
22
+ export declare function landingStructure(effects: Pick<StoredActionEffect, 'toUrl' | 'toSnapshot'>[]): LandingStructure[];
@@ -0,0 +1,49 @@
1
+ import { parseSnapshot } from '../playwright/snapshot.js';
2
+ import { PROBE_ROLES, namelessInteractive } from './probe.js';
3
+ export function coverage(events) {
4
+ const dropped = [];
5
+ let captured = 0;
6
+ for (const e of events) {
7
+ const d = e.disposition ?? 'dropped:unprocessed'; // never stamped = lost mid-pair
8
+ if (d.startsWith('step:')) {
9
+ captured++;
10
+ continue;
11
+ }
12
+ const desc = e.descriptor;
13
+ const candidates = [desc.ariaLabel, desc.leafText, desc.name, desc.placeholder];
14
+ const label = (candidates.find((c) => typeof c === 'string' && c.trim() !== '') ?? null);
15
+ dropped.push({ seq: e.seq, kind: e.kind, label, reason: d.replace(/^dropped:/, '') });
16
+ }
17
+ return { total: events.length, captured, dropped };
18
+ }
19
+ /** Dedupe key for "same landing page": host+pathname, matching `didNavigate`'s
20
+ * notion of same-page (query/hash differences don't make a new landing). */
21
+ function landingKey(url) {
22
+ try {
23
+ const u = new URL(url);
24
+ return u.host + u.pathname;
25
+ }
26
+ catch {
27
+ return url;
28
+ }
29
+ }
30
+ /** Structure audit (fidelity roadmap 1c/gap-3): per distinct landing, how many
31
+ * interactive nodes are named vs NAMELESS. A page thick with nameless controls
32
+ * is a sensor gap the review prompt should flag for a frame-by-frame compare. */
33
+ export function landingStructure(effects) {
34
+ const byKey = new Map();
35
+ for (const e of effects) {
36
+ if (!e.toUrl || !e.toSnapshot)
37
+ continue;
38
+ const nodes = parseSnapshot(e.toSnapshot);
39
+ const interactive = nodes.filter((n) => n.ref && PROBE_ROLES.has(n.role));
40
+ const nameless = namelessInteractive(nodes).length;
41
+ const named = interactive.length - nameless;
42
+ const k = landingKey(e.toUrl);
43
+ const prev = byKey.get(k);
44
+ // worst-wins: a later, healthier revisit must not hide an earlier gap (tie → keep existing)
45
+ if (!prev || nameless > prev.nameless)
46
+ byKey.set(k, { url: e.toUrl, named, nameless });
47
+ }
48
+ return [...byKey.values()];
49
+ }
@@ -0,0 +1,35 @@
1
+ import { type SnapNode } from '../playwright/snapshot.js';
2
+ /** STRUCTURAL, judgment-free candidate selection: nodes with aria-haspopup, menuitems, and
3
+ * named interactive nodes inside a banner/navigation landmark. Requires a ref (can only hover
4
+ * a resolvable element), deduped by ref, capped (default 12). Zero cost on a page with none. */
5
+ export declare function hoverCandidates(nodes: SnapNode[], limit?: number): SnapNode[];
6
+ export interface HoverProbeStore {
7
+ isActive(sessionId: string): boolean;
8
+ appendActionEffect(sessionId: string, fx: unknown): number | null;
9
+ appendEvent(sessionId: string, ev: unknown): number | null;
10
+ stampEvent(sessionId: string, seq: number, disposition: string): void;
11
+ }
12
+ export interface HoverProbeAdapter {
13
+ snapshot(): Promise<string>;
14
+ hover(ref: string): Promise<unknown>;
15
+ rightClick(ref: string): Promise<unknown>;
16
+ press(key: string): Promise<unknown>;
17
+ currentUrl(): Promise<string>;
18
+ }
19
+ export interface HoverProbeDeps {
20
+ adapter: HoverProbeAdapter;
21
+ store: HoverProbeStore;
22
+ sessionId: string;
23
+ limit: number;
24
+ rightClick: boolean;
25
+ log: (line: string) => void;
26
+ }
27
+ /** Probe the CURRENT page: per candidate, take a baseline snapshot, hover (or right-click),
28
+ * settle, diff; a non-empty ADDED diff records a reveal effect + a ledger event. Between
29
+ * candidates we press Escape to dismiss the just-opened overlay so reveals don't stack
30
+ * (Escape is the honest neutralizer — a re-hover of some "neutral" node is itself a guess
31
+ * at what's neutral; Escape closes menus/popovers without moving the pointer anywhere real). */
32
+ export declare function runHoverProbe(deps: HoverProbeDeps): Promise<{
33
+ probed: number;
34
+ revealed: number;
35
+ }>;