surf-cli 2.10.0 → 2.12.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,704 +1,144 @@
1
1
  const path = require("path");
2
- const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
2
+ const { raceAbort, throwIfAborted } = require("./abort.cjs");
3
+ const {
4
+ checkLoginStatus,
5
+ clickSend,
6
+ delay,
7
+ evaluate,
8
+ isCloudflareBlocked,
9
+ normalizeChatGPTEffortChoice,
10
+ normalizeChatGPTModelChoice,
11
+ resolveChatGPTEffortMenuOption,
12
+ resolveChatGPTModelMenuOption,
13
+ selectEffort,
14
+ selectModel,
15
+ typePrompt,
16
+ verifyChatGPTEffortSelection,
17
+ verifyChatGPTModelSelection,
18
+ waitForPageLoad,
19
+ waitForPromptReady,
20
+ } = require("./chatgpt-client-ui.cjs");
21
+ const {
22
+ cleanChatGPTResponseText,
23
+ extractLatestAssistantSnapshot,
24
+ isChatGPTResponseComplete,
25
+ isNewAssistantContent,
26
+ matchesPromptEcho,
27
+ normalizePromptEcho,
28
+ normalizeResponseSnapshot,
29
+ readChatGPTResponseSnapshot,
30
+ waitForResponse,
31
+ } = require("./chatgpt-client-response.cjs");
3
32
 
4
33
  const CHATGPT_URL = "https://chatgpt.com/";
5
-
6
- const SELECTORS = {
7
- promptTextarea: '#prompt-textarea, [data-testid="composer-textarea"], textarea[name="prompt-textarea"], .ProseMirror, [contenteditable="true"][data-virtualkeyboard="true"]',
8
- sendButton: 'button[data-testid="send-button"], button[data-testid*="composer-send"], form button[type="submit"]',
9
- modelButton: '[data-testid="model-switcher-dropdown-button"]',
10
- assistantMessage: '[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant-message"], [data-testid*="assistant-turn"], [data-testid*="assistant-response"]',
11
- assistantContent: '.markdown, [data-message-content], .prose, [class*="markdown"], [dir="auto"]',
12
- stopButton: '[data-testid="stop-button"], [data-testid*="stop"], button[aria-label*="Stop"], button[aria-label*="stop"]',
13
- 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"]',
14
- conversationTurn: '[data-testid^="conversation-turn"], [data-testid*="conversation-turn"]',
15
- cloudflareScript: 'script[src*="/challenge-platform/"]',
16
- };
17
-
18
- function delay(ms, signal) {
19
- return abortableDelay(ms, signal);
20
- }
21
-
22
- function buildClickDispatcher() {
23
- return `function dispatchClickSequence(target){
24
- if(!target || !(target instanceof EventTarget)) return false;
25
- const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
26
- for (const type of types) {
27
- const common = { bubbles: true, cancelable: true, view: window };
28
- let event;
29
- if (type.startsWith('pointer') && 'PointerEvent' in window) {
30
- event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
31
- } else {
32
- event = new MouseEvent(type, common);
33
- }
34
- target.dispatchEvent(event);
35
- }
36
- return true;
37
- }`;
38
- }
34
+ const RESPONSE_STARTED_AT = Symbol("responseStartedAt");
39
35
 
40
36
  function hasRequiredCookies(cookies) {
41
37
  if (!cookies || !Array.isArray(cookies)) return false;
42
38
  return cookies.some(
43
- (c) =>
44
- typeof c?.name === "string" &&
45
- Boolean(c.value) &&
46
- (c.name === "__Secure-next-auth.session-token" ||
47
- /^__Secure-next-auth\.session-token\.\d+$/.test(c.name))
39
+ (cookie) =>
40
+ typeof cookie?.name === "string" &&
41
+ Boolean(cookie.value) &&
42
+ (cookie.name === "__Secure-next-auth.session-token" ||
43
+ /^__Secure-next-auth\.session-token\.\d+$/.test(cookie.name)),
48
44
  );
49
45
  }
50
46
 
51
- function cleanChatGPTResponseText(rawText) {
52
- if (!rawText) return "";
53
-
54
- const chromeLines = new Set([
55
- "copy",
56
- "good response",
57
- "bad response",
58
- "read aloud",
59
- "edit",
60
- "retry",
61
- "continue generating",
62
- "share",
63
- ]);
64
-
65
- const lines = [];
66
- let inCodeFence = false;
67
-
68
- for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
69
- const trimmed = line.trim();
70
- const isFenceLine = trimmed.startsWith("```");
71
- const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
72
-
73
- lines.push({
74
- text: normalizedLine,
75
- trimmed,
76
- isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
77
- inCodeFence,
78
- isFenceLine,
79
- });
80
-
81
- if (isFenceLine) {
82
- inCodeFence = !inCodeFence;
83
- }
84
- }
85
-
86
- while (lines.length > 0 && lines[0].trimmed.length === 0) {
87
- lines.shift();
88
- }
89
- while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
90
- lines.pop();
91
- }
92
-
93
- let trailingChromeStart = lines.length;
94
- while (trailingChromeStart > 0) {
95
- const line = lines[trailingChromeStart - 1];
96
- if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
97
- trailingChromeStart--;
98
- }
99
-
100
- const trailingChromeCount = lines.length - trailingChromeStart;
101
- if (trailingChromeCount >= 2) {
102
- lines.splice(trailingChromeStart);
103
- }
104
-
105
- while (lines.length > 0 && lines[0].trimmed.length === 0) {
106
- lines.shift();
107
- }
108
- while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
109
- lines.pop();
110
- }
111
-
112
- return lines.map((line) => line.text).join("\n");
113
- }
114
-
115
- function extractLatestAssistantSnapshot(candidates) {
116
- if (!Array.isArray(candidates)) return null;
117
-
118
- let latestEmptyAssistant = null;
119
-
120
- for (let i = candidates.length - 1; i >= 0; i--) {
121
- const candidate = candidates[i];
122
- if (!candidate?.isAssistant) continue;
123
-
124
- const snapshot = {
125
- ...candidate,
126
- text: cleanChatGPTResponseText(candidate?.text || ""),
127
- turnIndex: i,
128
- };
129
-
130
- if (snapshot.text) {
131
- return snapshot;
132
- }
133
-
134
- if (!latestEmptyAssistant) {
135
- latestEmptyAssistant = snapshot;
136
- }
47
+ function extractConversationUrl(value) {
48
+ try {
49
+ const url = new URL(String(value));
50
+ const match = url.pathname.match(/^\/c\/([^/]+)\/?$/);
51
+ if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || !match) return null;
52
+ return `${url.origin}/c/${match[1]}`;
53
+ } catch {
54
+ return null;
137
55
  }
138
-
139
- return latestEmptyAssistant;
140
56
  }
141
57
 
142
- function normalizeResponseSnapshot(rawSnapshot) {
143
- const candidates = rawSnapshot?.candidates;
144
- return {
145
- latestAssistant: extractLatestAssistantSnapshot(candidates),
146
- assistantCount: Array.isArray(candidates)
147
- ? candidates.filter((candidate) => candidate?.isAssistant).length
148
- : 0,
149
- stopVisible: Boolean(rawSnapshot?.stopVisible),
150
- };
58
+ function codedError(message, code) {
59
+ const error = new Error(message);
60
+ error.code = code;
61
+ return error;
151
62
  }
152
63
 
153
- function isNewAssistantContent(
154
- latestAssistant,
155
- baselineAssistant,
156
- assistantCount = 0,
157
- baselineAssistantCount = 0
158
- ) {
159
- if (!latestAssistant) return false;
160
- if (!baselineAssistant) return true;
161
- if (latestAssistant.messageId && baselineAssistant.messageId) {
162
- if (latestAssistant.messageId !== baselineAssistant.messageId) {
163
- return true;
164
- }
165
- }
166
-
167
- const currentText = latestAssistant.text || "";
168
- const baselineText = baselineAssistant.text || "";
169
-
170
- if (assistantCount > baselineAssistantCount) {
171
- if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
172
- return true;
173
- }
174
- if (currentText !== baselineText) {
175
- return true;
176
- }
177
- return false;
178
- }
179
-
180
- if (currentText !== baselineText) {
181
- return true;
64
+ function classifyError(error, fallbackCode, preservedCodes = []) {
65
+ const classified = error instanceof Error ? error : new Error(String(error));
66
+ if (
67
+ classified.code !== "SURF_REQUEST_ABORTED" &&
68
+ !preservedCodes.includes(classified.code)
69
+ ) {
70
+ classified.code = fallbackCode;
182
71
  }
183
- return false;
184
- }
185
-
186
- function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
187
- if (!snapshot?.text) return false;
188
- if (snapshot.stopVisible) return false;
189
- if (snapshot.hasFinishedActions) return true;
190
- return stableCycles >= 6 && stableMs >= 1200;
72
+ return classified;
191
73
  }
192
74
 
193
- async function evaluate(cdp, expression, signal) {
194
- throwIfAborted(signal);
195
- const result = await cdp(expression);
196
- throwIfAborted(signal);
197
- if (result.exceptionDetails) {
198
- const desc = result.exceptionDetails.exception?.description ||
199
- result.exceptionDetails.text ||
200
- "Evaluation failed";
201
- throw new Error(desc);
202
- }
203
- if (result.error) {
204
- throw new Error(result.error);
205
- }
206
- return result.result?.value;
207
- }
208
-
209
- async function waitForPageLoad(cdp, timeoutMs = 45000, signal) {
210
- throwIfAborted(signal);
75
+ async function waitForConversationUrl(cdp, timeoutMs = 30000, signal) {
211
76
  const deadline = Date.now() + timeoutMs;
212
77
  while (Date.now() < deadline) {
213
- const ready = await evaluate(cdp, "document.readyState");
214
- if (ready === "complete" || ready === "interactive") {
215
- return;
216
- }
217
- await delay(100, signal);
218
- }
219
- throw new Error("Page did not load in time");
220
- }
221
-
222
- async function isCloudflareBlocked(cdp) {
223
- const title = await evaluate(cdp, "document.title.toLowerCase()");
224
- if (title && title.includes("just a moment")) return true;
225
- const hasScript = await evaluate(
226
- cdp,
227
- `Boolean(document.querySelector('${SELECTORS.cloudflareScript}'))`
228
- );
229
- return hasScript;
230
- }
231
-
232
- async function checkLoginStatus(cdp) {
233
- const result = await evaluate(
234
- cdp,
235
- `(async () => {
236
- try {
237
- const response = await fetch('/backend-api/me', {
238
- cache: 'no-store',
239
- credentials: 'include'
240
- });
241
- const hasLoginCta = Array.from(document.querySelectorAll('a[href*="/auth/login"], button'))
242
- .some(el => {
243
- const text = (el.textContent || '').toLowerCase().trim();
244
- return text.startsWith('log in') || text.startsWith('sign in');
245
- });
246
- return {
247
- status: response.status,
248
- hasLoginCta,
249
- url: location.href
250
- };
251
- } catch (e) {
252
- return { status: 0, error: e.message, url: location.href };
253
- }
254
- })()`
255
- );
256
- return result || { status: 0 };
257
- }
258
-
259
- async function waitForPromptReady(cdp, timeoutMs = 30000, signal) {
260
- throwIfAborted(signal);
261
- const deadline = Date.now() + timeoutMs;
262
- const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
263
- while (Date.now() < deadline) {
264
- const found = await evaluate(
265
- cdp,
266
- `(() => {
267
- const selectors = ${selectors};
268
- for (const selector of selectors) {
269
- const node = document.querySelector(selector);
270
- if (node && !node.hasAttribute('disabled')) {
271
- return true;
272
- }
273
- }
274
- return false;
275
- })()`
276
- );
277
- if (found) return true;
78
+ const conversationUrl = extractConversationUrl(await evaluate(cdp, "location.href", signal));
79
+ if (conversationUrl) return conversationUrl;
278
80
  await delay(200, signal);
279
81
  }
280
- return false;
281
- }
282
-
283
- function normalizeChatGPTModelChoice(desiredModel) {
284
- const normalized = String(desiredModel || "")
285
- .toLowerCase()
286
- .replace(/[^a-z0-9]/g, "");
287
-
288
- if (["instant", "gpt53"].includes(normalized)) return "instant";
289
- if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
290
- if (["pro", "gpt54pro"].includes(normalized)) return "pro";
291
-
292
- return normalized;
82
+ return null;
293
83
  }
294
84
 
295
- function resolveChatGPTModelMenuOption(items, desiredModel) {
296
- if (!Array.isArray(items)) return null;
297
-
298
- const targetModel = normalizeChatGPTModelChoice(desiredModel);
299
-
300
- return items.find((item) => {
301
- if (item?.role !== "menuitemradio") return false;
302
- if (typeof item?.testId !== "string" || !item.testId.startsWith("model-switcher-")) return false;
303
-
304
- const label = normalizeChatGPTModelChoice(item.label || "");
305
- const testId = normalizeChatGPTModelChoice(item.testId.replace(/^model-switcher-/, ""));
306
- return label === targetModel || testId === targetModel;
307
- }) || null;
308
- }
309
-
310
- async function selectModel(cdp, desiredModel, timeoutMs = 8000, signal) {
311
- throwIfAborted(signal);
312
- const modelButton = await evaluate(
313
- cdp,
314
- `(() => {
315
- const btn = document.querySelector('${SELECTORS.modelButton}');
316
- return btn ? true : false;
317
- })()`
318
- );
319
- if (!modelButton) {
320
- throw new Error("Model selector button not found");
321
- }
322
- await evaluate(
323
- cdp,
324
- `(() => {
325
- ${buildClickDispatcher()}
326
- const btn = document.querySelector('${SELECTORS.modelButton}');
327
- if (btn) dispatchClickSequence(btn);
328
- })()`
329
- );
330
- await delay(300, signal);
331
-
332
- const normalizedModel = normalizeChatGPTModelChoice(desiredModel);
333
- const deadline = Date.now() + timeoutMs;
334
-
335
- while (Date.now() < deadline) {
336
- const result = await evaluate(
337
- cdp,
338
- `(() => {
339
- const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
340
- if (!menu) {
341
- return { found: false, waiting: true };
342
- }
343
-
344
- return {
345
- found: true,
346
- items: Array.from(menu.children).map((item) => {
347
- const primary = item.querySelector?.('.min-w-0 > span');
348
- return {
349
- role: item.getAttribute?.('role') || null,
350
- label: (primary?.textContent || item.getAttribute?.('aria-label') || item.textContent || '').trim(),
351
- testId: item.getAttribute?.('data-testid') || null,
352
- };
353
- }),
354
- };
355
- })()`
356
- );
357
-
358
- if (result && result.found) {
359
- const match = resolveChatGPTModelMenuOption(result.items, normalizedModel);
360
- if (match) {
361
- await evaluate(
362
- cdp,
363
- `(() => {
364
- ${buildClickDispatcher()}
365
- const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
366
- const item = menu?.querySelector('[data-testid="${match.testId}"]');
367
- if (item) dispatchClickSequence(item);
368
- })()`
369
- );
370
- await delay(200, signal);
371
- return match.label;
372
- }
373
-
374
- const available = Array.isArray(result.items)
375
- ? result.items
376
- .filter((item) => item?.role === "menuitemradio" && typeof item?.testId === "string" && item.testId.startsWith("model-switcher-"))
377
- .map((item) => item.label)
378
- .filter(Boolean)
379
- .join(", ")
380
- : "";
381
- throw new Error(
382
- available
383
- ? `Model not found: ${desiredModel}. Available: ${available}`
384
- : `Model not found: ${desiredModel}`
385
- );
386
- }
387
-
388
- await delay(100, signal);
389
- }
390
-
391
- throw new Error(`Model not found: ${desiredModel} (timeout)`);
392
- }
393
-
394
- async function typePrompt(cdp, inputCdp, prompt, signal) {
395
- throwIfAborted(signal);
396
- const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
397
- const encodedPrompt = JSON.stringify(prompt);
398
- const focused = await evaluate(
399
- cdp,
400
- `(() => {
401
- ${buildClickDispatcher()}
402
- const selectors = ${selectors};
403
- for (const selector of selectors) {
404
- const node = document.querySelector(selector);
405
- if (!node) continue;
406
- dispatchClickSequence(node);
407
- if (typeof node.focus === 'function') node.focus();
408
- const doc = node.ownerDocument;
409
- const selection = doc?.getSelection?.();
410
- if (selection) {
411
- const range = doc.createRange();
412
- range.selectNodeContents(node);
413
- range.collapse(false);
414
- selection.removeAllRanges();
415
- selection.addRange(range);
416
- }
417
- return true;
418
- }
419
- return false;
420
- })()`
421
- );
422
- if (!focused) {
423
- throw new Error("Failed to focus prompt textarea");
424
- }
425
- await inputCdp("Input.insertText", { text: prompt });
426
- await delay(300, signal);
427
- const verified = await evaluate(
428
- cdp,
429
- `(() => {
430
- const selectors = ${selectors};
431
- for (const selector of selectors) {
432
- const node = document.querySelector(selector);
433
- if (!node) continue;
434
- const text = node.innerText || node.value || node.textContent || '';
435
- if (text.trim().length > 0) return true;
436
- }
437
- return false;
438
- })()`
439
- );
440
- if (!verified) {
441
- await evaluate(
442
- cdp,
443
- `(() => {
444
- const editor = document.querySelector('#prompt-textarea');
445
- const fallback = document.querySelector('textarea[name="prompt-textarea"]');
446
- if (fallback) {
447
- fallback.value = ${encodedPrompt};
448
- fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
449
- }
450
- if (editor) {
451
- editor.textContent = ${encodedPrompt};
452
- editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
453
- }
454
- })()`
455
- );
456
- }
457
- }
458
-
459
- async function clickSend(cdp, inputCdp, signal) {
460
- throwIfAborted(signal);
461
- const selectors = SELECTORS.sendButton.split(", ");
462
- const selectorsJson = JSON.stringify(selectors);
463
- const deadline = Date.now() + 8000;
464
- while (Date.now() < deadline) {
465
- const result = await evaluate(
466
- cdp,
467
- `(() => {
468
- ${buildClickDispatcher()}
469
- const selectors = ${selectorsJson};
470
- let button = null;
471
- for (const selector of selectors) {
472
- button = document.querySelector(selector);
473
- if (button) break;
474
- }
475
- if (!button) return 'missing';
476
- const disabled = button.hasAttribute('disabled') ||
477
- button.getAttribute('aria-disabled') === 'true' ||
478
- button.getAttribute('data-disabled') === 'true';
479
- if (disabled) return 'disabled';
480
- dispatchClickSequence(button);
481
- return 'clicked';
482
- })()`
483
- );
484
- if (result === "clicked") return true;
485
- if (result === "missing") break;
486
- await delay(100, signal);
487
- }
488
- await inputCdp("Input.dispatchKeyEvent", {
489
- type: "keyDown",
490
- key: "Enter",
491
- code: "Enter",
492
- windowsVirtualKeyCode: 13,
493
- nativeVirtualKeyCode: 13,
494
- text: "\r",
495
- });
496
- await inputCdp("Input.dispatchKeyEvent", {
497
- type: "keyUp",
498
- key: "Enter",
499
- code: "Enter",
500
- windowsVirtualKeyCode: 13,
501
- nativeVirtualKeyCode: 13,
502
- });
503
- return true;
504
- }
505
-
506
- async function readChatGPTResponseSnapshot(cdp) {
507
- return evaluate(
508
- cdp,
509
- `(() => {
510
- const scope = document.querySelector('main') || document;
511
- const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
512
- const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
513
- const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
514
- const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
515
- const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
516
-
517
- const toCandidate = (turnNode, messageRoot = null) => {
518
- const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
519
- ? turnNode
520
- : turnNode.querySelector(ASSISTANT_SELECTOR));
521
- const searchRoot = resolvedMessageRoot || turnNode;
522
- let contentRoot = null;
523
-
524
- for (const selector of CONTENT_SELECTORS) {
525
- const match = selector === '[dir="auto"]'
526
- ? (searchRoot.matches?.(selector) ? searchRoot : null)
527
- : (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
528
- if (match) {
529
- contentRoot = match;
530
- break;
531
- }
532
- }
533
-
534
- const role =
535
- resolvedMessageRoot?.getAttribute('data-message-author-role') ||
536
- turnNode.getAttribute('data-message-author-role') ||
537
- null;
538
- const turn =
539
- resolvedMessageRoot?.getAttribute('data-turn') ||
540
- turnNode.getAttribute('data-turn') ||
541
- null;
542
- const isAssistant =
543
- role === 'assistant' ||
544
- turn === 'assistant' ||
545
- resolvedMessageRoot !== null;
546
- const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
547
- const messageId =
548
- resolvedMessageRoot?.getAttribute('data-message-id') ||
549
- turnNode.getAttribute('data-message-id') ||
550
- null;
551
- const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
552
-
553
- return {
554
- role,
555
- turn,
556
- isAssistant,
557
- text,
558
- messageId,
559
- hasFinishedActions,
560
- };
561
- };
562
-
563
- let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
564
- toCandidate(turnNode)
565
- );
566
-
567
- if (candidates.length === 0) {
568
- candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
569
- toCandidate(messageRoot, messageRoot)
570
- );
571
- }
572
-
573
- return {
574
- candidates,
575
- stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
576
- };
577
- })()`
578
- );
579
- }
580
-
581
- async function waitForResponse(
582
- cdp,
583
- timeoutMs = 2700000,
584
- baselineAssistant,
585
- baselineAssistantCount,
586
- signal
587
- ) {
588
- throwIfAborted(signal);
589
- const deadline = Date.now() + timeoutMs;
590
- let previousText = "";
591
- let stableCycles = 0;
592
- let lastChangeAt = Date.now();
593
-
594
- previousText = baselineAssistant?.text || "";
595
- lastChangeAt = Date.now();
596
-
597
- while (Date.now() < deadline) {
598
- const snapshot = await readChatGPTResponseSnapshot(cdp);
599
-
600
- if (!snapshot) {
601
- await delay(400, signal);
602
- continue;
603
- }
604
-
605
- const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
606
- const currentText = latestAssistant?.text || "";
607
- const hasNewAssistantContent = isNewAssistantContent(
608
- latestAssistant,
609
- baselineAssistant,
610
- assistantCount,
611
- baselineAssistantCount
612
- );
613
-
614
- if (!hasNewAssistantContent) {
615
- await delay(400, signal);
616
- continue;
617
- }
618
-
619
- if (currentText !== previousText) {
620
- previousText = currentText;
621
- stableCycles = 0;
622
- lastChangeAt = Date.now();
623
- } else if (currentText) {
624
- stableCycles++;
625
- } else {
626
- stableCycles = 0;
627
- lastChangeAt = Date.now();
628
- }
629
-
630
- const stableMs = Date.now() - lastChangeAt;
631
- const completionSnapshot = latestAssistant
632
- ? { ...latestAssistant, stopVisible }
633
- : { text: "", stopVisible, hasFinishedActions: false };
634
-
635
- if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
636
- return {
637
- text: latestAssistant.text,
638
- messageId: latestAssistant.messageId,
639
- turnIndex: latestAssistant.turnIndex,
640
- };
641
- }
642
-
643
- await delay(400, signal);
644
- }
645
-
646
- throw new Error("Response timeout");
647
- }
648
-
649
- async function query(options) {
85
+ async function dispatch(options) {
650
86
  const {
651
87
  prompt,
652
88
  model,
89
+ effort,
653
90
  file,
654
- timeout = 2700000,
655
91
  getCookies,
656
92
  createTab,
657
- closeTab,
658
93
  cdpEvaluate,
659
94
  cdpCommand,
660
95
  uploadFile,
661
96
  beforeSubmit,
97
+ afterSubmit,
98
+ startUrl,
662
99
  log = () => {},
663
100
  signal,
664
101
  } = options;
665
- throwIfAborted(signal);
666
- const guardedUploadFile = uploadFile
667
- ? (...args) => raceAbort(() => uploadFile(...args), signal)
668
- : uploadFile;
669
- const startTime = Date.now();
670
- log("Starting ChatGPT query");
671
- const { cookies } = await raceAbort(getCookies, signal);
672
- if (!hasRequiredCookies(cookies)) {
673
- throw new Error("ChatGPT login required");
674
- }
675
- log(`Got ${cookies.length} cookies`);
676
- const tabInfo = await raceAbort(createTab, signal);
677
- const { tabId } = tabInfo;
678
- if (!tabId) {
679
- throw new Error("Failed to create ChatGPT tab");
680
- }
681
- log(`Created tab ${tabId}`);
682
-
683
- const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
684
- const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
685
-
102
+
686
103
  try {
104
+ throwIfAborted(signal);
105
+ const guardedUploadFile = uploadFile
106
+ ? (...args) => raceAbort(() => uploadFile(...args), signal)
107
+ : uploadFile;
108
+ log("Starting ChatGPT query");
109
+ const { cookies } = await raceAbort(getCookies, signal);
110
+ if (!hasRequiredCookies(cookies)) {
111
+ throw codedError("ChatGPT login required", "auth");
112
+ }
113
+ log(`Got ${cookies.length} cookies`);
114
+ const tabInfo = await raceAbort(createTab, signal);
115
+ const { tabId } = tabInfo;
116
+ if (!tabId) {
117
+ throw new Error("Failed to create ChatGPT tab");
118
+ }
119
+ log(`Created tab ${tabId}`);
120
+
121
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
122
+ const inputCdp = (method, params) =>
123
+ raceAbort(() => cdpCommand(tabId, method, params), signal);
124
+
125
+ if (startUrl) await inputCdp("Page.navigate", { url: startUrl });
687
126
  await waitForPageLoad(cdp, 45000, signal);
688
127
  log("Page loaded");
689
128
  if (await isCloudflareBlocked(cdp)) {
690
- throw new Error("Cloudflare challenge detected - complete in browser");
129
+ throw codedError("Cloudflare challenge detected - complete in browser", "cloudflare");
691
130
  }
692
131
  const loginStatus = await checkLoginStatus(cdp);
693
132
  if (loginStatus.status === 0) {
694
- throw new Error(
133
+ throw codedError(
695
134
  loginStatus.error
696
135
  ? `ChatGPT login check failed: ${loginStatus.error}`
697
- : "ChatGPT login check failed"
136
+ : "ChatGPT login check failed",
137
+ "auth",
698
138
  );
699
139
  }
700
140
  if (loginStatus.status !== 200 || loginStatus.hasLoginCta) {
701
- throw new Error("ChatGPT login required");
141
+ throw codedError("ChatGPT login required", "auth");
702
142
  }
703
143
  log("Login verified");
704
144
  const promptReady = await waitForPromptReady(cdp, 30000, signal);
@@ -706,13 +146,21 @@ async function query(options) {
706
146
  throw new Error("Prompt textarea not ready");
707
147
  }
708
148
  log("Prompt ready");
149
+ let modelVerified = null;
150
+ let effortVerified = null;
709
151
  if (model) {
710
- const selectedLabel = await selectModel(cdp, model, 8000, signal);
711
- log(`Selected model: ${selectedLabel}`);
152
+ modelVerified = await selectModel(cdp, model, 8000, signal);
153
+ log(`Verified model: ${modelVerified}`);
154
+ }
155
+ if (effort) {
156
+ effortVerified = await selectEffort(cdp, effort, 8000, signal);
157
+ log(`Verified effort: ${effortVerified}`);
712
158
  }
713
159
  if (file) {
714
160
  if (!uploadFile) {
715
- throw new Error("ChatGPT file upload unavailable: native host did not provide upload callback");
161
+ throw new Error(
162
+ "ChatGPT file upload unavailable: native host did not provide upload callback",
163
+ );
716
164
  }
717
165
  const files = Array.isArray(file) ? file : [file];
718
166
  const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
@@ -732,38 +180,178 @@ async function query(options) {
732
180
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
733
181
  if (beforeSubmit) await raceAbort(beforeSubmit, signal);
734
182
  await clickSend(cdp, inputCdp, signal);
183
+ baseline[RESPONSE_STARTED_AT] = Date.now();
184
+ const promptEcho = normalizePromptEcho(prompt);
185
+ if (afterSubmit) {
186
+ await afterSubmit({ tabId, promptEcho, modelVerified, effortVerified });
187
+ }
735
188
  log("Prompt sent, waiting for response...");
189
+ const conversationUrl = await waitForConversationUrl(cdp, 30000, signal);
190
+
191
+ return {
192
+ tabId,
193
+ conversationUrl,
194
+ promptEcho,
195
+ model: model || "current",
196
+ modelVerified,
197
+ effortVerified,
198
+ baseline,
199
+ };
200
+ } catch (error) {
201
+ throw classifyError(error, "dispatch_failed", [
202
+ "auth",
203
+ "cloudflare",
204
+ "model_verification_failed",
205
+ ]);
206
+ }
207
+ }
208
+
209
+ async function harvest(options) {
210
+ const {
211
+ tabId: liveTabId,
212
+ conversationUrl,
213
+ promptEcho,
214
+ baseline,
215
+ timeout = 2700000,
216
+ createTab,
217
+ closeTab,
218
+ cdpEvaluate,
219
+ cdpCommand,
220
+ keepCreatedTabOpen = false,
221
+ log = () => {},
222
+ signal,
223
+ } = options;
224
+ const startTime = Date.now();
225
+ let tabId = liveTabId;
226
+ let ownsTab = false;
227
+
228
+ try {
229
+ throwIfAborted(signal);
230
+ if (!tabId) {
231
+ if (!conversationUrl) {
232
+ throw new Error("ChatGPT conversation URL required for fresh-tab harvest");
233
+ }
234
+ const tabInfo = await raceAbort(createTab, signal);
235
+ tabId = tabInfo?.tabId;
236
+ if (!tabId) {
237
+ throw new Error("Failed to create ChatGPT tab");
238
+ }
239
+ ownsTab = true;
240
+ }
241
+
242
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
243
+ const inputCdp = (method, params) =>
244
+ raceAbort(() => cdpCommand(tabId, method, params), signal);
245
+
246
+ if (ownsTab) {
247
+ await inputCdp("Page.navigate", { url: conversationUrl });
248
+ await waitForPageLoad(cdp, 45000, signal);
249
+ if (await isCloudflareBlocked(cdp)) {
250
+ throw codedError("Cloudflare challenge detected - complete in browser", "cloudflare");
251
+ }
252
+ const loginStatus = await checkLoginStatus(cdp);
253
+ if (loginStatus.status === 0) {
254
+ throw codedError(
255
+ loginStatus.error
256
+ ? `ChatGPT login check failed: ${loginStatus.error}`
257
+ : "ChatGPT login check failed",
258
+ "auth",
259
+ );
260
+ }
261
+ if (loginStatus.status !== 200 || loginStatus.hasLoginCta) {
262
+ throw codedError("ChatGPT login required", "auth");
263
+ }
264
+ }
265
+
266
+ const elapsedSinceSend = baseline?.[RESPONSE_STARTED_AT]
267
+ ? Date.now() - baseline[RESPONSE_STARTED_AT]
268
+ : 0;
736
269
  const response = await waitForResponse(
737
270
  cdp,
738
- timeout,
739
- baseline.latestAssistant,
740
- baseline.assistantCount,
741
- signal
271
+ Math.max(0, timeout - elapsedSinceSend),
272
+ baseline?.latestAssistant,
273
+ baseline?.assistantCount,
274
+ signal,
275
+ baseline ? undefined : promptEcho,
742
276
  );
743
277
  log(`Response received (${response.text.length} chars)`);
744
278
  return {
745
279
  response: response.text,
746
- model: model || "current",
747
280
  messageId: response.messageId,
748
281
  tookMs: Date.now() - startTime,
749
282
  };
283
+ } catch (error) {
284
+ const fallbackCode = error?.message === "Response timeout" ? "timeout" : "harvest_failed";
285
+ throw classifyError(error, fallbackCode, ["auth", "cloudflare", "timeout"]);
286
+ } finally {
287
+ if (ownsTab && !keepCreatedTabOpen) {
288
+ try {
289
+ await closeTab(tabId);
290
+ } catch (error) {
291
+ log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ async function query(options) {
298
+ const { closeTab, createTab, log = () => {}, signal, timeout = 2700000 } = options;
299
+ throwIfAborted(signal);
300
+ const startTime = Date.now();
301
+ let tabId = null;
302
+
303
+ try {
304
+ const dispatched = await dispatch({
305
+ ...options,
306
+ createTab: async () => {
307
+ const tabInfo = await createTab();
308
+ tabId = tabInfo?.tabId || null;
309
+ return tabInfo;
310
+ },
311
+ });
312
+ const result = await harvest({
313
+ ...options,
314
+ tabId: dispatched.tabId,
315
+ conversationUrl: dispatched.conversationUrl,
316
+ promptEcho: dispatched.promptEcho,
317
+ baseline: dispatched.baseline,
318
+ timeout,
319
+ });
320
+ return {
321
+ response: result.response,
322
+ model: dispatched.model,
323
+ messageId: result.messageId,
324
+ tookMs: Date.now() - startTime,
325
+ };
750
326
  } finally {
751
- try {
752
- await closeTab(tabId);
753
- } catch (error) {
754
- log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
327
+ if (tabId) {
328
+ try {
329
+ await closeTab(tabId);
330
+ } catch (error) {
331
+ log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
332
+ }
755
333
  }
756
334
  }
757
335
  }
758
336
 
759
337
  module.exports = {
760
338
  query,
339
+ dispatch,
340
+ harvest,
761
341
  hasRequiredCookies,
762
342
  cleanChatGPTResponseText,
763
343
  extractLatestAssistantSnapshot,
344
+ normalizeChatGPTEffortChoice,
764
345
  normalizeChatGPTModelChoice,
346
+ resolveChatGPTEffortMenuOption,
765
347
  resolveChatGPTModelMenuOption,
766
348
  isNewAssistantContent,
767
349
  isChatGPTResponseComplete,
350
+ isCloudflareBlocked,
351
+ normalizePromptEcho,
352
+ matchesPromptEcho,
353
+ extractConversationUrl,
354
+ verifyChatGPTEffortSelection,
355
+ verifyChatGPTModelSelection,
768
356
  CHATGPT_URL,
769
357
  };