veil-browser 0.2.0 → 0.3.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/dist/browser.d.ts CHANGED
@@ -8,5 +8,5 @@ export declare function ensureBrowser(opts?: {
8
8
  page: Page;
9
9
  }>;
10
10
  export declare function getPage(): Promise<Page | null>;
11
- export declare function closeBrowser(platform?: string): Promise<void>;
11
+ export declare function closeBrowser(_platform?: string): Promise<void>;
12
12
  export declare function humanDelay(min?: number, max?: number): Promise<void>;
package/dist/browser.js CHANGED
@@ -3,90 +3,52 @@ import { promises as fs } from 'fs';
3
3
  import { homedir } from 'os';
4
4
  import { join } from 'path';
5
5
  import { loadSession } from './session.js';
6
- const STATE_FILE = join(homedir(), '.veil', 'browser.json');
7
- // Singleton references (in-process)
6
+ const VEIL_DIR = join(homedir(), '.veil');
7
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36';
8
8
  let _browser = null;
9
9
  let _context = null;
10
10
  let _page = null;
11
- let _platform = 'default';
12
11
  export async function ensureBrowser(opts = {}) {
13
- const platform = opts.platform ?? 'default';
14
- if (_browser && _context && _page && !_page.isClosed()) {
12
+ if (_browser?.isConnected() && _page && !_page.isClosed()) {
15
13
  return { browser: _browser, context: _context, page: _page };
16
14
  }
17
- // Launch browser with stealth args
18
15
  const browser = await chromium.launch({
19
16
  headless: !opts.headed,
20
- args: [
21
- '--disable-blink-features=AutomationControlled',
22
- '--no-sandbox',
23
- '--disable-setuid-sandbox',
24
- '--disable-infobars',
25
- '--disable-dev-shm-usage',
26
- '--disable-accelerated-2d-canvas',
27
- '--no-first-run',
28
- '--no-zygote',
29
- '--disable-gpu',
30
- '--window-size=1280,800',
31
- '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
32
- ],
17
+ args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--window-size=1280,800'],
33
18
  });
34
- // Create context with realistic settings
35
19
  const context = await browser.newContext({
36
20
  viewport: { width: 1280, height: 800 },
37
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
21
+ userAgent: UA,
38
22
  locale: 'en-US',
39
23
  timezoneId: 'America/New_York',
40
- permissions: ['notifications'],
41
- extraHTTPHeaders: {
42
- 'Accept-Language': 'en-US,en;q=0.9',
43
- },
24
+ extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
44
25
  });
45
- // Inject stealth scripts
46
26
  await context.addInitScript(() => {
47
27
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
48
28
  window.chrome = { runtime: {} };
49
- Object.defineProperty(navigator, 'plugins', {
50
- get: () => [1, 2, 3, 4, 5],
51
- });
52
- Object.defineProperty(navigator, 'languages', {
53
- get: () => ['en-US', 'en'],
54
- });
29
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
30
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
55
31
  });
56
- // Restore session cookies if available
57
- const session = await loadSession(platform);
58
- if (session?.cookies && session.cookies.length > 0) {
59
- await context.addCookies(session.cookies);
60
- }
32
+ const session = await loadSession(opts.platform ?? 'default').catch(() => null);
33
+ if (session?.cookies?.length)
34
+ await context.addCookies(session.cookies).catch(() => { });
61
35
  const page = await context.newPage();
62
36
  _browser = browser;
63
37
  _context = context;
64
38
  _page = page;
65
- _platform = platform;
66
- // Save state for reference
67
- await fs.mkdir(join(homedir(), '.veil'), { recursive: true });
68
- await fs.writeFile(STATE_FILE, JSON.stringify({
69
- platform,
70
- pid: process.pid,
71
- timestamp: Date.now(),
72
- }), 'utf-8').catch(() => { });
39
+ await fs.mkdir(VEIL_DIR, { recursive: true });
40
+ process.once('exit', () => { browser.close().catch(() => { }); });
73
41
  return { browser, context, page };
74
42
  }
75
43
  export async function getPage() {
76
- if (_page && !_page.isClosed())
77
- return _page;
78
- return null;
44
+ return (_page && !_page.isClosed()) ? _page : null;
79
45
  }
80
- export async function closeBrowser(platform) {
81
- if (_browser) {
82
- await _browser.close().catch(() => { });
83
- _browser = null;
84
- _context = null;
85
- _page = null;
86
- }
87
- await fs.rm(STATE_FILE).catch(() => { });
46
+ export async function closeBrowser(_platform) {
47
+ await _browser?.close().catch(() => { });
48
+ _browser = null;
49
+ _context = null;
50
+ _page = null;
88
51
  }
89
- export function humanDelay(min = 500, max = 1200) {
90
- const delay = Math.floor(Math.random() * (max - min) + min);
91
- return new Promise(r => setTimeout(r, delay));
52
+ export function humanDelay(min = 400, max = 900) {
53
+ return new Promise(r => setTimeout(r, Math.floor(Math.random() * (max - min) + min)));
92
54
  }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ const program = new Command();
7
7
  program
8
8
  .name('veil')
9
9
  .description('šŸ•¶ļø OpenClaw browser remote — stealth headless browser')
10
- .version('0.2.0');
10
+ .version('0.3.0');
11
11
  // ─── Session ──────────────────────────────────────────────────────────────────
12
12
  program
13
13
  .command('login <platform>')
@@ -351,6 +351,151 @@ program
351
351
  const title = await page.title();
352
352
  console.log(JSON.stringify({ ok: true, platform, url: page.url(), title }));
353
353
  });
354
+ // ─── X / Social Interactions ─────────────────────────────────────────────────
355
+ program
356
+ .command('like')
357
+ .description('Like the Nth post on the current page')
358
+ .option('--nth <n>', 'Which post (0-indexed)', '0')
359
+ .option('--platform <platform>', 'Platform for session', 'x')
360
+ .action(async (opts) => {
361
+ const { ensureBrowser, humanDelay } = await import('./browser.js');
362
+ const { browser, page } = await ensureBrowser({ platform: opts.platform });
363
+ try {
364
+ await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded', timeout: 30000 });
365
+ await page.waitForSelector("article[data-testid='tweet']", { timeout: 20000 });
366
+ await humanDelay(1000, 1600);
367
+ await page.locator("[data-testid='like']").nth(parseInt(opts.nth)).click({ force: true });
368
+ await humanDelay(1000, 1400);
369
+ const isLiked = await page.locator("[data-testid='unlike']").count() > 0;
370
+ console.log(JSON.stringify({ ok: true, action: 'like', nth: opts.nth, confirmed: isLiked }));
371
+ }
372
+ catch (err) {
373
+ console.log(JSON.stringify({ ok: false, error: err.message }));
374
+ process.exit(1);
375
+ }
376
+ finally {
377
+ await browser.close();
378
+ }
379
+ });
380
+ program
381
+ .command('reply <text>')
382
+ .description('Reply to the Nth post on the current X feed')
383
+ .option('--nth <n>', 'Which post (0-indexed)', '0')
384
+ .option('--platform <platform>', 'Platform for session', 'x')
385
+ .action(async (text, opts) => {
386
+ const { ensureBrowser, humanDelay } = await import('./browser.js');
387
+ const { browser, page } = await ensureBrowser({ platform: opts.platform });
388
+ try {
389
+ await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded', timeout: 30000 });
390
+ await page.waitForSelector("article[data-testid='tweet']", { timeout: 20000 });
391
+ await humanDelay(1000, 1500);
392
+ await page.locator("[data-testid='reply']").nth(parseInt(opts.nth)).click({ force: true });
393
+ await humanDelay(800, 1100);
394
+ await page.locator("[data-testid='tweetTextarea_0']").first().waitFor({ timeout: 8000 });
395
+ await page.locator("[data-testid='tweetTextarea_0']").first().click({ force: true });
396
+ await humanDelay(300, 500);
397
+ await page.keyboard.type(text, { delay: 38 });
398
+ await humanDelay(600, 900);
399
+ await page.locator("[data-testid='tweetButton']").first().click({ force: true });
400
+ await humanDelay(1800, 2400);
401
+ console.log(JSON.stringify({ ok: true, action: 'reply', nth: opts.nth, text }));
402
+ }
403
+ catch (err) {
404
+ console.log(JSON.stringify({ ok: false, error: err.message }));
405
+ process.exit(1);
406
+ }
407
+ finally {
408
+ await browser.close();
409
+ }
410
+ });
411
+ program
412
+ .command('repost')
413
+ .description('Repost (retweet) the Nth post on the current X feed')
414
+ .option('--nth <n>', 'Which post (0-indexed)', '0')
415
+ .option('--platform <platform>', 'Platform for session', 'x')
416
+ .action(async (opts) => {
417
+ const { ensureBrowser, humanDelay } = await import('./browser.js');
418
+ const { browser, page } = await ensureBrowser({ platform: opts.platform });
419
+ try {
420
+ await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded', timeout: 30000 });
421
+ await page.waitForSelector("article[data-testid='tweet']", { timeout: 20000 });
422
+ await humanDelay(1000, 1500);
423
+ await page.locator("[data-testid='retweet']").nth(parseInt(opts.nth)).click({ force: true });
424
+ await humanDelay(500, 800);
425
+ await page.locator("[data-testid='retweetConfirm']").first().waitFor({ timeout: 5000 });
426
+ await page.locator("[data-testid='retweetConfirm']").first().click({ force: true });
427
+ await humanDelay(1200, 1800);
428
+ console.log(JSON.stringify({ ok: true, action: 'repost', nth: opts.nth }));
429
+ }
430
+ catch (err) {
431
+ console.log(JSON.stringify({ ok: false, error: err.message }));
432
+ process.exit(1);
433
+ }
434
+ finally {
435
+ await browser.close();
436
+ }
437
+ });
438
+ program
439
+ .command('quote <text>')
440
+ .description('Quote the Nth post on the current X feed with your comment')
441
+ .option('--nth <n>', 'Which post (0-indexed)', '0')
442
+ .option('--platform <platform>', 'Platform for session', 'x')
443
+ .action(async (text, opts) => {
444
+ const { ensureBrowser, humanDelay } = await import('./browser.js');
445
+ const { browser, page } = await ensureBrowser({ platform: opts.platform });
446
+ try {
447
+ await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded', timeout: 30000 });
448
+ await page.waitForSelector("article[data-testid='tweet']", { timeout: 20000 });
449
+ await humanDelay(1000, 1500);
450
+ // Get tweet URL for the target post
451
+ const tweetUrls = await page.locator('a[href*="/status/"]').evaluateAll((els) => els.map(el => el.href).filter(h => /\/status\/\d+$/.test(h)));
452
+ const targetUrl = tweetUrls[parseInt(opts.nth)] ?? tweetUrls[0];
453
+ if (!targetUrl)
454
+ throw new Error('Could not find tweet URL for quoting');
455
+ // Navigate to compose with tweet URL appended
456
+ const composeUrl = `https://x.com/compose/post?text=${encodeURIComponent(text + '\n\n' + targetUrl)}`;
457
+ await page.goto(composeUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
458
+ await humanDelay(1500, 2000);
459
+ await page.locator("[data-testid='tweetButtonInline']").first().waitFor({ timeout: 8000 });
460
+ await page.locator("[data-testid='tweetButtonInline']").first().click({ force: true });
461
+ await humanDelay(2000, 2500);
462
+ console.log(JSON.stringify({ ok: true, action: 'quote', nth: opts.nth, text, quotedUrl: targetUrl }));
463
+ }
464
+ catch (err) {
465
+ console.log(JSON.stringify({ ok: false, error: err.message }));
466
+ process.exit(1);
467
+ }
468
+ finally {
469
+ await browser.close();
470
+ }
471
+ });
472
+ program
473
+ .command('post <text>')
474
+ .description('Post a tweet on X')
475
+ .option('--platform <platform>', 'Platform for session', 'x')
476
+ .action(async (text, opts) => {
477
+ const { ensureBrowser, humanDelay } = await import('./browser.js');
478
+ const { browser, page } = await ensureBrowser({ platform: opts.platform });
479
+ try {
480
+ await page.goto('https://x.com/home', { waitUntil: 'domcontentloaded', timeout: 30000 });
481
+ await page.waitForSelector("[data-testid='primaryColumn']", { timeout: 20000 });
482
+ await humanDelay(800, 1400);
483
+ await page.locator("[data-testid='tweetTextarea_0']").first().click({ force: true });
484
+ await humanDelay(400, 700);
485
+ await page.keyboard.type(text, { delay: 38 });
486
+ await humanDelay(600, 1000);
487
+ await page.locator("[data-testid='tweetButtonInline']").first().click({ force: true });
488
+ await humanDelay(2000, 2500);
489
+ console.log(JSON.stringify({ ok: true, action: 'post', text }));
490
+ }
491
+ catch (err) {
492
+ console.log(JSON.stringify({ ok: false, error: err.message }));
493
+ process.exit(1);
494
+ }
495
+ finally {
496
+ await browser.close();
497
+ }
498
+ });
354
499
  program
355
500
  .command('close')
356
501
  .description('Close the current browser session')
@@ -368,7 +513,7 @@ program
368
513
  const { isFlareSolverrUp } = await import('./local-captcha.js');
369
514
  const sessions = await listSessions();
370
515
  const flare = await isFlareSolverrUp();
371
- console.log(chalk.cyan('\nšŸ•¶ļø veil v0.2.0 — OpenClaw Browser Remote\n'));
516
+ console.log(chalk.cyan('\nšŸ•¶ļø veil v0.3.0 — OpenClaw Browser Remote\n'));
372
517
  console.log(` Sessions: ${sessions.length > 0 ? chalk.green(sessions.join(', ')) : chalk.gray('none')}`);
373
518
  console.log(` FlareSolverr: ${flare ? chalk.green('running') : chalk.gray('not running (auto-starts on use)')}`);
374
519
  console.log('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veil-browser",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Stealth browser CLI for AI agents — bypass bot detection, persist sessions, local CAPTCHA solving, MCP server",
5
5
  "keywords": [
6
6
  "browser",
package/dist/ai.js DELETED
@@ -1,377 +0,0 @@
1
- import { humanDelay } from './browser.js';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { promises as fs } from 'fs';
5
- import { homedir } from 'os';
6
- import { join } from 'path';
7
- async function loadConfig() {
8
- const configFile = join(homedir(), '.veil', 'config.json');
9
- try {
10
- const raw = await fs.readFile(configFile, 'utf-8');
11
- return JSON.parse(raw);
12
- }
13
- catch {
14
- return {};
15
- }
16
- }
17
- function getLLMConfig(config) {
18
- // Priority: config file > env vars > ollama auto-detect
19
- if (config.llm?.provider)
20
- return config.llm;
21
- const openaiKey = process.env.OPENAI_API_KEY;
22
- if (openaiKey)
23
- return { provider: 'openai', apiKey: openaiKey, model: 'gpt-4o-mini' };
24
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
25
- if (anthropicKey)
26
- return { provider: 'anthropic', apiKey: anthropicKey, model: 'claude-haiku-4-5' };
27
- const openrouterKey = process.env.OPENROUTER_API_KEY;
28
- if (openrouterKey)
29
- return { provider: 'openrouter', apiKey: openrouterKey, model: 'openai/gpt-4o-mini' };
30
- // Ollama fallback — no key needed
31
- const ollamaUrl = process.env.OLLAMA_URL ?? 'http://localhost:11434';
32
- const ollamaModel = process.env.OLLAMA_MODEL ?? 'llava';
33
- return { provider: 'ollama', model: ollamaModel, baseUrl: ollamaUrl };
34
- }
35
- // Get a compact accessibility snapshot of the page for LLM consumption
36
- async function getPageSnapshot(page) {
37
- const snapshot = await page.evaluate(() => {
38
- const elements = [];
39
- function processNode(el, depth = 0) {
40
- if (depth > 6)
41
- return;
42
- const tag = el.tagName.toLowerCase();
43
- const role = el.getAttribute('role');
44
- const ariaLabel = el.getAttribute('aria-label');
45
- const testId = el.getAttribute('data-testid');
46
- const type = el.getAttribute('type');
47
- const placeholder = el.getAttribute('placeholder');
48
- const text = el.innerText?.slice(0, 80).trim().replace(/\n/g, ' ');
49
- const href = el.getAttribute('href');
50
- const disabled = el.getAttribute('disabled') !== null || el.getAttribute('aria-disabled') === 'true';
51
- const isInteractive = ['a', 'button', 'input', 'textarea', 'select'].includes(tag) || role;
52
- if (!isInteractive && !text)
53
- return;
54
- const attrs = [];
55
- if (testId)
56
- attrs.push(`data-testid="${testId}"`);
57
- if (role)
58
- attrs.push(`role="${role}"`);
59
- if (ariaLabel)
60
- attrs.push(`aria-label="${ariaLabel}"`);
61
- if (type)
62
- attrs.push(`type="${type}"`);
63
- if (placeholder)
64
- attrs.push(`placeholder="${placeholder}"`);
65
- if (href)
66
- attrs.push(`href="${href.slice(0, 60)}"`);
67
- if (disabled)
68
- attrs.push('disabled');
69
- const indent = ' '.repeat(depth);
70
- const attrStr = attrs.length ? ` [${attrs.join(', ')}]` : '';
71
- const textStr = text ? ` "${text.slice(0, 60)}"` : '';
72
- elements.push(`${indent}<${tag}${attrStr}${textStr}>`);
73
- for (const child of el.children) {
74
- processNode(child, depth + 1);
75
- }
76
- }
77
- processNode(document.body);
78
- return elements.slice(0, 200).join('\n');
79
- });
80
- return snapshot;
81
- }
82
- // Call LLM to get action steps
83
- async function getActionsFromLLM(instruction, snapshot, pageUrl, llm) {
84
- // Load platform-specific guide
85
- let platformGuide = '';
86
- if (pageUrl.includes('x.com') || pageUrl.includes('twitter.com')) {
87
- try {
88
- const { readFileSync } = await import('fs');
89
- platformGuide = readFileSync(new URL('../prompts/x-guide.md', import.meta.url), 'utf-8');
90
- }
91
- catch { }
92
- }
93
- else if (pageUrl.includes('linkedin.com')) {
94
- try {
95
- const { readFileSync } = await import('fs');
96
- platformGuide = readFileSync(new URL('../prompts/linkedin-guide.md', import.meta.url), 'utf-8');
97
- }
98
- catch { }
99
- }
100
- const systemPrompt = `You are an expert browser automation assistant. Given a page snapshot and a user instruction, return ONLY a valid JSON array of action steps.
101
-
102
- ${platformGuide ? `\n## Platform-Specific Guide\n${platformGuide}\n` : ''}
103
-
104
- ## Available Actions
105
- - click: { action: "click", selector: "CSS or data-testid selector" }
106
- - type: { action: "type", selector: "...", text: "text to type" }
107
- - press: { action: "press", key: "Enter|Tab|Escape|..." }
108
- - navigate: { action: "navigate", url: "https://..." }
109
- - wait: { action: "wait", ms: 1000 }
110
- - scroll: { action: "scroll", direction: "down" }
111
-
112
- ## Rules
113
- 1. Return ONLY valid JSON array, nothing else
114
- 2. Each action MUST have all required fields
115
- 3. Never generate navigate actions unless instruction explicitly says to go to a URL
116
- 4. Add description field to every action
117
- 5. After clicks that open modals/menus, always add 500ms wait
118
- 6. Use data-testid selectors when available (most stable)
119
- 7. For contenteditable areas, click first then type
120
- 8. Always filter posts by visible content before interacting`;
121
- const userPrompt = `Current URL: ${pageUrl}
122
-
123
- Page snapshot:
124
- ${snapshot.slice(0, 3500)}
125
-
126
- Instruction: ${instruction}
127
-
128
- Return ONLY a valid JSON array of action steps with no other text:`;
129
- let response;
130
- if (llm.provider === 'ollama') {
131
- const baseUrl = llm.baseUrl ?? 'http://localhost:11434';
132
- response = await fetch(`${baseUrl}/api/chat`, {
133
- method: 'POST',
134
- headers: { 'Content-Type': 'application/json' },
135
- body: JSON.stringify({
136
- model: llm.model,
137
- stream: false,
138
- messages: [
139
- { role: 'system', content: systemPrompt },
140
- { role: 'user', content: userPrompt },
141
- ],
142
- }),
143
- });
144
- if (!response.ok)
145
- throw new Error(`Ollama error: ${response.status} ${await response.text()}`);
146
- const data = await response.json();
147
- const content = data.message?.content ?? '';
148
- let jsonMatch = content.match(/\[[\s\S]*\]/);
149
- if (!jsonMatch)
150
- throw new Error('Ollama returned no valid JSON array');
151
- let jsonStr = jsonMatch[0];
152
- try {
153
- return JSON.parse(jsonStr);
154
- }
155
- catch {
156
- jsonStr = jsonStr.replace(/("description":\s*)"([^"]*)"([^"]*?)"([^"]*)"/g, '$1"$2\\\"$3\\\"$4"');
157
- try {
158
- return JSON.parse(jsonStr);
159
- }
160
- catch {
161
- const actions = [];
162
- const blocks = jsonStr.match(/\{\s*"?action"?\s*:\s*"([^"]+)"/g) ?? [];
163
- blocks.forEach((block) => {
164
- const actionMatch = block.match(/"action"\s*:\s*"([^"]+)"/);
165
- if (actionMatch)
166
- actions.push({ action: actionMatch[1] });
167
- });
168
- if (actions.length === 0)
169
- throw new Error('Could not extract actions from Ollama response');
170
- return actions;
171
- }
172
- }
173
- }
174
- if (llm.provider === 'openai' || llm.provider === 'openrouter') {
175
- const baseUrl = llm.provider === 'openrouter'
176
- ? 'https://openrouter.ai/api/v1'
177
- : 'https://api.openai.com/v1';
178
- response = await fetch(`${baseUrl}/chat/completions`, {
179
- method: 'POST',
180
- headers: {
181
- 'Content-Type': 'application/json',
182
- 'Authorization': `Bearer ${llm.apiKey}`,
183
- },
184
- body: JSON.stringify({
185
- model: llm.model,
186
- messages: [
187
- { role: 'system', content: systemPrompt },
188
- { role: 'user', content: userPrompt },
189
- ],
190
- temperature: 0,
191
- max_tokens: 1000,
192
- }),
193
- });
194
- }
195
- else {
196
- // Anthropic
197
- response = await fetch('https://api.anthropic.com/v1/messages', {
198
- method: 'POST',
199
- headers: {
200
- 'Content-Type': 'application/json',
201
- 'x-api-key': llm.apiKey ?? '',
202
- 'anthropic-version': '2023-06-01',
203
- },
204
- body: JSON.stringify({
205
- model: llm.model,
206
- max_tokens: 1000,
207
- system: systemPrompt,
208
- messages: [{ role: 'user', content: userPrompt }],
209
- }),
210
- });
211
- }
212
- if (!response.ok) {
213
- throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
214
- }
215
- const data = await response.json();
216
- const content = llm.provider === 'anthropic'
217
- ? data.content[0].text
218
- : data.choices[0].message.content;
219
- // Extract JSON more robustly — handles malformed JSON
220
- let jsonMatch = content.match(/\[[\s\S]*\]/);
221
- if (!jsonMatch)
222
- throw new Error('LLM returned no valid JSON array');
223
- let jsonStr = jsonMatch[0];
224
- // Try to parse as-is first
225
- try {
226
- return JSON.parse(jsonStr);
227
- }
228
- catch {
229
- // If that fails, try to fix common issues
230
- // Remove unescaped quotes in description fields
231
- jsonStr = jsonStr.replace(/("description":\s*)"([^"]*)"([^"]*?)"([^"]*)"/g, '$1"$2\\\"$3\\\"$4"');
232
- try {
233
- return JSON.parse(jsonStr);
234
- }
235
- catch {
236
- // Last resort: extract actions manually
237
- const actions = [];
238
- const blocks = jsonStr.match(/\{\s*"?action"?\s*:\s*"([^"]+)"/g) ?? [];
239
- blocks.forEach((block) => {
240
- const actionMatch = block.match(/"action"\s*:\s*"([^"]+)"/);
241
- if (actionMatch)
242
- actions.push({ action: actionMatch[1] });
243
- });
244
- if (actions.length === 0)
245
- throw new Error('Could not extract actions from LLM response');
246
- return actions;
247
- }
248
- }
249
- }
250
- // Execute a single action step with X-specific handling
251
- async function executeStep(page, step) {
252
- // Skip invalid steps silently
253
- if (step.action === 'navigate' && !step.url) {
254
- return; // Skip invalid navigate
255
- }
256
- if (step.action === 'click' && !step.selector) {
257
- return; // Skip click without selector
258
- }
259
- if (step.action === 'type' && (!step.selector || !step.text)) {
260
- return; // Skip type without selector or text
261
- }
262
- switch (step.action) {
263
- case 'click': {
264
- if (!step.selector)
265
- throw new Error('click requires selector');
266
- const el = page.locator(step.selector).first();
267
- await el.waitFor({ timeout: 5000 }).catch(() => { });
268
- // For X, use force click to bypass overlay interceptors
269
- const isX = page.url().includes('x.com') || page.url().includes('twitter.com');
270
- if (isX) {
271
- await el.click({ force: true, timeout: 5000 });
272
- }
273
- else {
274
- await el.click({ timeout: 5000 });
275
- }
276
- await humanDelay(300, 700);
277
- break;
278
- }
279
- case 'type': {
280
- if (!step.selector || !step.text)
281
- throw new Error('type requires selector and text');
282
- const el = page.locator(step.selector).first();
283
- await el.waitFor({ timeout: 5000 }).catch(() => { });
284
- await el.click({ force: true });
285
- await humanDelay(200, 400);
286
- // Type with human-like delays
287
- for (const char of step.text) {
288
- await page.keyboard.type(char, { delay: Math.random() * 60 + 30 });
289
- }
290
- await humanDelay(300, 600);
291
- break;
292
- }
293
- case 'press': {
294
- if (!step.key)
295
- throw new Error('press requires key');
296
- await page.keyboard.press(step.key);
297
- await humanDelay(200, 400);
298
- break;
299
- }
300
- case 'navigate': {
301
- if (!step.url)
302
- throw new Error('navigate requires url');
303
- await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
304
- await humanDelay(800, 1500);
305
- break;
306
- }
307
- case 'wait': {
308
- await new Promise((r) => setTimeout(r, step.ms ?? 1000));
309
- break;
310
- }
311
- case 'scroll': {
312
- const amount = step.direction === 'up' ? -600 : 600;
313
- await page.evaluate((y) => window.scrollBy(0, y), amount);
314
- await humanDelay(300, 500);
315
- break;
316
- }
317
- }
318
- }
319
- // Main AI-powered act function
320
- export async function aiAct(page, instruction, opts = {}) {
321
- const config = await loadConfig();
322
- const llm = getLLMConfig(config);
323
- if (!llm) {
324
- throw new Error('No LLM configured. Options:\n' +
325
- ' Ollama (local): veil config llm.provider ollama && veil config llm.model llama3.2\n' +
326
- ' OpenAI: veil config llm.provider openai && veil config llm.apiKey sk-...\n' +
327
- ' Anthropic: veil config llm.provider anthropic && veil config llm.apiKey sk-ant-...\n' +
328
- ' OpenRouter: veil config llm.provider openrouter && veil config llm.apiKey sk-or-...');
329
- }
330
- const spinner = ora({ text: '🧠 Analyzing page...', color: 'cyan' }).start();
331
- try {
332
- // 1. Get page snapshot
333
- const snapshot = await getPageSnapshot(page);
334
- const pageUrl = page.url();
335
- spinner.text = '🧠 Asking AI what to do...';
336
- // 2. Get action steps from LLM
337
- let steps = await getActionsFromLLM(instruction, snapshot, pageUrl, llm);
338
- // 3. Filter out invalid steps (navigate without URL)
339
- steps = steps.filter(s => {
340
- if (s.action === 'navigate' && !s.url) {
341
- console.warn(chalk.yellow('āš ļø Filtered out navigate action without URL'));
342
- return false;
343
- }
344
- return true;
345
- });
346
- if (steps.length === 0) {
347
- throw new Error('No valid action steps generated');
348
- }
349
- if (opts.verbose) {
350
- spinner.stop();
351
- console.log(chalk.cyan('\nšŸ“‹ AI action plan:'));
352
- steps.forEach((s, i) => {
353
- console.log(chalk.gray(` ${i + 1}. ${s.action}${s.description ? ': ' + s.description : ''}`));
354
- });
355
- console.log('');
356
- spinner.start('Executing...');
357
- }
358
- else {
359
- spinner.text = `Executing ${steps.length} steps...`;
360
- }
361
- // 3. Execute each step
362
- for (let i = 0; i < steps.length; i++) {
363
- const step = steps[i];
364
- if (opts.verbose) {
365
- spinner.text = `Step ${i + 1}/${steps.length}: ${step.action} ${step.description ?? ''}`;
366
- }
367
- await executeStep(page, step);
368
- }
369
- spinner.succeed(chalk.green(`āœ… Done: ${instruction}`));
370
- return { success: true, steps };
371
- }
372
- catch (err) {
373
- spinner.fail(chalk.red(`āŒ AI act failed: ${err.message}`));
374
- return { success: false, steps: [], error: err.message };
375
- }
376
- }
377
- export { getActionsFromLLM, executeStep, humanDelay };
package/dist/captcha.js DELETED
@@ -1,203 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import { homedir } from 'os';
3
- import { join } from 'path';
4
- import chalk from 'chalk';
5
- import ora from 'ora';
6
- const CONFIG_FILE = join(homedir(), '.veil', 'config.json');
7
- async function loadConfig() {
8
- try {
9
- const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
10
- return JSON.parse(raw);
11
- }
12
- catch {
13
- return {};
14
- }
15
- }
16
- export class VeilError extends Error {
17
- code;
18
- screenshotPath;
19
- suggestion;
20
- constructor(code, message, suggestion) {
21
- super(message);
22
- this.code = code;
23
- this.suggestion = suggestion;
24
- this.name = 'VeilError';
25
- }
26
- }
27
- // Detect CAPTCHA on current page
28
- export async function detectCaptcha(page) {
29
- return await page.evaluate(() => {
30
- if (document.querySelector('iframe[src*="challenges.cloudflare.com"]'))
31
- return 'turnstile';
32
- if (document.querySelector('iframe[src*="recaptcha"]') || document.querySelector('.g-recaptcha'))
33
- return 'recaptcha';
34
- if (document.querySelector('iframe[src*="hcaptcha"]') || document.querySelector('.h-captcha'))
35
- return 'hcaptcha';
36
- if (document.querySelector('img[alt*="captcha" i]') || document.querySelector('input[name*="captcha" i]'))
37
- return 'image';
38
- return null;
39
- });
40
- }
41
- // Auto-solve via 2captcha API
42
- async function solveWith2Captcha(apiKey, type, page) {
43
- const siteKey = await page.evaluate((t) => {
44
- if (t === 'recaptcha') {
45
- const el = document.querySelector('.g-recaptcha');
46
- return el?.getAttribute('data-sitekey') ?? null;
47
- }
48
- if (t === 'hcaptcha') {
49
- const el = document.querySelector('.h-captcha');
50
- return el?.getAttribute('data-sitekey') ?? null;
51
- }
52
- if (t === 'turnstile') {
53
- const el = document.querySelector('[data-sitekey]');
54
- return el?.getAttribute('data-sitekey') ?? null;
55
- }
56
- return null;
57
- }, type);
58
- if (!siteKey)
59
- return null;
60
- const pageUrl = page.url();
61
- const taskType = type === 'recaptcha' ? 'RecaptchaV2TaskProxyless'
62
- : type === 'hcaptcha' ? 'HCaptchaTaskProxyless'
63
- : 'TurnstileTaskProxyless';
64
- const spinner = ora(`Solving ${type} CAPTCHA via 2captcha...`).start();
65
- try {
66
- // Submit task
67
- const submitRes = await fetch('https://api.2captcha.com/createTask', {
68
- method: 'POST',
69
- headers: { 'Content-Type': 'application/json' },
70
- body: JSON.stringify({
71
- clientKey: apiKey,
72
- task: { type: taskType, websiteURL: pageUrl, websiteKey: siteKey },
73
- }),
74
- });
75
- const submitData = await submitRes.json();
76
- if (submitData.errorId > 0)
77
- throw new Error(submitData.errorDescription);
78
- const taskId = submitData.taskId;
79
- // Poll for result (up to 120s)
80
- for (let i = 0; i < 24; i++) {
81
- await new Promise((r) => setTimeout(r, 5000));
82
- const pollRes = await fetch('https://api.2captcha.com/getTaskResult', {
83
- method: 'POST',
84
- headers: { 'Content-Type': 'application/json' },
85
- body: JSON.stringify({ clientKey: apiKey, taskId }),
86
- });
87
- const pollData = await pollRes.json();
88
- if (pollData.status === 'ready') {
89
- spinner.succeed(chalk.green('āœ… CAPTCHA solved!'));
90
- return pollData.solution?.gRecaptchaResponse ?? pollData.solution?.token ?? null;
91
- }
92
- }
93
- spinner.fail('CAPTCHA solve timed out');
94
- return null;
95
- }
96
- catch (err) {
97
- spinner.fail(`CAPTCHA solve failed: ${err.message}`);
98
- return null;
99
- }
100
- }
101
- // Inject solved token into page
102
- async function injectCaptchaToken(page, type, token) {
103
- if (type === 'recaptcha') {
104
- await page.evaluate((t) => {
105
- const el = document.querySelector('#g-recaptcha-response');
106
- if (el)
107
- el.value = t;
108
- // @ts-ignore
109
- if (window.___grecaptcha_cfg) {
110
- // Trigger callback
111
- const callbacks = Object.values(window.___grecaptcha_cfg.clients ?? {});
112
- // @ts-ignore
113
- callbacks.forEach((c) => c?.['']?.['']?.callback?.(t));
114
- }
115
- }, token);
116
- }
117
- else if (type === 'hcaptcha') {
118
- await page.evaluate((t) => {
119
- const el = document.querySelector('[name="h-captcha-response"]');
120
- if (el)
121
- el.value = t;
122
- }, token);
123
- }
124
- else if (type === 'turnstile') {
125
- await page.evaluate((t) => {
126
- const el = document.querySelector('[name="cf-turnstile-response"]');
127
- if (el)
128
- el.value = t;
129
- }, token);
130
- }
131
- }
132
- // Main CAPTCHA handler — call this before/after any page action
133
- export async function handleCaptcha(page, screenshotDir) {
134
- const captchaType = await detectCaptcha(page);
135
- if (!captchaType)
136
- return false;
137
- console.log(chalk.yellow(`\nāš ļø CAPTCHA detected: ${chalk.bold(captchaType)}`));
138
- const config = await loadConfig();
139
- // Try auto-solve
140
- if (config.captcha?.apiKey && config.captcha.provider === '2captcha') {
141
- const token = await solveWith2Captcha(config.captcha.apiKey, captchaType, page);
142
- if (token) {
143
- await injectCaptchaToken(page, captchaType, token);
144
- await page.waitForTimeout(1500);
145
- return true;
146
- }
147
- }
148
- // Human fallback
149
- console.log(chalk.cyan('\nšŸ§‘ Human needed! Opening visible browser for CAPTCHA solve...'));
150
- console.log(chalk.gray(' Solve the CAPTCHA in the browser window. Veil will continue automatically.\n'));
151
- // Screenshot the failure state
152
- if (screenshotDir) {
153
- const path = join(screenshotDir, `captcha-${Date.now()}.png`);
154
- await page.screenshot({ path }).catch(() => { });
155
- console.log(chalk.gray(` Screenshot: ${path}`));
156
- }
157
- // Wait for human to solve (up to 5 min)
158
- const startUrl = page.url();
159
- await new Promise((resolve) => {
160
- const check = setInterval(async () => {
161
- const captchaStillThere = await detectCaptcha(page).catch(() => null);
162
- if (!captchaStillThere || page.url() !== startUrl) {
163
- clearInterval(check);
164
- resolve();
165
- }
166
- }, 2000);
167
- setTimeout(() => { clearInterval(check); resolve(); }, 300000);
168
- });
169
- console.log(chalk.green('āœ… CAPTCHA cleared, resuming...\n'));
170
- return true;
171
- }
172
- // Retry wrapper with backoff
173
- export async function withRetry(fn, opts = {}) {
174
- const { attempts = 3, delay = 2000, label = 'action' } = opts;
175
- let lastError = null;
176
- for (let i = 1; i <= attempts; i++) {
177
- try {
178
- return await fn();
179
- }
180
- catch (err) {
181
- lastError = err;
182
- if (i < attempts) {
183
- const wait = delay * i;
184
- console.log(chalk.yellow(` ⟳ Retry ${i}/${attempts} for "${label}" in ${wait}ms...`));
185
- await new Promise((r) => setTimeout(r, wait));
186
- }
187
- }
188
- }
189
- throw lastError;
190
- }
191
- // Screenshot on error helper
192
- export async function screenshotOnError(page, label) {
193
- const dir = join(homedir(), '.veil', 'errors');
194
- await fs.mkdir(dir, { recursive: true });
195
- const path = join(dir, `error-${label}-${Date.now()}.png`);
196
- try {
197
- await page.screenshot({ path });
198
- return path;
199
- }
200
- catch {
201
- return null;
202
- }
203
- }
package/dist/mcp.js DELETED
@@ -1,232 +0,0 @@
1
- import { createServer } from 'http';
2
- import { getBrowser, closeBrowser, humanDelay } from './browser.js';
3
- import { searchCommand } from './commands/search.js';
4
- import { listSessions, loadSession } from './session.js';
5
- import chalk from 'chalk';
6
- const TOOLS = [
7
- {
8
- name: 'veil_navigate',
9
- description: 'Navigate to a URL in stealth headless mode using a saved session',
10
- inputSchema: {
11
- type: 'object',
12
- properties: {
13
- url: { type: 'string', description: 'URL to navigate to' },
14
- platform: { type: 'string', description: 'Platform session to use (e.g. twitter, reddit)' },
15
- headed: { type: 'boolean', description: 'Run in visible browser mode' },
16
- },
17
- required: ['url'],
18
- },
19
- },
20
- {
21
- name: 'veil_act',
22
- description: 'Perform a natural language action in the browser (click, type, post tweet, scroll, etc)',
23
- inputSchema: {
24
- type: 'object',
25
- properties: {
26
- instruction: { type: 'string', description: 'Natural language instruction, e.g. "post tweet: hello world"' },
27
- platform: { type: 'string', description: 'Platform session to use' },
28
- url: { type: 'string', description: 'Navigate to this URL first' },
29
- headed: { type: 'boolean' },
30
- },
31
- required: ['instruction'],
32
- },
33
- },
34
- {
35
- name: 'veil_search',
36
- description: 'Search the web using Google, Bing, DuckDuckGo, or Twitter. Returns JSON results.',
37
- inputSchema: {
38
- type: 'object',
39
- properties: {
40
- query: { type: 'string', description: 'Search query' },
41
- engine: { type: 'string', description: 'Comma-separated engines: google,bing,duckduckgo,twitter', default: 'google' },
42
- limit: { type: 'number', description: 'Max results', default: 10 },
43
- },
44
- required: ['query'],
45
- },
46
- },
47
- {
48
- name: 'veil_extract',
49
- description: 'Extract data from a web page. Supports: tweets, links, text, title, metadata.',
50
- inputSchema: {
51
- type: 'object',
52
- properties: {
53
- query: { type: 'string', description: 'What to extract: tweets, links, text, title' },
54
- url: { type: 'string', description: 'URL to extract from' },
55
- platform: { type: 'string', description: 'Platform session to use' },
56
- },
57
- required: ['query'],
58
- },
59
- },
60
- {
61
- name: 'veil_screenshot',
62
- description: 'Take a screenshot of a page. Returns the file path.',
63
- inputSchema: {
64
- type: 'object',
65
- properties: {
66
- url: { type: 'string' },
67
- platform: { type: 'string' },
68
- output: { type: 'string', description: 'Output file path' },
69
- },
70
- },
71
- },
72
- {
73
- name: 'veil_session_list',
74
- description: 'List all saved platform sessions',
75
- inputSchema: { type: 'object', properties: {} },
76
- },
77
- {
78
- name: 'veil_login_status',
79
- description: 'Check if a session exists for a given platform',
80
- inputSchema: {
81
- type: 'object',
82
- properties: {
83
- platform: { type: 'string' },
84
- },
85
- required: ['platform'],
86
- },
87
- },
88
- ];
89
- async function callTool(name, args) {
90
- switch (name) {
91
- case 'veil_navigate': {
92
- const { page } = await getBrowser({ headed: args.headed, platform: args.platform });
93
- await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
94
- await humanDelay(500, 1200);
95
- const title = await page.title();
96
- const url = page.url();
97
- await closeBrowser(args.platform);
98
- return { success: true, url, title };
99
- }
100
- case 'veil_act': {
101
- const { actCommand } = await import('./commands/act.js');
102
- // Capture stdout
103
- const chunks = [];
104
- const orig = process.stdout.write.bind(process.stdout);
105
- process.stdout.write = (c) => chunks.push(c.toString());
106
- await actCommand(args.instruction, { platform: args.platform, url: args.url, headed: args.headed, json: true });
107
- process.stdout.write = orig;
108
- const out = chunks.join('');
109
- try {
110
- return JSON.parse(out.split('\n').filter(Boolean).pop() ?? '{}');
111
- }
112
- catch {
113
- return { success: true };
114
- }
115
- }
116
- case 'veil_search': {
117
- const chunks = [];
118
- const orig = process.stdout.write.bind(process.stdout);
119
- process.stdout.write = (c) => chunks.push(c.toString());
120
- await searchCommand(args.query, { engine: args.engine ?? 'google', json: true, limit: String(args.limit ?? 10) });
121
- process.stdout.write = orig;
122
- const out = chunks.join('');
123
- try {
124
- return JSON.parse(out.split('\n').filter(Boolean).pop() ?? '{}');
125
- }
126
- catch {
127
- return { success: false };
128
- }
129
- }
130
- case 'veil_extract': {
131
- const { extractCommand } = await import('./commands/extract.js');
132
- const chunks = [];
133
- const orig = process.stdout.write.bind(process.stdout);
134
- process.stdout.write = (c) => chunks.push(c.toString());
135
- await extractCommand(args.query, { platform: args.platform, url: args.url, json: true });
136
- process.stdout.write = orig;
137
- const out = chunks.join('');
138
- try {
139
- return JSON.parse(out.split('\n').filter(Boolean).pop() ?? '{}');
140
- }
141
- catch {
142
- return { success: false };
143
- }
144
- }
145
- case 'veil_screenshot': {
146
- const { screenshotCommand } = await import('./commands/screenshot.js');
147
- const chunks = [];
148
- const orig = process.stdout.write.bind(process.stdout);
149
- process.stdout.write = (c) => chunks.push(c.toString());
150
- await screenshotCommand({ platform: args.platform, url: args.url, output: args.output, json: true });
151
- process.stdout.write = orig;
152
- const out = chunks.join('');
153
- try {
154
- return JSON.parse(out.split('\n').filter(Boolean).pop() ?? '{}');
155
- }
156
- catch {
157
- return { success: false };
158
- }
159
- }
160
- case 'veil_session_list': {
161
- const sessions = await listSessions();
162
- return { sessions };
163
- }
164
- case 'veil_login_status': {
165
- const session = await loadSession(args.platform);
166
- return { platform: args.platform, loggedIn: !!session };
167
- }
168
- default:
169
- throw new Error(`Unknown tool: ${name}`);
170
- }
171
- }
172
- function jsonRpc(id, result, error) {
173
- if (error)
174
- return { jsonrpc: '2.0', id, error: { code: -32000, message: error } };
175
- return { jsonrpc: '2.0', id, result };
176
- }
177
- async function handleRequest(body) {
178
- const { id, method, params } = body;
179
- if (method === 'initialize') {
180
- return jsonRpc(id, {
181
- protocolVersion: '2024-11-05',
182
- capabilities: { tools: {} },
183
- serverInfo: { name: 'veil', version: '0.1.0' },
184
- });
185
- }
186
- if (method === 'tools/list') {
187
- return jsonRpc(id, { tools: TOOLS });
188
- }
189
- if (method === 'tools/call') {
190
- try {
191
- const result = await callTool(params.name, params.arguments ?? {});
192
- return jsonRpc(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
193
- }
194
- catch (err) {
195
- return jsonRpc(id, undefined, err.message);
196
- }
197
- }
198
- return jsonRpc(id, undefined, `Method not found: ${method}`);
199
- }
200
- export async function startMcpServer(port = 3456) {
201
- const server = createServer(async (req, res) => {
202
- if (req.method === 'POST') {
203
- let body = '';
204
- req.on('data', (chunk) => (body += chunk));
205
- req.on('end', async () => {
206
- try {
207
- const parsed = JSON.parse(body);
208
- const response = await handleRequest(parsed);
209
- res.writeHead(200, { 'Content-Type': 'application/json' });
210
- res.end(JSON.stringify(response));
211
- }
212
- catch (err) {
213
- res.writeHead(400, { 'Content-Type': 'application/json' });
214
- res.end(JSON.stringify({ error: err.message }));
215
- }
216
- });
217
- }
218
- else if (req.method === 'GET' && req.url === '/health') {
219
- res.writeHead(200, { 'Content-Type': 'application/json' });
220
- res.end(JSON.stringify({ status: 'ok', version: '0.1.0' }));
221
- }
222
- else {
223
- res.writeHead(404);
224
- res.end();
225
- }
226
- });
227
- server.listen(port, () => {
228
- console.log(chalk.cyan(`\nšŸ•¶ļø veil MCP server running on http://localhost:${port}`));
229
- console.log(chalk.gray(` Tools: ${TOOLS.map((t) => t.name).join(', ')}\n`));
230
- });
231
- process.on('SIGINT', () => { server.close(); process.exit(0); });
232
- }
package/dist/startup.js DELETED
@@ -1,9 +0,0 @@
1
- import { ensureFlareSolverr } from './local-captcha.js';
2
- /**
3
- * Called once when veil starts any browser command.
4
- * Boots FlareSolverr in the background so it's ready before we hit a CAPTCHA.
5
- */
6
- export async function veilStartup() {
7
- // Start FlareSolverr silently in background — don't await, don't block
8
- ensureFlareSolverr().catch(() => { });
9
- }
package/dist/state.js DELETED
@@ -1,45 +0,0 @@
1
- import { promises as fs } from 'fs';
2
- import { homedir } from 'os';
3
- import { join } from 'path';
4
- const STATE_DIR = join(homedir(), '.veil', 'state');
5
- async function ensureDir() {
6
- await fs.mkdir(STATE_DIR, { recursive: true });
7
- }
8
- export async function saveState(sessionId, state) {
9
- await ensureDir();
10
- const file = join(STATE_DIR, `${sessionId}.json`);
11
- let existing = {
12
- sessionId,
13
- currentUrl: '',
14
- lastAction: '',
15
- timestamp: Date.now(),
16
- history: [],
17
- };
18
- try {
19
- const raw = await fs.readFile(file, 'utf-8');
20
- existing = JSON.parse(raw);
21
- }
22
- catch { }
23
- const updated = { ...existing, ...state, timestamp: Date.now() };
24
- if (state.currentUrl && state.currentUrl !== existing.currentUrl) {
25
- updated.history = [...(existing.history ?? []).slice(-19), state.currentUrl];
26
- }
27
- await fs.writeFile(file, JSON.stringify(updated, null, 2), 'utf-8');
28
- }
29
- export async function loadState(sessionId) {
30
- const file = join(STATE_DIR, `${sessionId}.json`);
31
- try {
32
- const raw = await fs.readFile(file, 'utf-8');
33
- return JSON.parse(raw);
34
- }
35
- catch {
36
- return null;
37
- }
38
- }
39
- export async function clearState(sessionId) {
40
- const file = join(STATE_DIR, `${sessionId}.json`);
41
- try {
42
- await fs.unlink(file);
43
- }
44
- catch { }
45
- }