@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,175 @@
1
+ import { adaptAXTreeWithRefs } from '../playwright/ax-adapter.js';
2
+ import { parseSnapshot, findByRoleAndName } from '../playwright/snapshot.js';
3
+ /**
4
+ * WalkBrowser over an AgentChannel — the extension-driven counterpart to
5
+ * makeLiveWalkBrowser (walk-live.ts), so walkRoute runs UNMODIFIED against a live
6
+ * tab instead of a playwright-cli session.
7
+ *
8
+ * REF FORMAT NOTE: adaptAXTreeWithRefs assigns each node a synthetic `bN` ref
9
+ * (settled format, tests/playwright/ax-adapter.test.ts asserts /^b\d+$/). But
10
+ * parseSnapshot's REF_RE only matches `e\d+` (playwright-cli's own format) — a
11
+ * literal `bN` token would round-trip as SnapNode.ref === null, and every walk-side
12
+ * consumer (resolveStep, resolveByFingerprint) filters on `n.ref` truthy, so the
13
+ * walk could never resolve anything. Fix: re-key each node's ref to a sequential
14
+ * `eN` "walk ref" when serializing, and cache walkRef -> real {nodeId,
15
+ * backendDOMNodeId} for act() to dispatch against. This is entirely internal to
16
+ * this file — ax-adapter.ts / snapshot.ts are untouched.
17
+ */
18
+ export function makeLiveExtensionBrowser(channel, inputs) {
19
+ let calls = 0;
20
+ let lastSnapshot = '';
21
+ let refMap = new Map();
22
+ // --- recording state (all internal; snapshot()/act()/goto() behavior is unchanged) ---
23
+ // The raw AX + eN->bN map of the CURRENT snapshot. We must keep the RAW ax (not the
24
+ // eN-rekeyed SnapNode[]) because ingestAX re-adapts fromAX itself and looks up the
25
+ // clicked node by its bN ref — the same ref adaptAXTreeWithRefs assigned here.
26
+ let lastAX = [];
27
+ let lastUrl = '';
28
+ let eToB = new Map();
29
+ const steps = [];
30
+ // A step whose action has fired but whose post-action AX isn't observed yet. The next
31
+ // refresh() completes it (toAX = that snapshot); getRecordedSteps() finalizes any leftover.
32
+ let pending = null;
33
+ // URL is fetched only when a step actually needs it (begin/complete), never on every
34
+ // bare snapshot — avoids an extra channel round-trip per agent read. Falls back to the
35
+ // last known url when the channel can't report one (unit fake / test channel).
36
+ async function url() {
37
+ return channel.currentUrl ? await channel.currentUrl() : lastUrl;
38
+ }
39
+ async function completePending(toAX) {
40
+ if (!pending)
41
+ return;
42
+ const p = pending;
43
+ pending = null; // clear before await so a re-entrant begin can't double-close
44
+ // tMs = when this step's post-action snapshot landed → the ledger shows real per-step
45
+ // times instead of the single flush-time. (Browser runtime: Date.now() is fine here.)
46
+ steps.push({ fromUrl: p.fromUrl, fromAX: p.fromAX, toUrl: await url(), toAX, clickedRef: p.clickedRef, tMs: Date.now() });
47
+ }
48
+ // Open a step for a just-fired action. fromAX/fromUrl = the last observed snapshot;
49
+ // clickedRef = the bN in that fromAX for the eN acted on (null for a pure goto). If an
50
+ // action was already pending (no snapshot since), close it first using the current AX
51
+ // so we never drop a step or mis-pair two actions.
52
+ async function beginStep(clickedRef) {
53
+ if (pending)
54
+ await completePending(lastAX);
55
+ pending = { fromUrl: await url(), fromAX: lastAX, clickedRef };
56
+ }
57
+ async function refresh() {
58
+ const ax = await channel.getAX();
59
+ calls++;
60
+ // A prior action was awaiting its landing — THIS snapshot is it.
61
+ await completePending(ax);
62
+ const { nodes, refMap: bRefMap } = adaptAXTreeWithRefs(ax);
63
+ const walkRefMap = new Map();
64
+ const walkEToB = new Map();
65
+ const lines = [];
66
+ nodes.forEach((n, i) => {
67
+ const walkRef = 'e' + (i + 1);
68
+ const real = bRefMap.get(n.ref); // every emitted node has a ref (ax-adapter invariant)
69
+ walkRefMap.set(walkRef, real);
70
+ walkEToB.set(walkRef, n.ref); // retain eN -> bN so a recorded click maps to fromAX's bN
71
+ const raw = n.ref ? n.raw.replace(`[ref=${n.ref}]`, `[ref=${walkRef}]`) : n.raw;
72
+ lines.push(' '.repeat(n.depth) + raw);
73
+ if (n.url)
74
+ lines.push(' '.repeat(n.depth + 1) + '/url: ' + n.url);
75
+ });
76
+ refMap = walkRefMap;
77
+ eToB = walkEToB;
78
+ lastAX = ax;
79
+ lastSnapshot = lines.join('\n');
80
+ return lastSnapshot;
81
+ }
82
+ // eN (what the walk/agent acts on) -> bN (the ref indexing the RAW fromAX ingestAX
83
+ // re-adapts). THIS is the pairing trap: recording the eN would make ingestAX's
84
+ // fromNodes.find(n => n.ref === clickedRef) miss (adapted nodes carry bN, not eN).
85
+ function clickedBRef(ref) {
86
+ return eToB.get(ref) ?? null;
87
+ }
88
+ // Resolve a textbox ref by accessible name against the CACHED snapshot (mirrors
89
+ // makeLiveWalkBrowser's fieldRef), refreshing once on miss.
90
+ async function fieldRef(name) {
91
+ let nodes = parseSnapshot(lastSnapshot);
92
+ let node = findByRoleAndName(nodes, 'textbox', name);
93
+ if (!node || !node.ref) {
94
+ await refresh();
95
+ nodes = parseSnapshot(lastSnapshot);
96
+ node = findByRoleAndName(nodes, 'textbox', name);
97
+ }
98
+ if (!node || !node.ref)
99
+ throw new Error('live-extension-browser: could not resolve textbox "' + name + '"');
100
+ return node.ref;
101
+ }
102
+ function nodeIdFor(ref) {
103
+ const entry = refMap.get(ref);
104
+ if (!entry)
105
+ throw new Error('live-extension-browser: ref "' + ref + '" not found in the current snapshot');
106
+ return entry.nodeId;
107
+ }
108
+ async function dispatchClick(ref) {
109
+ await channel.dispatch({ kind: 'click', nodeId: nodeIdFor(ref) });
110
+ calls++;
111
+ }
112
+ async function dispatchType(ref, text) {
113
+ await channel.dispatch({ kind: 'type', nodeId: nodeIdFor(ref), text });
114
+ calls++;
115
+ }
116
+ const browser = {
117
+ snapshot: () => refresh(),
118
+ callCount: () => calls,
119
+ waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
120
+ act: async (ref, inputSlot) => {
121
+ // ponytail: commit-gating (hard rule #2) is walkRoute's job, not the
122
+ // browser's — act() doesn't see the edge, so it just resolves+dispatches.
123
+ // RECORD the SEMANTIC clicked node (ref = the button), NOT the credential/shipping
124
+ // pre-fills below. Resolve its bN NOW — a fieldRef miss can refresh() and rebuild
125
+ // eToB, which would invalidate this ref's mapping.
126
+ await beginStep(clickedBRef(ref));
127
+ if (inputSlot === 'credentials') {
128
+ await dispatchType(await fieldRef('Username'), inputs.username);
129
+ await dispatchType(await fieldRef('Password'), inputs.password);
130
+ await dispatchClick(ref);
131
+ return;
132
+ }
133
+ if (inputSlot === 'shipping') {
134
+ await dispatchType(await fieldRef('First Name'), inputs.firstName ?? 'A');
135
+ await dispatchType(await fieldRef('Last Name'), inputs.lastName ?? 'B');
136
+ await dispatchType(await fieldRef('Zip/Postal Code'), inputs.zip);
137
+ await dispatchClick(ref);
138
+ return;
139
+ }
140
+ await dispatchClick(ref);
141
+ },
142
+ // A standalone type (the agent's `type` tool). Records the typed FIELD's identity
143
+ // (role+name+fp), NEVER the text — the step carries no value (secret-safe).
144
+ typeText: async (ref, text) => {
145
+ await beginStep(clickedBRef(ref));
146
+ await dispatchType(ref, text);
147
+ },
148
+ getRecordedSteps: () => {
149
+ // Finalize a step whose landing was never snapshotted (run ended right after an
150
+ // action): use its own fromAX as toAX. didNavigate('','') → false; a degenerate but
151
+ // honest same-page effect, better than dropping the click entirely.
152
+ if (pending) {
153
+ steps.push({ fromUrl: pending.fromUrl, fromAX: pending.fromAX, toUrl: pending.fromUrl, toAX: pending.fromAX, clickedRef: pending.clickedRef, tMs: Date.now() });
154
+ pending = null;
155
+ }
156
+ return steps;
157
+ },
158
+ };
159
+ if (channel.goto) {
160
+ // A pure navigation: clickedRef = null. beginStep captures the current page as fromAX;
161
+ // the next snapshot (or getRecordedSteps) supplies toAX.
162
+ browser.goto = async (url) => { await beginStep(null); await channel.goto(url); };
163
+ }
164
+ if (channel.scroll) {
165
+ // A pure in-page reveal (no navigation, no click) — records NO step. The next
166
+ // snapshot() naturally picks up whatever scrolled into view.
167
+ browser.scroll = (dy) => channel.scroll(dy);
168
+ }
169
+ if (channel.currentUrl) {
170
+ browser.currentUrl = () => channel.currentUrl();
171
+ }
172
+ // ponytail: no reopenFresh — the extension can't reopen a fresh browser tab;
173
+ // walkRoute already treats an absent reopenFresh as "skip the wall retry".
174
+ return browser;
175
+ }
@@ -0,0 +1,7 @@
1
+ import type { MapStore } from '../mapstore/store.js';
2
+ /**
3
+ * Weighted shortest path (Dijkstra) over graph edges from startId to goalId.
4
+ * Returns the ordered state-id list (inclusive of both ends), or null if the
5
+ * goal is unreachable. Pure: reads the store only. Cycles terminate (visited set).
6
+ */
7
+ export declare function findPath(store: MapStore, startId: string, goalId: string): string[] | null;
@@ -0,0 +1,55 @@
1
+ /** Weight for an edge: lower = preferred. Cheap edges win; cost is static
2
+ * declared data (playwright-cli call count). Usage-learned weighting
3
+ * (reliability/confidence) is a hosted-service concern, not stored here. */
4
+ function edgeWeight(e) {
5
+ return 1 + e.cost;
6
+ }
7
+ /**
8
+ * Weighted shortest path (Dijkstra) over graph edges from startId to goalId.
9
+ * Returns the ordered state-id list (inclusive of both ends), or null if the
10
+ * goal is unreachable. Pure: reads the store only. Cycles terminate (visited set).
11
+ */
12
+ export function findPath(store, startId, goalId) {
13
+ if (startId === goalId)
14
+ return [startId];
15
+ const dist = new Map([[startId, 0]]);
16
+ const prev = new Map();
17
+ const visited = new Set();
18
+ const frontier = new Set([startId]);
19
+ while (frontier.size > 0) {
20
+ let cur = '';
21
+ let best = Infinity;
22
+ for (const id of frontier) {
23
+ const d = dist.get(id) ?? Infinity;
24
+ if (d < best) {
25
+ best = d;
26
+ cur = id;
27
+ }
28
+ }
29
+ frontier.delete(cur);
30
+ if (cur === goalId)
31
+ break;
32
+ if (visited.has(cur))
33
+ continue;
34
+ visited.add(cur);
35
+ for (const e of store.edgesFrom(cur)) {
36
+ if (visited.has(e.toState))
37
+ continue;
38
+ const nd = (dist.get(cur) ?? Infinity) + edgeWeight(e);
39
+ if (nd < (dist.get(e.toState) ?? Infinity)) {
40
+ dist.set(e.toState, nd);
41
+ prev.set(e.toState, cur);
42
+ }
43
+ frontier.add(e.toState);
44
+ }
45
+ }
46
+ if (!dist.has(goalId))
47
+ return null;
48
+ const path = [];
49
+ let node = goalId;
50
+ while (node !== undefined) {
51
+ path.unshift(node);
52
+ node = prev.get(node);
53
+ }
54
+ return path[0] === startId ? path : null;
55
+ }
@@ -0,0 +1,26 @@
1
+ import { type ContentEvidence } from './extract-content.js';
2
+ export type ReadResponse = {
3
+ status: 'done';
4
+ url: string;
5
+ content: ContentEvidence;
6
+ raw?: string;
7
+ } | {
8
+ status: 'blocked';
9
+ url: string;
10
+ reason: string;
11
+ } | {
12
+ status: 'failed';
13
+ url: string;
14
+ reason: string;
15
+ };
16
+ export interface ReadOpts {
17
+ raw?: boolean;
18
+ queryTerms?: string[];
19
+ }
20
+ /**
21
+ * Open a URL and return its DISTILLED content (or the raw snapshot with --raw).
22
+ * Zero-LLM: readiness + extraction are deterministic. On a loading/interstitial
23
+ * page we report `blocked` and do NOT evade (principle: detect, never bypass).
24
+ * `fetchSnapshot` is injected so this is unit-testable without a browser.
25
+ */
26
+ export declare function readUrl(url: string, fetchSnapshot: (url: string) => Promise<string>, opts?: ReadOpts): Promise<ReadResponse>;
@@ -0,0 +1,30 @@
1
+ import { classifyReadiness } from './readiness.js';
2
+ import { extractContent } from './extract-content.js';
3
+ /**
4
+ * Open a URL and return its DISTILLED content (or the raw snapshot with --raw).
5
+ * Zero-LLM: readiness + extraction are deterministic. On a loading/interstitial
6
+ * page we report `blocked` and do NOT evade (principle: detect, never bypass).
7
+ * `fetchSnapshot` is injected so this is unit-testable without a browser.
8
+ */
9
+ export async function readUrl(url, fetchSnapshot, opts = {}) {
10
+ let snapshot;
11
+ try {
12
+ snapshot = await fetchSnapshot(url);
13
+ }
14
+ catch (e) {
15
+ return { status: 'failed', url, reason: String(e) };
16
+ }
17
+ const readiness = classifyReadiness(snapshot);
18
+ if (readiness !== 'ready') {
19
+ return {
20
+ status: 'blocked',
21
+ url,
22
+ reason: `page not ready (${readiness}); webnav does not evade walls`,
23
+ };
24
+ }
25
+ const content = extractContent(snapshot, url, opts.queryTerms);
26
+ if (opts.raw) {
27
+ return { status: 'done', url, content, raw: snapshot };
28
+ }
29
+ return { status: 'done', url, content };
30
+ }
@@ -0,0 +1,9 @@
1
+ export type Readiness = 'ready' | 'loading' | 'interstitial';
2
+ export interface ReadinessOpts {
3
+ minNodes?: number;
4
+ }
5
+ /**
6
+ * Classify whether a snapshot represents a ready page, an unfinished render to
7
+ * retry, or a hard interstitial/bot-wall to escalate. Pure + deterministic.
8
+ */
9
+ export declare function classifyReadiness(snapshotYaml: string, opts?: ReadinessOpts): Readiness;
@@ -0,0 +1,55 @@
1
+ import { parseSnapshot } from '../playwright/snapshot.js';
2
+ // Known bot-wall / verification phrases. These are matched against a lowercased
3
+ // blob of all node names to DETECT a hard interstitial so the caller can
4
+ // ESCALATE — never to evade it. Each entry is specific enough that ordinary
5
+ // prose ("human resources") does not trip it: we match phrases, not bare words.
6
+ const INTERSTITIAL_PATTERNS = [
7
+ /just a moment/,
8
+ /checking your browser/,
9
+ /verify you are (?:a )?human/,
10
+ /cloudflare/,
11
+ /please enable javascript and cookies/,
12
+ /attention required/,
13
+ /ddos protection/,
14
+ /are you a robot/,
15
+ /complete the (?:captcha|security check)/,
16
+ ];
17
+ // Roles that carry real page content. A finished render has at least one such
18
+ // node with a non-empty name; a nav-only/empty shell has none.
19
+ const CONTENT_ROLES = new Set([
20
+ 'heading',
21
+ 'paragraph',
22
+ 'link',
23
+ 'button',
24
+ 'listitem',
25
+ 'article',
26
+ 'cell',
27
+ 'textbox',
28
+ 'img',
29
+ ]);
30
+ const DEFAULT_MIN_NODES = 8;
31
+ /**
32
+ * Classify whether a snapshot represents a ready page, an unfinished render to
33
+ * retry, or a hard interstitial/bot-wall to escalate. Pure + deterministic.
34
+ */
35
+ export function classifyReadiness(snapshotYaml, opts) {
36
+ const minNodes = opts?.minNodes ?? DEFAULT_MIN_NODES;
37
+ const nodes = parseSnapshot(snapshotYaml);
38
+ // 1. Lowercased blob of all node names.
39
+ const blob = nodes
40
+ .map((n) => n.name ?? '')
41
+ .join(' ')
42
+ .toLowerCase();
43
+ // 2. Interstitial FIRST: a verification screen can be small AND match these.
44
+ if (INTERSTITIAL_PATTERNS.some((re) => re.test(blob))) {
45
+ return 'interstitial';
46
+ }
47
+ // 3. Loading: too sparse to be a finished render — fewer than minNodes total,
48
+ // OR no content-ish node with a non-empty name (nav-only/empty shell).
49
+ const hasContent = nodes.some((n) => CONTENT_ROLES.has(n.role.toLowerCase()) && (n.name ?? '').trim() !== '');
50
+ if (nodes.length < minNodes || !hasContent) {
51
+ return 'loading';
52
+ }
53
+ // 4. Ready otherwise.
54
+ return 'ready';
55
+ }
@@ -0,0 +1,14 @@
1
+ import type { Edge } from '../mapstore/types.js';
2
+ import type { SnapNode } from '../playwright/snapshot.js';
3
+ export type ReplayResult = {
4
+ status: 'ok';
5
+ ref: string;
6
+ repaired: boolean;
7
+ } | {
8
+ status: 'escalate';
9
+ } | {
10
+ status: 'needs-classify';
11
+ } | {
12
+ status: 'blocked-commit';
13
+ };
14
+ export declare function replayStep(edge: Edge, nodes: SnapNode[]): ReplayResult;
@@ -0,0 +1,23 @@
1
+ import { resolveStep } from './resolve.js';
2
+ import { resolveByFingerprint } from '../playwright/fingerprint.js';
3
+ export function replayStep(edge, nodes) {
4
+ if (edge.kind === 'commit-point')
5
+ return { status: 'blocked-commit' };
6
+ if (edge.kind === 'unclassified')
7
+ return { status: 'needs-classify' };
8
+ // Deterministic resolve, zero-LLM. When the edge carries a durable elementFp
9
+ // (role+name+content anchor), use the layered fingerprint resolver — this
10
+ // disambiguates heading-vs-button and identical siblings (e.g. 50 icon buttons in
11
+ // a table row) that name-only matching can't. Legacy edges (no elementFp) fall back
12
+ // to resolveStep's name match (+ selectorCache self-heal) — unchanged behavior.
13
+ if (edge.elementFp) {
14
+ const ref = resolveByFingerprint(edge.elementFp, nodes);
15
+ if (ref)
16
+ return { status: 'ok', ref, repaired: false };
17
+ return { status: 'escalate' };
18
+ }
19
+ const ref = resolveStep(edge.semanticStep, nodes, edge.selectorCache);
20
+ if (ref)
21
+ return { status: 'ok', ref, repaired: !!edge.selectorCache };
22
+ return { status: 'escalate' };
23
+ }
@@ -0,0 +1,21 @@
1
+ import type { SnapNode } from '../playwright/snapshot.js';
2
+ /**
3
+ * Deterministically map a semantic step to a live element ref by matching a
4
+ * NAME against snapshot node names. Returns null on no/ambiguous match — the
5
+ * caller then escalates to the agent (needs-navigation). No LLM.
6
+ *
7
+ * Two name sources, tried in order:
8
+ * 1. the step's own quoted name (`click "Insights"` → "Insights").
9
+ * 2. `cachedSelector` — the SELF-HEAL repair: a name an agent's ref resolved to
10
+ * on a PRIOR walk, written back to the edge when the step's own name failed
11
+ * (drift / icon-only / renamed element). Lets a once-broken step resolve
12
+ * deterministically again without re-asking the agent (principle #3).
13
+ * The step's own name wins when it matches; the cache is the fallback.
14
+ *
15
+ * Strict by design: returns a ref ONLY when exactly one node carries the name.
16
+ * If several do (e.g. 6 equivalent "Add to cart" buttons, or a product label
17
+ * shared by a link + its image), it returns null and the caller escalates —
18
+ * webnav never guesses among genuinely-equivalent targets; that judgment is the
19
+ * agent's (principle #5a). This is correct behavior, not a limitation.
20
+ */
21
+ export declare function resolveStep(semanticStep: string, nodes: SnapNode[], cachedSelector?: string | null): string | null;
@@ -0,0 +1,33 @@
1
+ // Pull the quoted name out of a semantic step like `click "Insights"`.
2
+ function quotedName(semanticStep) {
3
+ const m = semanticStep.match(/"([^"]+)"/);
4
+ return m ? m[1] : null;
5
+ }
6
+ /**
7
+ * Deterministically map a semantic step to a live element ref by matching a
8
+ * NAME against snapshot node names. Returns null on no/ambiguous match — the
9
+ * caller then escalates to the agent (needs-navigation). No LLM.
10
+ *
11
+ * Two name sources, tried in order:
12
+ * 1. the step's own quoted name (`click "Insights"` → "Insights").
13
+ * 2. `cachedSelector` — the SELF-HEAL repair: a name an agent's ref resolved to
14
+ * on a PRIOR walk, written back to the edge when the step's own name failed
15
+ * (drift / icon-only / renamed element). Lets a once-broken step resolve
16
+ * deterministically again without re-asking the agent (principle #3).
17
+ * The step's own name wins when it matches; the cache is the fallback.
18
+ *
19
+ * Strict by design: returns a ref ONLY when exactly one node carries the name.
20
+ * If several do (e.g. 6 equivalent "Add to cart" buttons, or a product label
21
+ * shared by a link + its image), it returns null and the caller escalates —
22
+ * webnav never guesses among genuinely-equivalent targets; that judgment is the
23
+ * agent's (principle #5a). This is correct behavior, not a limitation.
24
+ */
25
+ export function resolveStep(semanticStep, nodes, cachedSelector) {
26
+ const tryName = (name) => {
27
+ if (!name)
28
+ return null;
29
+ const hits = nodes.filter((n) => n.ref && n.name === name);
30
+ return hits.length === 1 ? hits[0].ref : null;
31
+ };
32
+ return tryName(quotedName(semanticStep)) ?? tryName(cachedSelector ?? null);
33
+ }
@@ -0,0 +1,21 @@
1
+ import { type ContentEvidence } from './extract-content.js';
2
+ import { type TokenSavings } from './tokens.js';
3
+ export interface SearchGatherResult {
4
+ query: string;
5
+ results: {
6
+ title: string;
7
+ url: string;
8
+ }[];
9
+ evidence: ContentEvidence[];
10
+ blocked: string[];
11
+ providers: {
12
+ id: string;
13
+ results: number;
14
+ blocked: boolean;
15
+ }[];
16
+ cost: {
17
+ playwright_calls: number;
18
+ savings: TokenSavings;
19
+ };
20
+ }
21
+ export declare function runSearchLive(query: string, topN?: number): Promise<SearchGatherResult>;
@@ -0,0 +1,98 @@
1
+ import { PlaywrightAdapter } from '../playwright/adapter.js';
2
+ import { parseSearchResults } from './search.js';
3
+ import { classifyReadiness } from './readiness.js';
4
+ import { extractContent } from './extract-content.js';
5
+ import { tokenSavings } from './tokens.js';
6
+ import { SEARCH_PROVIDERS } from './search-providers.js';
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ // Snapshot with a readiness retry: re-snapshot up to 3 times while 'loading'.
9
+ async function readySnapshot(adapter) {
10
+ let yaml = await adapter.snapshot();
11
+ let readiness = classifyReadiness(yaml);
12
+ for (let attempt = 1; attempt < 3 && readiness === 'loading'; attempt++) {
13
+ await sleep(1500);
14
+ yaml = await adapter.snapshot();
15
+ readiness = classifyReadiness(yaml);
16
+ }
17
+ return { yaml, readiness };
18
+ }
19
+ /**
20
+ * Gather results from ONE provider: open its search url, then re-snapshot until
21
+ * results actually parse out (the search shell renders BEFORE the result list —
22
+ * a render race) or a bounded number of attempts. An interstitial search page is
23
+ * recorded as blocked (DETECT + escalate, never evade). Caps to topN.
24
+ */
25
+ async function gatherFromProvider(adapter, provider, query, topN) {
26
+ await adapter.open(provider.searchUrl(query));
27
+ let resultsYaml = '';
28
+ let searchReadiness = 'loading';
29
+ let results = [];
30
+ for (let attempt = 0; attempt < 4; attempt++) {
31
+ const snap = await readySnapshot(adapter);
32
+ resultsYaml = snap.yaml;
33
+ searchReadiness = snap.readiness;
34
+ if (searchReadiness === 'interstitial')
35
+ break;
36
+ results = parseSearchResults(resultsYaml, topN);
37
+ if (results.length > 0)
38
+ break; // results rendered — proceed
39
+ await sleep(1500); // shell-only render — wait for results
40
+ }
41
+ if (searchReadiness === 'interstitial') {
42
+ // The provider's search page is bot-walled — record + skip. Never evade.
43
+ return { results: [], blocked: true, rawChars: resultsYaml.length };
44
+ }
45
+ // results already parsed in the loop above (possibly empty for a thin index).
46
+ return { results, blocked: false, rawChars: resultsYaml.length };
47
+ }
48
+ export async function runSearchLive(query, topN = 3) {
49
+ const adapter = new PlaywrightAdapter('search-' + Date.now());
50
+ const evidence = [];
51
+ const blocked = [];
52
+ const providers = [];
53
+ let rawChars = 0;
54
+ let results = [];
55
+ try {
56
+ // 1. Fan out across providers sequentially (one shared adapter). A blocked
57
+ // provider is recorded and skipped; the others still contribute (the whole
58
+ // point — resilience + broader coverage).
59
+ const perProvider = [];
60
+ for (const provider of SEARCH_PROVIDERS) {
61
+ const gather = await gatherFromProvider(adapter, provider, query, topN);
62
+ rawChars += gather.rawChars;
63
+ providers.push({ id: provider.id, results: gather.results.length, blocked: gather.blocked });
64
+ perProvider.push({ provider, gather });
65
+ }
66
+ // 2. MERGE + DEDUPE by url, preserving first-seen order (Marginalia first).
67
+ // Cap the merged list to topN*2 so both providers can contribute.
68
+ const seen = new Set();
69
+ for (const { gather } of perProvider) {
70
+ for (const r of gather.results) {
71
+ if (seen.has(r.url))
72
+ continue;
73
+ seen.add(r.url);
74
+ results.push(r);
75
+ }
76
+ }
77
+ results = results.slice(0, topN * 2);
78
+ // 3. Visit each merged result url, readiness-retry, interstitial -> blocked,
79
+ // else extract content.
80
+ const queryTerms = query.toLowerCase().split(/\s+/);
81
+ for (const result of results) {
82
+ await adapter.goto(result.url);
83
+ const { yaml, readiness } = await readySnapshot(adapter);
84
+ if (readiness === 'interstitial') {
85
+ // DETECT + escalate, never evade.
86
+ blocked.push(result.url);
87
+ continue;
88
+ }
89
+ rawChars += yaml.length;
90
+ evidence.push(extractContent(yaml, result.url, queryTerms));
91
+ }
92
+ }
93
+ finally {
94
+ await adapter.close();
95
+ }
96
+ const savings = tokenSavings(rawChars, JSON.stringify({ results, evidence }));
97
+ return { query, results, evidence, blocked, providers, cost: { playwright_calls: adapter.callCount, savings } };
98
+ }
@@ -0,0 +1,5 @@
1
+ export interface SearchProvider {
2
+ id: string;
3
+ searchUrl: (query: string) => string;
4
+ }
5
+ export declare const SEARCH_PROVIDERS: SearchProvider[];
@@ -0,0 +1,12 @@
1
+ // Browser-navigable search engines webnav fans out across. Live-probed: only
2
+ // these TWO work cleanly without bot-walling a real browser AND have DIFFERENT
3
+ // indexes (Marginalia: broad ~40 results; Wiby: curated/older-web ~8). The
4
+ // good mainstream engines (Google/Bing/Brave/DuckDuckGo) bot-wall real browsers;
5
+ // others probed (mojeek/ecosia/searx/stract/4get) return empty shells — NOT
6
+ // registered. Pure data + a URL builder; no logic.
7
+ // Ordered list — Marginalia first (broader index), then Wiby. Merge preserves
8
+ // this first-seen order.
9
+ export const SEARCH_PROVIDERS = [
10
+ { id: 'marginalia', searchUrl: (q) => 'https://search.marginalia.nu/search?query=' + encodeURIComponent(q) },
11
+ { id: 'wiby', searchUrl: (q) => 'https://wiby.me/?q=' + encodeURIComponent(q) },
12
+ ];
@@ -0,0 +1,6 @@
1
+ export interface SearchResult {
2
+ title: string;
3
+ url: string;
4
+ }
5
+ /** Parse external result links from a Marginalia results snapshot. Pure. */
6
+ export declare function parseSearchResults(resultsYaml: string, limit: number): SearchResult[];