@browsercash/chase 1.0.0 → 1.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/README.md +1 -1
- package/dist/cli.js +14 -5
- package/dist/config.js +1 -1
- package/dist/prompts/agentic-prompt.js +18 -0
- package/dist/server.js +142 -16
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* chase task <task-id>
|
|
14
14
|
*/
|
|
15
15
|
import * as https from 'https';
|
|
16
|
-
|
|
16
|
+
import * as http from 'http';
|
|
17
|
+
const API_BASE = process.env.CHASE_API_URL || 'https://chase-api-gth2quoxyq-uc.a.run.app';
|
|
17
18
|
function getApiKey() {
|
|
18
19
|
const key = process.env.BROWSER_CASH_API_KEY;
|
|
19
20
|
if (!key) {
|
|
@@ -57,16 +58,18 @@ function parseArgs() {
|
|
|
57
58
|
async function streamRequest(endpoint, body, onEvent) {
|
|
58
59
|
return new Promise((resolve, reject) => {
|
|
59
60
|
const url = new URL(endpoint, API_BASE);
|
|
61
|
+
const isHttps = url.protocol === 'https:';
|
|
60
62
|
const options = {
|
|
61
63
|
hostname: url.hostname,
|
|
62
|
-
port: 443,
|
|
64
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
63
65
|
path: url.pathname,
|
|
64
66
|
method: 'POST',
|
|
65
67
|
headers: {
|
|
66
68
|
'Content-Type': 'application/json',
|
|
67
69
|
},
|
|
68
70
|
};
|
|
69
|
-
const
|
|
71
|
+
const transport = isHttps ? https : http;
|
|
72
|
+
const req = transport.request(options, (res) => {
|
|
70
73
|
let buffer = '';
|
|
71
74
|
res.on('data', (chunk) => {
|
|
72
75
|
buffer += chunk.toString();
|
|
@@ -106,16 +109,18 @@ async function streamRequest(endpoint, body, onEvent) {
|
|
|
106
109
|
async function apiGet(endpoint, apiKey) {
|
|
107
110
|
return new Promise((resolve, reject) => {
|
|
108
111
|
const url = new URL(endpoint, API_BASE);
|
|
112
|
+
const isHttps = url.protocol === 'https:';
|
|
109
113
|
const options = {
|
|
110
114
|
hostname: url.hostname,
|
|
111
|
-
port: 443,
|
|
115
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
112
116
|
path: url.pathname,
|
|
113
117
|
method: 'GET',
|
|
114
118
|
headers: {
|
|
115
119
|
'x-api-key': apiKey,
|
|
116
120
|
},
|
|
117
121
|
};
|
|
118
|
-
const
|
|
122
|
+
const transport = isHttps ? https : http;
|
|
123
|
+
const req = transport.request(options, (res) => {
|
|
119
124
|
let data = '';
|
|
120
125
|
res.on('data', (chunk) => (data += chunk.toString()));
|
|
121
126
|
res.on('end', () => {
|
|
@@ -154,6 +159,9 @@ async function commandAutomate(task, flags) {
|
|
|
154
159
|
if (flags.captcha) {
|
|
155
160
|
body.browserOptions = { ...(body.browserOptions || {}), captchaSolver: true };
|
|
156
161
|
}
|
|
162
|
+
if (flags['max-turns']) {
|
|
163
|
+
body.maxTurns = parseInt(flags['max-turns'], 10);
|
|
164
|
+
}
|
|
157
165
|
let taskId = null;
|
|
158
166
|
let result = null;
|
|
159
167
|
await streamRequest('/automate/stream', body, (type, data) => {
|
|
@@ -433,6 +441,7 @@ OPTIONS:
|
|
|
433
441
|
--country <code> Use a browser from specific country (e.g., US, DE, JP)
|
|
434
442
|
--adblock Enable ad-blocking
|
|
435
443
|
--captcha Enable CAPTCHA solving
|
|
444
|
+
--max-turns <n> Max Claude iterations (default: 30, use 50+ for complex tasks)
|
|
436
445
|
--quiet Reduce output verbosity
|
|
437
446
|
--skip-test Skip script testing (generate only)
|
|
438
447
|
--help Show this help message
|
package/dist/config.js
CHANGED
|
@@ -100,7 +100,7 @@ export function loadConfig(taskDescriptionOrOptions) {
|
|
|
100
100
|
cdpUrl,
|
|
101
101
|
outputDir: process.env.OUTPUT_DIR || './generated',
|
|
102
102
|
sessionsDir: process.env.SESSIONS_DIR || './sessions',
|
|
103
|
-
maxTurns: parseInt(process.env.MAX_TURNS || '
|
|
103
|
+
maxTurns: parseInt(process.env.MAX_TURNS || '30', 10),
|
|
104
104
|
model: process.env.MODEL || 'claude-opus-4-5-20251101',
|
|
105
105
|
maxFixIterations: parseInt(process.env.MAX_FIX_ITERATIONS || '5', 10),
|
|
106
106
|
// Script execution timeout (default 5 minutes for complex multi-page tasks)
|
|
@@ -74,5 +74,23 @@ agent-browser --cdp "$CDP_URL" eval 'JSON.stringify(Array.from(document.querySel
|
|
|
74
74
|
|
|
75
75
|
4. Output final JSON result
|
|
76
76
|
|
|
77
|
+
## CRITICAL: Output Format Requirement
|
|
78
|
+
|
|
79
|
+
Your FINAL message MUST contain a JSON code block. This is REQUIRED for the system to process your results.
|
|
80
|
+
|
|
81
|
+
**For success - use EXACTLY this format:**
|
|
82
|
+
\`\`\`json
|
|
83
|
+
{"success": true, "data": {...}, "summary": "Brief description"}
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
**For failure - use EXACTLY this format:**
|
|
87
|
+
\`\`\`json
|
|
88
|
+
{"success": false, "error": "What went wrong", "attempted": "What was tried"}
|
|
89
|
+
\`\`\`
|
|
90
|
+
|
|
91
|
+
Do NOT output results as plain text. Always wrap in \`\`\`json code fence.
|
|
92
|
+
Do NOT use comments inside JSON. JSON must be valid and parseable.
|
|
93
|
+
The JSON block should be your LAST output after completing all actions.
|
|
94
|
+
|
|
77
95
|
NOW: Perform the requested task and return the results.`;
|
|
78
96
|
}
|
package/dist/server.js
CHANGED
|
@@ -121,14 +121,21 @@ function generateTaskId() {
|
|
|
121
121
|
return `task-${timestamp}-${random}`;
|
|
122
122
|
}
|
|
123
123
|
/**
|
|
124
|
-
* Save a task record to GCS
|
|
124
|
+
* Save a task record to GCS (gracefully handles missing credentials for local dev)
|
|
125
125
|
*/
|
|
126
126
|
async function saveTask(task) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
try {
|
|
128
|
+
const bucket = storage.bucket(BUCKET_NAME);
|
|
129
|
+
const file = bucket.file(`tasks/${task.taskId}.json`);
|
|
130
|
+
await file.save(JSON.stringify(task, null, 2), {
|
|
131
|
+
contentType: 'application/json',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Gracefully handle GCS errors (e.g., missing credentials in local dev)
|
|
136
|
+
// Task will still work, just won't be persisted for later retrieval
|
|
137
|
+
console.warn(`[saveTask] Could not persist task ${task.taskId} to GCS:`, err.message);
|
|
138
|
+
}
|
|
132
139
|
}
|
|
133
140
|
/**
|
|
134
141
|
* Get a task record from GCS, verifying ownership
|
|
@@ -789,11 +796,12 @@ server.post('/automate/stream', {
|
|
|
789
796
|
captchaSolver: { type: 'boolean' },
|
|
790
797
|
},
|
|
791
798
|
},
|
|
799
|
+
maxTurns: { type: 'integer', minimum: 1, maximum: 100 },
|
|
792
800
|
},
|
|
793
801
|
},
|
|
794
802
|
},
|
|
795
803
|
}, async (request, reply) => {
|
|
796
|
-
const { task, browserCashApiKey, cdpUrl, browserOptions } = request.body;
|
|
804
|
+
const { task, browserCashApiKey, cdpUrl, browserOptions, maxTurns } = request.body;
|
|
797
805
|
// Validate that either browserCashApiKey or cdpUrl is provided
|
|
798
806
|
if (!browserCashApiKey && !cdpUrl) {
|
|
799
807
|
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -879,6 +887,10 @@ server.post('/automate/stream', {
|
|
|
879
887
|
cdpUrl: effectiveCdpUrl,
|
|
880
888
|
taskDescription: task,
|
|
881
889
|
});
|
|
890
|
+
// Override maxTurns if provided in request
|
|
891
|
+
if (maxTurns) {
|
|
892
|
+
config.maxTurns = maxTurns;
|
|
893
|
+
}
|
|
882
894
|
sendEvent('start', {
|
|
883
895
|
taskId,
|
|
884
896
|
task,
|
|
@@ -1405,10 +1417,23 @@ async function runAgenticAutomation(taskPrompt, config, sendEvent) {
|
|
|
1405
1417
|
catch {
|
|
1406
1418
|
// Ignore cleanup errors
|
|
1407
1419
|
}
|
|
1420
|
+
// Check if we likely hit max turns limit
|
|
1421
|
+
const assistantTurns = output.split('\n').filter((l) => l.includes('"type":"assistant"')).length;
|
|
1422
|
+
if (assistantTurns >= config.maxTurns - 1) {
|
|
1423
|
+
sendEvent('log', {
|
|
1424
|
+
message: `Task may have hit max turns limit (${config.maxTurns}). Consider increasing MAX_TURNS env var or --max-turns flag.`,
|
|
1425
|
+
level: 'warn',
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1408
1428
|
// Extract the JSON result from Claude's output
|
|
1409
1429
|
const result = extractAgenticResult(output);
|
|
1410
1430
|
if (result) {
|
|
1411
|
-
|
|
1431
|
+
if (result.success) {
|
|
1432
|
+
sendEvent('log', { message: 'Successfully extracted result from Claude output', level: 'info' });
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
sendEvent('log', { message: 'Task completed with failure status', level: 'warn' });
|
|
1436
|
+
}
|
|
1412
1437
|
resolve({
|
|
1413
1438
|
success: result.success,
|
|
1414
1439
|
result: result.data,
|
|
@@ -1417,11 +1442,17 @@ async function runAgenticAutomation(taskPrompt, config, sendEvent) {
|
|
|
1417
1442
|
});
|
|
1418
1443
|
}
|
|
1419
1444
|
else {
|
|
1420
|
-
|
|
1445
|
+
// No result could be extracted - provide context for debugging
|
|
1446
|
+
const textContent = extractTextFromStreamJson(output);
|
|
1447
|
+
const lastOutput = textContent.slice(-500);
|
|
1448
|
+
sendEvent('log', {
|
|
1449
|
+
message: `Could not extract structured result from Claude output. Last output: ${lastOutput.substring(0, 200)}...`,
|
|
1450
|
+
level: 'warn',
|
|
1451
|
+
});
|
|
1421
1452
|
resolve({
|
|
1422
1453
|
success: false,
|
|
1423
1454
|
result: null,
|
|
1424
|
-
error: code !== 0 ? `Exit code: ${code}` : 'Failed to extract result from output',
|
|
1455
|
+
error: code !== 0 ? `Exit code: ${code}` : 'Failed to extract result from output. Claude may not have produced JSON in expected format.',
|
|
1425
1456
|
});
|
|
1426
1457
|
}
|
|
1427
1458
|
});
|
|
@@ -1445,10 +1476,11 @@ async function runAgenticAutomation(taskPrompt, config, sendEvent) {
|
|
|
1445
1476
|
/**
|
|
1446
1477
|
* Extract the final JSON result from agentic automation output.
|
|
1447
1478
|
* Looks for the structured JSON output format in Claude's response.
|
|
1479
|
+
* Uses multiple strategies to be resilient to format variations.
|
|
1448
1480
|
*/
|
|
1449
1481
|
function extractAgenticResult(output) {
|
|
1450
1482
|
const textContent = extractTextFromStreamJson(output);
|
|
1451
|
-
// Look for JSON code blocks with our expected format
|
|
1483
|
+
// Strategy 1: Look for JSON code blocks with our expected format (```json ... ```)
|
|
1452
1484
|
const jsonBlockPattern = /```json\s*([\s\S]*?)```/g;
|
|
1453
1485
|
let lastValidResult = null;
|
|
1454
1486
|
let match;
|
|
@@ -1471,22 +1503,29 @@ function extractAgenticResult(output) {
|
|
|
1471
1503
|
// Not valid JSON, continue looking
|
|
1472
1504
|
}
|
|
1473
1505
|
}
|
|
1474
|
-
// If we found a structured result, return it
|
|
1475
1506
|
if (lastValidResult) {
|
|
1476
1507
|
return lastValidResult;
|
|
1477
1508
|
}
|
|
1478
|
-
//
|
|
1479
|
-
// This handles cases where Claude outputs data directly without our wrapper format
|
|
1509
|
+
// Strategy 2: Look for any code blocks with JSON data (``` ... ```)
|
|
1480
1510
|
const anyJsonPattern = /```(?:json)?\s*([\s\S]*?)```/g;
|
|
1481
1511
|
while ((match = anyJsonPattern.exec(textContent)) !== null) {
|
|
1482
1512
|
try {
|
|
1483
1513
|
const jsonStr = match[1].trim();
|
|
1484
|
-
// Skip if it doesn't look like JSON
|
|
1485
1514
|
if (!jsonStr.startsWith('{') && !jsonStr.startsWith('['))
|
|
1486
1515
|
continue;
|
|
1487
1516
|
const parsed = JSON.parse(jsonStr);
|
|
1488
|
-
// If it's an array or object with data, wrap it in our format
|
|
1489
1517
|
if (Array.isArray(parsed) || (typeof parsed === 'object' && parsed !== null)) {
|
|
1518
|
+
// Check if it has success field (format we expect)
|
|
1519
|
+
if ('success' in parsed) {
|
|
1520
|
+
return {
|
|
1521
|
+
success: parsed.success === true,
|
|
1522
|
+
data: parsed.data,
|
|
1523
|
+
summary: parsed.summary,
|
|
1524
|
+
error: parsed.error,
|
|
1525
|
+
attempted: parsed.attempted,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
// Otherwise wrap it
|
|
1490
1529
|
return {
|
|
1491
1530
|
success: true,
|
|
1492
1531
|
data: parsed,
|
|
@@ -1498,6 +1537,93 @@ function extractAgenticResult(output) {
|
|
|
1498
1537
|
// Not valid JSON, continue looking
|
|
1499
1538
|
}
|
|
1500
1539
|
}
|
|
1540
|
+
// Strategy 3: Look for unfenced JSON objects with success field anywhere in text
|
|
1541
|
+
// This handles cases where Claude outputs JSON without code fences
|
|
1542
|
+
const unfencedPattern = /\{\s*"success"\s*:\s*(true|false)[\s\S]*?\}(?=\s*$|\s*\n\s*\n|\s*[^,\]}])/g;
|
|
1543
|
+
while ((match = unfencedPattern.exec(textContent)) !== null) {
|
|
1544
|
+
try {
|
|
1545
|
+
// Try to find the complete JSON object by balancing braces
|
|
1546
|
+
const startIdx = match.index;
|
|
1547
|
+
let depth = 0;
|
|
1548
|
+
let endIdx = startIdx;
|
|
1549
|
+
for (let i = startIdx; i < textContent.length; i++) {
|
|
1550
|
+
if (textContent[i] === '{')
|
|
1551
|
+
depth++;
|
|
1552
|
+
else if (textContent[i] === '}') {
|
|
1553
|
+
depth--;
|
|
1554
|
+
if (depth === 0) {
|
|
1555
|
+
endIdx = i + 1;
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const jsonStr = textContent.substring(startIdx, endIdx);
|
|
1561
|
+
const parsed = JSON.parse(jsonStr);
|
|
1562
|
+
if (typeof parsed === 'object' && parsed !== null && 'success' in parsed) {
|
|
1563
|
+
return {
|
|
1564
|
+
success: parsed.success === true,
|
|
1565
|
+
data: parsed.data,
|
|
1566
|
+
summary: parsed.summary,
|
|
1567
|
+
error: parsed.error,
|
|
1568
|
+
attempted: parsed.attempted,
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
// Continue looking
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// Strategy 4: Look for any JSON array/object in the last portion of output
|
|
1577
|
+
// Sometimes Claude puts the result at the end without code fences
|
|
1578
|
+
const lastChunk = textContent.slice(-3000);
|
|
1579
|
+
const jsonMatches = lastChunk.match(/(\[[\s\S]*\]|\{[\s\S]*\})/g);
|
|
1580
|
+
if (jsonMatches) {
|
|
1581
|
+
// Try from last to first (most likely to be the result)
|
|
1582
|
+
for (let i = jsonMatches.length - 1; i >= 0; i--) {
|
|
1583
|
+
try {
|
|
1584
|
+
const parsed = JSON.parse(jsonMatches[i]);
|
|
1585
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
1586
|
+
return {
|
|
1587
|
+
success: true,
|
|
1588
|
+
data: parsed,
|
|
1589
|
+
summary: `Extracted ${parsed.length} items from output`,
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
1593
|
+
if ('success' in parsed) {
|
|
1594
|
+
return {
|
|
1595
|
+
success: parsed.success === true,
|
|
1596
|
+
data: parsed.data,
|
|
1597
|
+
summary: parsed.summary,
|
|
1598
|
+
error: parsed.error,
|
|
1599
|
+
attempted: parsed.attempted,
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
if (Object.keys(parsed).length > 0) {
|
|
1603
|
+
return {
|
|
1604
|
+
success: true,
|
|
1605
|
+
data: parsed,
|
|
1606
|
+
summary: 'Extracted data from output',
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
catch {
|
|
1612
|
+
// Not valid JSON
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
// Strategy 5: If we have substantial text content but no JSON, return it as a failure with context
|
|
1617
|
+
// This helps users understand what happened
|
|
1618
|
+
if (textContent.trim().length > 50) {
|
|
1619
|
+
return {
|
|
1620
|
+
success: false,
|
|
1621
|
+
error: 'Could not parse structured JSON result from output',
|
|
1622
|
+
data: null,
|
|
1623
|
+
summary: 'Task completed but output was not in expected format',
|
|
1624
|
+
rawOutput: textContent.slice(-2000), // Last 2000 chars for debugging
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1501
1627
|
return null;
|
|
1502
1628
|
}
|
|
1503
1629
|
/**
|