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