@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.
- package/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- 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
|
+
}
|