@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,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
|
+
}
|