@stevederico/dotbot 0.18.0 → 0.20.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/core/cdp.js ADDED
@@ -0,0 +1,617 @@
1
+ // core/cdp.js
2
+ // Minimal Chrome DevTools Protocol client using Node.js built-in WebSocket.
3
+ // Zero external dependencies - requires Node.js 22+.
4
+
5
+ /**
6
+ * Lightweight CDP client for browser automation.
7
+ * Communicates with Chrome via DevTools Protocol over WebSocket.
8
+ */
9
+ export class CDPClient {
10
+ /**
11
+ * Create a CDP client instance.
12
+ * @param {string} wsUrl - WebSocket debugger URL from Chrome
13
+ */
14
+ constructor(wsUrl) {
15
+ this.wsUrl = wsUrl;
16
+ this.ws = null;
17
+ this.id = 0;
18
+ this.pending = new Map();
19
+ this.eventHandlers = new Map();
20
+ }
21
+
22
+ /**
23
+ * Connect to the browser's WebSocket debugger endpoint.
24
+ * @returns {Promise<void>}
25
+ */
26
+ async connect() {
27
+ return new Promise((resolve, reject) => {
28
+ // Use Node.js 22+ native WebSocket (global, browser-compatible API)
29
+ this.ws = new WebSocket(this.wsUrl);
30
+
31
+ this.ws.addEventListener('open', () => {
32
+ resolve();
33
+ });
34
+
35
+ this.ws.addEventListener('error', (event) => {
36
+ reject(new Error(`CDP connection failed: ${event.message || 'Unknown error'}`));
37
+ });
38
+
39
+ this.ws.addEventListener('message', (event) => {
40
+ const msg = JSON.parse(event.data);
41
+
42
+ // Handle responses to our requests
43
+ if (msg.id !== undefined) {
44
+ const handler = this.pending.get(msg.id);
45
+ if (handler) {
46
+ this.pending.delete(msg.id);
47
+ if (msg.error) {
48
+ handler.reject(new Error(msg.error.message));
49
+ } else {
50
+ handler.resolve(msg.result);
51
+ }
52
+ }
53
+ }
54
+
55
+ // Handle events
56
+ if (msg.method) {
57
+ const handlers = this.eventHandlers.get(msg.method);
58
+ if (handlers) {
59
+ for (const fn of handlers) {
60
+ fn(msg.params);
61
+ }
62
+ }
63
+ }
64
+ });
65
+
66
+ this.ws.addEventListener('close', () => {
67
+ // Reject all pending requests
68
+ for (const [, handler] of this.pending) {
69
+ handler.reject(new Error('CDP connection closed'));
70
+ }
71
+ this.pending.clear();
72
+ });
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Send a CDP command and wait for response.
78
+ * @param {string} method - CDP method name (e.g., 'Page.navigate')
79
+ * @param {Object} params - Command parameters
80
+ * @param {number} timeout - Timeout in ms (default 30s)
81
+ * @returns {Promise<Object>} Command result
82
+ */
83
+ async send(method, params = {}, timeout = 30000) {
84
+ if (!this.ws || this.ws.readyState !== 1) { // 1 = OPEN
85
+ throw new Error('CDP not connected');
86
+ }
87
+
88
+ const id = ++this.id;
89
+
90
+ return new Promise((resolve, reject) => {
91
+ const timer = setTimeout(() => {
92
+ this.pending.delete(id);
93
+ reject(new Error(`CDP command timed out: ${method}`));
94
+ }, timeout);
95
+
96
+ this.pending.set(id, {
97
+ resolve: (result) => {
98
+ clearTimeout(timer);
99
+ resolve(result);
100
+ },
101
+ reject: (err) => {
102
+ clearTimeout(timer);
103
+ reject(err);
104
+ }
105
+ });
106
+
107
+ this.ws.send(JSON.stringify({ id, method, params }));
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Subscribe to a CDP event.
113
+ * @param {string} event - Event name (e.g., 'Page.loadEventFired')
114
+ * @param {Function} handler - Event handler function
115
+ */
116
+ on(event, handler) {
117
+ if (!this.eventHandlers.has(event)) {
118
+ this.eventHandlers.set(event, new Set());
119
+ }
120
+ this.eventHandlers.get(event).add(handler);
121
+ }
122
+
123
+ /**
124
+ * Unsubscribe from a CDP event.
125
+ * @param {string} event - Event name
126
+ * @param {Function} handler - Handler to remove
127
+ */
128
+ off(event, handler) {
129
+ const handlers = this.eventHandlers.get(event);
130
+ if (handlers) {
131
+ handlers.delete(handler);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Navigate to a URL.
137
+ * @param {string} url - URL to navigate to
138
+ * @returns {Promise<Object>} Navigation result with frameId
139
+ */
140
+ async navigate(url) {
141
+ await this.send('Page.enable');
142
+ return this.send('Page.navigate', { url });
143
+ }
144
+
145
+ /**
146
+ * Wait for the page to finish loading.
147
+ * @param {number} timeout - Timeout in ms
148
+ * @returns {Promise<void>}
149
+ */
150
+ async waitForLoad(timeout = 30000) {
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(() => {
153
+ this.off('Page.loadEventFired', handler);
154
+ reject(new Error('Page load timed out'));
155
+ }, timeout);
156
+
157
+ const handler = () => {
158
+ clearTimeout(timer);
159
+ this.off('Page.loadEventFired', handler);
160
+ resolve();
161
+ };
162
+
163
+ this.on('Page.loadEventFired', handler);
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Evaluate JavaScript in the page context.
169
+ * @param {string} expression - JavaScript expression to evaluate
170
+ * @returns {Promise<any>} Result value
171
+ */
172
+ async evaluate(expression) {
173
+ await this.send('Runtime.enable');
174
+ const result = await this.send('Runtime.evaluate', {
175
+ expression,
176
+ returnByValue: true,
177
+ awaitPromise: true
178
+ });
179
+
180
+ if (result.exceptionDetails) {
181
+ throw new Error(result.exceptionDetails.text || 'Evaluation failed');
182
+ }
183
+
184
+ return result.result?.value;
185
+ }
186
+
187
+ /**
188
+ * Get the page title.
189
+ * @returns {Promise<string>}
190
+ */
191
+ async getTitle() {
192
+ return this.evaluate('document.title');
193
+ }
194
+
195
+ /**
196
+ * Get the current URL.
197
+ * @returns {Promise<string>}
198
+ */
199
+ async getUrl() {
200
+ return this.evaluate('window.location.href');
201
+ }
202
+
203
+ /**
204
+ * Get text content of the page body.
205
+ * @returns {Promise<string>}
206
+ */
207
+ async getBodyText() {
208
+ return this.evaluate('document.body?.innerText || ""');
209
+ }
210
+
211
+ /**
212
+ * Get text content of an element by CSS selector.
213
+ * @param {string} selector - CSS selector
214
+ * @returns {Promise<string>}
215
+ */
216
+ async getText(selector) {
217
+ const escaped = selector.replace(/"/g, '\\"');
218
+ return this.evaluate(`document.querySelector("${escaped}")?.innerText || ""`);
219
+ }
220
+
221
+ /**
222
+ * Query a selector and return element info for clicking.
223
+ * @param {string} selector - CSS selector
224
+ * @returns {Promise<{x: number, y: number}|null>} Element center coordinates
225
+ */
226
+ async querySelector(selector) {
227
+ await this.send('DOM.enable');
228
+ const doc = await this.send('DOM.getDocument');
229
+ const result = await this.send('DOM.querySelector', {
230
+ nodeId: doc.root.nodeId,
231
+ selector
232
+ });
233
+
234
+ if (!result.nodeId) return null;
235
+
236
+ const boxes = await this.send('DOM.getBoxModel', { nodeId: result.nodeId });
237
+ if (!boxes.model) return null;
238
+
239
+ // Get center point of the content box
240
+ const content = boxes.model.content;
241
+ const x = (content[0] + content[2] + content[4] + content[6]) / 4;
242
+ const y = (content[1] + content[3] + content[5] + content[7]) / 4;
243
+
244
+ return { x, y, nodeId: result.nodeId };
245
+ }
246
+
247
+ /**
248
+ * Find element by text content.
249
+ * @param {string} text - Text to search for
250
+ * @param {boolean} exact - Exact match (default: false)
251
+ * @returns {Promise<{x: number, y: number}|null>}
252
+ */
253
+ async getByText(text, exact = false) {
254
+ const escaped = text.replace(/"/g, '\\"').replace(/\n/g, '\\n');
255
+ const xpath = exact
256
+ ? `//*[text()="${escaped}"]`
257
+ : `//*[contains(text(), "${escaped}")]`;
258
+
259
+ await this.send('DOM.enable');
260
+ const doc = await this.send('DOM.getDocument');
261
+
262
+ const result = await this.send('DOM.performSearch', {
263
+ query: xpath,
264
+ includeUserAgentShadowDOM: false
265
+ });
266
+
267
+ if (!result.resultCount) return null;
268
+
269
+ const nodes = await this.send('DOM.getSearchResults', {
270
+ searchId: result.searchId,
271
+ fromIndex: 0,
272
+ toIndex: 1
273
+ });
274
+
275
+ await this.send('DOM.discardSearchResults', { searchId: result.searchId });
276
+
277
+ if (!nodes.nodeIds?.length) return null;
278
+
279
+ const boxes = await this.send('DOM.getBoxModel', { nodeId: nodes.nodeIds[0] });
280
+ if (!boxes.model) return null;
281
+
282
+ const content = boxes.model.content;
283
+ const x = (content[0] + content[2] + content[4] + content[6]) / 4;
284
+ const y = (content[1] + content[3] + content[5] + content[7]) / 4;
285
+
286
+ return { x, y, nodeId: nodes.nodeIds[0] };
287
+ }
288
+
289
+ /**
290
+ * Click at specific coordinates.
291
+ * @param {number} x - X coordinate
292
+ * @param {number} y - Y coordinate
293
+ */
294
+ async click(x, y) {
295
+ await this.send('Input.dispatchMouseEvent', {
296
+ type: 'mousePressed',
297
+ x,
298
+ y,
299
+ button: 'left',
300
+ clickCount: 1
301
+ });
302
+ await this.send('Input.dispatchMouseEvent', {
303
+ type: 'mouseReleased',
304
+ x,
305
+ y,
306
+ button: 'left',
307
+ clickCount: 1
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Click an element by CSS selector.
313
+ * @param {string} selector - CSS selector
314
+ */
315
+ async clickSelector(selector) {
316
+ const el = await this.querySelector(selector);
317
+ if (!el) throw new Error(`Element not found: ${selector}`);
318
+ await this.click(el.x, el.y);
319
+ }
320
+
321
+ /**
322
+ * Click an element by visible text.
323
+ * @param {string} text - Text content to find
324
+ */
325
+ async clickText(text) {
326
+ const el = await this.getByText(text);
327
+ if (!el) throw new Error(`Element with text "${text}" not found`);
328
+ await this.click(el.x, el.y);
329
+ }
330
+
331
+ /**
332
+ * Type text character by character.
333
+ * @param {string} text - Text to type
334
+ */
335
+ async type(text) {
336
+ for (const char of text) {
337
+ await this.send('Input.dispatchKeyEvent', {
338
+ type: 'keyDown',
339
+ text: char
340
+ });
341
+ await this.send('Input.dispatchKeyEvent', {
342
+ type: 'keyUp',
343
+ text: char
344
+ });
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Press a special key (Enter, Tab, etc).
350
+ * @param {string} key - Key name
351
+ */
352
+ async press(key) {
353
+ const keyMap = {
354
+ 'Enter': { key: 'Enter', code: 'Enter', keyCode: 13 },
355
+ 'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
356
+ 'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
357
+ 'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 }
358
+ };
359
+
360
+ const keyInfo = keyMap[key] || { key, code: key };
361
+
362
+ await this.send('Input.dispatchKeyEvent', {
363
+ type: 'keyDown',
364
+ ...keyInfo
365
+ });
366
+ await this.send('Input.dispatchKeyEvent', {
367
+ type: 'keyUp',
368
+ ...keyInfo
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Focus an input element and fill it with text.
374
+ * @param {string} selector - CSS selector for the input
375
+ * @param {string} text - Text to fill
376
+ */
377
+ async fill(selector, text) {
378
+ const el = await this.querySelector(selector);
379
+ if (!el) throw new Error(`Input not found: ${selector}`);
380
+
381
+ // Focus the element
382
+ await this.send('DOM.focus', { nodeId: el.nodeId });
383
+
384
+ // Clear existing value
385
+ await this.evaluate(`document.querySelector("${selector.replace(/"/g, '\\"')}").value = ""`);
386
+
387
+ // Type the text
388
+ await this.type(text);
389
+ }
390
+
391
+ /**
392
+ * Take a screenshot of the page.
393
+ * @param {Object} options - Screenshot options
394
+ * @param {boolean} options.fullPage - Capture full scrollable page
395
+ * @returns {Promise<Buffer>} PNG image buffer
396
+ */
397
+ async screenshot(options = {}) {
398
+ const params = { format: 'png' };
399
+
400
+ if (options.fullPage) {
401
+ // Get full page dimensions
402
+ const metrics = await this.send('Page.getLayoutMetrics');
403
+ params.clip = {
404
+ x: 0,
405
+ y: 0,
406
+ width: metrics.contentSize.width,
407
+ height: metrics.contentSize.height,
408
+ scale: 1
409
+ };
410
+ params.captureBeyondViewport = true;
411
+ }
412
+
413
+ const result = await this.send('Page.captureScreenshot', params);
414
+ return Buffer.from(result.data, 'base64');
415
+ }
416
+
417
+ /**
418
+ * Screenshot a specific element.
419
+ * @param {string} selector - CSS selector
420
+ * @returns {Promise<Buffer>} PNG image buffer
421
+ */
422
+ async screenshotElement(selector) {
423
+ const el = await this.querySelector(selector);
424
+ if (!el) throw new Error(`Element not found: ${selector}`);
425
+
426
+ const boxes = await this.send('DOM.getBoxModel', { nodeId: el.nodeId });
427
+ const border = boxes.model.border;
428
+
429
+ const x = Math.min(border[0], border[2], border[4], border[6]);
430
+ const y = Math.min(border[1], border[3], border[5], border[7]);
431
+ const width = Math.max(border[0], border[2], border[4], border[6]) - x;
432
+ const height = Math.max(border[1], border[3], border[5], border[7]) - y;
433
+
434
+ const result = await this.send('Page.captureScreenshot', {
435
+ format: 'png',
436
+ clip: { x, y, width, height, scale: 1 }
437
+ });
438
+
439
+ return Buffer.from(result.data, 'base64');
440
+ }
441
+
442
+ /**
443
+ * Set viewport size.
444
+ * @param {number} width - Viewport width
445
+ * @param {number} height - Viewport height
446
+ */
447
+ async setViewport(width, height) {
448
+ await this.send('Emulation.setDeviceMetricsOverride', {
449
+ width,
450
+ height,
451
+ deviceScaleFactor: 1,
452
+ mobile: false
453
+ });
454
+ }
455
+
456
+ /**
457
+ * Close the connection.
458
+ */
459
+ close() {
460
+ if (this.ws) {
461
+ this.ws.close();
462
+ this.ws = null;
463
+ }
464
+ }
465
+
466
+ // ── Retry & Wait Helpers ──
467
+
468
+ /**
469
+ * Retry a function with exponential backoff.
470
+ * @param {Function} fn - Async function to retry
471
+ * @param {Object} options - Retry options
472
+ * @param {number} options.retries - Max retry attempts (default: 3)
473
+ * @param {number} options.delay - Initial delay in ms (default: 100)
474
+ * @param {number} options.maxDelay - Max delay in ms (default: 2000)
475
+ * @returns {Promise<any>} Function result
476
+ */
477
+ async retry(fn, { retries = 3, delay = 100, maxDelay = 2000 } = {}) {
478
+ let lastError;
479
+ for (let i = 0; i <= retries; i++) {
480
+ try {
481
+ return await fn();
482
+ } catch (err) {
483
+ lastError = err;
484
+ if (i < retries) {
485
+ const wait = Math.min(delay * Math.pow(2, i), maxDelay);
486
+ await new Promise(r => setTimeout(r, wait));
487
+ }
488
+ }
489
+ }
490
+ throw lastError;
491
+ }
492
+
493
+ /**
494
+ * Wait for an element to appear in the DOM.
495
+ * @param {string} selector - CSS selector
496
+ * @param {Object} options - Wait options
497
+ * @param {number} options.timeout - Timeout in ms (default: 5000)
498
+ * @param {number} options.interval - Poll interval in ms (default: 100)
499
+ * @returns {Promise<{x: number, y: number, nodeId: number}>} Element info
500
+ */
501
+ async waitForSelector(selector, { timeout = 5000, interval = 100 } = {}) {
502
+ const start = Date.now();
503
+ while (Date.now() - start < timeout) {
504
+ const el = await this.querySelector(selector);
505
+ if (el) return el;
506
+ await new Promise(r => setTimeout(r, interval));
507
+ }
508
+ throw new Error(`Timeout waiting for selector: ${selector}`);
509
+ }
510
+
511
+ /**
512
+ * Wait for network to be idle (no requests for a period).
513
+ * @param {Object} options - Wait options
514
+ * @param {number} options.timeout - Timeout in ms (default: 10000)
515
+ * @param {number} options.idleTime - Idle time to wait in ms (default: 500)
516
+ * @returns {Promise<void>}
517
+ */
518
+ async waitForNetworkIdle({ timeout = 10000, idleTime = 500 } = {}) {
519
+ await this.send('Network.enable');
520
+
521
+ return new Promise((resolve, reject) => {
522
+ let pending = 0;
523
+ let idleTimer = null;
524
+ const timeoutTimer = setTimeout(() => {
525
+ cleanup();
526
+ resolve(); // Don't reject on timeout, just continue
527
+ }, timeout);
528
+
529
+ const checkIdle = () => {
530
+ if (pending === 0) {
531
+ idleTimer = setTimeout(() => {
532
+ cleanup();
533
+ resolve();
534
+ }, idleTime);
535
+ }
536
+ };
537
+
538
+ const onRequestWillBeSent = () => {
539
+ pending++;
540
+ if (idleTimer) {
541
+ clearTimeout(idleTimer);
542
+ idleTimer = null;
543
+ }
544
+ };
545
+
546
+ const onLoadingFinished = () => {
547
+ pending = Math.max(0, pending - 1);
548
+ checkIdle();
549
+ };
550
+
551
+ const onLoadingFailed = () => {
552
+ pending = Math.max(0, pending - 1);
553
+ checkIdle();
554
+ };
555
+
556
+ const cleanup = () => {
557
+ clearTimeout(timeoutTimer);
558
+ if (idleTimer) clearTimeout(idleTimer);
559
+ this.off('Network.requestWillBeSent', onRequestWillBeSent);
560
+ this.off('Network.loadingFinished', onLoadingFinished);
561
+ this.off('Network.loadingFailed', onLoadingFailed);
562
+ };
563
+
564
+ this.on('Network.requestWillBeSent', onRequestWillBeSent);
565
+ this.on('Network.loadingFinished', onLoadingFinished);
566
+ this.on('Network.loadingFailed', onLoadingFailed);
567
+
568
+ // Check if already idle
569
+ checkIdle();
570
+ });
571
+ }
572
+
573
+ /**
574
+ * Wait for element to be visible (has dimensions).
575
+ * @param {string} selector - CSS selector
576
+ * @param {Object} options - Wait options
577
+ * @param {number} options.timeout - Timeout in ms (default: 5000)
578
+ * @returns {Promise<{x: number, y: number, nodeId: number}>} Element info
579
+ */
580
+ async waitForVisible(selector, { timeout = 5000 } = {}) {
581
+ const start = Date.now();
582
+ while (Date.now() - start < timeout) {
583
+ try {
584
+ const el = await this.querySelector(selector);
585
+ if (el && el.x > 0 && el.y > 0) return el;
586
+ } catch {
587
+ // Element may not exist yet
588
+ }
589
+ await new Promise(r => setTimeout(r, 100));
590
+ }
591
+ throw new Error(`Timeout waiting for visible element: ${selector}`);
592
+ }
593
+
594
+ /**
595
+ * Click an element with auto-retry and wait.
596
+ * @param {string} selector - CSS selector
597
+ * @param {Object} options - Click options
598
+ * @param {number} options.timeout - Wait timeout in ms (default: 5000)
599
+ * @param {number} options.retries - Retry attempts (default: 2)
600
+ */
601
+ async clickWithRetry(selector, { timeout = 5000, retries = 2 } = {}) {
602
+ const el = await this.waitForVisible(selector, { timeout });
603
+ await this.retry(() => this.click(el.x, el.y), { retries });
604
+ }
605
+
606
+ /**
607
+ * Fill an input with auto-retry and wait.
608
+ * @param {string} selector - CSS selector
609
+ * @param {string} text - Text to fill
610
+ * @param {Object} options - Fill options
611
+ * @param {number} options.timeout - Wait timeout in ms (default: 5000)
612
+ */
613
+ async fillWithRetry(selector, text, { timeout = 5000 } = {}) {
614
+ await this.waitForVisible(selector, { timeout });
615
+ await this.retry(() => this.fill(selector, text));
616
+ }
617
+ }
package/dotbot.db CHANGED
Binary file
package/index.js CHANGED
@@ -30,25 +30,20 @@ import {
30
30
  export {
31
31
  SessionStore,
32
32
  SQLiteSessionStore,
33
- MongoSessionStore,
34
33
  MemorySessionStore,
35
34
  defaultSystemPrompt,
36
35
  CronStore,
37
- MongoCronStore,
38
36
  SQLiteCronStore,
39
37
  parseInterval,
40
38
  HEARTBEAT_INTERVAL_MS,
41
39
  HEARTBEAT_PROMPT,
42
40
  runWithConcurrency,
43
41
  TaskStore,
44
- MongoTaskStore,
45
42
  SQLiteTaskStore,
46
43
  // Backwards compatibility aliases
47
44
  GoalStore,
48
- MongoGoalStore,
49
45
  SQLiteGoalStore,
50
46
  TriggerStore,
51
- MongoTriggerStore,
52
47
  SQLiteTriggerStore,
53
48
  SQLiteMemoryStore,
54
49
  EventStore,
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@stevederico/dotbot",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "AI agent CLI and library for Node.js — streaming, multi-provider, tool execution, autonomous tasks",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
8
8
  "dotbot": "./bin/dotbot.js"
9
9
  },
10
+ "scripts": {
11
+ "test": "node --test test/*.test.js"
12
+ },
10
13
  "exports": {
11
14
  ".": "./index.js",
12
15
  "./core/*": "./core/*.js",
@@ -28,12 +31,10 @@
28
31
  ],
29
32
  "author": "Steve Derico",
30
33
  "license": "MIT",
31
- "peerDependencies": {
32
- "mongodb": "^6.0.0"
33
- },
34
- "dependencies": {
35
- "playwright": "^1.58.2"
34
+ "engines": {
35
+ "node": ">=22.0.0"
36
36
  },
37
+ "dependencies": {},
37
38
  "devDependencies": {},
38
39
  "repository": {
39
40
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  import crypto from 'crypto';
2
2
  import { SessionStore } from './SessionStore.js';
3
- import { defaultSystemPrompt } from './MongoAdapter.js';
3
+ import { defaultSystemPrompt } from './SQLiteAdapter.js';
4
4
  import { toStandardFormat } from '../core/normalize.js';
5
5
 
6
6
  /**