@steipete/oracle 0.7.0 → 0.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.
@@ -87,7 +87,7 @@ program.hook('preAction', (thisCommand) => {
87
87
  });
88
88
  program
89
89
  .name('oracle')
90
- .description('One-shot GPT-5.1 Pro / GPT-5.1 / GPT-5.1 Codex tool for hard questions that benefit from large file context and server-side search.')
90
+ .description('One-shot GPT-5.2 Pro / GPT-5.2 / GPT-5.1 Codex tool for hard questions that benefit from large file context and server-side search.')
91
91
  .version(VERSION)
92
92
  .argument('[prompt]', 'Prompt text (shorthand for --prompt).')
93
93
  .option('-p, --prompt <text>', 'User prompt to send to the model.')
@@ -112,8 +112,8 @@ program
112
112
  .addOption(new Option('--copy-markdown', 'Copy the assembled markdown bundle to the clipboard; pair with --render to print it too.').default(false))
113
113
  .addOption(new Option('--copy').hideHelp().default(false))
114
114
  .option('-s, --slug <words>', 'Custom session slug (3-5 words).')
115
- .option('-m, --model <model>', 'Model to target (gpt-5.1-pro default; aliases to gpt-5.2-pro on API. Also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).', normalizeModelOption)
116
- .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.1-pro,gemini-3-pro").')
115
+ .option('-m, --model <model>', 'Model to target (gpt-5.2-pro default; also supports gpt-5.1-pro alias). Also gpt-5-pro, gpt-5.1, gpt-5.1-codex API-only, gpt-5.2, gpt-5.2-instant, gpt-5.2-pro, gemini-3-pro, claude-4.5-sonnet, claude-4.1-opus, or ChatGPT labels like "5.2 Thinking" for browser runs).', normalizeModelOption)
116
+ .addOption(new Option('--models <models>', 'Comma-separated API model list to query in parallel (e.g., "gpt-5.2-pro,gemini-3-pro").')
117
117
  .argParser(collectModelList)
118
118
  .default([]))
119
119
  .addOption(new Option('-e, --engine <mode>', 'Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.').choices(['api', 'browser']))
@@ -123,7 +123,7 @@ program
123
123
  .addOption(new Option('--[no-]notify', 'Desktop notification when a session finishes (default on unless CI/SSH).')
124
124
  .default(undefined))
125
125
  .addOption(new Option('--[no-]notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
126
- .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.1-pro, 120s otherwise).')
126
+ .addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.2-pro, 120s otherwise).')
127
127
  .argParser(parseTimeoutOption)
128
128
  .default('auto'))
129
129
  .addOption(new Option('--preview [mode]', '(alias) Preview the request without calling the model (summary | json | full). Deprecated: use --dry-run instead.')
@@ -4,6 +4,7 @@ import { logDomFailure, logConversationSnapshot, buildConversationDebugExpressio
4
4
  import { buildClickDispatcher } from './domEvents.js';
5
5
  const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
6
6
  export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
7
+ const start = Date.now();
7
8
  logger('Waiting for ChatGPT response');
8
9
  const expression = buildResponseObserverExpression(timeoutMs);
9
10
  const evaluationPromise = Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true });
@@ -61,7 +62,24 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
61
62
  throw new Error('Unable to capture assistant response');
62
63
  }
63
64
  const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger);
64
- return refreshed ?? parsed;
65
+ const candidate = refreshed ?? parsed;
66
+ // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
67
+ const elapsedMs = Date.now() - start;
68
+ const remainingMs = Math.max(0, timeoutMs - elapsedMs);
69
+ if (remainingMs > 0) {
70
+ const [stopVisible, completionVisible] = await Promise.all([
71
+ isStopButtonVisible(Runtime),
72
+ isCompletionVisible(Runtime),
73
+ ]);
74
+ if (stopVisible && !completionVisible) {
75
+ logger('Assistant still generating; waiting for completion');
76
+ const completed = await pollAssistantCompletion(Runtime, remainingMs);
77
+ if (completed) {
78
+ return completed;
79
+ }
80
+ }
81
+ }
82
+ return candidate;
65
83
  }
66
84
  export async function readAssistantSnapshot(Runtime) {
67
85
  const { result } = await Runtime.evaluate({ expression: buildAssistantSnapshotExpression(), returnByValue: true });
@@ -118,11 +136,14 @@ async function parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, lo
118
136
  const messageId = typeof result.value.messageId === 'string'
119
137
  ? (result.value.messageId ?? undefined)
120
138
  : undefined;
121
- return {
122
- text: cleanAssistantText(String(result.value.text ?? '')),
123
- html,
124
- meta: { turnId, messageId },
125
- };
139
+ const text = cleanAssistantText(String(result.value.text ?? ''));
140
+ const normalized = text.toLowerCase();
141
+ if (normalized.includes('answer now') &&
142
+ (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
143
+ const recovered = await recoverAssistantResponse(Runtime, Math.min(timeoutMs, 10_000), logger);
144
+ return recovered ?? null;
145
+ }
146
+ return { text, html, meta: { turnId, messageId } };
126
147
  }
127
148
  const fallbackText = typeof result.value === 'string' ? cleanAssistantText(result.value) : '';
128
149
  if (!fallbackText) {
@@ -218,6 +239,8 @@ async function isCompletionVisible(Runtime) {
218
239
  const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
219
240
  const isAssistantTurn = (node) => {
220
241
  if (!(node instanceof HTMLElement)) return false;
242
+ const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
243
+ if (turnAttr === 'assistant') return true;
221
244
  const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
222
245
  if (role === 'assistant') return true;
223
246
  const testId = (node.getAttribute('data-testid') || '').toLowerCase();
@@ -257,6 +280,12 @@ function normalizeAssistantSnapshot(snapshot) {
257
280
  if (!text.trim()) {
258
281
  return null;
259
282
  }
283
+ const normalized = text.toLowerCase();
284
+ // "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
285
+ // Treat it as incomplete so browser mode keeps waiting for the real assistant text.
286
+ if (normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
287
+ return null;
288
+ }
260
289
  return {
261
290
  text,
262
291
  html: snapshot?.html ?? undefined,
@@ -295,10 +324,16 @@ function buildResponseObserverExpression(timeoutMs) {
295
324
  const CONVERSATION_SELECTOR = ${conversationLiteral};
296
325
  const ASSISTANT_SELECTOR = ${assistantLiteral};
297
326
  const settleDelayMs = 800;
327
+ const isAnswerNowPlaceholder = (snapshot) => {
328
+ const normalized = String(snapshot?.text ?? '').toLowerCase();
329
+ return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
330
+ };
298
331
 
299
332
  // Helper to detect assistant turns - matches buildAssistantExtractor logic
300
333
  const isAssistantTurn = (node) => {
301
334
  if (!(node instanceof HTMLElement)) return false;
335
+ const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
336
+ if (turnAttr === 'assistant') return true;
302
337
  const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
303
338
  if (role === 'assistant') return true;
304
339
  const testId = (node.getAttribute('data-testid') || '').toLowerCase();
@@ -313,7 +348,8 @@ function buildResponseObserverExpression(timeoutMs) {
313
348
  const deadline = Date.now() + ${timeoutMs};
314
349
  let stopInterval = null;
315
350
  const observer = new MutationObserver(() => {
316
- const extracted = extractFromTurns();
351
+ const extractedRaw = extractFromTurns();
352
+ const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
317
353
  if (extracted) {
318
354
  observer.disconnect();
319
355
  if (stopInterval) {
@@ -371,27 +407,28 @@ function buildResponseObserverExpression(timeoutMs) {
371
407
  const waitForSettle = async (snapshot) => {
372
408
  const settleWindowMs = 5000;
373
409
  const settleIntervalMs = 400;
374
- const deadline = Date.now() + settleWindowMs;
375
- let latest = snapshot;
376
- let lastLength = snapshot?.text?.length ?? 0;
377
- while (Date.now() < deadline) {
378
- await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
379
- const refreshed = extractFromTurns();
380
- if (refreshed && (refreshed.text?.length ?? 0) >= lastLength) {
381
- latest = refreshed;
382
- lastLength = refreshed.text?.length ?? lastLength;
383
- }
384
- const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
385
- const finishedVisible = isLastAssistantTurnFinished();
410
+ const deadline = Date.now() + settleWindowMs;
411
+ let latest = snapshot;
412
+ let lastLength = snapshot?.text?.length ?? 0;
413
+ while (Date.now() < deadline) {
414
+ await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
415
+ const refreshed = extractFromTurns();
416
+ if (refreshed && !isAnswerNowPlaceholder(refreshed) && (refreshed.text?.length ?? 0) >= lastLength) {
417
+ latest = refreshed;
418
+ lastLength = refreshed.text?.length ?? lastLength;
419
+ }
420
+ const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
421
+ const finishedVisible = isLastAssistantTurnFinished();
386
422
 
387
- if (!stopVisible || finishedVisible) {
388
- break;
423
+ if (!stopVisible || finishedVisible) {
424
+ break;
425
+ }
389
426
  }
390
- }
391
- return latest ?? snapshot;
392
- };
427
+ return latest ?? snapshot;
428
+ };
393
429
 
394
- const extracted = extractFromTurns();
430
+ const extractedRaw = extractFromTurns();
431
+ const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
395
432
  if (extracted) {
396
433
  return waitForSettle(extracted);
397
434
  }
@@ -407,6 +444,10 @@ function buildAssistantExtractor(functionName) {
407
444
  const ASSISTANT_SELECTOR = ${assistantLiteral};
408
445
  const isAssistantTurn = (node) => {
409
446
  if (!(node instanceof HTMLElement)) return false;
447
+ const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
448
+ if (turnAttr === 'assistant') {
449
+ return true;
450
+ }
410
451
  const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
411
452
  if (role === 'assistant') {
412
453
  return true;
@@ -443,11 +484,13 @@ function buildAssistantExtractor(functionName) {
443
484
  }
444
485
  const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) ?? turn;
445
486
  expandCollapsibles(messageRoot);
446
- const preferred =
447
- messageRoot.querySelector('.markdown') ||
448
- messageRoot.querySelector('[data-message-content]') ||
449
- messageRoot;
450
- const text = preferred?.innerText ?? '';
487
+ const preferred = messageRoot.querySelector('.markdown') || messageRoot.querySelector('[data-message-content]');
488
+ if (!preferred) {
489
+ continue;
490
+ }
491
+ const innerText = preferred?.innerText ?? '';
492
+ const textContent = preferred?.textContent ?? '';
493
+ const text = innerText.trim().length > 0 ? innerText : textContent;
451
494
  const html = preferred?.innerHTML ?? '';
452
495
  const messageId = messageRoot.getAttribute('data-message-id');
453
496
  const turnId = messageRoot.getAttribute('data-testid');
@@ -462,7 +505,7 @@ function buildCopyExpression(meta) {
462
505
  return `(() => {
463
506
  ${buildClickDispatcher()}
464
507
  const BUTTON_SELECTOR = '${COPY_BUTTON_SELECTOR}';
465
- const TIMEOUT_MS = 5000;
508
+ const TIMEOUT_MS = 10000;
466
509
 
467
510
  const locateButton = () => {
468
511
  const hint = ${JSON.stringify(meta ?? {})};
@@ -526,53 +569,62 @@ function buildCopyExpression(meta) {
526
569
  };
527
570
 
528
571
  return new Promise((resolve) => {
529
- const button = locateButton();
530
- if (!button) {
531
- resolve({ success: false, status: 'missing-button' });
532
- return;
533
- }
534
- const interception = interceptClipboard();
535
- let settled = false;
536
- let pollId = null;
537
- let timeoutId = null;
538
- const finish = (payload) => {
539
- if (settled) {
572
+ const deadline = Date.now() + TIMEOUT_MS;
573
+ const waitForButton = () => {
574
+ const button = locateButton();
575
+ if (button) {
576
+ const interception = interceptClipboard();
577
+ let settled = false;
578
+ let pollId = null;
579
+ let timeoutId = null;
580
+ const finish = (payload) => {
581
+ if (settled) {
582
+ return;
583
+ }
584
+ settled = true;
585
+ if (pollId) {
586
+ clearInterval(pollId);
587
+ }
588
+ if (timeoutId) {
589
+ clearTimeout(timeoutId);
590
+ }
591
+ button.removeEventListener('copy', handleCopy, true);
592
+ interception.restore?.();
593
+ resolve(payload);
594
+ };
595
+
596
+ const readIntercepted = () => {
597
+ const markdown = interception.state.text ?? '';
598
+ return { success: Boolean(markdown.trim()), markdown };
599
+ };
600
+
601
+ const handleCopy = () => {
602
+ finish(readIntercepted());
603
+ };
604
+
605
+ button.addEventListener('copy', handleCopy, true);
606
+ button.scrollIntoView({ block: 'center', behavior: 'instant' });
607
+ dispatchClickSequence(button);
608
+ pollId = setInterval(() => {
609
+ const payload = readIntercepted();
610
+ if (payload.success) {
611
+ finish(payload);
612
+ }
613
+ }, 100);
614
+ timeoutId = setTimeout(() => {
615
+ button.removeEventListener('copy', handleCopy, true);
616
+ finish({ success: false, status: 'timeout' });
617
+ }, TIMEOUT_MS);
540
618
  return;
541
619
  }
542
- settled = true;
543
- if (pollId) {
544
- clearInterval(pollId);
545
- }
546
- if (timeoutId) {
547
- clearTimeout(timeoutId);
620
+ if (Date.now() > deadline) {
621
+ resolve({ success: false, status: 'missing-button' });
622
+ return;
548
623
  }
549
- button.removeEventListener('copy', handleCopy, true);
550
- interception.restore?.();
551
- resolve(payload);
624
+ setTimeout(waitForButton, 120);
552
625
  };
553
626
 
554
- const readIntercepted = () => {
555
- const markdown = interception.state.text ?? '';
556
- return { success: Boolean(markdown.trim()), markdown };
557
- };
558
-
559
- const handleCopy = () => {
560
- finish(readIntercepted());
561
- };
562
-
563
- button.addEventListener('copy', handleCopy, true);
564
- button.scrollIntoView({ block: 'center', behavior: 'instant' });
565
- dispatchClickSequence(button);
566
- pollId = setInterval(() => {
567
- const payload = readIntercepted();
568
- if (payload.success) {
569
- finish(payload);
570
- }
571
- }, 100);
572
- timeoutId = setTimeout(() => {
573
- button.removeEventListener('copy', handleCopy, true);
574
- finish({ success: false, status: 'timeout' });
575
- }, TIMEOUT_MS);
627
+ waitForButton();
576
628
  });
577
629
  })()`;
578
630
  }