@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,47 @@
1
+ import type { MapStore } from '../mapstore/store.js';
2
+ import { type AffordanceKind, type ElementFingerprint, type DeclaredShadow } from '../mapstore/types.js';
3
+ export interface EditAffordanceObj {
4
+ id?: string;
5
+ label: string;
6
+ kind?: AffordanceKind;
7
+ scope?: 'row' | 'widget';
8
+ to?: string;
9
+ commit?: boolean;
10
+ needs?: string[];
11
+ addressableUrl?: string;
12
+ acceptsInput?: string;
13
+ core?: boolean;
14
+ children?: EditAffordance[];
15
+ elementFp?: ElementFingerprint;
16
+ }
17
+ export type EditAffordance = string | EditAffordanceObj;
18
+ export interface EditState {
19
+ label: string;
20
+ urlPattern?: string;
21
+ fingerprint?: string[];
22
+ affordances?: EditAffordance[];
23
+ declaredShadow?: DeclaredShadow;
24
+ role?: string;
25
+ parentState?: string | null;
26
+ provisional?: string | null;
27
+ template?: string | null;
28
+ }
29
+ export interface EditEdge {
30
+ from: string;
31
+ to: string;
32
+ via: string;
33
+ needsInput?: boolean;
34
+ why?: string;
35
+ requiresAffordances?: string[];
36
+ core?: boolean;
37
+ }
38
+ export interface EditGraph {
39
+ states: EditState[];
40
+ edges: EditEdge[];
41
+ }
42
+ export interface EditResult {
43
+ node: string;
44
+ statesWritten: number;
45
+ edgesWritten: number;
46
+ }
47
+ export declare function editGraph(store: MapStore, node: string, graph: EditGraph): EditResult;
@@ -0,0 +1,211 @@
1
+ import { makeState, makeEdge, makeAffordance } from '../mapstore/types.js';
2
+ let _affSeq = 0;
3
+ const slug = (s) => s.replace(/\W+/g, '_').slice(0, 24);
4
+ // `stateId` maps an author's state LABEL to its full id (`node:label`); `to` in a
5
+ // teach affordance is a label, so we resolve it here.
6
+ const VALID_KINDS = new Set(['navigate', 'reveal', 'mutate', 'input']);
7
+ function toAffordance(a, stateId) {
8
+ if (typeof a === 'string') {
9
+ return makeAffordance({ id: 'aff_' + (_affSeq++) + '_' + slug(a), label: a, kind: 'mutate' });
10
+ }
11
+ // Validate LOUDLY — a teach payload using the wrong field names (e.g. `type`
12
+ // instead of `kind`, `name` instead of `label`) must fail, not silently store
13
+ // a `mutate:undefined` affordance. (Found via dogfooding: an agent invented its
14
+ // own schema and editGraph happily stored garbage.)
15
+ if (typeof a.label !== 'string' || !a.label) {
16
+ throw new Error(`editGraph: affordance is missing a string "label" (got keys: ${Object.keys(a).join(',')})`);
17
+ }
18
+ if (a.kind !== undefined && !VALID_KINDS.has(a.kind)) {
19
+ throw new Error(`editGraph: affordance "${a.label}" has invalid kind "${a.kind}" (expected navigate|reveal|mutate|input)`);
20
+ }
21
+ return makeAffordance({
22
+ id: a.id ?? 'aff_' + (_affSeq++) + '_' + slug(a.label),
23
+ label: a.label,
24
+ kind: a.kind ?? 'mutate',
25
+ ...(a.scope ? { scope: a.scope } : {}),
26
+ toState: a.to ? stateId(a.to) : null,
27
+ commit: a.commit ?? false,
28
+ needs: a.needs ?? [],
29
+ addressableUrl: a.addressableUrl ?? null,
30
+ acceptsInput: a.acceptsInput ?? null,
31
+ core: a.core ?? false,
32
+ elementFp: a.elementFp ?? null,
33
+ children: a.children ? a.children.map((c) => toAffordance(c, stateId)) : null,
34
+ });
35
+ }
36
+ // Find the ONE navigate/reveal affordance (recursing into reveal children) that
37
+ // carries the (toState, via) transition an edge declares. Prefers an exact
38
+ // label/semanticStep match on `via`; falls back to destination-only when it is
39
+ // unambiguous. Returns null when several candidates exist and none matches the
40
+ // label — we never guess among genuinely-equivalent targets (#5a).
41
+ function findNavTarget(affs, toId, via) {
42
+ const flat = [];
43
+ const walk = (list) => {
44
+ for (const a of list) {
45
+ flat.push(a);
46
+ if (a.children)
47
+ walk(a.children);
48
+ }
49
+ };
50
+ walk(affs);
51
+ const targets = flat.filter((a) => (a.kind === 'navigate' || a.kind === 'reveal') && a.toState === toId);
52
+ const byLabel = targets.find((a) => a.label === via || a.semanticStep === via);
53
+ if (byLabel)
54
+ return byLabel;
55
+ return targets.length === 1 ? targets[0] : null;
56
+ }
57
+ // Dedup key = the affordance's SEMANTIC identity, NOT its id (toAffordance auto-generates a fresh
58
+ // id per call, so the same logical affordance from two edits would double under an id key). The
59
+ // identity is kind|label|toState PLUS the elementFp — critically the `near` content-anchor, which
60
+ // is what distinguishes same-(role,label) SIBLINGS (row-1 vs row-2 "Press Space to toggle", each
61
+ // with a different `near`). Omitting the fp collapsed 50 distinct row buttons into 1. True
62
+ // duplicates (identical role+name+near) still dedupe; genuinely-distinct siblings stay separate.
63
+ function affKey(a) {
64
+ const fp = a.elementFp ? `${a.elementFp.role ?? ''}~${a.elementFp.name ?? ''}~${a.elementFp.near ?? ''}` : '';
65
+ return `${a.kind}|${a.label}|${a.toState ?? ''}|${fp}`;
66
+ }
67
+ // The semantic tuple WITHOUT the fp — same role+label+dest but possibly differing fp. Used for
68
+ // the "fp upgrade" pass: a no-fp affordance that later gains an fp should be UPGRADED, not doubled.
69
+ function affSemanticKey(a) { return `${a.kind}|${a.label}|${a.toState ?? ''}`; }
70
+ /** Merge `incoming` affordances into `existing`, KEEPING existing (commons) and ADDING new ones.
71
+ * Distinct same-(role,label) siblings (different `near`) stay separate (affKey includes the fp).
72
+ * A no-fp entry that a later capture upgrades WITH an fp is replaced, not doubled. Recurses into
73
+ * reveal children. Pure. */
74
+ function mergeAffordances(existing, incoming) {
75
+ const byKey = new Map();
76
+ const add = (a) => {
77
+ const k = affKey(a);
78
+ const prev = byKey.get(k);
79
+ if (prev && (prev.children || a.children))
80
+ prev.children = mergeAffordances(prev.children ?? [], a.children ?? []);
81
+ if (!prev)
82
+ byKey.set(k, a);
83
+ };
84
+ for (const a of existing)
85
+ add(a);
86
+ for (const a of incoming)
87
+ add(a);
88
+ // fp-upgrade pass: if a FP-carrying affordance shares the semantic tuple with a NO-FP one,
89
+ // drop the no-fp one (the fp is the richer, healed coordinate — never keep both).
90
+ const out = [...byKey.values()];
91
+ const fpSemantics = new Set(out.filter((a) => a.elementFp).map(affSemanticKey));
92
+ return out.filter((a) => a.elementFp || !fpSemantics.has(affSemanticKey(a)));
93
+ }
94
+ /** Merge two DeclaredShadows: union collections/filters/subTabs, keep an existing createsEntity. */
95
+ function mergeShadow(existing, incoming) {
96
+ if (!existing)
97
+ return incoming ?? null;
98
+ if (!incoming)
99
+ return existing;
100
+ const uniq = (arr) => { const seen = new Set(); return arr.filter((x) => { const k = JSON.stringify(x); if (seen.has(k))
101
+ return false; seen.add(k); return true; }); };
102
+ return {
103
+ collections: uniq([...(existing.collections ?? []), ...(incoming.collections ?? [])]),
104
+ filters: uniq([...(existing.filters ?? []), ...(incoming.filters ?? [])]),
105
+ createsEntity: existing.createsEntity ?? incoming.createsEntity ?? null,
106
+ subTabs: uniq([...(existing.subTabs ?? []), ...(incoming.subTabs ?? [])]),
107
+ };
108
+ }
109
+ export function editGraph(store, node, graph) {
110
+ // Tolerate a graph missing `edges`/`states` (e.g. a `--draft` piped through `jq '{node,states}'`,
111
+ // or a hand-authored spec with only states). The draft carries edges AS navigate affordances on
112
+ // states, so `edges: []` is the common, valid case — never throw "edges is not iterable" on it.
113
+ graph = { ...graph, states: graph.states ?? [], edges: graph.edges ?? [] };
114
+ const stateId = (label) => `${node}:${label}`;
115
+ // Labels that will exist after this edit: payload states + already-stored states.
116
+ const payloadLabels = new Set(graph.states.map((s) => s.label));
117
+ const knownLabel = (label) => payloadLabels.has(label) || store.getState(stateId(label)) !== null;
118
+ // Validate edge endpoints BEFORE any write (fail fast, atomic).
119
+ for (const e of graph.edges) {
120
+ for (const ep of [e.from, e.to]) {
121
+ if (!knownLabel(ep)) {
122
+ throw new Error(`editGraph: edge endpoint "${ep}" is not a declared or stored state for node "${node}"`);
123
+ }
124
+ }
125
+ }
126
+ // Build payload states up front so the edge pass can author onto their affordances before
127
+ // anything is written. RE-EDIT MERGES, never clobbers: when a state already exists, keep its
128
+ // affordances (commons) + union the incoming ones, so extending a map with a new session that
129
+ // shares some steps and adds new ones preserves both. (Without this, upsertState's
130
+ // affordances=@aff replaces the whole blob — a partial re-edit would wipe prior captures.)
131
+ const payloadStates = new Map(graph.states.map((s) => {
132
+ const incomingAff = (s.affordances ?? []).map((a) => toAffordance(a, stateId));
133
+ const prior = store.getState(stateId(s.label));
134
+ // Always run through mergeAffordances — even first-time (prior=[]) — so the payload dedups
135
+ // WITHIN itself too. Two sessions' fragments of one page can each carry the same "Help
136
+ // Center" link with different auto-ids; without this self-merge, both survived as an exact
137
+ // duplicate edge (live finding: report-list → help-center appeared twice).
138
+ const affordances = mergeAffordances(prior?.affordances ?? [], incomingAff);
139
+ const fingerprint = prior ? [...new Set([...(prior.fingerprint ?? []), ...(s.fingerprint ?? [])])] : (s.fingerprint ?? []);
140
+ const declaredShadow = prior ? mergeShadow(prior.declaredShadow, s.declaredShadow) : (s.declaredShadow ?? null);
141
+ // hierarchy: role + parentState. On RE-EDIT the PRIOR wins — it was computed from the full
142
+ // (multi-session) build, whereas a partial single-session re-edit sees an incomplete nav
143
+ // structure and would mis-derive (e.g. call a known 'detail' a 'section' because this session
144
+ // never captured the parent edge). Only take the incoming when there's no prior (first write).
145
+ const role = prior?.role ?? s.role ?? 'detail';
146
+ const parentState = prior ? (prior.parentState ?? (s.parentState != null ? stateId(s.parentState) : null))
147
+ : (s.parentState != null ? stateId(s.parentState) : null);
148
+ // provisional is a tri-state: `s.provisional === undefined` (key omitted — the common case;
149
+ // draftFromEffects only emits the key when it has a note) means LEAVE the prior note as-is;
150
+ // `null` means the incoming edit explicitly CLEARS it (a confirming re-record removing the
151
+ // seen-once flag); a string SETS/replaces it. Never merge/append here — that's draft's job
152
+ // (it appends onto ITS OWN provisional before this ever reaches editGraph).
153
+ const provisional = s.provisional !== undefined ? s.provisional : (prior?.provisional ?? null);
154
+ // template: additive like urlPattern — take the incoming template, else keep the prior's.
155
+ const template = s.template !== undefined ? s.template : (prior?.template ?? null);
156
+ return [s.label, makeState({
157
+ id: stateId(s.label), nodeId: node, semanticName: s.label,
158
+ urlPattern: s.urlPattern ?? prior?.urlPattern ?? '', role,
159
+ fingerprint, affordances, declaredShadow, parentState, provisional, template,
160
+ })];
161
+ }));
162
+ let statesWritten = 0, edgesWritten = 0;
163
+ store.transaction(() => {
164
+ const existing = store.getNode(node);
165
+ store.upsertNode({
166
+ id: node,
167
+ homeUrl: existing?.homeUrl ?? `https://${node}`,
168
+ });
169
+ // Edge pass FIRST (it may author onto payload/stored states, written after).
170
+ // Affordances are the SOURCE OF TRUTH: when the from-state has a matching
171
+ // navigate affordance, the edge's gate is authored as that affordance's
172
+ // `needs` (and core merged) and NO edge row is written — a stored row would
173
+ // shadow the gated projection on dedup. Edge rows remain only for edge-only
174
+ // authoring (no backing affordance) and needsInput/unclassified forks.
175
+ const patchedStored = new Map();
176
+ for (const e of graph.edges) {
177
+ if (!e.needsInput) {
178
+ const owner = payloadStates.get(e.from)
179
+ ?? patchedStored.get(e.from)
180
+ ?? store.getState(stateId(e.from)) ?? undefined;
181
+ const aff = owner ? findNavTarget(owner.affordances ?? [], stateId(e.to), e.via) : null;
182
+ if (owner && aff) {
183
+ for (const id of e.requiresAffordances ?? []) {
184
+ if (!aff.needs.includes(id))
185
+ aff.needs.push(id);
186
+ }
187
+ aff.core = aff.core || (e.core ?? false);
188
+ if (!payloadStates.has(e.from))
189
+ patchedStored.set(e.from, owner);
190
+ edgesWritten++;
191
+ continue;
192
+ }
193
+ }
194
+ const step = e.needsInput ? `${e.via} [needs-input: ${e.why ?? 'unspecified'}]` : e.via;
195
+ store.upsertEdge(makeEdge({
196
+ fromState: stateId(e.from), toState: stateId(e.to),
197
+ semanticStep: step, kind: e.needsInput ? 'unclassified' : 'navigate',
198
+ requiresAffordances: e.requiresAffordances ?? [],
199
+ core: e.core ?? false,
200
+ }));
201
+ edgesWritten++;
202
+ }
203
+ for (const s of payloadStates.values()) {
204
+ store.upsertState(s);
205
+ statesWritten++;
206
+ }
207
+ for (const s of patchedStored.values())
208
+ store.upsertState(s);
209
+ });
210
+ return { node, statesWritten, edgesWritten };
211
+ }
@@ -0,0 +1,24 @@
1
+ import type { State, Affordance } from '../mapstore/types.js';
2
+ export type FrontierReason = 'dangling-target' | 'ambiguous-action' | 'unopened-panel';
3
+ export interface FrontierItem {
4
+ state: string;
5
+ kind: Affordance['kind'];
6
+ label: string;
7
+ reason: FrontierReason;
8
+ hint: string;
9
+ }
10
+ export interface Frontier {
11
+ status: 'ok';
12
+ node: string;
13
+ total: number;
14
+ frontier: FrontierItem[];
15
+ excluded: FrontierItem[];
16
+ byState: Record<string, number>;
17
+ }
18
+ /**
19
+ * Compute a node's unexplored frontier from its stored states. Pure: takes the
20
+ * states, returns the report. `exclude` = the caller's hard "never click" labels
21
+ * (matched case-insensitively as a substring); matched items are moved to
22
+ * `excluded` (visible, not on the worklist) rather than dropped.
23
+ */
24
+ export declare function computeFrontier(node: string, states: State[], exclude?: string[]): Frontier;
@@ -0,0 +1,95 @@
1
+ // In-place-action shapes: labels that CLEARLY don't open a new surface, so a
2
+ // mutate/input carrying one is NOT frontier. Declared-evidence only — matched on
3
+ // the affordance's own label text, case-insensitively, as a whole-word/substring
4
+ // signal. Grouped by the shape the task named (sort/refresh/pagination/toggle)
5
+ // plus the obviously-in-place widget controls the demo maps declare. This list is
6
+ // the knob: too broad and a real switcher hides; too narrow and every button
7
+ // screams "explore me". Tune it here, never downstream.
8
+ // SAFETY INVARIANT: this list only ever prunes mutate/input affordances — never navigate/reveal
9
+ // (consider() checks kind first), so it can never hide a dangling opener from the frontier.
10
+ const IN_PLACE_SHAPES = [
11
+ // pagination
12
+ /\bpage\b/i, /\bpage size\b/i, /first page/i, /previous page/i, /next page/i, /last page/i,
13
+ // refresh / reload
14
+ /\brefresh\b/i, /\breload\b/i,
15
+ // sort / order
16
+ /\bsort\b/i, /ascending/i, /descending/i, /order by/i,
17
+ // toggle / select / check (row + column selection, dark mode, legend items)
18
+ /toggle/i, /\bcheck(ed|box)?\b/i, /uncheck/i, /select all/i, /deselect/i, /row selection/i,
19
+ /dark mode/i, /light mode/i, /\blegend\b/i,
20
+ // dismiss / close / cancel (overlay teardown, never a new surface)
21
+ /\bclose\b/i, /\bcancel\b/i, /dismiss/i, /\bdone\b/i,
22
+ // in-place view controls the analytics maps declare (chart axis / column tweaks)
23
+ /secondary axis/i, /^remove\b/i, /view all/i, /expand drilldown/i, /full screen/i,
24
+ /\bcolumns?\b/i, /\bfunctions?\b/i,
25
+ // search / filter inputs (an input labeled "search…" filters in place; opens nothing)
26
+ /search/i, /\bfilter\b/i,
27
+ // form-fill inputs — a field you type INTO (login creds, checkout, "enter X").
28
+ // Per the affordance model an input never routes; it's a precondition, not a
29
+ // surface to open. So a plainly-labeled field is in-place, not frontier.
30
+ /^enter /i, /^type /i, /username/i, /password/i, /e-?mail/i,
31
+ // add-to-cart / add-X is the canonical declared in-place MUTATE (affordance
32
+ // model: add-to-cart never routes; the empty cart is a valid state).
33
+ /add .*(to|an item)/i, /^add\b/i,
34
+ ];
35
+ function isInPlace(label) {
36
+ return IN_PLACE_SHAPES.some((re) => re.test(label));
37
+ }
38
+ // A reveal that exposes children is already-explored; a navigate/reveal that
39
+ // resolved to a state is already-explored. `scope` templates are generalized.
40
+ function isGeneralized(a) {
41
+ return a.scope === 'row' || a.scope === 'widget';
42
+ }
43
+ const HINTS = {
44
+ 'dangling-target': 'follow this to capture where it leads',
45
+ 'ambiguous-action': 'fire this and see if it opens a new surface to map',
46
+ 'unopened-panel': 'open this and record the panel contents',
47
+ };
48
+ /**
49
+ * Compute a node's unexplored frontier from its stored states. Pure: takes the
50
+ * states, returns the report. `exclude` = the caller's hard "never click" labels
51
+ * (matched case-insensitively as a substring); matched items are moved to
52
+ * `excluded` (visible, not on the worklist) rather than dropped.
53
+ */
54
+ export function computeFrontier(node, states, exclude = []) {
55
+ const frontier = [];
56
+ const excluded = [];
57
+ const exLower = exclude.map((e) => e.toLowerCase());
58
+ // A reveal's children are its EXPOSED affordances, not a separate frontier item
59
+ // to open — so we do NOT recurse into children here (unlike coverage's flatten).
60
+ // We only judge each state's TOP-LEVEL declared affordances.
61
+ const consider = (state, a) => {
62
+ if (isGeneralized(a))
63
+ return null;
64
+ if (a.kind === 'navigate') {
65
+ // resolved navigate → done; dangling navigate → frontier
66
+ return a.toState ? null
67
+ : { state, kind: a.kind, label: a.label, reason: 'dangling-target', hint: HINTS['dangling-target'] };
68
+ }
69
+ if (a.kind === 'reveal') {
70
+ if (a.children && a.children.length)
71
+ return null; // panel captured
72
+ // an unopened reveal: opener seen, no children (and no toState) → frontier
73
+ return { state, kind: a.kind, label: a.label, reason: 'unopened-panel', hint: HINTS['unopened-panel'] };
74
+ }
75
+ // mutate | input: frontier only if the label is NOT a known in-place shape
76
+ if (isInPlace(a.label))
77
+ return null;
78
+ return { state, kind: a.kind, label: a.label, reason: 'ambiguous-action', hint: HINTS['ambiguous-action'] };
79
+ };
80
+ for (const s of states) {
81
+ for (const a of s.affordances ?? []) {
82
+ const item = consider(s.id, a);
83
+ if (!item)
84
+ continue;
85
+ if (exLower.some((e) => item.label.toLowerCase().includes(e)))
86
+ excluded.push(item);
87
+ else
88
+ frontier.push(item);
89
+ }
90
+ }
91
+ const byState = {};
92
+ for (const item of frontier)
93
+ byState[item.state] = (byState[item.state] ?? 0) + 1;
94
+ return { status: 'ok', node, total: frontier.length, frontier, excluded, byState };
95
+ }
@@ -0,0 +1,23 @@
1
+ import type { MapStore } from '../mapstore/store.js';
2
+ /**
3
+ * Seed the out-of-the-box map for a FRESH install (idempotent upserts).
4
+ *
5
+ * Deliberately MINIMAL: the ONLY thing seeded is the **saucedemo** walk map — a
6
+ * single, complete worked example (login → checkout-complete + the burger menu)
7
+ * so `webnav walk` does something real on first run. Everything else a new user
8
+ * builds themselves (record → graph-analyse --draft → graph-edit). webnav is a
9
+ * blank-slate map tool; saucedemo is just the example that proves it works.
10
+ */
11
+ export declare function seedGraph(store: MapStore): void;
12
+ /**
13
+ * Ensure the default out-of-the-box map is present — but ONLY on a DB where the
14
+ * user has never touched saucedemo (no node row). The old guard keyed on a known
15
+ * interior STATE (checkout-complete), which meant any user-authored saucedemo map
16
+ * lacking that exact state was force-re-seeded on EVERY open: `dev node-clear`
17
+ * (the documented re-learn flow) was silently undone, and a human-recorded map
18
+ * collided with the resurrected seed (live finding: walk matched both the seeded
19
+ * `inventory` and the drafted `inventory-html` → ambiguous). A node-row guard
20
+ * respects clear/re-author; the trade-off: `node-rm` of saucedemo brings the
21
+ * shipped example back on next open (to re-learn, use node-clear — that sticks).
22
+ */
23
+ export declare function ensureSeeded(store: MapStore): void;
@@ -0,0 +1,29 @@
1
+ import { seedSaucedemoComplete } from '../router/walk-live.js';
2
+ /**
3
+ * Seed the out-of-the-box map for a FRESH install (idempotent upserts).
4
+ *
5
+ * Deliberately MINIMAL: the ONLY thing seeded is the **saucedemo** walk map — a
6
+ * single, complete worked example (login → checkout-complete + the burger menu)
7
+ * so `webnav walk` does something real on first run. Everything else a new user
8
+ * builds themselves (record → graph-analyse --draft → graph-edit). webnav is a
9
+ * blank-slate map tool; saucedemo is just the example that proves it works.
10
+ */
11
+ export function seedGraph(store) {
12
+ seedSaucedemoComplete(store);
13
+ }
14
+ /**
15
+ * Ensure the default out-of-the-box map is present — but ONLY on a DB where the
16
+ * user has never touched saucedemo (no node row). The old guard keyed on a known
17
+ * interior STATE (checkout-complete), which meant any user-authored saucedemo map
18
+ * lacking that exact state was force-re-seeded on EVERY open: `dev node-clear`
19
+ * (the documented re-learn flow) was silently undone, and a human-recorded map
20
+ * collided with the resurrected seed (live finding: walk matched both the seeded
21
+ * `inventory` and the drafted `inventory-html` → ambiguous). A node-row guard
22
+ * respects clear/re-author; the trade-off: `node-rm` of saucedemo brings the
23
+ * shipped example back on next open (to re-learn, use node-clear — that sticks).
24
+ */
25
+ export function ensureSeeded(store) {
26
+ if (store.getNode('www.saucedemo.com') === null) {
27
+ seedGraph(store);
28
+ }
29
+ }
@@ -0,0 +1,8 @@
1
+ import type { MapStore } from '../mapstore/store.js';
2
+ import type { State, Edge } from '../mapstore/types.js';
3
+ export interface Interior {
4
+ node: string;
5
+ states: State[];
6
+ edges: Edge[];
7
+ }
8
+ export declare function showInterior(store: MapStore, node: string): Interior;
@@ -0,0 +1,6 @@
1
+ export function showInterior(store, node) {
2
+ const states = store.statesForNode(node);
3
+ const prefix = `${node}:`;
4
+ const edges = store.allEdges().filter((e) => e.fromState.startsWith(prefix));
5
+ return { node, states, edges };
6
+ }
@@ -0,0 +1,8 @@
1
+ import type { MapStore } from '../mapstore/store.js';
2
+ import type { SiteNode } from '../mapstore/types.js';
3
+ export interface AddNodeInput {
4
+ id: string;
5
+ homeUrl: string;
6
+ }
7
+ /** Teach webnav a new site (or update an existing one). Returns the stored node. */
8
+ export declare function addNode(store: MapStore, input: AddNodeInput): SiteNode;
@@ -0,0 +1,10 @@
1
+ /** Teach webnav a new site (or update an existing one). Returns the stored node. */
2
+ export function addNode(store, input) {
3
+ if (!input.id.trim())
4
+ throw new Error('node-add: id must not be empty');
5
+ if (!input.homeUrl.trim())
6
+ throw new Error('node-add: url must not be empty');
7
+ const node = { id: input.id, homeUrl: input.homeUrl };
8
+ store.upsertNode(node);
9
+ return node;
10
+ }
@@ -0,0 +1,36 @@
1
+ import { type MapPack } from './contract.js';
2
+ import type { IMapStore } from './mapstore/store.js';
3
+ export type { MapPack } from './contract.js';
4
+ export interface HostedConfig {
5
+ apiKey?: string;
6
+ apiBase?: string;
7
+ }
8
+ /** Where the CLI looks for the hosted API base + key, in priority order:
9
+ * explicit arg → env (WEBNAV_API / WEBNAV_KEY) → ~/.webnav/config.json → default. */
10
+ export declare function readConfig(): HostedConfig;
11
+ /** Persist the hosted API key (and optional base) to ~/.webnav/config.json.
12
+ * This file holds ONLY the service key — never site credentials. */
13
+ export declare function saveConfig(cfg: HostedConfig): void;
14
+ export declare function resolveApiBase(explicit?: string): string;
15
+ export declare function resolveApiKey(explicit?: string): string | undefined;
16
+ export type FetchFn = (url: string, init?: {
17
+ headers?: Record<string, string>;
18
+ }) => Promise<{
19
+ ok: boolean;
20
+ status: number;
21
+ json: () => Promise<unknown>;
22
+ }>;
23
+ /**
24
+ * Fetch one site's map pack LIVE from the hosted service (the metered hot path).
25
+ * Sends ONLY the site id (in the URL) and the API key (header) — never any
26
+ * credential. Throws a clear, actionable error on missing key / quota / network.
27
+ * `fetchImpl` is injectable for tests (defaults to global fetch).
28
+ */
29
+ export declare function fetchHostedMap(site: string, opts?: {
30
+ key?: string;
31
+ apiBase?: string;
32
+ fetchImpl?: FetchFn;
33
+ }): Promise<MapPack>;
34
+ /** Import a fetched map pack into a store via the existing idempotent upserts, so
35
+ * the SAME walkRoute can travel it. Pure map data — no credentials involved. */
36
+ export declare function importMapPack(store: IMapStore, pack: MapPack): void;
package/dist/hosted.js ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { WEBNAV_KEY_HEADER, mapFetchPath } from './contract.js';
3
+ import { configPath } from './paths.js';
4
+ const DEFAULT_API_BASE = 'https://webnav-site.vercel.app';
5
+ /** Where the CLI looks for the hosted API base + key, in priority order:
6
+ * explicit arg → env (WEBNAV_API / WEBNAV_KEY) → ~/.webnav/config.json → default. */
7
+ export function readConfig() {
8
+ const path = configPath();
9
+ if (!existsSync(path))
10
+ return {};
11
+ try {
12
+ return JSON.parse(readFileSync(path, 'utf8'));
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ /** Persist the hosted API key (and optional base) to ~/.webnav/config.json.
19
+ * This file holds ONLY the service key — never site credentials. */
20
+ export function saveConfig(cfg) {
21
+ const path = configPath();
22
+ const merged = { ...readConfig(), ...cfg };
23
+ writeFileSync(path, JSON.stringify(merged, null, 2), { mode: 0o600 });
24
+ }
25
+ export function resolveApiBase(explicit) {
26
+ return explicit ?? process.env.WEBNAV_API ?? readConfig().apiBase ?? DEFAULT_API_BASE;
27
+ }
28
+ export function resolveApiKey(explicit) {
29
+ return explicit ?? process.env.WEBNAV_KEY ?? readConfig().apiKey;
30
+ }
31
+ /**
32
+ * Fetch one site's map pack LIVE from the hosted service (the metered hot path).
33
+ * Sends ONLY the site id (in the URL) and the API key (header) — never any
34
+ * credential. Throws a clear, actionable error on missing key / quota / network.
35
+ * `fetchImpl` is injectable for tests (defaults to global fetch).
36
+ */
37
+ export async function fetchHostedMap(site, opts = {}) {
38
+ const key = resolveApiKey(opts.key);
39
+ if (!key) {
40
+ throw new Error('hosted route needs an API key. Get a free key at the webnav site, then run ' +
41
+ '`webnav login <key>` — or use the free self-host route (drop --hosted).');
42
+ }
43
+ const base = resolveApiBase(opts.apiBase).replace(/\/$/, '');
44
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
45
+ const url = `${base}${mapFetchPath(site)}`;
46
+ const res = await doFetch(url, { headers: { [WEBNAV_KEY_HEADER]: key } });
47
+ if (res.status === 401)
48
+ throw new Error('hosted route: invalid or unknown API key (re-run `webnav login <key>`).');
49
+ if (res.status === 429)
50
+ throw new Error('hosted route: usage quota exceeded for this key — upgrade your tier or use the self-host route.');
51
+ if (res.status === 404)
52
+ throw new Error(`hosted route: no shared map for "${site}" yet.`);
53
+ if (!res.ok)
54
+ throw new Error(`hosted route: API error ${res.status} fetching "${site}".`);
55
+ const pack = await res.json();
56
+ if (!pack || !pack.node || !Array.isArray(pack.states)) {
57
+ throw new Error(`hosted route: malformed map pack for "${site}".`);
58
+ }
59
+ return pack;
60
+ }
61
+ /** Import a fetched map pack into a store via the existing idempotent upserts, so
62
+ * the SAME walkRoute can travel it. Pure map data — no credentials involved. */
63
+ export function importMapPack(store, pack) {
64
+ store.transaction(() => {
65
+ store.upsertNode(pack.node);
66
+ for (const s of pack.states)
67
+ store.upsertState(s);
68
+ });
69
+ }
@@ -0,0 +1,2 @@
1
+ export { VERSION } from './cli-spec.js';
2
+ export * from './contract.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { VERSION } from './cli-spec.js';
2
+ // The CLI <-> hosted-service contract (MapPack, map types, API protocol) —
3
+ // also importable directly as `@dikshanty94/webnav/contract`.
4
+ export * from './contract.js';