surf-cli 2.1.0 → 2.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/README.md +37 -2
- package/dist/content/accessibility-tree.js +8 -8
- package/dist/content/accessibility-tree.js.map +1 -1
- package/dist/service-worker/index.js +11 -11
- package/dist/service-worker/index.js.map +1 -1
- package/native/chatgpt-client.cjs +29 -22
- package/native/cli.cjs +67 -1
- package/native/config.cjs +12 -0
- package/native/grok-client.cjs +906 -0
- package/native/host-helpers.cjs +108 -1
- package/native/host.cjs +198 -0
- package/native/perplexity-client.cjs +26 -22
- package/package.json +1 -1
- package/native/stream-viewer.html +0 -415
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok Web Client for surf-cli
|
|
3
|
+
*
|
|
4
|
+
* CDP-based client for X.com's Grok AI using browser automation.
|
|
5
|
+
* Provides access to Grok's unique real-time X/Twitter data capabilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { loadConfig, getConfigPath, clearCache } = require("./config.cjs");
|
|
9
|
+
|
|
10
|
+
const GROK_URL = "https://x.com/i/grok";
|
|
11
|
+
const DEFAULT_MODEL = "thinking";
|
|
12
|
+
|
|
13
|
+
// Default models (as of Jan 2026)
|
|
14
|
+
const DEFAULT_GROK_MODELS = {
|
|
15
|
+
"auto": { id: "auto", name: "Auto", desc: "Chooses Fast or Expert" },
|
|
16
|
+
"fast": { id: "fast", name: "Fast", desc: "Quick responses" },
|
|
17
|
+
"expert": { id: "expert", name: "Expert", desc: "Thinks hard" },
|
|
18
|
+
"thinking": { id: "thinking", name: "Grok 4.1 Thinking", desc: "Thinks fast" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Load models from surf.json config or use defaults
|
|
22
|
+
function getGrokModels() {
|
|
23
|
+
try {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
if (config.grok?.models && typeof config.grok.models === "object" && Object.keys(config.grok.models).length > 0) {
|
|
26
|
+
return config.grok.models;
|
|
27
|
+
}
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// Ignore errors, use defaults
|
|
30
|
+
}
|
|
31
|
+
return DEFAULT_GROK_MODELS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// For backwards compatibility
|
|
35
|
+
const GROK_MODELS = DEFAULT_GROK_MODELS;
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Helpers
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
function delay(ms) {
|
|
42
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildClickDispatcher() {
|
|
46
|
+
return `function dispatchClickSequence(target) {
|
|
47
|
+
if (!target || !(target instanceof EventTarget)) return false;
|
|
48
|
+
const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
|
|
49
|
+
for (const type of types) {
|
|
50
|
+
const common = { bubbles: true, cancelable: true, view: window };
|
|
51
|
+
let event;
|
|
52
|
+
if (type.startsWith('pointer') && 'PointerEvent' in window) {
|
|
53
|
+
event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
|
|
54
|
+
} else {
|
|
55
|
+
event = new MouseEvent(type, common);
|
|
56
|
+
}
|
|
57
|
+
target.dispatchEvent(event);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasRequiredCookies(cookies) {
|
|
64
|
+
if (!cookies || !Array.isArray(cookies)) return false;
|
|
65
|
+
// auth_token is the primary session cookie for X.com
|
|
66
|
+
// ct0 is a CSRF token that's set dynamically, not strictly required for page load
|
|
67
|
+
const authToken = cookies.find(c => c.name === "auth_token" && c.value);
|
|
68
|
+
return Boolean(authToken);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function evaluate(cdp, expression) {
|
|
72
|
+
const result = await cdp(expression);
|
|
73
|
+
if (result.exceptionDetails) {
|
|
74
|
+
const desc = result.exceptionDetails.exception?.description ||
|
|
75
|
+
result.exceptionDetails.text ||
|
|
76
|
+
"Evaluation failed";
|
|
77
|
+
throw new Error(desc);
|
|
78
|
+
}
|
|
79
|
+
if (result.error) {
|
|
80
|
+
throw new Error(result.error);
|
|
81
|
+
}
|
|
82
|
+
return result.result?.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Page State Functions
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
async function waitForPageLoad(cdp, timeoutMs = 30000) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const ready = await evaluate(cdp, "document.readyState");
|
|
93
|
+
if (ready === "complete" || ready === "interactive") {
|
|
94
|
+
// Extra wait for X.com's React app to hydrate
|
|
95
|
+
await delay(1500);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await delay(100);
|
|
99
|
+
}
|
|
100
|
+
throw new Error("Page did not load in time");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function checkLoginStatus(cdp) {
|
|
104
|
+
const result = await evaluate(cdp, `(() => {
|
|
105
|
+
const body = document.body.innerText.toLowerCase();
|
|
106
|
+
const hasLoginButton = !!document.querySelector('a[href*="/login"], [data-testid="loginButton"]');
|
|
107
|
+
const hasGrokUI = body.includes('ask anything') || body.includes('grok');
|
|
108
|
+
const hasPremiumPrompt = body.includes('subscribe') || body.includes('premium required');
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
loggedIn: !hasLoginButton && hasGrokUI,
|
|
112
|
+
hasPremium: hasGrokUI && !hasPremiumPrompt,
|
|
113
|
+
url: location.href
|
|
114
|
+
};
|
|
115
|
+
})()`);
|
|
116
|
+
|
|
117
|
+
return result || { loggedIn: false, hasPremium: false };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function waitForGrokReady(cdp, timeoutMs = 20000) {
|
|
121
|
+
const deadline = Date.now() + timeoutMs;
|
|
122
|
+
let lastState = null;
|
|
123
|
+
|
|
124
|
+
while (Date.now() < deadline) {
|
|
125
|
+
const state = await evaluate(cdp, `(() => {
|
|
126
|
+
// Check for Grok-specific elements
|
|
127
|
+
const hasInput = !!document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
|
|
128
|
+
const hasGrokBranding = document.body.innerText.includes('Grok') ||
|
|
129
|
+
!!document.querySelector('[data-testid*="grok"]');
|
|
130
|
+
const isGrokPage = location.pathname.includes('/grok');
|
|
131
|
+
const isLoginPage = location.pathname.includes('/login') || location.pathname.includes('/i/flow');
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ready: isGrokPage && (hasInput || hasGrokBranding),
|
|
135
|
+
hasInput,
|
|
136
|
+
isGrokPage,
|
|
137
|
+
isLoginPage,
|
|
138
|
+
url: location.href
|
|
139
|
+
};
|
|
140
|
+
})()`);
|
|
141
|
+
|
|
142
|
+
lastState = state;
|
|
143
|
+
|
|
144
|
+
if (state && state.ready) {
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If redirected to login, fail fast
|
|
149
|
+
if (state && state.isLoginPage) {
|
|
150
|
+
throw new Error("Redirected to login page - X.com login required");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await delay(200);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Timeout - provide helpful error based on last state
|
|
157
|
+
if (lastState && !lastState.isGrokPage) {
|
|
158
|
+
throw new Error(`Not on Grok page (current: ${lastState.url}) - may need to log in`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Return fallback for edge cases where we're on Grok page but UI isn't detected
|
|
162
|
+
return { ready: true, fallback: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Model Selection
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
|
|
170
|
+
const normalizedModel = desiredModel.toLowerCase().replace(/[^a-z0-9.-]/g, "");
|
|
171
|
+
|
|
172
|
+
// First, find and click the model selector button
|
|
173
|
+
const buttonClicked = await evaluate(cdp, `(() => {
|
|
174
|
+
${buildClickDispatcher()}
|
|
175
|
+
|
|
176
|
+
// Look for model selector button (shows current model: Auto, Fast, Expert, or Grok 4.1 Thinking)
|
|
177
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
178
|
+
const modelBtn = buttons.find(b => {
|
|
179
|
+
const text = (b.textContent || '').toLowerCase();
|
|
180
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
181
|
+
const testId = (b.getAttribute('data-testid') || '').toLowerCase();
|
|
182
|
+
// Match model names or model-related attributes
|
|
183
|
+
const hasModelName = /^(auto|fast|expert|grok\\s*4)/i.test(text.trim());
|
|
184
|
+
const hasModelLabel = label.includes('model') || testId.includes('model');
|
|
185
|
+
return hasModelName || hasModelLabel;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!modelBtn) return { success: false, error: 'Model selector not found' };
|
|
189
|
+
|
|
190
|
+
dispatchClickSequence(modelBtn);
|
|
191
|
+
return { success: true };
|
|
192
|
+
})()`);
|
|
193
|
+
|
|
194
|
+
if (!buttonClicked || !buttonClicked.success) {
|
|
195
|
+
// Model selector might not exist (single model), continue anyway
|
|
196
|
+
return desiredModel;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await delay(400);
|
|
200
|
+
|
|
201
|
+
// Select from menu - loop in Node.js to avoid CDP timeout issues
|
|
202
|
+
const deadline = Date.now() + timeoutMs;
|
|
203
|
+
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const result = await evaluate(cdp, `(() => {
|
|
206
|
+
${buildClickDispatcher()}
|
|
207
|
+
|
|
208
|
+
const targetModel = ${JSON.stringify(normalizedModel)};
|
|
209
|
+
const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9.-]/g, '');
|
|
210
|
+
|
|
211
|
+
// Look for menu items
|
|
212
|
+
const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
|
|
213
|
+
|
|
214
|
+
if (items.length === 0) {
|
|
215
|
+
return { found: false, waiting: true };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let bestMatch = null;
|
|
219
|
+
let bestScore = 0;
|
|
220
|
+
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
const text = normalize(item.textContent || '');
|
|
223
|
+
let score = 0;
|
|
224
|
+
|
|
225
|
+
if (text.includes(targetModel)) score = 100;
|
|
226
|
+
else if (targetModel.includes(text) && text.length > 3) score = 50;
|
|
227
|
+
else if (text.includes('thinking') && targetModel.includes('thinking')) score = 75;
|
|
228
|
+
|
|
229
|
+
if (score > bestScore) {
|
|
230
|
+
bestScore = score;
|
|
231
|
+
bestMatch = item;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (bestMatch) {
|
|
236
|
+
dispatchClickSequence(bestMatch);
|
|
237
|
+
return { found: true, success: true, model: bestMatch.textContent?.trim() };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { found: true, success: false, error: 'No matching model in menu' };
|
|
241
|
+
})()`);
|
|
242
|
+
|
|
243
|
+
if (result && result.found) {
|
|
244
|
+
if (result.success) {
|
|
245
|
+
await delay(200);
|
|
246
|
+
return result.model;
|
|
247
|
+
}
|
|
248
|
+
// Items found but no match - close menu and return default
|
|
249
|
+
await evaluate(cdp, `document.body.click()`);
|
|
250
|
+
return desiredModel;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await delay(100);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Timeout - close menu
|
|
257
|
+
await evaluate(cdp, `document.body.click()`);
|
|
258
|
+
return desiredModel;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// DeepSearch Toggle
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
async function enableDeepSearch(cdp) {
|
|
266
|
+
const result = await evaluate(cdp, `(() => {
|
|
267
|
+
${buildClickDispatcher()}
|
|
268
|
+
|
|
269
|
+
// Look for DeepSearch toggle or button
|
|
270
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="switch"]'));
|
|
271
|
+
const deepSearchBtn = buttons.find(b => {
|
|
272
|
+
const text = (b.textContent || '').toLowerCase();
|
|
273
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
274
|
+
// Be specific to avoid clicking unrelated search buttons
|
|
275
|
+
return text.includes('deepsearch') || text.includes('deep search') ||
|
|
276
|
+
label.includes('deepsearch') || label.includes('deep search');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (!deepSearchBtn) {
|
|
280
|
+
return { success: false, error: 'DeepSearch toggle not found' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check if already enabled
|
|
284
|
+
const isEnabled = deepSearchBtn.getAttribute('aria-checked') === 'true' ||
|
|
285
|
+
deepSearchBtn.classList.contains('active');
|
|
286
|
+
|
|
287
|
+
if (isEnabled) {
|
|
288
|
+
return { success: true, alreadyEnabled: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
dispatchClickSequence(deepSearchBtn);
|
|
292
|
+
return { success: true };
|
|
293
|
+
})()`);
|
|
294
|
+
|
|
295
|
+
if (result && result.success) {
|
|
296
|
+
await delay(300);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return result || { success: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ============================================================================
|
|
303
|
+
// Input and Submission
|
|
304
|
+
// ============================================================================
|
|
305
|
+
|
|
306
|
+
async function typePrompt(cdp, inputCdp, prompt) {
|
|
307
|
+
// Focus the input area
|
|
308
|
+
const focused = await evaluate(cdp, `(() => {
|
|
309
|
+
${buildClickDispatcher()}
|
|
310
|
+
|
|
311
|
+
// Strategy 1: Find textarea or contenteditable
|
|
312
|
+
const inputs = document.querySelectorAll('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
|
|
313
|
+
for (const el of inputs) {
|
|
314
|
+
if (el.offsetParent !== null) {
|
|
315
|
+
dispatchClickSequence(el);
|
|
316
|
+
el.focus?.();
|
|
317
|
+
return { success: true, method: 'input' };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Strategy 2: Look for elements with "Ask" placeholder (more targeted selector)
|
|
322
|
+
const placeholderEls = document.querySelectorAll('[placeholder*="Ask"], [placeholder*="ask"], [aria-placeholder*="Ask"]');
|
|
323
|
+
for (const el of placeholderEls) {
|
|
324
|
+
if (el.offsetParent !== null) {
|
|
325
|
+
dispatchClickSequence(el);
|
|
326
|
+
el.focus?.();
|
|
327
|
+
return { success: true, method: 'placeholder' };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { success: false, error: 'Input not found' };
|
|
332
|
+
})()`);
|
|
333
|
+
|
|
334
|
+
if (!focused || !focused.success) {
|
|
335
|
+
throw new Error(`Could not focus input: ${focused?.error || 'unknown'}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await delay(300);
|
|
339
|
+
|
|
340
|
+
// Type using CDP Input API
|
|
341
|
+
await inputCdp("Input.insertText", { text: prompt });
|
|
342
|
+
await delay(200);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function submitPrompt(cdp, inputCdp) {
|
|
346
|
+
// Try to click send button
|
|
347
|
+
const clicked = await evaluate(cdp, `(() => {
|
|
348
|
+
${buildClickDispatcher()}
|
|
349
|
+
|
|
350
|
+
// Look for send button
|
|
351
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
352
|
+
const sendBtn = buttons.find(b => {
|
|
353
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
354
|
+
const testId = b.getAttribute('data-testid') || '';
|
|
355
|
+
return label.includes('send') || testId.includes('send') ||
|
|
356
|
+
testId.includes('submit') || testId.includes('grokSend');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (sendBtn && !sendBtn.disabled) {
|
|
360
|
+
dispatchClickSequence(sendBtn);
|
|
361
|
+
return { success: true, method: 'button' };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { success: false };
|
|
365
|
+
})()`);
|
|
366
|
+
|
|
367
|
+
if (!clicked || !clicked.success) {
|
|
368
|
+
// Fallback: press Enter
|
|
369
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
370
|
+
type: "keyDown",
|
|
371
|
+
key: "Enter",
|
|
372
|
+
code: "Enter",
|
|
373
|
+
windowsVirtualKeyCode: 13,
|
|
374
|
+
nativeVirtualKeyCode: 13,
|
|
375
|
+
text: "\r",
|
|
376
|
+
});
|
|
377
|
+
await inputCdp("Input.dispatchKeyEvent", {
|
|
378
|
+
type: "keyUp",
|
|
379
|
+
key: "Enter",
|
|
380
|
+
code: "Enter",
|
|
381
|
+
windowsVirtualKeyCode: 13,
|
|
382
|
+
nativeVirtualKeyCode: 13,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await delay(500);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ============================================================================
|
|
390
|
+
// Response Handling
|
|
391
|
+
// ============================================================================
|
|
392
|
+
|
|
393
|
+
// Extract Grok's response from the full page body text
|
|
394
|
+
function extractGrokResponse(bodyText, userPrompt = '') {
|
|
395
|
+
if (!bodyText) return null;
|
|
396
|
+
|
|
397
|
+
// Split into lines and filter out navigation/UI elements
|
|
398
|
+
const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l);
|
|
399
|
+
|
|
400
|
+
// Known UI elements to skip
|
|
401
|
+
const uiPatterns = [
|
|
402
|
+
/^(Home|Explore|Notifications|Messages|Chat|Grok|Premium|Bookmarks|Communities|Profile|More|Post)$/i,
|
|
403
|
+
/^(Creator Studio|Lists|Verified Orgs)$/i,
|
|
404
|
+
/^(History|Private|Create Images|Edit Image|Latest News)$/i,
|
|
405
|
+
/^(Create recurring tasks|Get access to|Explore)$/i,
|
|
406
|
+
/^(Think Harder)$/i,
|
|
407
|
+
/^(Auto|Fast|Expert)$/i, // Model names
|
|
408
|
+
/^Grok\s*\d/i, // "Grok 4.1 Thinking" etc
|
|
409
|
+
/^@\w+$/, // Username mentions alone
|
|
410
|
+
/^[A-Z][a-z]+ \d+$/, // Dates like "Jan 20"
|
|
411
|
+
/^(See new posts|Talk to Grok|Get access to)/, // Sidebar promos
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
// Normalize prompt for comparison (first 30 chars to handle truncation)
|
|
415
|
+
const promptNorm = userPrompt.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 30);
|
|
416
|
+
|
|
417
|
+
// Find the LAST occurrence of the user's question to get the most recent conversation
|
|
418
|
+
let lastQuestionIndex = -1;
|
|
419
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
420
|
+
const lineNorm = lines[i].toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
421
|
+
if (promptNorm && lineNorm.includes(promptNorm)) {
|
|
422
|
+
lastQuestionIndex = i;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Extract content after the last question
|
|
428
|
+
const contentLines = [];
|
|
429
|
+
const startIndex = lastQuestionIndex >= 0 ? lastQuestionIndex + 1 : 0;
|
|
430
|
+
|
|
431
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
432
|
+
const line = lines[i];
|
|
433
|
+
|
|
434
|
+
// Skip empty and UI lines
|
|
435
|
+
if (!line || uiPatterns.some(p => p.test(line))) continue;
|
|
436
|
+
|
|
437
|
+
// Skip very short lines that are likely icons/buttons (but keep numbers)
|
|
438
|
+
if (line.length <= 2 && !/^\d+$/.test(line)) continue;
|
|
439
|
+
|
|
440
|
+
// Stop at follow-up suggestions (they mark the end of the response)
|
|
441
|
+
if (/^(Explain|Tell me|Learn more|Show me|Multiplication)/i.test(line)) break;
|
|
442
|
+
|
|
443
|
+
contentLines.push(line);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// If we found content after the question, return the response
|
|
447
|
+
if (contentLines.length > 0) {
|
|
448
|
+
return contentLines.join('\n').trim();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Fallback: look for the LAST standalone numeric answer
|
|
452
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
453
|
+
const line = lines[i];
|
|
454
|
+
if (/^\d+\.?\d*$/.test(line)) {
|
|
455
|
+
return line;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
|
|
463
|
+
// Grok 4.1 Thinking can take a LONG time (47+ seconds in screenshot)
|
|
464
|
+
// Default to 5 minutes for thinking models
|
|
465
|
+
|
|
466
|
+
const deadline = Date.now() + timeoutMs;
|
|
467
|
+
let previousText = '';
|
|
468
|
+
let stableCycles = 0;
|
|
469
|
+
const requiredStableCycles = 4; // 4 cycles at 300ms = 1.2s stable
|
|
470
|
+
let lastChangeAt = Date.now();
|
|
471
|
+
const minStableMs = 1500; // 1.5 seconds stable
|
|
472
|
+
let thinkingTime = null;
|
|
473
|
+
|
|
474
|
+
// Capture initial page state to detect new content
|
|
475
|
+
const initialSnapshot = await evaluate(cdp, `document.body.innerText.length`);
|
|
476
|
+
const initialLength = initialSnapshot || 0;
|
|
477
|
+
|
|
478
|
+
while (Date.now() < deadline) {
|
|
479
|
+
// Simple approach: get full body text and parse in Node.js
|
|
480
|
+
const snapshot = await evaluate(cdp, `({
|
|
481
|
+
bodyText: document.body.innerText || '',
|
|
482
|
+
hasStopBtn: !!document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]'),
|
|
483
|
+
url: location.href
|
|
484
|
+
})`);
|
|
485
|
+
|
|
486
|
+
if (!snapshot || !snapshot.bodyText) {
|
|
487
|
+
await delay(300);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const bodyText = snapshot.bodyText;
|
|
492
|
+
|
|
493
|
+
// Parse thinking time from body text
|
|
494
|
+
const thinkMatch = bodyText.match(/Thought for (\d+)s/i);
|
|
495
|
+
if (thinkMatch) {
|
|
496
|
+
const t = parseInt(thinkMatch[1], 10);
|
|
497
|
+
if (!thinkingTime || t > thinkingTime) thinkingTime = t;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Check if still actively thinking
|
|
501
|
+
const isThinking = /\bthinking\.{0,3}$/im.test(bodyText);
|
|
502
|
+
|
|
503
|
+
// Check for completion indicators in body text
|
|
504
|
+
// Grok shows follow-up suggestions when done
|
|
505
|
+
const hasFollowUps = /Explain|Tell me more|Learn more/i.test(bodyText);
|
|
506
|
+
|
|
507
|
+
// Track body text changes for stability
|
|
508
|
+
if (bodyText.length !== previousText.length) {
|
|
509
|
+
previousText = bodyText;
|
|
510
|
+
stableCycles = 0;
|
|
511
|
+
lastChangeAt = Date.now();
|
|
512
|
+
} else {
|
|
513
|
+
stableCycles++;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const stableMs = Date.now() - lastChangeAt;
|
|
517
|
+
const isStable = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
518
|
+
|
|
519
|
+
// Response is complete when:
|
|
520
|
+
// 1. Not generating (no stop button)
|
|
521
|
+
// 2. Not actively thinking
|
|
522
|
+
// 3. Has follow-up suggestions OR body text is stable
|
|
523
|
+
// 4. Body is longer than initial (new content appeared)
|
|
524
|
+
const isDone = !snapshot.hasStopBtn && !isThinking &&
|
|
525
|
+
(hasFollowUps || isStable) &&
|
|
526
|
+
bodyText.length > initialLength;
|
|
527
|
+
|
|
528
|
+
if (isDone) {
|
|
529
|
+
// Extract the response from the body text
|
|
530
|
+
// The response is typically between the user's question and the follow-up suggestions
|
|
531
|
+
const responseText = extractGrokResponse(bodyText, userPrompt);
|
|
532
|
+
|
|
533
|
+
if (responseText) {
|
|
534
|
+
return {
|
|
535
|
+
text: responseText,
|
|
536
|
+
thinkingTime: thinkingTime,
|
|
537
|
+
url: snapshot.url,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await delay(300);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Timeout - return whatever we have
|
|
546
|
+
const finalText = extractGrokResponse(previousText, userPrompt);
|
|
547
|
+
if (finalText) {
|
|
548
|
+
return {
|
|
549
|
+
text: finalText,
|
|
550
|
+
thinkingTime: thinkingTime,
|
|
551
|
+
partial: true,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
throw new Error("Response timeout - Grok did not complete in time");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Main Query Function
|
|
560
|
+
// ============================================================================
|
|
561
|
+
|
|
562
|
+
async function query(options) {
|
|
563
|
+
const {
|
|
564
|
+
prompt,
|
|
565
|
+
model,
|
|
566
|
+
deepSearch = false,
|
|
567
|
+
timeout = 300000, // 5 minutes default (Grok Thinking is slow)
|
|
568
|
+
getCookies,
|
|
569
|
+
createTab,
|
|
570
|
+
closeTab,
|
|
571
|
+
cdpEvaluate,
|
|
572
|
+
cdpCommand,
|
|
573
|
+
log = () => {},
|
|
574
|
+
} = options;
|
|
575
|
+
|
|
576
|
+
const startTime = Date.now();
|
|
577
|
+
log("Starting Grok query");
|
|
578
|
+
|
|
579
|
+
// Check cookies for X.com authentication
|
|
580
|
+
const { cookies } = await getCookies();
|
|
581
|
+
if (!hasRequiredCookies(cookies)) {
|
|
582
|
+
throw new Error("X.com login required - log in to x.com in Chrome first");
|
|
583
|
+
}
|
|
584
|
+
log(`Got ${cookies.length} cookies`);
|
|
585
|
+
|
|
586
|
+
// Create tab
|
|
587
|
+
const tabInfo = await createTab();
|
|
588
|
+
const { tabId } = tabInfo || {};
|
|
589
|
+
|
|
590
|
+
if (!tabId) {
|
|
591
|
+
throw new Error(`Failed to create Grok tab: ${JSON.stringify(tabInfo)}`);
|
|
592
|
+
}
|
|
593
|
+
log(`Created tab ${tabId}`);
|
|
594
|
+
|
|
595
|
+
const cdp = (expr) => cdpEvaluate(tabId, expr);
|
|
596
|
+
const inputCdp = (method, params) => cdpCommand(tabId, method, params);
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
// Wait for page load
|
|
600
|
+
await waitForPageLoad(cdp);
|
|
601
|
+
log("Page loaded");
|
|
602
|
+
|
|
603
|
+
// Check login status
|
|
604
|
+
const loginStatus = await checkLoginStatus(cdp);
|
|
605
|
+
if (!loginStatus.loggedIn) {
|
|
606
|
+
throw new Error("X.com login required - log in to x.com in Chrome first");
|
|
607
|
+
}
|
|
608
|
+
if (!loginStatus.hasPremium) {
|
|
609
|
+
log("Warning: X Premium may be required for some Grok features");
|
|
610
|
+
}
|
|
611
|
+
log(`Login: yes${loginStatus.hasPremium ? ' (Premium)' : ''}`);
|
|
612
|
+
|
|
613
|
+
// Track warnings for agent feedback
|
|
614
|
+
const warnings = [];
|
|
615
|
+
|
|
616
|
+
// Wait for Grok UI
|
|
617
|
+
await waitForGrokReady(cdp);
|
|
618
|
+
log("Grok ready");
|
|
619
|
+
|
|
620
|
+
// Select model (use default if not specified)
|
|
621
|
+
const targetModel = model || DEFAULT_MODEL;
|
|
622
|
+
let selectedModel = targetModel;
|
|
623
|
+
let modelSelectionFailed = false;
|
|
624
|
+
try {
|
|
625
|
+
selectedModel = await selectModel(cdp, targetModel);
|
|
626
|
+
log(`Model: ${selectedModel}`);
|
|
627
|
+
// Check if we got a different model than requested
|
|
628
|
+
const requestedNorm = targetModel.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
629
|
+
const selectedNorm = selectedModel.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
630
|
+
if (!selectedNorm.includes(requestedNorm) && !requestedNorm.includes(selectedNorm)) {
|
|
631
|
+
warnings.push(`Requested model "${targetModel}" but got "${selectedModel}" - model may not be available`);
|
|
632
|
+
}
|
|
633
|
+
} catch (e) {
|
|
634
|
+
modelSelectionFailed = true;
|
|
635
|
+
warnings.push(`Model selection failed: ${e.message}. Run 'surf grok --validate' to check available models.`);
|
|
636
|
+
log(`Model selection failed: ${e.message}`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Enable DeepSearch if requested
|
|
640
|
+
let deepSearchEnabled = false;
|
|
641
|
+
if (deepSearch) {
|
|
642
|
+
try {
|
|
643
|
+
const dsResult = await enableDeepSearch(cdp);
|
|
644
|
+
if (dsResult.success) {
|
|
645
|
+
deepSearchEnabled = true;
|
|
646
|
+
log("DeepSearch enabled");
|
|
647
|
+
} else {
|
|
648
|
+
warnings.push(`DeepSearch toggle not found - feature may require X Premium or UI changed`);
|
|
649
|
+
}
|
|
650
|
+
} catch (e) {
|
|
651
|
+
warnings.push(`DeepSearch toggle failed: ${e.message}`);
|
|
652
|
+
log(`DeepSearch toggle failed: ${e.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Type prompt
|
|
657
|
+
await typePrompt(cdp, inputCdp, prompt);
|
|
658
|
+
log("Prompt typed");
|
|
659
|
+
|
|
660
|
+
// Submit
|
|
661
|
+
await submitPrompt(cdp, inputCdp);
|
|
662
|
+
log("Submitted, waiting for response...");
|
|
663
|
+
|
|
664
|
+
// Wait for response
|
|
665
|
+
const response = await waitForResponse(cdp, timeout, prompt);
|
|
666
|
+
const thinkingInfo = response.thinkingTime ? ` (thought for ${response.thinkingTime}s)` : '';
|
|
667
|
+
log(`Response: ${response.text.length} chars${thinkingInfo}${response.partial ? ' (partial)' : ''}`);
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
response: response.text,
|
|
671
|
+
model: selectedModel,
|
|
672
|
+
requestedModel: targetModel,
|
|
673
|
+
modelSelectionFailed,
|
|
674
|
+
thinkingTime: response.thinkingTime,
|
|
675
|
+
deepSearch: deepSearch,
|
|
676
|
+
deepSearchEnabled,
|
|
677
|
+
url: response.url,
|
|
678
|
+
partial: response.partial || false,
|
|
679
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
680
|
+
tookMs: Date.now() - startTime,
|
|
681
|
+
};
|
|
682
|
+
} finally {
|
|
683
|
+
await closeTab(tabId).catch(() => {});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ============================================================================
|
|
688
|
+
// Validate Function - Check UI structure and scrape available models
|
|
689
|
+
// ============================================================================
|
|
690
|
+
|
|
691
|
+
async function validate(options) {
|
|
692
|
+
const {
|
|
693
|
+
getCookies,
|
|
694
|
+
createTab,
|
|
695
|
+
closeTab,
|
|
696
|
+
cdpEvaluate,
|
|
697
|
+
log = () => {},
|
|
698
|
+
} = options;
|
|
699
|
+
|
|
700
|
+
const startTime = Date.now();
|
|
701
|
+
log("Starting Grok validation");
|
|
702
|
+
|
|
703
|
+
const result = {
|
|
704
|
+
authenticated: false,
|
|
705
|
+
premium: false,
|
|
706
|
+
models: [],
|
|
707
|
+
expectedModels: Object.keys(getGrokModels()),
|
|
708
|
+
modelMismatch: false,
|
|
709
|
+
inputFound: false,
|
|
710
|
+
sendButtonFound: false,
|
|
711
|
+
errors: [],
|
|
712
|
+
configPath: getConfigPath() || "~/surf.json",
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
// Check cookies
|
|
716
|
+
try {
|
|
717
|
+
const { cookies } = await getCookies();
|
|
718
|
+
result.authenticated = hasRequiredCookies(cookies);
|
|
719
|
+
if (!result.authenticated) {
|
|
720
|
+
result.errors.push("Not authenticated - log in to x.com in Chrome first");
|
|
721
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
722
|
+
}
|
|
723
|
+
log("Cookies OK");
|
|
724
|
+
} catch (e) {
|
|
725
|
+
result.errors.push(`Cookie check failed: ${e.message}`);
|
|
726
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Create tab
|
|
730
|
+
let tabId;
|
|
731
|
+
try {
|
|
732
|
+
const tabInfo = await createTab();
|
|
733
|
+
tabId = tabInfo?.tabId;
|
|
734
|
+
if (!tabId) {
|
|
735
|
+
result.errors.push("Failed to create tab");
|
|
736
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
737
|
+
}
|
|
738
|
+
log(`Created tab ${tabId}`);
|
|
739
|
+
} catch (e) {
|
|
740
|
+
result.errors.push(`Tab creation failed: ${e.message}`);
|
|
741
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const cdp = (expr) => cdpEvaluate(tabId, expr);
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
// Wait for page load
|
|
748
|
+
await waitForPageLoad(cdp);
|
|
749
|
+
log("Page loaded");
|
|
750
|
+
|
|
751
|
+
// Check login status
|
|
752
|
+
const loginStatus = await checkLoginStatus(cdp);
|
|
753
|
+
result.authenticated = loginStatus.loggedIn;
|
|
754
|
+
result.premium = loginStatus.hasPremium;
|
|
755
|
+
|
|
756
|
+
if (!loginStatus.loggedIn) {
|
|
757
|
+
result.errors.push("Page shows logged out state");
|
|
758
|
+
return { ...result, tookMs: Date.now() - startTime };
|
|
759
|
+
}
|
|
760
|
+
log(`Login: yes${result.premium ? ' (Premium)' : ''}`);
|
|
761
|
+
|
|
762
|
+
// Wait for Grok UI
|
|
763
|
+
await waitForGrokReady(cdp);
|
|
764
|
+
log("Grok ready");
|
|
765
|
+
|
|
766
|
+
// Check for input field
|
|
767
|
+
const inputCheck = await evaluate(cdp, `(() => {
|
|
768
|
+
const input = document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
|
|
769
|
+
return { found: !!input && input.offsetParent !== null };
|
|
770
|
+
})()`);
|
|
771
|
+
result.inputFound = inputCheck?.found || false;
|
|
772
|
+
log(`Input field: ${result.inputFound ? 'found' : 'NOT FOUND'}`);
|
|
773
|
+
|
|
774
|
+
// Check for send button
|
|
775
|
+
const sendCheck = await evaluate(cdp, `(() => {
|
|
776
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
777
|
+
const sendBtn = buttons.find(b => {
|
|
778
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
779
|
+
const testId = b.getAttribute('data-testid') || '';
|
|
780
|
+
return label.includes('send') || testId.includes('send') || testId.includes('submit');
|
|
781
|
+
});
|
|
782
|
+
return { found: !!sendBtn };
|
|
783
|
+
})()`);
|
|
784
|
+
result.sendButtonFound = sendCheck?.found || false;
|
|
785
|
+
log(`Send button: ${result.sendButtonFound ? 'found' : 'NOT FOUND'}`);
|
|
786
|
+
|
|
787
|
+
// Click model selector and scrape models
|
|
788
|
+
const modelButtonClicked = await evaluate(cdp, `(() => {
|
|
789
|
+
${buildClickDispatcher()}
|
|
790
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
791
|
+
const modelBtn = buttons.find(b => {
|
|
792
|
+
const text = (b.textContent || '').toLowerCase();
|
|
793
|
+
const label = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
794
|
+
const testId = (b.getAttribute('data-testid') || '').toLowerCase();
|
|
795
|
+
const hasModelName = /^(auto|fast|expert|grok\\s*4)/i.test(text.trim());
|
|
796
|
+
const hasModelLabel = label.includes('model') || testId.includes('model');
|
|
797
|
+
return hasModelName || hasModelLabel;
|
|
798
|
+
});
|
|
799
|
+
if (!modelBtn) return { success: false };
|
|
800
|
+
dispatchClickSequence(modelBtn);
|
|
801
|
+
return { success: true };
|
|
802
|
+
})()`);
|
|
803
|
+
|
|
804
|
+
if (modelButtonClicked?.success) {
|
|
805
|
+
await delay(500);
|
|
806
|
+
|
|
807
|
+
// Scrape model options
|
|
808
|
+
const modelScrape = await evaluate(cdp, `(() => {
|
|
809
|
+
const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
|
|
810
|
+
const models = [];
|
|
811
|
+
for (const item of items) {
|
|
812
|
+
const text = (item.textContent || '').trim();
|
|
813
|
+
// Skip non-model items like "Go to grok.com"
|
|
814
|
+
if (text && !text.toLowerCase().includes('go to') && !text.toLowerCase().includes('grok.com')) {
|
|
815
|
+
// Extract just the model name (first line if multi-line)
|
|
816
|
+
const name = text.split('\\n')[0].trim();
|
|
817
|
+
if (name) models.push(name);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return { models };
|
|
821
|
+
})()`);
|
|
822
|
+
|
|
823
|
+
result.models = modelScrape?.models || [];
|
|
824
|
+
log(`Found models: ${result.models.join(', ')}`);
|
|
825
|
+
|
|
826
|
+
// Close the menu
|
|
827
|
+
await evaluate(cdp, `document.body.click()`);
|
|
828
|
+
} else {
|
|
829
|
+
log("Could not open model selector");
|
|
830
|
+
result.errors.push("Model selector button not found");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Check for model mismatch
|
|
834
|
+
const expectedNames = Object.values(getGrokModels()).map(m => m.name.toLowerCase());
|
|
835
|
+
const foundNames = result.models.map(m => m.toLowerCase());
|
|
836
|
+
|
|
837
|
+
const missing = expectedNames.filter(e => !foundNames.some(f => f.includes(e) || e.includes(f)));
|
|
838
|
+
const extra = foundNames.filter(f => !expectedNames.some(e => f.includes(e) || e.includes(f)));
|
|
839
|
+
|
|
840
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
841
|
+
result.modelMismatch = true;
|
|
842
|
+
if (missing.length > 0) {
|
|
843
|
+
result.errors.push(`Expected models not found: ${missing.join(', ')}`);
|
|
844
|
+
}
|
|
845
|
+
if (extra.length > 0) {
|
|
846
|
+
result.errors.push(`Unexpected models found: ${extra.join(', ')}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
} catch (e) {
|
|
851
|
+
result.errors.push(`Validation error: ${e.message}`);
|
|
852
|
+
} finally {
|
|
853
|
+
await closeTab(tabId).catch(() => {});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
result.tookMs = Date.now() - startTime;
|
|
857
|
+
return result;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Save discovered models to surf.json config
|
|
861
|
+
function saveModels(models) {
|
|
862
|
+
const fs = require("fs");
|
|
863
|
+
const path = require("path");
|
|
864
|
+
const os = require("os");
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
// Use existing config path or default to ~/surf.json
|
|
868
|
+
let configPath = getConfigPath();
|
|
869
|
+
if (!configPath) {
|
|
870
|
+
configPath = path.join(os.homedir(), "surf.json");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Load existing config or start fresh
|
|
874
|
+
let config = {};
|
|
875
|
+
if (fs.existsSync(configPath)) {
|
|
876
|
+
try {
|
|
877
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
878
|
+
} catch (e) {
|
|
879
|
+
// Start fresh if parse fails
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Update grok.models
|
|
884
|
+
config.grok = config.grok || {};
|
|
885
|
+
config.grok.models = models;
|
|
886
|
+
|
|
887
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
888
|
+
clearCache(); // Clear config cache so subsequent reads see new values
|
|
889
|
+
return { success: true, path: configPath };
|
|
890
|
+
} catch (e) {
|
|
891
|
+
return { success: false, error: e.message };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
module.exports = {
|
|
896
|
+
query,
|
|
897
|
+
validate,
|
|
898
|
+
hasRequiredCookies,
|
|
899
|
+
getGrokModels,
|
|
900
|
+
saveModels,
|
|
901
|
+
extractGrokResponse,
|
|
902
|
+
GROK_URL,
|
|
903
|
+
GROK_MODELS,
|
|
904
|
+
DEFAULT_GROK_MODELS,
|
|
905
|
+
DEFAULT_MODEL,
|
|
906
|
+
};
|