leapfrog-mcp 0.6.2 → 0.6.3
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/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// ─── Per-Domain Knowledge Persistence ─────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Leapfrog remembers what it learned about every website across sessions.
|
|
4
|
+
// Storage: ~/.leapfrog/domains/{domain}.json
|
|
5
|
+
//
|
|
6
|
+
// This is the self-improvement foundation. Every navigation, block event,
|
|
7
|
+
// consent dismissal, and API discovery feeds back into future visits.
|
|
8
|
+
import { readFile, writeFile, mkdir, rename } from 'fs/promises';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a domain string for consistent storage.
|
|
15
|
+
* Strips `www.` prefix and lowercases so that `www.wikipedia.org`
|
|
16
|
+
* and `Wikipedia.org` resolve to the same record.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeDomain(domain) {
|
|
19
|
+
return domain.toLowerCase().replace(/^www\./, '');
|
|
20
|
+
}
|
|
21
|
+
/** Sanitize domain for use as a filename. Keep `.` and `-`, replace the rest. */
|
|
22
|
+
function domainToFilename(domain) {
|
|
23
|
+
return normalizeDomain(domain).replace(/[^a-zA-Z0-9.\-]/g, '_') + '.json';
|
|
24
|
+
}
|
|
25
|
+
// ─── Class ────────────────────────────────────────────────────────────────
|
|
26
|
+
export class DomainKnowledge {
|
|
27
|
+
cache = new Map();
|
|
28
|
+
dirty = new Set();
|
|
29
|
+
baseDir;
|
|
30
|
+
maxDomains;
|
|
31
|
+
dirEnsured = false;
|
|
32
|
+
constructor(baseDir, maxDomains) {
|
|
33
|
+
this.baseDir = baseDir ?? join(homedir(), '.leapfrog', 'domains');
|
|
34
|
+
this.maxDomains = maxDomains ?? 500;
|
|
35
|
+
}
|
|
36
|
+
// ── Read ──────────────────────────────────────────────────────────────
|
|
37
|
+
/** Load domain record from cache or disk. Creates empty if not found. */
|
|
38
|
+
async load(domain) {
|
|
39
|
+
const key = normalizeDomain(domain);
|
|
40
|
+
const cached = this.cache.get(key);
|
|
41
|
+
if (cached)
|
|
42
|
+
return cached;
|
|
43
|
+
const filePath = join(this.baseDir, domainToFilename(key));
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
46
|
+
const record = JSON.parse(raw);
|
|
47
|
+
record.domain = key; // ensure normalized
|
|
48
|
+
this.cache.set(key, record);
|
|
49
|
+
this.evict();
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// File missing or corrupt — start fresh
|
|
54
|
+
const record = this.createEmpty(key);
|
|
55
|
+
this.cache.set(key, record);
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Get from cache only (sync, for hot paths). */
|
|
60
|
+
get(domain) {
|
|
61
|
+
return this.cache.get(normalizeDomain(domain));
|
|
62
|
+
}
|
|
63
|
+
// ── Write ─────────────────────────────────────────────────────────────
|
|
64
|
+
/** Merge partial updates into a domain record. Marks dirty. */
|
|
65
|
+
update(domain, partial) {
|
|
66
|
+
const key = normalizeDomain(domain);
|
|
67
|
+
let record = this.cache.get(key);
|
|
68
|
+
if (!record) {
|
|
69
|
+
record = this.createEmpty(key);
|
|
70
|
+
this.cache.set(key, record);
|
|
71
|
+
}
|
|
72
|
+
Object.assign(record, partial, { domain: key }); // domain is immutable
|
|
73
|
+
this.dirty.add(key);
|
|
74
|
+
this.evict();
|
|
75
|
+
}
|
|
76
|
+
/** Record a successful navigation — updates waitStrategy running average. */
|
|
77
|
+
recordNavigation(domain, method, durationMs) {
|
|
78
|
+
const key = normalizeDomain(domain);
|
|
79
|
+
let record = this.cache.get(key);
|
|
80
|
+
if (!record) {
|
|
81
|
+
record = this.createEmpty(key);
|
|
82
|
+
this.cache.set(key, record);
|
|
83
|
+
}
|
|
84
|
+
if (!record.waitStrategy) {
|
|
85
|
+
record.waitStrategy = { method, avgLoadTime: durationMs, samples: 1 };
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
record.waitStrategy.samples++;
|
|
89
|
+
record.waitStrategy.avgLoadTime +=
|
|
90
|
+
(durationMs - record.waitStrategy.avgLoadTime) / record.waitStrategy.samples;
|
|
91
|
+
record.waitStrategy.method = method;
|
|
92
|
+
}
|
|
93
|
+
record.visitCount++;
|
|
94
|
+
record.lastVisit = Date.now();
|
|
95
|
+
this.dirty.add(key);
|
|
96
|
+
this.evict();
|
|
97
|
+
}
|
|
98
|
+
/** Record a block event — escalates stealth tier if 2+ blocks in the last hour. */
|
|
99
|
+
recordBlock(domain, reason) {
|
|
100
|
+
const key = normalizeDomain(domain);
|
|
101
|
+
let record = this.cache.get(key);
|
|
102
|
+
if (!record) {
|
|
103
|
+
record = this.createEmpty(key);
|
|
104
|
+
this.cache.set(key, record);
|
|
105
|
+
}
|
|
106
|
+
record.blockHistory.push({ timestamp: Date.now(), reason });
|
|
107
|
+
const oneHourAgo = Date.now() - 3_600_000;
|
|
108
|
+
const recentBlocks = record.blockHistory.filter(b => b.timestamp > oneHourAgo);
|
|
109
|
+
if (recentBlocks.length >= 2 && record.stealthTier < 3) {
|
|
110
|
+
record.stealthTier++;
|
|
111
|
+
logger.info('domain-knowledge:stealth-escalated', {
|
|
112
|
+
domain: key,
|
|
113
|
+
newTier: record.stealthTier,
|
|
114
|
+
recentBlocks: recentBlocks.length,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
this.dirty.add(key);
|
|
118
|
+
}
|
|
119
|
+
/** Record a consent dismissal selector for future visits. */
|
|
120
|
+
recordConsent(domain, selector) {
|
|
121
|
+
const key = normalizeDomain(domain);
|
|
122
|
+
let record = this.cache.get(key);
|
|
123
|
+
if (!record) {
|
|
124
|
+
record = this.createEmpty(key);
|
|
125
|
+
this.cache.set(key, record);
|
|
126
|
+
}
|
|
127
|
+
record.consentSelector = selector;
|
|
128
|
+
this.dirty.add(key);
|
|
129
|
+
}
|
|
130
|
+
/** Record a successful challenge resolution method for future visits. */
|
|
131
|
+
recordChallengeResolution(domain, method) {
|
|
132
|
+
const key = normalizeDomain(domain);
|
|
133
|
+
let record = this.cache.get(key);
|
|
134
|
+
if (!record) {
|
|
135
|
+
record = this.createEmpty(key);
|
|
136
|
+
this.cache.set(key, record);
|
|
137
|
+
}
|
|
138
|
+
if (!record.challengeResolution || record.challengeResolution.method !== method) {
|
|
139
|
+
record.challengeResolution = { method, successCount: 1, lastSuccess: Date.now() };
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
record.challengeResolution.successCount++;
|
|
143
|
+
record.challengeResolution.lastSuccess = Date.now();
|
|
144
|
+
}
|
|
145
|
+
this.dirty.add(key);
|
|
146
|
+
logger.info('domain-knowledge:challenge-method-learned', { domain: key, method, count: record.challengeResolution.successCount });
|
|
147
|
+
}
|
|
148
|
+
/** Get the known-good challenge resolution method for a domain. */
|
|
149
|
+
getLearnedChallengeMethod(domain) {
|
|
150
|
+
const record = this.cache.get(normalizeDomain(domain));
|
|
151
|
+
return record?.challengeResolution?.method ?? null;
|
|
152
|
+
}
|
|
153
|
+
/** Record discovered API endpoints, merging with existing. */
|
|
154
|
+
recordApiEndpoints(domain, endpoints) {
|
|
155
|
+
const key = normalizeDomain(domain);
|
|
156
|
+
let record = this.cache.get(key);
|
|
157
|
+
if (!record) {
|
|
158
|
+
record = this.createEmpty(key);
|
|
159
|
+
this.cache.set(key, record);
|
|
160
|
+
}
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
for (const ep of endpoints) {
|
|
163
|
+
const existing = record.apiEndpoints.find(a => a.path === ep.path && a.method === ep.method);
|
|
164
|
+
if (existing) {
|
|
165
|
+
existing.classification = ep.classification;
|
|
166
|
+
existing.lastSeen = now;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
record.apiEndpoints.push({ ...ep, lastSeen: now });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.dirty.add(key);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Record element fingerprints from a snapshot visit.
|
|
176
|
+
* Increments seenCount for known fingerprints, adds new ones,
|
|
177
|
+
* and removes fingerprints not seen in 3+ consecutive visits.
|
|
178
|
+
*/
|
|
179
|
+
recordElementFingerprints(domain, fingerprints) {
|
|
180
|
+
const key = normalizeDomain(domain);
|
|
181
|
+
let record = this.cache.get(key);
|
|
182
|
+
if (!record) {
|
|
183
|
+
record = this.createEmpty(key);
|
|
184
|
+
this.cache.set(key, record);
|
|
185
|
+
}
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const fpSet = new Set(fingerprints);
|
|
188
|
+
// Initialize misses tracker if needed (backwards compat with old records)
|
|
189
|
+
if (!record.stableElementMisses) {
|
|
190
|
+
record.stableElementMisses = {};
|
|
191
|
+
}
|
|
192
|
+
// Update existing stable elements
|
|
193
|
+
for (const el of record.stableElements) {
|
|
194
|
+
if (fpSet.has(el.fingerprint)) {
|
|
195
|
+
el.seenCount++;
|
|
196
|
+
// Reset miss counter — it appeared
|
|
197
|
+
record.stableElementMisses[el.fingerprint] = 0;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// Increment miss counter
|
|
201
|
+
record.stableElementMisses[el.fingerprint] =
|
|
202
|
+
(record.stableElementMisses[el.fingerprint] ?? 0) + 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Add new fingerprints not yet tracked
|
|
206
|
+
const tracked = new Set(record.stableElements.map(e => e.fingerprint));
|
|
207
|
+
for (const fp of fingerprints) {
|
|
208
|
+
if (!tracked.has(fp)) {
|
|
209
|
+
record.stableElements.push({
|
|
210
|
+
fingerprint: fp,
|
|
211
|
+
seenCount: 1,
|
|
212
|
+
firstSeen: now,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Remove fingerprints not seen in 3+ consecutive visits (they're no longer stable)
|
|
217
|
+
record.stableElements = record.stableElements.filter(el => {
|
|
218
|
+
const misses = record.stableElementMisses?.[el.fingerprint] ?? 0;
|
|
219
|
+
return misses < 3;
|
|
220
|
+
});
|
|
221
|
+
// Clean up miss tracker for removed entries
|
|
222
|
+
const remainingFPs = new Set(record.stableElements.map(e => e.fingerprint));
|
|
223
|
+
for (const fpKey of Object.keys(record.stableElementMisses)) {
|
|
224
|
+
if (!remainingFPs.has(fpKey)) {
|
|
225
|
+
delete record.stableElementMisses[fpKey];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.dirty.add(key);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Get fingerprints of stable elements (seenCount >= minSeen) for suppression.
|
|
232
|
+
* Returns them sorted by seenCount descending (highest confidence first).
|
|
233
|
+
*/
|
|
234
|
+
getStableFingerprints(domain, minSeen = 3) {
|
|
235
|
+
const key = normalizeDomain(domain);
|
|
236
|
+
const record = this.cache.get(key);
|
|
237
|
+
if (!record)
|
|
238
|
+
return [];
|
|
239
|
+
return record.stableElements
|
|
240
|
+
.filter(el => el.seenCount >= minSeen)
|
|
241
|
+
.sort((a, b) => b.seenCount - a.seenCount)
|
|
242
|
+
.map(el => el.fingerprint);
|
|
243
|
+
}
|
|
244
|
+
// ── Element Memory (selector healing) ─────────────────────────────────
|
|
245
|
+
static ELEMENT_MEMORY_CAP = 50;
|
|
246
|
+
/** Upsert an element fingerprint → selector mapping into domain memory. */
|
|
247
|
+
recordElement(domain, fingerprint, selector) {
|
|
248
|
+
const key = normalizeDomain(domain);
|
|
249
|
+
let record = this.cache.get(key);
|
|
250
|
+
if (!record) {
|
|
251
|
+
record = this.createEmpty(key);
|
|
252
|
+
this.cache.set(key, record);
|
|
253
|
+
}
|
|
254
|
+
if (!record.elementMemory) {
|
|
255
|
+
record.elementMemory = [];
|
|
256
|
+
}
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
const existing = record.elementMemory.find(e => e.fingerprint === fingerprint);
|
|
259
|
+
if (existing) {
|
|
260
|
+
existing.lastSelector = selector;
|
|
261
|
+
existing.lastSeen = now;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
record.elementMemory.push({ fingerprint, lastSelector: selector, lastSeen: now });
|
|
265
|
+
}
|
|
266
|
+
// LRU cap: sort by lastSeen descending, keep only the newest entries
|
|
267
|
+
if (record.elementMemory.length > DomainKnowledge.ELEMENT_MEMORY_CAP) {
|
|
268
|
+
record.elementMemory.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
269
|
+
record.elementMemory.length = DomainKnowledge.ELEMENT_MEMORY_CAP;
|
|
270
|
+
}
|
|
271
|
+
this.dirty.add(key);
|
|
272
|
+
}
|
|
273
|
+
/** Look up the last known selector for an element fingerprint on a domain. */
|
|
274
|
+
findElement(domain, fingerprint) {
|
|
275
|
+
const key = normalizeDomain(domain);
|
|
276
|
+
const record = this.cache.get(key);
|
|
277
|
+
if (!record?.elementMemory)
|
|
278
|
+
return undefined;
|
|
279
|
+
const entry = record.elementMemory.find(e => e.fingerprint === fingerprint);
|
|
280
|
+
return entry?.lastSelector;
|
|
281
|
+
}
|
|
282
|
+
// ── Persistence ───────────────────────────────────────────────────────
|
|
283
|
+
/** Flush all dirty records to disk. */
|
|
284
|
+
async flush() {
|
|
285
|
+
if (this.dirty.size === 0)
|
|
286
|
+
return;
|
|
287
|
+
await this.ensureDir();
|
|
288
|
+
const promises = [];
|
|
289
|
+
for (const domain of this.dirty) {
|
|
290
|
+
promises.push(this.writeDomain(domain));
|
|
291
|
+
}
|
|
292
|
+
await Promise.allSettled(promises);
|
|
293
|
+
this.dirty.clear();
|
|
294
|
+
logger.debug('domain-knowledge:flushed', { count: promises.length });
|
|
295
|
+
}
|
|
296
|
+
/** Flush a single domain to disk. */
|
|
297
|
+
async flushDomain(domain) {
|
|
298
|
+
const key = normalizeDomain(domain);
|
|
299
|
+
if (!this.dirty.has(key))
|
|
300
|
+
return;
|
|
301
|
+
await this.ensureDir();
|
|
302
|
+
await this.writeDomain(key);
|
|
303
|
+
this.dirty.delete(key);
|
|
304
|
+
}
|
|
305
|
+
// ── Query ─────────────────────────────────────────────────────────────
|
|
306
|
+
/** List all loaded domains (for MCP tool display). */
|
|
307
|
+
listDomains() {
|
|
308
|
+
return Array.from(this.cache.values()).map(r => ({
|
|
309
|
+
domain: r.domain,
|
|
310
|
+
visitCount: r.visitCount,
|
|
311
|
+
lastVisit: r.lastVisit,
|
|
312
|
+
stealthTier: r.stealthTier,
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
/** Get full record for display. Loads from disk if needed. */
|
|
316
|
+
async inspect(domain) {
|
|
317
|
+
const key = normalizeDomain(domain);
|
|
318
|
+
const cached = this.cache.get(key);
|
|
319
|
+
if (cached)
|
|
320
|
+
return cached;
|
|
321
|
+
const filePath = join(this.baseDir, domainToFilename(key));
|
|
322
|
+
try {
|
|
323
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
324
|
+
return JSON.parse(raw);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ── Navigation Hints ──────────────────────────────────────────────────
|
|
331
|
+
/**
|
|
332
|
+
* Return actionable navigation hints from stored domain knowledge.
|
|
333
|
+
* This is the read-side of the self-improvement loop: past visits
|
|
334
|
+
* inform future navigation strategy, stealth tier, and consent handling.
|
|
335
|
+
*
|
|
336
|
+
* Returns an empty object if no useful knowledge exists yet.
|
|
337
|
+
*/
|
|
338
|
+
async getNavigationHints(domain) {
|
|
339
|
+
const record = await this.load(domain);
|
|
340
|
+
const hints = {};
|
|
341
|
+
// Wait strategy: only trust it with 2+ samples
|
|
342
|
+
if (record.waitStrategy?.method && record.waitStrategy.samples >= 2) {
|
|
343
|
+
hints.waitUntil = record.waitStrategy.method;
|
|
344
|
+
}
|
|
345
|
+
// Stealth tier: propagate if non-zero
|
|
346
|
+
if (record.stealthTier > 0) {
|
|
347
|
+
hints.stealthTier = record.stealthTier;
|
|
348
|
+
}
|
|
349
|
+
// Consent selector: pass through if known
|
|
350
|
+
if (record.consentSelector) {
|
|
351
|
+
hints.consentSelector = record.consentSelector;
|
|
352
|
+
}
|
|
353
|
+
// Bandit state: signal presence so caller can restore it
|
|
354
|
+
if (record.banditState) {
|
|
355
|
+
hints.hasBanditState = true;
|
|
356
|
+
}
|
|
357
|
+
return hints;
|
|
358
|
+
}
|
|
359
|
+
/** Persist bandit state into a domain record and mark dirty. */
|
|
360
|
+
saveBanditState(domain, state) {
|
|
361
|
+
const key = normalizeDomain(domain);
|
|
362
|
+
let record = this.cache.get(key);
|
|
363
|
+
if (!record) {
|
|
364
|
+
record = this.createEmpty(key);
|
|
365
|
+
this.cache.set(key, record);
|
|
366
|
+
}
|
|
367
|
+
record.banditState = state;
|
|
368
|
+
this.dirty.add(key);
|
|
369
|
+
}
|
|
370
|
+
/** Get the persisted bandit state for a domain (from cache). */
|
|
371
|
+
getBanditState(domain) {
|
|
372
|
+
const key = normalizeDomain(domain);
|
|
373
|
+
const record = this.cache.get(key);
|
|
374
|
+
return record?.banditState ?? undefined;
|
|
375
|
+
}
|
|
376
|
+
// ── Internal ──────────────────────────────────────────────────────────
|
|
377
|
+
createEmpty(domain) {
|
|
378
|
+
return {
|
|
379
|
+
domain,
|
|
380
|
+
stealthTier: 0,
|
|
381
|
+
blockHistory: [],
|
|
382
|
+
waitStrategy: null,
|
|
383
|
+
rateLimit: null,
|
|
384
|
+
consentSelector: null,
|
|
385
|
+
challengeResolution: null,
|
|
386
|
+
stableElements: [],
|
|
387
|
+
elementMemory: [],
|
|
388
|
+
apiEndpoints: [],
|
|
389
|
+
visitCount: 0,
|
|
390
|
+
firstVisit: Date.now(),
|
|
391
|
+
lastVisit: Date.now(),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Ensure the storage directory exists (once per instance). */
|
|
395
|
+
async ensureDir() {
|
|
396
|
+
if (this.dirEnsured)
|
|
397
|
+
return;
|
|
398
|
+
try {
|
|
399
|
+
await mkdir(this.baseDir, { recursive: true });
|
|
400
|
+
this.dirEnsured = true;
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
logger.error('domain-knowledge:mkdir-failed', {
|
|
404
|
+
dir: this.baseDir,
|
|
405
|
+
error: String(err),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Write a single domain record to disk. */
|
|
410
|
+
async writeDomain(domain) {
|
|
411
|
+
const record = this.cache.get(domain);
|
|
412
|
+
if (!record)
|
|
413
|
+
return;
|
|
414
|
+
const filePath = join(this.baseDir, domainToFilename(domain));
|
|
415
|
+
const tmpPath = filePath + '.tmp';
|
|
416
|
+
try {
|
|
417
|
+
await writeFile(tmpPath, JSON.stringify(record, null, 2), 'utf-8');
|
|
418
|
+
await rename(tmpPath, filePath);
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
logger.error('domain-knowledge:write-failed', {
|
|
422
|
+
domain,
|
|
423
|
+
file: filePath,
|
|
424
|
+
error: String(err),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** LRU eviction — remove least-recently-visited domains when over cap. */
|
|
429
|
+
evict() {
|
|
430
|
+
if (this.cache.size <= this.maxDomains)
|
|
431
|
+
return;
|
|
432
|
+
const sorted = Array.from(this.cache.entries()).sort((a, b) => a[1].lastVisit - b[1].lastVisit);
|
|
433
|
+
// Evict oldest 10%
|
|
434
|
+
const evictCount = Math.ceil(this.maxDomains * 0.1);
|
|
435
|
+
for (let i = 0; i < evictCount && i < sorted.length; i++) {
|
|
436
|
+
const [key] = sorted[i];
|
|
437
|
+
// Don't evict dirty records — they haven't been saved yet
|
|
438
|
+
if (this.dirty.has(key))
|
|
439
|
+
continue;
|
|
440
|
+
this.cache.delete(key);
|
|
441
|
+
}
|
|
442
|
+
logger.debug('domain-knowledge:evicted', {
|
|
443
|
+
evicted: evictCount,
|
|
444
|
+
remaining: this.cache.size,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// ─── Singleton ────────────────────────────────────────────────────────────
|
|
449
|
+
export const domainKnowledge = new DomainKnowledge();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type ActionOutcome = "SUCCESS" | "SILENT_CLICK" | "NAVIGATION" | "WRONG_ELEMENT" | "BLOCKED" | "ERROR" | "PENDING";
|
|
2
|
+
export interface LoopWarning {
|
|
3
|
+
type: "same-element" | "same-url" | "ping-pong" | "action-repeat";
|
|
4
|
+
message: string;
|
|
5
|
+
count: number;
|
|
6
|
+
suggestion: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StuckWarning {
|
|
9
|
+
stuckActions: number;
|
|
10
|
+
message: string;
|
|
11
|
+
suggestions: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface ActionRecord {
|
|
14
|
+
index: number;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
actionType: string;
|
|
17
|
+
target?: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
url: string;
|
|
20
|
+
snapshotHash: string;
|
|
21
|
+
outcome: ActionOutcome;
|
|
22
|
+
duration: number;
|
|
23
|
+
/** Present on records created via recordToolCall (navigate, snapshot, etc.) */
|
|
24
|
+
toolCall?: {
|
|
25
|
+
toolName: string;
|
|
26
|
+
params: Record<string, unknown>;
|
|
27
|
+
resultSummary: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface HarnessState {
|
|
31
|
+
outcome: ActionOutcome;
|
|
32
|
+
outcomeDetail: string;
|
|
33
|
+
loopWarning?: LoopWarning;
|
|
34
|
+
stuckWarning?: StuckWarning;
|
|
35
|
+
}
|
|
36
|
+
declare function djb2(str: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Extract the ARIA role from the snapshot text for an @eN target.
|
|
39
|
+
* Snapshot lines look like: "@e5 paragraph "Some text content""
|
|
40
|
+
* Returns the role string (e.g. "paragraph") or undefined if not found.
|
|
41
|
+
*/
|
|
42
|
+
declare function extractRoleFromSnapshot(target: string, snapshotText: string): string | undefined;
|
|
43
|
+
export declare function formatHarnessOutput(state: HarnessState): string;
|
|
44
|
+
export declare class HarnessIntelligence {
|
|
45
|
+
/** Record pre-action state. Call BEFORE performing an action. */
|
|
46
|
+
static capturePreState(sessionId: string, url: string, snapshotText: string): void;
|
|
47
|
+
/** Analyze post-action state. Call AFTER performing an action. Returns guidance. */
|
|
48
|
+
static analyzePostAction(sessionId: string, actionType: string, target: string | undefined, value: string | undefined, url: string, snapshotText: string, error?: string): HarnessState;
|
|
49
|
+
/**
|
|
50
|
+
* Record a tool call (navigate, snapshot, batch_actions, execute, etc.)
|
|
51
|
+
* so that session_memory returns a complete timeline — not just `act` calls.
|
|
52
|
+
*/
|
|
53
|
+
static recordToolCall(sessionId: string, toolName: string, params: Record<string, unknown>, resultSummary: string, durationMs: number): void;
|
|
54
|
+
/** Get action history for a session */
|
|
55
|
+
static getHistory(sessionId: string, limit?: number): ActionRecord[];
|
|
56
|
+
/** Clear all state for a session */
|
|
57
|
+
static clearSession(sessionId: string): void;
|
|
58
|
+
/** Get loop/stuck status without recording an action (for diagnostics) */
|
|
59
|
+
static diagnose(sessionId: string): {
|
|
60
|
+
loopWarning?: LoopWarning;
|
|
61
|
+
stuckWarning?: StuckWarning;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export { djb2, extractRoleFromSnapshot };
|
|
65
|
+
export default HarnessIntelligence;
|