@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,128 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { SnapshotDiff } from '../explorer/diff.js';
3
+ import type { ElementFingerprint } from '../playwright/fingerprint.js';
4
+ export interface DeclaredLink {
5
+ to: string;
6
+ via: string;
7
+ }
8
+ export interface Observation {
9
+ url: string;
10
+ fingerprint: string[];
11
+ declaredLinks: DeclaredLink[];
12
+ }
13
+ export interface StoredObservation extends Observation {
14
+ seq: number;
15
+ capturedAt: number;
16
+ }
17
+ export interface ActionRef {
18
+ role: string;
19
+ name: string | null;
20
+ ref: string | null;
21
+ elementFp?: ElementFingerprint | null;
22
+ value?: string;
23
+ hover?: boolean;
24
+ rightClick?: boolean;
25
+ }
26
+ export interface ActionEffect {
27
+ fromUrl: string;
28
+ fromSnapshot: string;
29
+ action: ActionRef | null;
30
+ toUrl: string;
31
+ toSnapshot: string;
32
+ navigated: boolean;
33
+ diff: SnapshotDiff;
34
+ requestedUrl?: string;
35
+ nameHints?: Record<string, string>;
36
+ }
37
+ export interface StoredActionEffect extends ActionEffect {
38
+ seq: number;
39
+ capturedAt: number;
40
+ }
41
+ export interface LedgerEvent {
42
+ t?: number;
43
+ source: 'human' | 'agent';
44
+ kind: string;
45
+ descriptor: Record<string, unknown>;
46
+ }
47
+ export interface StoredLedgerEvent extends LedgerEvent {
48
+ seq: number;
49
+ disposition: string | null;
50
+ }
51
+ export interface RecordSessionInfo {
52
+ sessionId: string;
53
+ active: boolean;
54
+ startedAt: number;
55
+ stoppedAt: number | null;
56
+ steps: number;
57
+ site: string | null;
58
+ review?: {
59
+ approved: boolean;
60
+ gaps: number;
61
+ at: number;
62
+ model?: string;
63
+ reason?: string;
64
+ } | null;
65
+ }
66
+ /** Persists raw page observations per record-session. Sibling of MapStore;
67
+ * same Database handle, separate tables. No clustering here — that's analyse. */
68
+ export declare class RecordStore {
69
+ private db;
70
+ constructor(path?: string);
71
+ static fromDatabase(db: Database.Database): RecordStore;
72
+ private migrate;
73
+ /** Store the capture-review verdict for a session (graph-ready gate). */
74
+ setReview(sessionId: string, verdict: {
75
+ approved: boolean;
76
+ gaps: number;
77
+ at: number;
78
+ model?: string;
79
+ reason?: string;
80
+ }): void;
81
+ reviewOf(sessionId: string): {
82
+ approved: boolean;
83
+ gaps: number;
84
+ at: number;
85
+ model?: string;
86
+ reason?: string;
87
+ } | null;
88
+ /** Tag who recorded the session ('agent' | 'extension' | 'manual'); only sets if not already set. */
89
+ setOrigin(sessionId: string, origin: 'agent' | 'extension' | 'manual'): void;
90
+ originOf(sessionId: string): 'agent' | 'extension' | 'manual';
91
+ /** Record the intended start URL for a session (idempotent; only sets if given). */
92
+ setStartUrl(sessionId: string, url: string): void;
93
+ startUrl(sessionId: string): string | null;
94
+ /** The named profile a session runs under (null ⇒ caller applies 'default'). */
95
+ setProfile(sessionId: string, profile: string): void;
96
+ profileOf(sessionId: string): string | null;
97
+ /** Session ids using a given profile (for the Profiles tab count + rename). */
98
+ sessionsUsingProfile(profile: string): string[];
99
+ /** Repoint every session on `from` to `to` (rename). */
100
+ renameProfileRefs(from: string, to: string): void;
101
+ start(sessionId: string, nowMs?: number): string;
102
+ stop(sessionId: string, nowMs?: number): void;
103
+ /** Drop all buffered observations for a session (so re-recording into the same
104
+ * session id replaces, not appends — the ingest receiver's default session is reused). */
105
+ clearSession(sessionId: string): void;
106
+ /** Delete a recording ENTIRELY — observations AND the session row (live finding:
107
+ * clearSession alone left the row, so a "deleted" recording stayed in the list). */
108
+ deleteSession(sessionId: string): void;
109
+ /** Rename a recording's id across BOTH tables (session row + its observations). Refuses
110
+ * if `to` already exists (would merge two recordings silently). Returns false if `from`
111
+ * is unknown or `to` is taken. On-disk video/review dirs are moved by the CLI caller. */
112
+ renameSession(from: string, to: string): boolean;
113
+ isActive(sessionId: string): boolean;
114
+ append(sessionId: string, obs: Observation, nowMs?: number): void;
115
+ observations(sessionId: string): StoredObservation[];
116
+ appendActionEffect(sessionId: string, fx: ActionEffect, nowMs?: number): number | null;
117
+ /** Append one raw event to the session's ledger. isActive-gated like steps:
118
+ * recording off = off, for BOTH capture paths. Returns the ledger seq (for the
119
+ * later disposition stamp) or null when not recording. `ev.t` (the human path's
120
+ * page-clock timestamp) always wins; agent-path callers that pass no `t` get
121
+ * stamped with `nowMs` so the dashboard's ledger time column is never blank. */
122
+ appendEvent(sessionId: string, ev: LedgerEvent, nowMs?: number): number | null;
123
+ /** Stamp an event's fate: 'step:<stepSeq>' or 'dropped:<reason>'. */
124
+ stampEvent(sessionId: string, seq: number, disposition: string): void;
125
+ events(sessionId: string): StoredLedgerEvent[];
126
+ actionEffects(sessionId: string): StoredActionEffect[];
127
+ listSessions(): RecordSessionInfo[];
128
+ }
@@ -0,0 +1,224 @@
1
+ import Database from 'better-sqlite3';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { dbPath } from '../paths.js';
6
+ const SCHEMA = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'schema.sql'), 'utf8');
7
+ /** Persists raw page observations per record-session. Sibling of MapStore;
8
+ * same Database handle, separate tables. No clustering here — that's analyse. */
9
+ export class RecordStore {
10
+ db;
11
+ constructor(path = dbPath()) {
12
+ this.db = new Database(path);
13
+ this.db.exec(SCHEMA);
14
+ this.migrate();
15
+ }
16
+ static fromDatabase(db) {
17
+ const s = Object.create(RecordStore.prototype);
18
+ s.db = db;
19
+ db.exec(SCHEMA);
20
+ s.migrate();
21
+ return s;
22
+ }
23
+ migrate() {
24
+ const cols = this.db.prepare('PRAGMA table_info(record_observations)').all();
25
+ const have = new Set(cols.map((c) => c.name));
26
+ for (const [col, type] of [['from_url', 'TEXT'], ['from_snapshot', 'TEXT'], ['action', 'TEXT'],
27
+ ['to_url', 'TEXT'], ['to_snapshot', 'TEXT'], ['navigated', 'INTEGER'], ['diff', 'TEXT'],
28
+ ['requested_url', 'TEXT'], ['name_hints', 'TEXT']]) {
29
+ if (!have.has(col))
30
+ this.db.exec(`ALTER TABLE record_observations ADD COLUMN ${col} ${type}`);
31
+ }
32
+ // start_url = the URL the operator ASKED to record at (not wherever an auth
33
+ // redirect bounced them) — so reopen returns to the product, not the login page.
34
+ const scols = new Set(this.db.prepare('PRAGMA table_info(record_sessions)').all().map((c) => c.name));
35
+ if (!scols.has('start_url'))
36
+ this.db.exec('ALTER TABLE record_sessions ADD COLUMN start_url TEXT');
37
+ // profile = the NAMED browser profile a session runs under (shared logged-in
38
+ // state; null ⇒ 'default'). Lets "log in once" apply across every session.
39
+ if (!scols.has('profile'))
40
+ this.db.exec('ALTER TABLE record_sessions ADD COLUMN profile TEXT');
41
+ // origin = who recorded this session: 'agent' (use session / use-driven CLI walk),
42
+ // 'extension' (the webnav Chrome extension's agent run), or 'manual' (human
43
+ // record-live / dashboard). Null (legacy rows) reads as 'manual'.
44
+ if (!scols.has('origin'))
45
+ this.db.exec('ALTER TABLE record_sessions ADD COLUMN origin TEXT');
46
+ // review = the capture-review verdict JSON ({approved, gaps, at, model, reason}) — set
47
+ // by `dev review`. A session is GRAPH-READY only when approved (all on-screen actions
48
+ // captured as steps). Null (never reviewed) reads as not-approved.
49
+ if (!scols.has('review'))
50
+ this.db.exec('ALTER TABLE record_sessions ADD COLUMN review TEXT');
51
+ }
52
+ /** Store the capture-review verdict for a session (graph-ready gate). */
53
+ setReview(sessionId, verdict) {
54
+ this.db.prepare('UPDATE record_sessions SET review=? WHERE session_id=?').run(JSON.stringify(verdict), sessionId);
55
+ }
56
+ reviewOf(sessionId) {
57
+ const r = this.db.prepare('SELECT review FROM record_sessions WHERE session_id=?').get(sessionId);
58
+ if (!r?.review)
59
+ return null;
60
+ try {
61
+ return JSON.parse(r.review);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /** Tag who recorded the session ('agent' | 'extension' | 'manual'); only sets if not already set. */
68
+ setOrigin(sessionId, origin) {
69
+ this.db.prepare('UPDATE record_sessions SET origin=? WHERE session_id=? AND origin IS NULL').run(origin, sessionId);
70
+ }
71
+ originOf(sessionId) {
72
+ const r = this.db.prepare('SELECT origin FROM record_sessions WHERE session_id=?').get(sessionId);
73
+ return r?.origin === 'agent' || r?.origin === 'extension' ? r.origin : 'manual'; // legacy/null → manual
74
+ }
75
+ /** Record the intended start URL for a session (idempotent; only sets if given). */
76
+ setStartUrl(sessionId, url) {
77
+ this.db.prepare('UPDATE record_sessions SET start_url=? WHERE session_id=?').run(url, sessionId);
78
+ }
79
+ startUrl(sessionId) {
80
+ const r = this.db.prepare('SELECT start_url FROM record_sessions WHERE session_id=?').get(sessionId);
81
+ return r?.start_url ?? null;
82
+ }
83
+ /** The named profile a session runs under (null ⇒ caller applies 'default'). */
84
+ setProfile(sessionId, profile) {
85
+ this.db.prepare('UPDATE record_sessions SET profile=? WHERE session_id=?').run(profile, sessionId);
86
+ }
87
+ profileOf(sessionId) {
88
+ const r = this.db.prepare('SELECT profile FROM record_sessions WHERE session_id=?').get(sessionId);
89
+ return r?.profile ?? null;
90
+ }
91
+ /** Session ids using a given profile (for the Profiles tab count + rename). */
92
+ sessionsUsingProfile(profile) {
93
+ return this.db.prepare('SELECT session_id FROM record_sessions WHERE profile=?').all(profile).map((r) => r.session_id);
94
+ }
95
+ /** Repoint every session on `from` to `to` (rename). */
96
+ renameProfileRefs(from, to) {
97
+ this.db.prepare('UPDATE record_sessions SET profile=? WHERE profile=?').run(to, from);
98
+ }
99
+ start(sessionId, nowMs = Date.now()) {
100
+ this.db.prepare(`INSERT INTO record_sessions (session_id,active,started_at) VALUES (?,1,?)
101
+ ON CONFLICT(session_id) DO UPDATE SET active=1, started_at=?, stopped_at=NULL`)
102
+ .run(sessionId, nowMs, nowMs);
103
+ return sessionId;
104
+ }
105
+ stop(sessionId, nowMs = Date.now()) {
106
+ this.db.prepare('UPDATE record_sessions SET active=0, stopped_at=? WHERE session_id=?')
107
+ .run(nowMs, sessionId);
108
+ }
109
+ /** Drop all buffered observations for a session (so re-recording into the same
110
+ * session id replaces, not appends — the ingest receiver's default session is reused). */
111
+ clearSession(sessionId) {
112
+ this.db.prepare('DELETE FROM record_observations WHERE session_id=?').run(sessionId);
113
+ this.db.prepare('DELETE FROM record_events WHERE session_id=?').run(sessionId);
114
+ }
115
+ /** Delete a recording ENTIRELY — observations AND the session row (live finding:
116
+ * clearSession alone left the row, so a "deleted" recording stayed in the list). */
117
+ deleteSession(sessionId) {
118
+ this.clearSession(sessionId);
119
+ this.db.prepare('DELETE FROM record_sessions WHERE session_id=?').run(sessionId);
120
+ }
121
+ /** Rename a recording's id across BOTH tables (session row + its observations). Refuses
122
+ * if `to` already exists (would merge two recordings silently). Returns false if `from`
123
+ * is unknown or `to` is taken. On-disk video/review dirs are moved by the CLI caller. */
124
+ renameSession(from, to) {
125
+ if (from === to)
126
+ return true;
127
+ const exists = (id) => !!this.db.prepare('SELECT 1 FROM record_sessions WHERE session_id=?').get(id);
128
+ if (!exists(from) || exists(to))
129
+ return false;
130
+ this.db.transaction(() => {
131
+ this.db.prepare('UPDATE record_sessions SET session_id=? WHERE session_id=?').run(to, from);
132
+ this.db.prepare('UPDATE record_observations SET session_id=? WHERE session_id=?').run(to, from);
133
+ this.db.prepare('UPDATE record_events SET session_id=? WHERE session_id=?').run(to, from);
134
+ })();
135
+ return true;
136
+ }
137
+ isActive(sessionId) {
138
+ const r = this.db.prepare('SELECT active FROM record_sessions WHERE session_id=?').get(sessionId);
139
+ return !!r && r.active === 1;
140
+ }
141
+ append(sessionId, obs, nowMs = Date.now()) {
142
+ if (!this.isActive(sessionId))
143
+ return; // recording off → no-op
144
+ const seq = this.db.prepare('SELECT COUNT(*) AS c FROM record_observations WHERE session_id=?').get(sessionId);
145
+ this.db.prepare(`INSERT INTO record_observations (session_id,seq,url,fingerprint,declared_links,captured_at)
146
+ VALUES (?,?,?,?,?,?)`)
147
+ .run(sessionId, seq.c, obs.url, JSON.stringify(obs.fingerprint), JSON.stringify(obs.declaredLinks), nowMs);
148
+ }
149
+ observations(sessionId) {
150
+ const rows = this.db.prepare('SELECT * FROM record_observations WHERE session_id=? ORDER BY seq').all(sessionId);
151
+ return rows.map((r) => ({ url: r.url, fingerprint: JSON.parse(r.fingerprint),
152
+ declaredLinks: JSON.parse(r.declared_links), seq: r.seq, capturedAt: r.captured_at }));
153
+ }
154
+ appendActionEffect(sessionId, fx, nowMs = Date.now()) {
155
+ if (!this.isActive(sessionId))
156
+ return null;
157
+ const seq = this.db.prepare('SELECT COUNT(*) AS c FROM record_observations WHERE session_id=?').get(sessionId);
158
+ this.db.prepare(`INSERT INTO record_observations
159
+ (session_id,seq,url,fingerprint,declared_links,captured_at,
160
+ from_url,from_snapshot,action,to_url,to_snapshot,navigated,diff,requested_url,name_hints)
161
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
162
+ .run(sessionId, seq.c, fx.toUrl, '[]', '[]', nowMs, fx.fromUrl, fx.fromSnapshot, JSON.stringify(fx.action), fx.toUrl, fx.toSnapshot, fx.navigated ? 1 : 0, JSON.stringify(fx.diff), fx.requestedUrl ?? null, fx.nameHints ? JSON.stringify(fx.nameHints) : null);
163
+ return seq.c;
164
+ }
165
+ /** Append one raw event to the session's ledger. isActive-gated like steps:
166
+ * recording off = off, for BOTH capture paths. Returns the ledger seq (for the
167
+ * later disposition stamp) or null when not recording. `ev.t` (the human path's
168
+ * page-clock timestamp) always wins; agent-path callers that pass no `t` get
169
+ * stamped with `nowMs` so the dashboard's ledger time column is never blank. */
170
+ appendEvent(sessionId, ev, nowMs = Date.now()) {
171
+ if (!this.isActive(sessionId))
172
+ return null;
173
+ const seq = this.db.prepare('SELECT COUNT(*) AS c FROM record_events WHERE session_id=?').get(sessionId);
174
+ this.db.prepare('INSERT INTO record_events (session_id,seq,t,source,kind,descriptor) VALUES (?,?,?,?,?,?)')
175
+ .run(sessionId, seq.c, ev.t ?? nowMs, ev.source, ev.kind, JSON.stringify(ev.descriptor));
176
+ return seq.c;
177
+ }
178
+ /** Stamp an event's fate: 'step:<stepSeq>' or 'dropped:<reason>'. */
179
+ stampEvent(sessionId, seq, disposition) {
180
+ this.db.prepare('UPDATE record_events SET disposition=? WHERE session_id=? AND seq=?')
181
+ .run(disposition, sessionId, seq);
182
+ }
183
+ events(sessionId) {
184
+ const rows = this.db.prepare('SELECT * FROM record_events WHERE session_id=? ORDER BY seq').all(sessionId);
185
+ return rows.map((r) => ({ seq: r.seq, t: r.t ?? undefined, source: r.source, kind: r.kind,
186
+ descriptor: JSON.parse(r.descriptor), disposition: r.disposition ?? null }));
187
+ }
188
+ actionEffects(sessionId) {
189
+ const rows = this.db.prepare('SELECT * FROM record_observations WHERE session_id=? AND from_snapshot IS NOT NULL ORDER BY seq').all(sessionId);
190
+ return rows.map((r) => ({
191
+ fromUrl: r.from_url, fromSnapshot: r.from_snapshot,
192
+ action: JSON.parse(r.action), toUrl: r.to_url, toSnapshot: r.to_snapshot,
193
+ navigated: r.navigated === 1, diff: JSON.parse(r.diff),
194
+ requestedUrl: r.requested_url ?? undefined,
195
+ nameHints: r.name_hints ? JSON.parse(r.name_hints) : undefined,
196
+ seq: r.seq, capturedAt: r.captured_at,
197
+ }));
198
+ }
199
+ listSessions() {
200
+ const rows = this.db.prepare(`SELECT s.session_id, s.active, s.started_at, s.stopped_at, s.review,
201
+ (SELECT COUNT(*) FROM record_observations o
202
+ WHERE o.session_id = s.session_id AND o.from_snapshot IS NOT NULL) AS steps,
203
+ (SELECT o2.from_url FROM record_observations o2
204
+ WHERE o2.session_id = s.session_id AND o2.from_snapshot IS NOT NULL
205
+ ORDER BY o2.seq LIMIT 1) AS first_url
206
+ FROM record_sessions s ORDER BY s.started_at DESC`).all();
207
+ const hostOf = (u) => { try {
208
+ return u ? new URL(u).host : null;
209
+ }
210
+ catch {
211
+ return null;
212
+ } };
213
+ const parseReview = (v) => { if (!v)
214
+ return null; try {
215
+ return JSON.parse(v);
216
+ }
217
+ catch {
218
+ return null;
219
+ } };
220
+ return rows.map((r) => ({ sessionId: r.session_id, active: r.active === 1,
221
+ startedAt: r.started_at, stoppedAt: r.stopped_at ?? null, steps: r.steps, site: hostOf(r.first_url),
222
+ review: parseReview(r.review) }));
223
+ }
224
+ }
@@ -0,0 +1,47 @@
1
+ CREATE TABLE IF NOT EXISTS states (
2
+ id TEXT PRIMARY KEY, node_id TEXT, semantic_name TEXT NOT NULL, url_pattern TEXT NOT NULL,
3
+ role TEXT NOT NULL, available_signals TEXT NOT NULL, fingerprint TEXT NOT NULL, affordances TEXT,
4
+ declared_shadow TEXT
5
+ );
6
+ CREATE TABLE IF NOT EXISTS edges (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ from_state TEXT NOT NULL, to_state TEXT NOT NULL, semantic_step TEXT NOT NULL,
9
+ selector_cache TEXT, kind TEXT NOT NULL, accepts_input TEXT,
10
+ cost REAL NOT NULL DEFAULT 0,
11
+ requires_affordances TEXT, core INTEGER, element_fp TEXT,
12
+ UNIQUE(from_state, to_state, semantic_step)
13
+ );
14
+ CREATE TABLE IF NOT EXISTS nodes (
15
+ id TEXT PRIMARY KEY, home_url TEXT NOT NULL, capabilities TEXT NOT NULL, topics TEXT NOT NULL
16
+ );
17
+ CREATE TABLE IF NOT EXISTS node_edges (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ from_node TEXT NOT NULL, to_node TEXT NOT NULL, kind TEXT NOT NULL,
20
+ UNIQUE(from_node, to_node, kind)
21
+ );
22
+ CREATE TABLE IF NOT EXISTS record_sessions (
23
+ session_id TEXT PRIMARY KEY, active INTEGER NOT NULL DEFAULT 1,
24
+ started_at INTEGER NOT NULL, stopped_at INTEGER
25
+ );
26
+ CREATE TABLE IF NOT EXISTS record_observations (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ session_id TEXT NOT NULL, seq INTEGER NOT NULL,
29
+ url TEXT NOT NULL, fingerprint TEXT NOT NULL, declared_links TEXT NOT NULL,
30
+ captured_at INTEGER NOT NULL,
31
+ from_url TEXT, from_snapshot TEXT, action TEXT, to_url TEXT, to_snapshot TEXT,
32
+ navigated INTEGER, diff TEXT,
33
+ UNIQUE(session_id, seq)
34
+ );
35
+ CREATE TABLE IF NOT EXISTS record_events (
36
+ session_id TEXT NOT NULL, seq INTEGER NOT NULL,
37
+ t INTEGER, source TEXT NOT NULL, kind TEXT NOT NULL,
38
+ descriptor TEXT NOT NULL, disposition TEXT,
39
+ PRIMARY KEY (session_id, seq)
40
+ );
41
+ CREATE TABLE IF NOT EXISTS walk_sessions (
42
+ session_id TEXT PRIMARY KEY,
43
+ start_state TEXT NOT NULL, goal_state TEXT NOT NULL,
44
+ path TEXT NOT NULL, pos INTEGER NOT NULL DEFAULT 0,
45
+ browser_session TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'paused',
46
+ created_at INTEGER NOT NULL
47
+ );
@@ -0,0 +1,108 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { State, Edge, InteriorEdge, SiteNode, NodeEdge, ElementFingerprint } from './types.js';
3
+ /** The data-access seam. SqliteMapStore is the only impl today; a hosted
4
+ * backend (Firestore/Postgres) can implement the same interface later. */
5
+ export interface IMapStore {
6
+ transaction(fn: () => void): void;
7
+ upsertState(s: State): void;
8
+ getState(id: string): State | null;
9
+ allStates(): State[];
10
+ statesForNode(nodeId: string): State[];
11
+ upsertEdge(e: Edge): void;
12
+ deleteEdgesFromPrefix(prefix: string): void;
13
+ clearNode(nodeId: string): void;
14
+ removeNode(nodeId: string): void;
15
+ edgesFrom(fromState: string): Edge[];
16
+ allEdges(): Edge[];
17
+ interiorEdges(nodeId: string): InteriorEdge[];
18
+ recordSelector(fromState: string, toState: string, semanticStep: string, selector: string): void;
19
+ recordElementFp(fromState: string, affordanceId: string, fp: ElementFingerprint): boolean;
20
+ upsertNode(n: SiteNode): void;
21
+ getNode(id: string): SiteNode | null;
22
+ allNodes(): SiteNode[];
23
+ upsertNodeEdge(e: NodeEdge): void;
24
+ nodeEdgesFrom(fromNode: string): NodeEdge[];
25
+ allNodeEdges(): NodeEdge[];
26
+ }
27
+ export declare class MapStore implements IMapStore {
28
+ private db;
29
+ constructor(path?: string);
30
+ /** Open a store over an already-constructed Database handle (used by tests + migration). */
31
+ static fromDatabase(db: Database.Database): MapStore;
32
+ /** Idempotent: add states.node_id if missing, backfill from the id prefix. */
33
+ private migrate;
34
+ /** Run `fn` atomically — all writes commit together or none do (no torn skeleton). */
35
+ transaction(fn: () => void): void;
36
+ upsertState(s: State): void;
37
+ getState(id: string): State | null;
38
+ allStates(): State[];
39
+ statesForNode(nodeId: string): State[];
40
+ upsertEdge(e: Edge): void;
41
+ deleteEdgesFromPrefix(prefix: string): void;
42
+ /** Wipe a single node's INTERIOR — its states and their stored edges — so the site can be
43
+ * RE-LEARNED from scratch through webnav (never raw sqlite). The node row itself stays
44
+ * (its id/homeUrl), so a fresh graph-edit lands back under it. No-op if unknown. */
45
+ clearNode(nodeId: string): void;
46
+ /** Fully DELETE a node: its interior (states + edges, via clearNode), its node_edges (both
47
+ * directions), AND the node row itself — so a stale/empty site disappears entirely. For
48
+ * cleaning up dashboard cruft; never raw sqlite. No-op if unknown. Touches ONLY this node. */
49
+ removeNode(nodeId: string): void;
50
+ /**
51
+ * Project a state's navigate/reveal affordances (recursing into reveal children)
52
+ * into edges. Affordances are the SOURCE OF TRUTH; this is how the router/walk
53
+ * see them as edges WITHOUT changing their interface (principle: edges = actions).
54
+ * mutate/input affordances and navigate/reveal with no toState do NOT project.
55
+ */
56
+ private projectFromAffordances;
57
+ /** Edges leaving a state = stored edges UNION projected-from-affordances UNION the
58
+ * site's `_shell` navigate affordances (from-anywhere: the shell's nav/header/footer
59
+ * links are reachable from every page, not just the shell record itself), deduped
60
+ * by (from,to,semanticStep) preferring the stored row (carries the self-heal
61
+ * selector_cache and teach-written fields) then the state's own projected edges. */
62
+ edgesFrom(fromState: string): Edge[];
63
+ /** Project the node's `_shell` navigate affordances as edges FROM `fromState` (from-anywhere:
64
+ * shell chrome — nav/header/footer — is present on every page, not routable only from the
65
+ * shell record). No-op when `fromState` IS the shell (it projects its own affordances above,
66
+ * and re-adding them here would just be filtered as duplicates anyway) or when the node has
67
+ * no `_shell` state. */
68
+ private shellEdgesFor;
69
+ /** NOTE: does NOT fan the `_shell` from-anywhere edges out onto every page here — this is the
70
+ * flat human-facing listing (graph-show/outline/mermaid); duplicating N shell edges across
71
+ * every page would just be noise for a human reading the map. The router (`edgesFrom`, used
72
+ * per-node by findPath's Dijkstra) is where from-anywhere routing actually needs them. */
73
+ allEdges(): Edge[];
74
+ /**
75
+ * Viewer-facing projection for ONE node's interior: every navigate/reveal
76
+ * affordance becomes an edge tagged with the affordance id that triggers it
77
+ * (`viaAffordance`, so the UI can anchor the arrow to that row). navigate/reveal
78
+ * with no toState emit a `dangling` stub (to=null) so the UI shows "unexplored".
79
+ *
80
+ * DELIBERATELY does not fan `_shell`'s navigate affordances out onto every OTHER state here the
81
+ * way `edgesFrom` does (from-anywhere routing) — the `_shell` state's own affordances already
82
+ * walk() below (it's one of `statesForNode`), so its edges appear once, anchored to `_shell`.
83
+ * The viewer renders `_shell` as its own distinct chrome node (Task 13) rather than every page
84
+ * repeating identical "open the nav menu" arrows.
85
+ */
86
+ interiorEdges(nodeId: string): InteriorEdge[];
87
+ private allEdgesStored;
88
+ /** SELF-HEAL write-back (principle #3): record the durable name an agent's ref
89
+ * resolved to on a step that deterministic resolution had MISSED, so the next
90
+ * walk re-resolves it without re-asking. Stored as the edge's selector_cache.
91
+ * A no-op if the edge isn't a stored row (a purely affordance-projected edge
92
+ * with no edges-table backing); upserting one here is out of scope. */
93
+ recordSelector(fromState: string, toState: string, semanticStep: string, selector: string): void;
94
+ /** SELF-HEAL write-back, fingerprint flavor (B1): persist a `{role,name,near}` element
95
+ * fingerprint onto the OWNING AFFORDANCE in `fromState` (located by its id), so the next
96
+ * walk resolves the once-ambiguous step deterministically. Writes the affordance blob —
97
+ * NOT the edges table — because both motivating maps store affordances only (no edge rows),
98
+ * where an `UPDATE edges` would no-op. Recurses reveal children. No-op if the affordance
99
+ * isn't found (caller falls back to recordSelector for a legacy stored row). */
100
+ recordElementFp(fromState: string, affordanceId: string, fp: ElementFingerprint): boolean;
101
+ upsertNode(n: SiteNode): void;
102
+ getNode(id: string): SiteNode | null;
103
+ allNodes(): SiteNode[];
104
+ upsertNodeEdge(e: NodeEdge): void;
105
+ nodeEdgesFrom(fromNode: string): NodeEdge[];
106
+ /** ALL node edges (the export/visualization needs every edge, not just from one node). */
107
+ allNodeEdges(): NodeEdge[];
108
+ }