surf-cli 2.7.1 → 2.7.2

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.
@@ -4,13 +4,11 @@ const SELECTORS = {
4
4
  promptTextarea: '#prompt-textarea, [data-testid="composer-textarea"], textarea[name="prompt-textarea"], .ProseMirror, [contenteditable="true"][data-virtualkeyboard="true"]',
5
5
  sendButton: 'button[data-testid="send-button"], button[data-testid*="composer-send"], form button[type="submit"]',
6
6
  modelButton: '[data-testid="model-switcher-dropdown-button"]',
7
- menuContainer: '[role="menu"], [data-radix-collection-root]',
8
- menuItem: 'button, [role="menuitem"], [role="menuitemradio"], [data-testid*="model-switcher-"]',
9
- assistantMessage: '[data-message-author-role="assistant"], [data-turn="assistant"]',
10
- stopButton: '[data-testid="stop-button"]',
11
- finishedActions: 'button[data-testid="copy-turn-action-button"], button[data-testid="good-response-turn-action-button"]',
12
- conversationTurn: 'article[data-testid^="conversation-turn"], div[data-testid^="conversation-turn"]',
13
- fileInput: 'input[type="file"]',
7
+ assistantMessage: '[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant-message"], [data-testid*="assistant-turn"], [data-testid*="assistant-response"]',
8
+ assistantContent: '.markdown, [data-message-content], .prose, [class*="markdown"], [dir="auto"]',
9
+ stopButton: '[data-testid="stop-button"], [data-testid*="stop"], button[aria-label*="Stop"], button[aria-label*="stop"]',
10
+ finishedActions: 'button[data-testid="copy-turn-action-button"], button[data-testid="good-response-turn-action-button"], button[data-testid*="turn-action"], button[aria-label*="Copy"], button[aria-label*="copy"], button[aria-label*="Read aloud"], button[aria-label*="read aloud"]',
11
+ conversationTurn: '[data-testid^="conversation-turn"], [data-testid*="conversation-turn"]',
14
12
  cloudflareScript: 'script[src*="/challenge-platform/"]',
15
13
  };
16
14
 
@@ -38,10 +36,155 @@ function buildClickDispatcher() {
38
36
 
39
37
  function hasRequiredCookies(cookies) {
40
38
  if (!cookies || !Array.isArray(cookies)) return false;
41
- const sessionCookie = cookies.find(
42
- (c) => c.name === "__Secure-next-auth.session-token" && c.value
39
+ return cookies.some(
40
+ (c) =>
41
+ typeof c?.name === "string" &&
42
+ Boolean(c.value) &&
43
+ (c.name === "__Secure-next-auth.session-token" ||
44
+ /^__Secure-next-auth\.session-token\.\d+$/.test(c.name))
43
45
  );
44
- return Boolean(sessionCookie);
46
+ }
47
+
48
+ function cleanChatGPTResponseText(rawText) {
49
+ if (!rawText) return "";
50
+
51
+ const chromeLines = new Set([
52
+ "copy",
53
+ "good response",
54
+ "bad response",
55
+ "read aloud",
56
+ "edit",
57
+ "retry",
58
+ "continue generating",
59
+ "share",
60
+ ]);
61
+
62
+ const lines = [];
63
+ let inCodeFence = false;
64
+
65
+ for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
66
+ const trimmed = line.trim();
67
+ const isFenceLine = trimmed.startsWith("```");
68
+ const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
69
+
70
+ lines.push({
71
+ text: normalizedLine,
72
+ trimmed,
73
+ isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
74
+ inCodeFence,
75
+ isFenceLine,
76
+ });
77
+
78
+ if (isFenceLine) {
79
+ inCodeFence = !inCodeFence;
80
+ }
81
+ }
82
+
83
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
84
+ lines.shift();
85
+ }
86
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
87
+ lines.pop();
88
+ }
89
+
90
+ let trailingChromeStart = lines.length;
91
+ while (trailingChromeStart > 0) {
92
+ const line = lines[trailingChromeStart - 1];
93
+ if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
94
+ trailingChromeStart--;
95
+ }
96
+
97
+ const trailingChromeCount = lines.length - trailingChromeStart;
98
+ if (trailingChromeCount >= 2) {
99
+ lines.splice(trailingChromeStart);
100
+ }
101
+
102
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
103
+ lines.shift();
104
+ }
105
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
106
+ lines.pop();
107
+ }
108
+
109
+ return lines.map((line) => line.text).join("\n");
110
+ }
111
+
112
+ function extractLatestAssistantSnapshot(candidates) {
113
+ if (!Array.isArray(candidates)) return null;
114
+
115
+ let latestEmptyAssistant = null;
116
+
117
+ for (let i = candidates.length - 1; i >= 0; i--) {
118
+ const candidate = candidates[i];
119
+ if (!candidate?.isAssistant) continue;
120
+
121
+ const snapshot = {
122
+ ...candidate,
123
+ text: cleanChatGPTResponseText(candidate?.text || ""),
124
+ turnIndex: i,
125
+ };
126
+
127
+ if (snapshot.text) {
128
+ return snapshot;
129
+ }
130
+
131
+ if (!latestEmptyAssistant) {
132
+ latestEmptyAssistant = snapshot;
133
+ }
134
+ }
135
+
136
+ return latestEmptyAssistant;
137
+ }
138
+
139
+ function normalizeResponseSnapshot(rawSnapshot) {
140
+ const candidates = rawSnapshot?.candidates;
141
+ return {
142
+ latestAssistant: extractLatestAssistantSnapshot(candidates),
143
+ assistantCount: Array.isArray(candidates)
144
+ ? candidates.filter((candidate) => candidate?.isAssistant).length
145
+ : 0,
146
+ stopVisible: Boolean(rawSnapshot?.stopVisible),
147
+ };
148
+ }
149
+
150
+ function isNewAssistantContent(
151
+ latestAssistant,
152
+ baselineAssistant,
153
+ assistantCount = 0,
154
+ baselineAssistantCount = 0
155
+ ) {
156
+ if (!latestAssistant) return false;
157
+ if (!baselineAssistant) return true;
158
+ if (latestAssistant.messageId && baselineAssistant.messageId) {
159
+ if (latestAssistant.messageId !== baselineAssistant.messageId) {
160
+ return true;
161
+ }
162
+ }
163
+
164
+ const currentText = latestAssistant.text || "";
165
+ const baselineText = baselineAssistant.text || "";
166
+
167
+ if (assistantCount > baselineAssistantCount) {
168
+ if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
169
+ return true;
170
+ }
171
+ if (currentText !== baselineText) {
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ if (currentText !== baselineText) {
178
+ return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
184
+ if (!snapshot?.text) return false;
185
+ if (snapshot.stopVisible) return false;
186
+ if (snapshot.hasFinishedActions) return true;
187
+ return stableCycles >= 6 && stableMs >= 1200;
45
188
  }
46
189
 
47
190
  async function evaluate(cdp, expression) {
@@ -130,6 +273,33 @@ async function waitForPromptReady(cdp, timeoutMs = 30000) {
130
273
  return false;
131
274
  }
132
275
 
276
+ function normalizeChatGPTModelChoice(desiredModel) {
277
+ const normalized = String(desiredModel || "")
278
+ .toLowerCase()
279
+ .replace(/[^a-z0-9]/g, "");
280
+
281
+ if (["instant", "gpt53"].includes(normalized)) return "instant";
282
+ if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
283
+ if (["pro", "gpt54pro"].includes(normalized)) return "pro";
284
+
285
+ return normalized;
286
+ }
287
+
288
+ function resolveChatGPTModelMenuOption(items, desiredModel) {
289
+ if (!Array.isArray(items)) return null;
290
+
291
+ const targetModel = normalizeChatGPTModelChoice(desiredModel);
292
+
293
+ return items.find((item) => {
294
+ if (item?.role !== "menuitemradio") return false;
295
+ if (typeof item?.testId !== "string" || !item.testId.startsWith("model-switcher-")) return false;
296
+
297
+ const label = normalizeChatGPTModelChoice(item.label || "");
298
+ const testId = normalizeChatGPTModelChoice(item.testId.replace(/^model-switcher-/, ""));
299
+ return label === targetModel || testId === targetModel;
300
+ }) || null;
301
+ }
302
+
133
303
  async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
134
304
  const modelButton = await evaluate(
135
305
  cdp,
@@ -150,57 +320,66 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
150
320
  })()`
151
321
  );
152
322
  await delay(300);
153
- // Select from menu - loop in Node.js to avoid CDP timeout issues
154
- const normalizedModel = desiredModel.toLowerCase().replace(/[^a-z0-9]/g, "");
323
+
324
+ const normalizedModel = normalizeChatGPTModelChoice(desiredModel);
155
325
  const deadline = Date.now() + timeoutMs;
156
-
326
+
157
327
  while (Date.now() < deadline) {
158
328
  const result = await evaluate(
159
329
  cdp,
160
330
  `(() => {
161
- ${buildClickDispatcher()}
162
- const targetModel = ${JSON.stringify(normalizedModel)};
163
- const menuSelector = '${SELECTORS.menuContainer}';
164
- const itemSelector = '${SELECTORS.menuItem}';
165
- const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
166
-
167
- const menu = document.querySelector(menuSelector);
331
+ const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
168
332
  if (!menu) {
169
333
  return { found: false, waiting: true };
170
334
  }
171
- const items = Array.from(menu.querySelectorAll(itemSelector));
172
- let bestMatch = null;
173
- let bestScore = 0;
174
- for (const item of items) {
175
- const text = normalize(item.textContent || '');
176
- const testId = normalize(item.getAttribute('data-testid') || '');
177
- let score = 0;
178
- if (text.includes(targetModel) || testId.includes(targetModel)) score = 100;
179
- else if (targetModel.includes(text) || targetModel.includes(testId)) score = 50;
180
- if (score > bestScore) {
181
- bestScore = score;
182
- bestMatch = item;
183
- }
184
- }
185
- if (bestMatch) {
186
- dispatchClickSequence(bestMatch);
187
- return { found: true, success: true, label: bestMatch.textContent?.trim() };
188
- }
189
- return { found: true, success: false, error: 'No matching model in menu' };
335
+
336
+ return {
337
+ found: true,
338
+ items: Array.from(menu.children).map((item) => {
339
+ const primary = item.querySelector?.('.min-w-0 > span');
340
+ return {
341
+ role: item.getAttribute?.('role') || null,
342
+ label: (primary?.textContent || item.getAttribute?.('aria-label') || item.textContent || '').trim(),
343
+ testId: item.getAttribute?.('data-testid') || null,
344
+ };
345
+ }),
346
+ };
190
347
  })()`
191
348
  );
192
-
349
+
193
350
  if (result && result.found) {
194
- if (result.success) {
351
+ const match = resolveChatGPTModelMenuOption(result.items, normalizedModel);
352
+ if (match) {
353
+ await evaluate(
354
+ cdp,
355
+ `(() => {
356
+ ${buildClickDispatcher()}
357
+ const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
358
+ const item = menu?.querySelector('[data-testid="${match.testId}"]');
359
+ if (item) dispatchClickSequence(item);
360
+ })()`
361
+ );
195
362
  await delay(200);
196
- return result.label;
363
+ return match.label;
197
364
  }
198
- throw new Error(`Model not found: ${desiredModel}`);
365
+
366
+ const available = Array.isArray(result.items)
367
+ ? result.items
368
+ .filter((item) => item?.role === "menuitemradio" && typeof item?.testId === "string" && item.testId.startsWith("model-switcher-"))
369
+ .map((item) => item.label)
370
+ .filter(Boolean)
371
+ .join(", ")
372
+ : "";
373
+ throw new Error(
374
+ available
375
+ ? `Model not found: ${desiredModel}. Available: ${available}`
376
+ : `Model not found: ${desiredModel}`
377
+ );
199
378
  }
200
-
379
+
201
380
  await delay(100);
202
381
  }
203
-
382
+
204
383
  throw new Error(`Model not found: ${desiredModel} (timeout)`);
205
384
  }
206
385
 
@@ -314,78 +493,144 @@ async function clickSend(cdp, inputCdp) {
314
493
  return true;
315
494
  }
316
495
 
317
- async function waitForResponse(cdp, timeoutMs = 2700000) {
496
+ async function readChatGPTResponseSnapshot(cdp) {
497
+ return evaluate(
498
+ cdp,
499
+ `(() => {
500
+ const scope = document.querySelector('main') || document;
501
+ const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
502
+ const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
503
+ const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
504
+ const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
505
+ const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
506
+
507
+ const toCandidate = (turnNode, messageRoot = null) => {
508
+ const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
509
+ ? turnNode
510
+ : turnNode.querySelector(ASSISTANT_SELECTOR));
511
+ const searchRoot = resolvedMessageRoot || turnNode;
512
+ let contentRoot = null;
513
+
514
+ for (const selector of CONTENT_SELECTORS) {
515
+ const match = selector === '[dir="auto"]'
516
+ ? (searchRoot.matches?.(selector) ? searchRoot : null)
517
+ : (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
518
+ if (match) {
519
+ contentRoot = match;
520
+ break;
521
+ }
522
+ }
523
+
524
+ const role =
525
+ resolvedMessageRoot?.getAttribute('data-message-author-role') ||
526
+ turnNode.getAttribute('data-message-author-role') ||
527
+ null;
528
+ const turn =
529
+ resolvedMessageRoot?.getAttribute('data-turn') ||
530
+ turnNode.getAttribute('data-turn') ||
531
+ null;
532
+ const isAssistant =
533
+ role === 'assistant' ||
534
+ turn === 'assistant' ||
535
+ resolvedMessageRoot !== null;
536
+ const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
537
+ const messageId =
538
+ resolvedMessageRoot?.getAttribute('data-message-id') ||
539
+ turnNode.getAttribute('data-message-id') ||
540
+ null;
541
+ const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
542
+
543
+ return {
544
+ role,
545
+ turn,
546
+ isAssistant,
547
+ text,
548
+ messageId,
549
+ hasFinishedActions,
550
+ };
551
+ };
552
+
553
+ let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
554
+ toCandidate(turnNode)
555
+ );
556
+
557
+ if (candidates.length === 0) {
558
+ candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
559
+ toCandidate(messageRoot, messageRoot)
560
+ );
561
+ }
562
+
563
+ return {
564
+ candidates,
565
+ stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
566
+ };
567
+ })()`
568
+ );
569
+ }
570
+
571
+ async function waitForResponse(
572
+ cdp,
573
+ timeoutMs = 2700000,
574
+ baselineAssistant,
575
+ baselineAssistantCount
576
+ ) {
318
577
  const deadline = Date.now() + timeoutMs;
319
- let previousLength = 0;
578
+ let previousText = "";
320
579
  let stableCycles = 0;
321
- const requiredStableCycles = 6;
322
- const minStableMs = 1200;
323
580
  let lastChangeAt = Date.now();
581
+
582
+ previousText = baselineAssistant?.text || "";
583
+ lastChangeAt = Date.now();
584
+
324
585
  while (Date.now() < deadline) {
325
- const snapshot = await evaluate(
326
- cdp,
327
- `(() => {
328
- const CONVERSATION_SELECTOR = '${SELECTORS.conversationTurn}';
329
- const ASSISTANT_SELECTOR = '${SELECTORS.assistantMessage}';
330
- const STOP_SELECTOR = '${SELECTORS.stopButton}';
331
- const FINISHED_SELECTOR = '${SELECTORS.finishedActions}';
332
- const isAssistantTurn = (node) => {
333
- if (!(node instanceof HTMLElement)) return false;
334
- const role = (node.getAttribute('data-message-author-role') || '').toLowerCase();
335
- if (role === 'assistant') return true;
336
- const turn = (node.getAttribute('data-turn') || '').toLowerCase();
337
- if (turn === 'assistant') return true;
338
- return Boolean(node.querySelector(ASSISTANT_SELECTOR));
339
- };
340
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
341
- let lastAssistantTurn = null;
342
- for (let i = turns.length - 1; i >= 0; i--) {
343
- if (isAssistantTurn(turns[i])) {
344
- lastAssistantTurn = turns[i];
345
- break;
346
- }
347
- }
348
- if (!lastAssistantTurn) {
349
- return { text: '', stopVisible: Boolean(document.querySelector(STOP_SELECTOR)), finished: false };
350
- }
351
- const messageRoot = lastAssistantTurn.querySelector(ASSISTANT_SELECTOR) || lastAssistantTurn;
352
- const contentRoot = messageRoot.querySelector('.markdown') ||
353
- messageRoot.querySelector('[data-message-content]') ||
354
- messageRoot.querySelector('.prose') ||
355
- messageRoot;
356
- const text = (contentRoot?.innerText || contentRoot?.textContent || '').trim();
357
- const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
358
- const finished = Boolean(lastAssistantTurn.querySelector(FINISHED_SELECTOR));
359
- const messageId = messageRoot.getAttribute('data-message-id') || null;
360
- return { text, stopVisible, finished, messageId, turnIndex: turns.length - 1 };
361
- })()`
362
- );
586
+ const snapshot = await readChatGPTResponseSnapshot(cdp);
587
+
363
588
  if (!snapshot) {
364
589
  await delay(400);
365
590
  continue;
366
591
  }
367
- const currentLength = (snapshot.text || "").length;
368
- if (currentLength > previousLength) {
369
- previousLength = currentLength;
592
+
593
+ const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
594
+ const currentText = latestAssistant?.text || "";
595
+ const hasNewAssistantContent = isNewAssistantContent(
596
+ latestAssistant,
597
+ baselineAssistant,
598
+ assistantCount,
599
+ baselineAssistantCount
600
+ );
601
+
602
+ if (!hasNewAssistantContent) {
603
+ await delay(400);
604
+ continue;
605
+ }
606
+
607
+ if (currentText !== previousText) {
608
+ previousText = currentText;
370
609
  stableCycles = 0;
371
610
  lastChangeAt = Date.now();
372
- } else {
611
+ } else if (currentText) {
373
612
  stableCycles++;
613
+ } else {
614
+ stableCycles = 0;
615
+ lastChangeAt = Date.now();
374
616
  }
617
+
375
618
  const stableMs = Date.now() - lastChangeAt;
376
- if (!snapshot.stopVisible) {
377
- const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
378
- const finishedVisible = snapshot.finished;
379
- if ((finishedVisible || stableEnough) && currentLength > 0) {
380
- return {
381
- text: snapshot.text,
382
- messageId: snapshot.messageId,
383
- turnIndex: snapshot.turnIndex,
384
- };
385
- }
619
+ const completionSnapshot = latestAssistant
620
+ ? { ...latestAssistant, stopVisible }
621
+ : { text: "", stopVisible, hasFinishedActions: false };
622
+
623
+ if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
624
+ return {
625
+ text: latestAssistant.text,
626
+ messageId: latestAssistant.messageId,
627
+ turnIndex: latestAssistant.turnIndex,
628
+ };
386
629
  }
630
+
387
631
  await delay(400);
388
632
  }
633
+
389
634
  throw new Error("Response timeout");
390
635
  }
391
636
 
@@ -426,6 +671,13 @@ async function query(options) {
426
671
  throw new Error("Cloudflare challenge detected - complete in browser");
427
672
  }
428
673
  const loginStatus = await checkLoginStatus(cdp);
674
+ if (loginStatus.status === 0) {
675
+ throw new Error(
676
+ loginStatus.error
677
+ ? `ChatGPT login check failed: ${loginStatus.error}`
678
+ : "ChatGPT login check failed"
679
+ );
680
+ }
429
681
  if (loginStatus.status !== 200 || loginStatus.hasLoginCta) {
430
682
  throw new Error("ChatGPT login required");
431
683
  }
@@ -444,9 +696,15 @@ async function query(options) {
444
696
  }
445
697
  await typePrompt(cdp, inputCdp, prompt);
446
698
  log("Prompt typed");
699
+ const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
447
700
  await clickSend(cdp, inputCdp);
448
701
  log("Prompt sent, waiting for response...");
449
- const response = await waitForResponse(cdp, timeout);
702
+ const response = await waitForResponse(
703
+ cdp,
704
+ timeout,
705
+ baseline.latestAssistant,
706
+ baseline.assistantCount
707
+ );
450
708
  log(`Response received (${response.text.length} chars)`);
451
709
  return {
452
710
  response: response.text,
@@ -455,8 +713,22 @@ async function query(options) {
455
713
  tookMs: Date.now() - startTime,
456
714
  };
457
715
  } finally {
458
- await closeTab(tabId).catch(() => {});
716
+ try {
717
+ await closeTab(tabId);
718
+ } catch (error) {
719
+ log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
720
+ }
459
721
  }
460
722
  }
461
723
 
462
- module.exports = { query, hasRequiredCookies, CHATGPT_URL };
724
+ module.exports = {
725
+ query,
726
+ hasRequiredCookies,
727
+ cleanChatGPTResponseText,
728
+ extractLatestAssistantSnapshot,
729
+ normalizeChatGPTModelChoice,
730
+ resolveChatGPTModelMenuOption,
731
+ isNewAssistantContent,
732
+ isChatGPTResponseComplete,
733
+ CHATGPT_URL,
734
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
package/native/edited.png DELETED
Binary file