@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,384 @@
1
+ import { parseSnapshot } from '../playwright/snapshot.js';
2
+ import { matchState } from '../explorer/fingerprint.js';
3
+ import { replayStep } from './replay.js';
4
+ import { resolveStep } from './resolve.js';
5
+ import { deriveNear } from '../playwright/fingerprint.js';
6
+ import { classifyAuthLanding } from './auth-status.js';
7
+ /**
8
+ * SELF-HEAL write-back after an agent picks an element at a fork. Persist a DURABLE
9
+ * fingerprint so the next walk resolves the once-ambiguous step without re-asking (#3):
10
+ * - compute `near` for the chosen node via the SHARED deriveNear (so record + heal agree);
11
+ * - if the edge was projected from an affordance (viaAffordance), write {role,name,near}
12
+ * onto that AFFORDANCE (recordElementFp — both motivating maps have no edge rows);
13
+ * - else fall back to recordSelector on the legacy stored row.
14
+ * A chosen node with a null name and no derivable `near` can't be made durable → store
15
+ * nothing (the step honestly stays a per-walk escalation; D1).
16
+ */
17
+ function healStep(store, edge, beforeNodes, chosen, chosenIdx) {
18
+ if (!chosen)
19
+ return;
20
+ const near = chosenIdx >= 0 ? deriveNear(beforeNodes, chosenIdx, chosen.role, chosen.name) : null;
21
+ if (edge.viaAffordance && (chosen.name || near)) {
22
+ // affordanceOwner: a projected _shell edge rewrites fromState to the asking page, but the
23
+ // affordance lives on the shell state — write the repair where the affordance actually is.
24
+ store.recordElementFp(edge.affordanceOwner ?? edge.fromState, edge.viaAffordance, { role: chosen.role, name: chosen.name, near });
25
+ return;
26
+ }
27
+ // legacy stored-edge fallback (no backing affordance): name-only selector cache, as before.
28
+ if (chosen.name)
29
+ store.recordSelector(edge.fromState, edge.toState, edge.semanticStep, chosen.name);
30
+ }
31
+ /**
32
+ * SSO-wall handling (design item 2): when a navigate-edge step lands somewhere that
33
+ * doesn't match the expected toState, check whether the landing IS an auth wall —
34
+ * classifyAuthLanding against the site's own map states (the map is the oracle, same
35
+ * as profile-status / Task A). If it's a wall AND the browser can reopen fresh
36
+ * (`reopenFresh` present — live wiring only), retry ONCE: close this session, open a
37
+ * brand-new one under the SAME profile (same identity/cookies — equivalent to the
38
+ * user reopening a tab; NOT evasion), reload `retryUrl`. A fresh session's first
39
+ * load reliably passes Cloudflare-Access-style step-up (observed live). Never
40
+ * loops: a wall on the retry is reported honestly as `needs-auth` instead of a
41
+ * generic drift escalation.
42
+ *
43
+ * `retryUrl` vs `landedUrl`: classification always uses the LANDED url (that IS the
44
+ * wall, and it's what needs-auth reports as loginUrl), but the RELOAD target should
45
+ * be a known-good coordinate when one exists — challenge urls often carry one-time
46
+ * nonces that error on reload. The addressableUrl call site passes the edge's
47
+ * canonical url so the redirect chain re-runs from a clean start.
48
+ *
49
+ * Returns `null` when there's no wall (caller falls through to its normal drift
50
+ * escalation), the retried snapshot when the retry cleared the wall (caller
51
+ * re-runs its own match against this fresh snapshot), or a `needs-auth`
52
+ * RecallResponse when the wall persists through the retry (or no retry was
53
+ * possible — a bare foreign-host/interstitial landing is reported the same way,
54
+ * never silently swallowed as generic drift).
55
+ */
56
+ async function checkAuthWall(browser, states, at, retriedRef, landedUrl, landedSnapshot, retryUrl, profile) {
57
+ const site = states[0]?.nodeId;
58
+ if (!site)
59
+ return null;
60
+ const first = classifyAuthLanding(landedUrl, landedSnapshot, site, states);
61
+ if (first.auth !== 'needs-login')
62
+ return null;
63
+ if (retriedRef.done || !browser.reopenFresh) {
64
+ return { response: { status: 'needs-auth', at, profile: profile ?? 'default', site, loginUrl: first.loginUrl ?? landedUrl } };
65
+ }
66
+ retriedRef.done = true; // once per walk — a wall on the retry is persistent, never loop
67
+ const afterSnapshot = await browser.reopenFresh(retryUrl);
68
+ const afterUrl = browser.currentUrl ? await browser.currentUrl() : retryUrl;
69
+ const retryClass = classifyAuthLanding(afterUrl, afterSnapshot, site, states);
70
+ if (retryClass.auth === 'needs-login') {
71
+ return { response: { status: 'needs-auth', at, profile: profile ?? 'default', site, loginUrl: retryClass.loginUrl ?? afterUrl } };
72
+ }
73
+ return { retried: afterSnapshot };
74
+ }
75
+ // A state is "dynamic" (per the checkpoint design) when its identity may not be
76
+ // stable structure — either it's `provisional` (seen only once) or it has an
77
+ // in-page affordance folded from repeated siblings (`scope: 'row'|'widget'`,
78
+ // e.g. a table row or a repeated card). Judgment-free: a fixed structural test,
79
+ // not a guess about what's "interesting".
80
+ function isDynamicState(state) {
81
+ if (state.provisional)
82
+ return true;
83
+ return state.affordances.some((a) => hasDynamicScope(a));
84
+ }
85
+ function hasDynamicScope(a) {
86
+ if (a.scope === 'row' || a.scope === 'widget')
87
+ return true;
88
+ return (a.children ?? []).some(hasDynamicScope);
89
+ }
90
+ // Build the checkpoint response for a CONFIRMED arrival at `state`, or null if no
91
+ // checkpoint should fire (not requested, or already fired once this walk).
92
+ async function checkForCheckpoint(args, observedSoFar, at, state, browser) {
93
+ if (observedSoFar.has(state.id))
94
+ return null;
95
+ const wantsNamed = (args.observe ?? []).includes(state.id);
96
+ const wantsDynamic = !!args.observeDynamic && isDynamicState(state);
97
+ if (!wantsNamed && !wantsDynamic)
98
+ return null;
99
+ observedSoFar.add(state.id);
100
+ const snapshot = await browser.snapshot();
101
+ return { status: 'checkpoint', at, state: state.id, snapshot, repertoire: state.affordances };
102
+ }
103
+ /**
104
+ * The interactive multi-step walk (design §3). Walks a linear route edge-by-edge
105
+ * from `startStateId` toward `goalStateId`, verifying every step (prediction vs
106
+ * observation) and escalating to the agent on drift or at a commit point.
107
+ *
108
+ * Zero LLM: replayStep resolves deterministically (cached ref, then role+name);
109
+ * any decision webnav isn't allowed to make is handed back as a `needs-*` response.
110
+ */
111
+ export async function walkRoute(args) {
112
+ const { goalName, startStateId, goalStateId, store, states, browser } = args;
113
+ let current = startStateId;
114
+ let at = 0;
115
+ let firstStep = true;
116
+ // Fresh-session wall retry: at most ONCE per walk call (design item 2 — a wall
117
+ // surviving the retry is persistent, never loop).
118
+ const authRetried = { done: false };
119
+ // Checkpoint fired-once tracking (persisted across resumes via args.observed).
120
+ const observedSoFar = new Set(args.observed ?? []);
121
+ // Halt as soon as we've arrived: this check at the TOP means when goalStateId is
122
+ // a state the route passes THROUGH (e.g. sd:checkout-overview), the walk stops
123
+ // there and never attempts the next edge (the Finish commit point).
124
+ while (current !== goalStateId) {
125
+ // CHECKPOINT: `current` is always a CONFIRMED arrival at the top of this loop
126
+ // (the start state, or set only after prediction-vs-observation matched) — the
127
+ // right place to trigger, per the design (a checkpointed state must be a
128
+ // confirmed arrival). Skipped when we're about to consume a resume answer for
129
+ // THIS state (the agent already saw it via the pause that led here).
130
+ if (!(firstStep && args.answer)) {
131
+ const here = store.getState(current);
132
+ if (here) {
133
+ const cp = await checkForCheckpoint(args, observedSoFar, at, here, browser);
134
+ if (cp)
135
+ return cp;
136
+ }
137
+ }
138
+ const edges = store.edgesFrom(current);
139
+ if (edges.length === 0) {
140
+ return { status: 'failed', reason: 'no edge from ' + current };
141
+ }
142
+ // Linear route: each non-goal state has exactly one outgoing edge.
143
+ let edge = edges[0];
144
+ if (args.path) {
145
+ const i = args.path.indexOf(current);
146
+ const next = i >= 0 ? args.path[i + 1] : undefined;
147
+ const onPath = next ? edges.find((e) => e.toState === next) : undefined;
148
+ if (!onPath)
149
+ return { status: 'failed', reason: 'no path edge from ' + current };
150
+ edge = onPath;
151
+ }
152
+ // Gated edge: pause for the agent to fire the required in-page affordances
153
+ // FIRST, whenever the walk is about to traverse it — NOT just on the first
154
+ // step (a gate is usually mid-route, e.g. inventory->cart after login). The
155
+ // only time we DON'T pause is when a resume answer is being consumed for this
156
+ // very step (firstStep && args.answer) — the agent has already fired them.
157
+ // Ungated edges never pause (autopilot preserved).
158
+ if (!(firstStep && args.answer) && edge.requiresAffordances && edge.requiresAffordances.length > 0) {
159
+ const yaml = await browser.snapshot();
160
+ return {
161
+ status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: yaml,
162
+ question: 'before "' + edge.semanticStep + '", fire these in-page affordances on the current page: '
163
+ + edge.requiresAffordances.join('; '),
164
+ };
165
+ }
166
+ // Resume answer applies only on the FIRST iteration of THIS call.
167
+ if (firstStep && args.answer) {
168
+ const ans = args.answer;
169
+ firstStep = false;
170
+ if (ans.kind === 'classify') {
171
+ if (ans.verdict === 'commit') {
172
+ return doneHalted(args, browser); // hard halt — never fire a commit point (#2)
173
+ }
174
+ // 'safe': the AGENT has taken responsibility for this step (e.g. it's a
175
+ // demo/dry-run, or genuinely reversible). Resolve + act it DIRECTLY,
176
+ // bypassing replayStep's commit/unclassified guard — otherwise the guard
177
+ // would just re-escalate needs-classification and the answer is ignored
178
+ // (the R5 resume bug). This is the ONLY path that fires a commit edge, and
179
+ // only on an explicit agent "safe" verdict.
180
+ const yaml = await browser.snapshot();
181
+ const ref = resolveStep(edge.semanticStep, parseSnapshot(yaml), edge.selectorCache);
182
+ if (!ref) {
183
+ return { status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: yaml,
184
+ question: 'classified safe, but cannot resolve "' + edge.semanticStep + '" on the current page' };
185
+ }
186
+ await browser.act(ref, edge.acceptsInput);
187
+ const afterYaml = await browser.snapshot();
188
+ const observed = matchState(parseSnapshot(afterYaml), states);
189
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
190
+ return { status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: afterYaml,
191
+ question: 'after the classified-safe step, expected ' + edge.toState + ' but observed '
192
+ + (observed.status === 'matched' ? observed.state.id : observed.status) };
193
+ }
194
+ current = edge.toState;
195
+ at++;
196
+ continue;
197
+ }
198
+ else if (ans.kind === 'ref') {
199
+ // 'ref': act on the agent-chosen element, skip replayStep for THIS step.
200
+ // SELF-HEAL: recover a DURABLE element fingerprint for the chosen node from
201
+ // the current page (the raw ref `e42` is ephemeral — reassigned per snapshot —
202
+ // so we never persist it). We only reach here because resolution MISSED, so
203
+ // the durable role+name+near is what re-resolves next time (#3).
204
+ const beforeNodes = parseSnapshot(await browser.snapshot());
205
+ const chosen = beforeNodes.find((n) => n.ref === ans.ref);
206
+ const chosenIdx = beforeNodes.findIndex((n) => n.ref === ans.ref);
207
+ await browser.act(ans.ref, edge.acceptsInput);
208
+ const afterYaml = await browser.snapshot();
209
+ const observed = matchState(parseSnapshot(afterYaml), states);
210
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
211
+ return { status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: afterYaml,
212
+ question: 'after applying the supplied ref, expected ' + edge.toState + ' but observed '
213
+ + (observed.status === 'matched' ? observed.state.id : observed.status) };
214
+ }
215
+ healStep(store, edge, beforeNodes, chosen, chosenIdx);
216
+ current = edge.toState;
217
+ at++;
218
+ continue;
219
+ }
220
+ // ans.kind === 'continue': a checkpoint answer — no action to consume. Fall
221
+ // through to the normal step logic below for the CURRENT state (the checkpoint
222
+ // already skipped itself via observedSoFar, so it won't re-fire).
223
+ }
224
+ firstStep = false;
225
+ // Tier-1 addressable jump: the destination has a canonical URL, so the link
226
+ // need not be resolved as a ref (it may be icon-only / unstable). Jump, then
227
+ // verify by observation exactly like a resolved action. Commit points still
228
+ // never auto-fire — an addressableUrl on a commit edge would be a misconfig, so
229
+ // we still route commit/unclassified through replayStep's guard below.
230
+ if (browser.goto && edge.addressableUrl && edge.kind !== 'commit-point' && edge.kind !== 'unclassified') {
231
+ await browser.goto(edge.addressableUrl, edge.acceptsInput);
232
+ let afterYaml = await browser.snapshot();
233
+ let observed = matchState(parseSnapshot(afterYaml), states);
234
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
235
+ // Retry target = the edge's canonical addressableUrl (a known-good
236
+ // deterministic coordinate), NOT the landed wall url — challenge urls often
237
+ // carry one-time nonces that error on reload; reloading the canonical url
238
+ // fresh lets the redirect chain re-run.
239
+ const landedUrl = browser.currentUrl ? await browser.currentUrl() : edge.addressableUrl;
240
+ const wall = await checkAuthWall(browser, states, at, authRetried, landedUrl, afterYaml, edge.addressableUrl, args.profile);
241
+ if (wall && 'response' in wall)
242
+ return wall.response;
243
+ if (wall && 'retried' in wall) {
244
+ afterYaml = wall.retried;
245
+ observed = matchState(parseSnapshot(afterYaml), states);
246
+ }
247
+ }
248
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
249
+ return { status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: afterYaml,
250
+ question: 'jumped to ' + edge.addressableUrl + ' but expected ' + edge.toState + ' — observed '
251
+ + (observed.status === 'matched' ? observed.state.id : observed.status) };
252
+ }
253
+ current = edge.toState;
254
+ at++;
255
+ continue;
256
+ }
257
+ // Read the CURRENT page (before acting) so commit/drift checks see this page.
258
+ // READINESS RETRY: JS-heavy apps (React/Angular/Vue) render the
259
+ // page asynchronously; an immediate snapshot can catch it empty (0 nodes) or
260
+ // pre-render, so the step won't resolve yet. Re-snapshot a bounded number of times
261
+ // until the step resolves, before treating an unresolved step as real drift. This
262
+ // is the same race live.ts handles for search results. `browser.waitMs` lets the
263
+ // live browser sleep between tries; the unit fake omits it (resolves immediately).
264
+ let yaml = await browser.snapshot();
265
+ let nodes = parseSnapshot(yaml);
266
+ let r = replayStep(edge, nodes);
267
+ let prevYaml = '';
268
+ for (let attempt = 0; r.status === 'escalate' && browser.waitMs && attempt < 5; attempt++) {
269
+ prevYaml = yaml;
270
+ await browser.waitMs(800);
271
+ yaml = await browser.snapshot();
272
+ nodes = parseSnapshot(yaml);
273
+ r = replayStep(edge, nodes);
274
+ }
275
+ if (r.status === 'blocked-commit' || r.status === 'needs-classify') {
276
+ // Commit-point halt: NEVER act. Hand the action to the agent to classify.
277
+ // Carry `at` (the absolute path index we paused ON) so the session position
278
+ // stays in sync — a single resume can traverse several states before this
279
+ // halt, and without `at` the handler would advance by only 1 and desync,
280
+ // restarting the NEXT resume at the wrong step.
281
+ return { status: 'needs-classification', at, action: edge.semanticStep, snapshot: yaml };
282
+ }
283
+ if (r.status === 'escalate') {
284
+ // Distinguish a NON-HYDRATING SOFT-BLOCK from real drift (review #9): if after the
285
+ // full retry budget NO known state matched (the expected page never appeared at all)
286
+ // AND the snapshot is STABLE across the last two retries, the page loaded its shell but
287
+ // never rendered — almost always a rate-limit / bot-throttle. Report that honestly so
288
+ // the agent backs off rather than chasing a phantom drift. Gated on fingerprint ABSENCE
289
+ // (matchState none), not size — a legit sparse page (saucedemo login) still matches.
290
+ const unmatched = matchState(nodes, states).status === 'none';
291
+ const stable = prevYaml !== '' && prevYaml === yaml;
292
+ if (unmatched && stable) {
293
+ return {
294
+ status: 'needs-navigation', at, semanticStep: edge.semanticStep, snapshot: yaml,
295
+ question: 'the page loaded but did not render any known state (stable across retries) — '
296
+ + 'likely rate-limited or bot-throttled. Back off and retry later; do NOT hammer it.',
297
+ };
298
+ }
299
+ // Real drift: deterministic resolve couldn't find the step on this page.
300
+ return {
301
+ status: 'needs-navigation',
302
+ at,
303
+ semanticStep: edge.semanticStep,
304
+ snapshot: yaml,
305
+ question: 'expected to reach ' + edge.toState + ' but cannot resolve the step on the current page',
306
+ };
307
+ }
308
+ // r.status === 'ok' — perform the resolved action. Input filling for
309
+ // acceptsInput edges is handled by the live browser via its captured `inputs`;
310
+ // we hand it the slot NAME (edge.acceptsInput) so it knows whether/which input
311
+ // to fill. The unit fake's act() ignores both args and just advances.
312
+ await browser.act(r.ref, edge.acceptsInput);
313
+ // PREDICTION vs OBSERVATION: compare the edge's expected toState against the
314
+ // live snapshot. Mismatch or ambiguity → escalate, never march on blind.
315
+ let afterYaml = await browser.snapshot();
316
+ let observed = matchState(parseSnapshot(afterYaml), states);
317
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
318
+ const landedUrl = browser.currentUrl ? await browser.currentUrl() : '';
319
+ // Retry target is the landed url itself — a click has no known-good target
320
+ // URL to reload (State.urlPattern is a wildcard, not a concrete coordinate),
321
+ // so "reopen the tab you landed on" is the best available retry here.
322
+ const wall = landedUrl
323
+ ? await checkAuthWall(browser, states, at, authRetried, landedUrl, afterYaml, landedUrl, args.profile)
324
+ : null;
325
+ if (wall && 'response' in wall)
326
+ return wall.response;
327
+ if (wall && 'retried' in wall) {
328
+ afterYaml = wall.retried;
329
+ observed = matchState(parseSnapshot(afterYaml), states);
330
+ }
331
+ }
332
+ if (observed.status !== 'matched' || observed.state.id !== edge.toState) {
333
+ return {
334
+ status: 'needs-navigation',
335
+ at,
336
+ semanticStep: edge.semanticStep,
337
+ snapshot: afterYaml,
338
+ question: 'expected ' + edge.toState + ' but observed '
339
+ + (observed.status === 'matched' ? observed.state.id : observed.status),
340
+ };
341
+ }
342
+ // Success: self-heal write-back, then advance.
343
+ current = edge.toState;
344
+ at++;
345
+ }
346
+ // Reached the goal. Goal-state evidence is minimal for W1 (YAGNI) — the focus of
347
+ // this increment is the WALK + escalation; a later increment enriches evidence.
348
+ // CHECKPOINT goal-state enrichment: done is done (no pause) — but if the goal
349
+ // qualifies by the same dynamic test (or was named via --observe), attach the
350
+ // live snapshot + repertoire so the agent doesn't have to snapshot manually.
351
+ const goalStateRec = store.getState(goalStateId);
352
+ const wantsGoal = !!goalStateRec
353
+ && ((args.observe ?? []).includes(goalStateId) || (!!args.observeDynamic && isDynamicState(goalStateRec)));
354
+ const goalExtra = wantsGoal
355
+ ? { snapshot: await browser.snapshot(), repertoire: goalStateRec.affordances }
356
+ : {};
357
+ return {
358
+ status: 'done',
359
+ evidence: {
360
+ goal: goalName,
361
+ query: goalName,
362
+ candidates: [],
363
+ cost: {
364
+ playwright_calls: browser.callCount(),
365
+ savings: { raw_snapshot_tokens: 0, bundle_tokens: 0, tokens_saved: 0, chars_per_token: 4 },
366
+ },
367
+ ...goalExtra,
368
+ },
369
+ };
370
+ }
371
+ // A `done` response that HALTED at a commit point: the agent classified the next
372
+ // action as a commit, so the walk stops without firing it (#2). Mirrors the final
373
+ // `done` evidence shape exactly, with the `halted` marker set.
374
+ function doneHalted(args, browser) {
375
+ return {
376
+ status: 'done',
377
+ evidence: {
378
+ goal: args.goalName, query: args.goalName, candidates: [],
379
+ cost: { playwright_calls: browser.callCount(),
380
+ savings: { raw_snapshot_tokens: 0, bundle_tokens: 0, tokens_saved: 0, chars_per_token: 4 } },
381
+ },
382
+ halted: 'commit-point',
383
+ };
384
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@dikshanty94/webnav",
3
+ "version": "0.2.0",
4
+ "description": "A navigation memory for AI agents — zero-LLM web-navigation CLI that recalls cheap, reliable routes instead of re-exploring.",
5
+ "mcpName": "io.github.lucyfuur94/webnav",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lucyfuur94/webnav-core.git"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": "^18.0.0 || ^20.0.0 || ^22.0.0"
14
+ },
15
+ "bin": {
16
+ "webnav": "bin/webnav"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./contract": {
26
+ "types": "./dist/contract.d.ts",
27
+ "default": "./dist/contract.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "bin"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "preinstall": "node scripts/check-node.mjs",
39
+ "build": "tsc && cp src/mapstore/schema.sql dist/mapstore/",
40
+ "prepack": "rm -rf dist && npm run build",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
43
+ "webnav": "tsx src/cli.ts",
44
+ "test:e2e": "vitest run --config vitest.e2e.config.ts"
45
+ },
46
+ "dependencies": {
47
+ "@anthropic-ai/claude-agent-sdk": "^0.3.214",
48
+ "better-sqlite3": "^11.0.0",
49
+ "yaml": "^2.5.0",
50
+ "zod": "^4.4.3"
51
+ },
52
+ "devDependencies": {
53
+ "@types/better-sqlite3": "^7.6.0",
54
+ "@types/node": "^22.0.0",
55
+ "tsx": "^4.0.0",
56
+ "typescript": "^5.5.0",
57
+ "vitest": "^2.0.0"
58
+ }
59
+ }