@steipete/oracle 0.7.5 → 0.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.
@@ -3,15 +3,31 @@ import { delay } from '../utils.js';
3
3
  import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression } from '../domDebug.js';
4
4
  import { buildClickDispatcher } from './domEvents.js';
5
5
  const ASSISTANT_POLL_TIMEOUT_ERROR = 'assistant-response-watchdog-timeout';
6
- export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
6
+ function isAnswerNowPlaceholderText(normalized) {
7
+ const text = normalized.trim();
8
+ if (!text)
9
+ return false;
10
+ // Learned: "Pro thinking" shows a placeholder turn that contains "Answer now".
11
+ // That is not the final answer and must be ignored in browser automation.
12
+ if (text === 'chatgpt said:' || text === 'chatgpt said')
13
+ return true;
14
+ if (text.includes('file upload request') && (text.includes('pro thinking') || text.includes('chatgpt said'))) {
15
+ return true;
16
+ }
17
+ return text.includes('answer now') && (text.includes('pro thinking') || text.includes('chatgpt said'));
18
+ }
19
+ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex) {
7
20
  const start = Date.now();
8
21
  logger('Waiting for ChatGPT response');
9
- const expression = buildResponseObserverExpression(timeoutMs);
22
+ // Learned: two paths are needed:
23
+ // 1) DOM observer (fast when mutations fire),
24
+ // 2) snapshot poller (fallback when observers miss or JS stalls).
25
+ const expression = buildResponseObserverExpression(timeoutMs, minTurnIndex);
10
26
  const evaluationPromise = Runtime.evaluate({ expression, awaitPromise: true, returnByValue: true });
11
27
  const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: 'evaluation', value }), (error) => {
12
28
  throw { source: 'evaluation', error };
13
29
  });
14
- const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs).then((value) => {
30
+ const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex).then((value) => {
15
31
  if (!value) {
16
32
  throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
17
33
  }
@@ -40,7 +56,7 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
40
56
  throw error;
41
57
  }
42
58
  else if (source === 'evaluation') {
43
- const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger);
59
+ const recovered = await recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
44
60
  if (recovered) {
45
61
  return recovered;
46
62
  }
@@ -56,12 +72,29 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
56
72
  await logDomFailure(Runtime, logger, 'assistant-response');
57
73
  throw new Error('Failed to capture assistant response');
58
74
  }
59
- const parsed = await parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, logger);
75
+ const parsed = await parseAssistantEvaluationResult(Runtime, evaluation, logger);
60
76
  if (!parsed) {
77
+ let remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
78
+ if (remainingMs > 0) {
79
+ const recovered = await recoverAssistantResponse(Runtime, remainingMs, logger, minTurnIndex);
80
+ if (recovered) {
81
+ return recovered;
82
+ }
83
+ remainingMs = Math.max(0, timeoutMs - (Date.now() - start));
84
+ if (remainingMs > 0) {
85
+ const polled = await Promise.race([
86
+ pollerPromise.catch(() => null),
87
+ delay(remainingMs).then(() => null),
88
+ ]);
89
+ if (polled && polled.kind === 'poll') {
90
+ return polled.value;
91
+ }
92
+ }
93
+ }
61
94
  await logDomFailure(Runtime, logger, 'assistant-response');
62
95
  throw new Error('Unable to capture assistant response');
63
96
  }
64
- const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger);
97
+ const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger, minTurnIndex);
65
98
  const candidate = refreshed ?? parsed;
66
99
  // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
67
100
  const elapsedMs = Date.now() - start;
@@ -71,21 +104,37 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger) {
71
104
  isStopButtonVisible(Runtime),
72
105
  isCompletionVisible(Runtime),
73
106
  ]);
74
- if (stopVisible && !completionVisible) {
107
+ if (stopVisible) {
75
108
  logger('Assistant still generating; waiting for completion');
76
- const completed = await pollAssistantCompletion(Runtime, remainingMs);
109
+ const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex);
77
110
  if (completed) {
78
111
  return completed;
79
112
  }
80
113
  }
114
+ else if (completionVisible) {
115
+ // No-op: completion UI surfaced and stop button is gone.
116
+ }
81
117
  }
82
118
  return candidate;
83
119
  }
84
- export async function readAssistantSnapshot(Runtime) {
85
- const { result } = await Runtime.evaluate({ expression: buildAssistantSnapshotExpression(), returnByValue: true });
120
+ export async function readAssistantSnapshot(Runtime, minTurnIndex) {
121
+ const { result } = await Runtime.evaluate({
122
+ expression: buildAssistantSnapshotExpression(minTurnIndex),
123
+ returnByValue: true,
124
+ });
86
125
  const value = result?.value;
87
126
  if (value && typeof value === 'object') {
88
- return value;
127
+ const snapshot = value;
128
+ if (typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex)) {
129
+ const turnIndex = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : null;
130
+ if (turnIndex === null) {
131
+ return snapshot;
132
+ }
133
+ if (turnIndex < minTurnIndex) {
134
+ return null;
135
+ }
136
+ }
137
+ return snapshot;
89
138
  }
90
139
  return null;
91
140
  }
@@ -114,9 +163,21 @@ export function buildAssistantExtractorForTest(name) {
114
163
  export function buildConversationDebugExpressionForTest() {
115
164
  return buildConversationDebugExpression();
116
165
  }
117
- async function recoverAssistantResponse(Runtime, timeoutMs, logger) {
118
- const snapshot = await waitForAssistantSnapshot(Runtime, Math.min(timeoutMs, 10_000));
119
- const recovered = normalizeAssistantSnapshot(snapshot);
166
+ export function buildMarkdownFallbackExtractorForTest(minTurnLiteral = '0') {
167
+ return buildMarkdownFallbackExtractor(minTurnLiteral);
168
+ }
169
+ export function buildCopyExpressionForTest(meta = {}) {
170
+ return buildCopyExpression(meta);
171
+ }
172
+ async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex) {
173
+ const recoveryTimeoutMs = Math.max(0, timeoutMs);
174
+ if (recoveryTimeoutMs === 0) {
175
+ return null;
176
+ }
177
+ const recovered = await waitForCondition(async () => {
178
+ const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
179
+ return normalizeAssistantSnapshot(snapshot);
180
+ }, recoveryTimeoutMs, 400);
120
181
  if (recovered) {
121
182
  logger('Recovered assistant response via polling fallback');
122
183
  return recovered;
@@ -124,7 +185,7 @@ async function recoverAssistantResponse(Runtime, timeoutMs, logger) {
124
185
  await logConversationSnapshot(Runtime, logger).catch(() => undefined);
125
186
  return null;
126
187
  }
127
- async function parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, logger) {
188
+ async function parseAssistantEvaluationResult(_Runtime, evaluation, _logger) {
128
189
  const { result } = evaluation;
129
190
  if (result.type === 'object' && result.value && typeof result.value === 'object' && 'text' in result.value) {
130
191
  const html = typeof result.value.html === 'string'
@@ -138,37 +199,56 @@ async function parseAssistantEvaluationResult(Runtime, evaluation, timeoutMs, lo
138
199
  : undefined;
139
200
  const text = cleanAssistantText(String(result.value.text ?? ''));
140
201
  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;
202
+ if (isAnswerNowPlaceholderText(normalized)) {
203
+ return null;
145
204
  }
146
205
  return { text, html, meta: { turnId, messageId } };
147
206
  }
148
207
  const fallbackText = typeof result.value === 'string' ? cleanAssistantText(result.value) : '';
149
208
  if (!fallbackText) {
150
- const recovered = await recoverAssistantResponse(Runtime, Math.min(timeoutMs, 10_000), logger);
151
- if (recovered) {
152
- return recovered;
153
- }
209
+ return null;
210
+ }
211
+ if (isAnswerNowPlaceholderText(fallbackText.toLowerCase())) {
154
212
  return null;
155
213
  }
156
214
  return { text: fallbackText, html: undefined, meta: {} };
157
215
  }
158
- async function refreshAssistantSnapshot(Runtime, current, logger) {
159
- const latestSnapshot = await waitForCondition(() => readAssistantSnapshot(Runtime), 5_000, 300);
160
- const latest = normalizeAssistantSnapshot(latestSnapshot);
161
- if (!latest) {
216
+ async function refreshAssistantSnapshot(Runtime, current, logger, minTurnIndex) {
217
+ const deadline = Date.now() + 5_000;
218
+ let best = null;
219
+ let stableCycles = 0;
220
+ const stableTarget = 3;
221
+ while (Date.now() < deadline) {
222
+ // Learned: short/fast answers can race; poll a few extra cycles to pick up messageId + full text.
223
+ const latestSnapshot = await readAssistantSnapshot(Runtime, minTurnIndex).catch(() => null);
224
+ const latest = normalizeAssistantSnapshot(latestSnapshot);
225
+ if (latest) {
226
+ if (!best ||
227
+ latest.text.length > best.text.length ||
228
+ (!best.meta.messageId && latest.meta.messageId)) {
229
+ best = latest;
230
+ stableCycles = 0;
231
+ }
232
+ else if (latest.text.trim() === best.text.trim()) {
233
+ stableCycles += 1;
234
+ }
235
+ }
236
+ if (best && stableCycles >= stableTarget) {
237
+ break;
238
+ }
239
+ await delay(300);
240
+ }
241
+ if (!best) {
162
242
  return null;
163
243
  }
164
244
  const currentLength = cleanAssistantText(current.text).trim().length;
165
- const latestLength = latest.text.length;
166
- const hasBetterId = !current.meta?.messageId && Boolean(latest.meta.messageId);
245
+ const latestLength = best.text.length;
246
+ const hasBetterId = !current.meta?.messageId && Boolean(best.meta.messageId);
167
247
  const isLonger = latestLength > currentLength;
168
- const hasDifferentText = latest.text.trim() !== current.text.trim();
248
+ const hasDifferentText = best.text.trim() !== current.text.trim();
169
249
  if (isLonger || hasBetterId || hasDifferentText) {
170
250
  logger('Refreshed assistant response via latest snapshot');
171
- return latest;
251
+ return best;
172
252
  }
173
253
  return null;
174
254
  }
@@ -183,19 +263,20 @@ async function terminateRuntimeExecution(Runtime) {
183
263
  // ignore termination failures
184
264
  }
185
265
  }
186
- async function pollAssistantCompletion(Runtime, timeoutMs) {
266
+ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
187
267
  const watchdogDeadline = Date.now() + timeoutMs;
188
268
  let previousLength = 0;
189
269
  let stableCycles = 0;
190
- const requiredStableCycles = 6;
270
+ let lastChangeAt = Date.now();
191
271
  while (Date.now() < watchdogDeadline) {
192
- const snapshot = await readAssistantSnapshot(Runtime);
272
+ const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
193
273
  const normalized = normalizeAssistantSnapshot(snapshot);
194
274
  if (normalized) {
195
275
  const currentLength = normalized.text.length;
196
276
  if (currentLength > previousLength) {
197
277
  previousLength = currentLength;
198
278
  stableCycles = 0;
279
+ lastChangeAt = Date.now();
199
280
  }
200
281
  else {
201
282
  stableCycles += 1;
@@ -204,10 +285,19 @@ async function pollAssistantCompletion(Runtime, timeoutMs) {
204
285
  isStopButtonVisible(Runtime),
205
286
  isCompletionVisible(Runtime),
206
287
  ]);
207
- // Require at least 2 stable cycles even when completion buttons are visible
208
- // to ensure DOM text has fully rendered (buttons can appear before text settles)
209
- if ((completionVisible && stableCycles >= 2) || (!stopVisible && stableCycles >= requiredStableCycles)) {
210
- return normalized;
288
+ const shortAnswer = currentLength > 0 && currentLength < 16;
289
+ // Learned: short answers need a longer stability window or they truncate.
290
+ const completionStableTarget = shortAnswer ? 12 : currentLength < 40 ? 8 : 4;
291
+ const requiredStableCycles = shortAnswer ? 12 : 6;
292
+ const stableMs = Date.now() - lastChangeAt;
293
+ const minStableMs = shortAnswer ? 8000 : 1200;
294
+ // Require stop button to disappear before treating completion as final.
295
+ if (!stopVisible) {
296
+ const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
297
+ const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
298
+ if (completionEnough || stableEnough) {
299
+ return normalized;
300
+ }
211
301
  }
212
302
  }
213
303
  else {
@@ -283,7 +373,11 @@ function normalizeAssistantSnapshot(snapshot) {
283
373
  const normalized = text.toLowerCase();
284
374
  // "Pro thinking" often renders a placeholder turn containing an "Answer now" gate.
285
375
  // 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'))) {
376
+ if (isAnswerNowPlaceholderText(normalized)) {
377
+ return null;
378
+ }
379
+ // Ignore user echo turns that can show up in project view fallbacks.
380
+ if (normalized.startsWith('you said')) {
287
381
  return null;
288
382
  }
289
383
  return {
@@ -292,9 +386,6 @@ function normalizeAssistantSnapshot(snapshot) {
292
386
  meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
293
387
  };
294
388
  }
295
- async function waitForAssistantSnapshot(Runtime, timeoutMs) {
296
- return waitForCondition(() => readAssistantSnapshot(Runtime), timeoutMs);
297
- }
298
389
  async function waitForCondition(getter, timeoutMs, pollIntervalMs = 400) {
299
390
  const deadline = Date.now() + timeoutMs;
300
391
  while (Date.now() < deadline) {
@@ -306,16 +397,38 @@ async function waitForCondition(getter, timeoutMs, pollIntervalMs = 400) {
306
397
  }
307
398
  return null;
308
399
  }
309
- function buildAssistantSnapshotExpression() {
400
+ function buildAssistantSnapshotExpression(minTurnIndex) {
401
+ const minTurnLiteral = typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
402
+ ? Math.floor(minTurnIndex)
403
+ : -1;
310
404
  return `(() => {
405
+ const MIN_TURN_INDEX = ${minTurnLiteral};
406
+ // Learned: the default turn DOM misses project view; keep a fallback extractor.
311
407
  ${buildAssistantExtractor('extractAssistantTurn')}
312
- return extractAssistantTurn();
408
+ const extracted = extractAssistantTurn();
409
+ const isPlaceholder = (snapshot) => {
410
+ const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
411
+ if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
412
+ if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
413
+ return true;
414
+ }
415
+ return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
416
+ };
417
+ if (extracted && extracted.text && !isPlaceholder(extracted)) {
418
+ return extracted;
419
+ }
420
+ // Fallback for ChatGPT project view: answers can live outside conversation turns.
421
+ const fallback = ${buildMarkdownFallbackExtractor('MIN_TURN_INDEX')};
422
+ return fallback ?? extracted;
313
423
  })()`;
314
424
  }
315
- function buildResponseObserverExpression(timeoutMs) {
425
+ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
316
426
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
317
427
  const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
318
428
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
429
+ const minTurnLiteral = typeof minTurnIndex === 'number' && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
430
+ ? Math.floor(minTurnIndex)
431
+ : -1;
319
432
  return `(() => {
320
433
  ${buildClickDispatcher()}
321
434
  const SELECTORS = ${selectorsLiteral};
@@ -323,13 +436,18 @@ function buildResponseObserverExpression(timeoutMs) {
323
436
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
324
437
  const CONVERSATION_SELECTOR = ${conversationLiteral};
325
438
  const ASSISTANT_SELECTOR = ${assistantLiteral};
439
+ // Learned: settling avoids capturing mid-stream HTML; keep short.
326
440
  const settleDelayMs = 800;
327
441
  const isAnswerNowPlaceholder = (snapshot) => {
328
- const normalized = String(snapshot?.text ?? '').toLowerCase();
442
+ const normalized = String(snapshot?.text ?? '').toLowerCase().trim();
443
+ if (normalized === 'chatgpt said:' || normalized === 'chatgpt said') return true;
444
+ if (normalized.includes('file upload request') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'))) {
445
+ return true;
446
+ }
329
447
  return normalized.includes('answer now') && (normalized.includes('pro thinking') || normalized.includes('chatgpt said'));
330
448
  };
331
449
 
332
- // Helper to detect assistant turns - matches buildAssistantExtractor logic
450
+ // Helper to detect assistant turns - must match buildAssistantExtractor logic for consistency.
333
451
  const isAssistantTurn = (node) => {
334
452
  if (!(node instanceof HTMLElement)) return false;
335
453
  const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -341,7 +459,21 @@ function buildResponseObserverExpression(timeoutMs) {
341
459
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
342
460
  };
343
461
 
462
+ const MIN_TURN_INDEX = ${minTurnLiteral};
344
463
  ${buildAssistantExtractor('extractFromTurns')}
464
+ // Learned: some layouts (project view) render markdown without assistant turn wrappers.
465
+ const extractFromMarkdownFallback = ${buildMarkdownFallbackExtractor('MIN_TURN_INDEX')};
466
+
467
+ const acceptSnapshot = (snapshot) => {
468
+ if (!snapshot) return null;
469
+ const index = typeof snapshot.turnIndex === 'number' ? snapshot.turnIndex : -1;
470
+ if (MIN_TURN_INDEX >= 0) {
471
+ if (index < 0 || index < MIN_TURN_INDEX) {
472
+ return null;
473
+ }
474
+ }
475
+ return snapshot;
476
+ };
345
477
 
346
478
  const captureViaObserver = () =>
347
479
  new Promise((resolve, reject) => {
@@ -349,7 +481,15 @@ function buildResponseObserverExpression(timeoutMs) {
349
481
  let stopInterval = null;
350
482
  const observer = new MutationObserver(() => {
351
483
  const extractedRaw = extractFromTurns();
352
- const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
484
+ const extractedCandidate =
485
+ extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
486
+ let extracted = acceptSnapshot(extractedCandidate);
487
+ if (!extracted) {
488
+ const fallbackRaw = extractFromMarkdownFallback();
489
+ const fallbackCandidate =
490
+ fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
491
+ extracted = acceptSnapshot(fallbackCandidate);
492
+ }
353
493
  if (extracted) {
354
494
  observer.disconnect();
355
495
  if (stopInterval) {
@@ -386,7 +526,7 @@ function buildResponseObserverExpression(timeoutMs) {
386
526
  }, ${timeoutMs});
387
527
  });
388
528
 
389
- // Check if the last assistant turn has finished (scoped to avoid detecting old turns)
529
+ // Check if the last assistant turn has finished (scoped to avoid detecting old turns).
390
530
  const isLastAssistantTurnFinished = () => {
391
531
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
392
532
  let lastAssistantTurn = null;
@@ -405,30 +545,56 @@ function buildResponseObserverExpression(timeoutMs) {
405
545
  };
406
546
 
407
547
  const waitForSettle = async (snapshot) => {
408
- const settleWindowMs = 5000;
548
+ // Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
549
+ const initialLength = snapshot?.text?.length ?? 0;
550
+ const shortAnswer = initialLength > 0 && initialLength < 16;
551
+ const settleWindowMs = shortAnswer ? 12_000 : 5_000;
409
552
  const settleIntervalMs = 400;
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();
553
+ const deadline = Date.now() + settleWindowMs;
554
+ let latest = snapshot;
555
+ let lastLength = snapshot?.text?.length ?? 0;
556
+ let stableCycles = 0;
557
+ const stableTarget = shortAnswer ? 6 : 3;
558
+ while (Date.now() < deadline) {
559
+ await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
560
+ const refreshedRaw = extractFromTurns();
561
+ const refreshedCandidate =
562
+ refreshedRaw && !isAnswerNowPlaceholder(refreshedRaw) ? refreshedRaw : null;
563
+ let refreshed = acceptSnapshot(refreshedCandidate);
564
+ if (!refreshed) {
565
+ const fallbackRaw = extractFromMarkdownFallback();
566
+ const fallbackCandidate =
567
+ fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
568
+ refreshed = acceptSnapshot(fallbackCandidate);
569
+ }
570
+ const nextLength = refreshed?.text?.length ?? lastLength;
571
+ if (refreshed && nextLength >= lastLength) {
572
+ latest = refreshed;
573
+ }
574
+ if (nextLength > lastLength) {
575
+ lastLength = nextLength;
576
+ stableCycles = 0;
577
+ } else {
578
+ stableCycles += 1;
579
+ }
580
+ const stopVisible = Boolean(document.querySelector(STOP_SELECTOR));
581
+ const finishedVisible = isLastAssistantTurnFinished();
422
582
 
423
- if (!stopVisible || finishedVisible) {
424
- break;
425
- }
583
+ if (finishedVisible || (!stopVisible && stableCycles >= stableTarget)) {
584
+ break;
426
585
  }
427
- return latest ?? snapshot;
428
- };
586
+ }
587
+ return latest ?? snapshot;
588
+ };
429
589
 
430
590
  const extractedRaw = extractFromTurns();
431
- const extracted = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
591
+ const extractedCandidate = extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
592
+ let extracted = acceptSnapshot(extractedCandidate);
593
+ if (!extracted) {
594
+ const fallbackRaw = extractFromMarkdownFallback();
595
+ const fallbackCandidate = fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
596
+ extracted = acceptSnapshot(fallbackCandidate);
597
+ }
432
598
  if (extracted) {
433
599
  return waitForSettle(extracted);
434
600
  }
@@ -484,23 +650,164 @@ function buildAssistantExtractor(functionName) {
484
650
  }
485
651
  const messageRoot = turn.querySelector(ASSISTANT_SELECTOR) ?? turn;
486
652
  expandCollapsibles(messageRoot);
487
- const preferred = messageRoot.querySelector('.markdown') || messageRoot.querySelector('[data-message-content]');
488
- if (!preferred) {
653
+ const preferred =
654
+ (messageRoot.matches?.('.markdown') || messageRoot.matches?.('[data-message-content]') ? messageRoot : null) ||
655
+ messageRoot.querySelector('.markdown') ||
656
+ messageRoot.querySelector('[data-message-content]') ||
657
+ messageRoot.querySelector('[data-testid*="message"]') ||
658
+ messageRoot.querySelector('[data-testid*="assistant"]') ||
659
+ messageRoot.querySelector('.prose') ||
660
+ messageRoot.querySelector('[class*="markdown"]');
661
+ const contentRoot = preferred ?? messageRoot;
662
+ if (!contentRoot) {
489
663
  continue;
490
664
  }
491
- const innerText = preferred?.innerText ?? '';
492
- const textContent = preferred?.textContent ?? '';
665
+ const innerText = contentRoot?.innerText ?? '';
666
+ const textContent = contentRoot?.textContent ?? '';
493
667
  const text = innerText.trim().length > 0 ? innerText : textContent;
494
- const html = preferred?.innerHTML ?? '';
668
+ const html = contentRoot?.innerHTML ?? '';
495
669
  const messageId = messageRoot.getAttribute('data-message-id');
496
670
  const turnId = messageRoot.getAttribute('data-testid');
497
671
  if (text.trim()) {
498
- return { text, html, messageId, turnId };
672
+ return { text, html, messageId, turnId, turnIndex: index };
499
673
  }
500
674
  }
501
675
  return null;
502
676
  };`;
503
677
  }
678
+ function buildMarkdownFallbackExtractor(minTurnLiteral) {
679
+ const turnIndexValue = minTurnLiteral ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` : 'null';
680
+ return `(() => {
681
+ const MIN_TURN_INDEX = ${turnIndexValue};
682
+ const roots = [
683
+ document.querySelector('section[data-testid="screen-threadFlyOut"]'),
684
+ document.querySelector('[data-testid="chat-thread"]'),
685
+ document.querySelector('main'),
686
+ document.querySelector('[role="main"]'),
687
+ ].filter(Boolean);
688
+ if (roots.length === 0) return null;
689
+ const markdownSelector = '.markdown,[data-message-content],[data-testid*="message"],.prose,[class*="markdown"]';
690
+ const isExcluded = (node) =>
691
+ Boolean(
692
+ node?.closest?.(
693
+ 'nav, aside, [data-testid*="sidebar"], [data-testid*="chat-history"], [data-testid*="composer"], form',
694
+ ),
695
+ );
696
+ const scoreRoot = (node) => {
697
+ const actions = node.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}').length;
698
+ const assistants = node.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]').length;
699
+ const markdowns = node.querySelectorAll(markdownSelector).length;
700
+ return actions * 10 + assistants * 5 + markdowns;
701
+ };
702
+ let root = roots[0];
703
+ let bestScore = scoreRoot(root);
704
+ for (let i = 1; i < roots.length; i += 1) {
705
+ const candidate = roots[i];
706
+ const score = scoreRoot(candidate);
707
+ if (score > bestScore) {
708
+ bestScore = score;
709
+ root = candidate;
710
+ }
711
+ }
712
+ if (!root) return null;
713
+ const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
714
+ const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
715
+ const hasTurns = turnNodes.length > 0;
716
+ const resolveTurnIndex = (node) => {
717
+ const turn = node?.closest?.(CONVERSATION_SELECTOR);
718
+ if (!turn) return null;
719
+ const idx = turnNodes.indexOf(turn);
720
+ return idx >= 0 ? idx : null;
721
+ };
722
+ const isAfterMinTurn = (node) => {
723
+ if (MIN_TURN_INDEX === null) return true;
724
+ if (!hasTurns) return true;
725
+ const idx = resolveTurnIndex(node);
726
+ return idx !== null && idx >= MIN_TURN_INDEX;
727
+ };
728
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
729
+ const collectUserText = (scope) => {
730
+ if (!scope?.querySelectorAll) return '';
731
+ const userTurns = Array.from(scope.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'));
732
+ const lastUser = userTurns[userTurns.length - 1];
733
+ return lastUser ? normalize(lastUser.innerText || lastUser.textContent || '') : '';
734
+ };
735
+ const userText = collectUserText(root) || collectUserText(document);
736
+ const isUserEcho = (text) => {
737
+ if (!userText) return false;
738
+ const normalized = normalize(text);
739
+ if (!normalized) return false;
740
+ return normalized === userText || normalized.startsWith(userText);
741
+ };
742
+ const markdowns = Array.from(root.querySelectorAll(markdownSelector))
743
+ .filter((node) => !isExcluded(node))
744
+ .filter((node) => {
745
+ const container = node.closest('[data-message-author-role], [data-turn]');
746
+ if (!container) return true;
747
+ const role =
748
+ (container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
749
+ return role !== 'user';
750
+ });
751
+ if (markdowns.length === 0) return null;
752
+ const actionButtons = Array.from(root.querySelectorAll('${FINISHED_ACTIONS_SELECTOR}'));
753
+ const actionMarkdowns = [];
754
+ for (const button of actionButtons) {
755
+ const container =
756
+ button.closest('${CONVERSATION_TURN_SELECTOR}') ||
757
+ button.closest('[data-message-author-role="assistant"], [data-turn="assistant"]') ||
758
+ button.closest('[data-message-author-role], [data-turn]') ||
759
+ button.closest('[data-testid*="assistant"]');
760
+ if (!container || container === root || container === document.body) continue;
761
+ const scoped = Array.from(container.querySelectorAll(markdownSelector))
762
+ .filter((node) => !isExcluded(node))
763
+ .filter((node) => {
764
+ const roleNode = node.closest('[data-message-author-role], [data-turn]');
765
+ if (!roleNode) return true;
766
+ const role =
767
+ (roleNode.getAttribute('data-message-author-role') || roleNode.getAttribute('data-turn') || '').toLowerCase();
768
+ return role !== 'user';
769
+ });
770
+ if (scoped.length === 0) continue;
771
+ for (const node of scoped) {
772
+ actionMarkdowns.push(node);
773
+ }
774
+ }
775
+ const assistantMarkdowns = markdowns.filter((node) => {
776
+ const container = node.closest('[data-message-author-role], [data-turn], [data-testid*="assistant"]');
777
+ if (!container) return false;
778
+ const role =
779
+ (container.getAttribute('data-message-author-role') || container.getAttribute('data-turn') || '').toLowerCase();
780
+ if (role === 'assistant') return true;
781
+ const testId = (container.getAttribute('data-testid') || '').toLowerCase();
782
+ return testId.includes('assistant');
783
+ });
784
+ const hasAssistantIndicators = Boolean(
785
+ root.querySelector('${FINISHED_ACTIONS_SELECTOR}') ||
786
+ root.querySelector('[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant"]'),
787
+ );
788
+ const allowMarkdownFallback = hasAssistantIndicators || hasTurns || Boolean(userText);
789
+ const candidates =
790
+ actionMarkdowns.length > 0
791
+ ? actionMarkdowns
792
+ : assistantMarkdowns.length > 0
793
+ ? assistantMarkdowns
794
+ : allowMarkdownFallback
795
+ ? markdowns
796
+ : [];
797
+ for (let i = candidates.length - 1; i >= 0; i -= 1) {
798
+ const node = candidates[i];
799
+ if (!node) continue;
800
+ if (!isAfterMinTurn(node)) continue;
801
+ const text = (node.innerText || node.textContent || '').trim();
802
+ if (!text) continue;
803
+ if (isUserEcho(text)) continue;
804
+ const html = node.innerHTML ?? '';
805
+ const turnIndex = resolveTurnIndex(node);
806
+ return { text, html, messageId: null, turnId: null, turnIndex };
807
+ }
808
+ return null;
809
+ })`;
810
+ }
504
811
  function buildCopyExpression(meta) {
505
812
  return `(() => {
506
813
  ${buildClickDispatcher()}
@@ -525,13 +832,41 @@ function buildCopyExpression(meta) {
525
832
  return button;
526
833
  }
527
834
  }
835
+ const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
836
+ const ASSISTANT_SELECTOR = '${ASSISTANT_ROLE_SELECTOR}';
837
+ const isAssistantTurn = (node) => {
838
+ if (!(node instanceof HTMLElement)) return false;
839
+ const turnAttr = (node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
840
+ if (turnAttr === 'assistant') return true;
841
+ const role = (node.getAttribute('data-message-author-role') || node.dataset?.messageAuthorRole || '').toLowerCase();
842
+ if (role === 'assistant') return true;
843
+ const testId = (node.getAttribute('data-testid') || '').toLowerCase();
844
+ if (testId.includes('assistant')) return true;
845
+ return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
846
+ };
847
+ const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
848
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
849
+ const turn = turns[i];
850
+ if (!isAssistantTurn(turn)) continue;
851
+ const button = turn.querySelector(BUTTON_SELECTOR);
852
+ if (button) {
853
+ return button;
854
+ }
855
+ }
528
856
  const all = Array.from(document.querySelectorAll(BUTTON_SELECTOR));
529
- return all.at(-1) ?? null;
857
+ for (let i = all.length - 1; i >= 0; i -= 1) {
858
+ const button = all[i];
859
+ const turn = button?.closest?.(CONVERSATION_SELECTOR);
860
+ if (turn && isAssistantTurn(turn)) {
861
+ return button;
862
+ }
863
+ }
864
+ return null;
530
865
  };
531
866
 
532
867
  const interceptClipboard = () => {
533
868
  const clipboard = navigator.clipboard;
534
- const state = { text: '' };
869
+ const state = { text: '', updatedAt: 0 };
535
870
  if (!clipboard) {
536
871
  return { state, restore: () => {} };
537
872
  }
@@ -539,6 +874,7 @@ function buildCopyExpression(meta) {
539
874
  const originalWrite = clipboard.write;
540
875
  clipboard.writeText = (value) => {
541
876
  state.text = typeof value === 'string' ? value : '';
877
+ state.updatedAt = Date.now();
542
878
  return Promise.resolve();
543
879
  };
544
880
  clipboard.write = async (items) => {
@@ -551,11 +887,13 @@ function buildCopyExpression(meta) {
551
887
  const blob = await item.getType('text/plain');
552
888
  const text = await blob.text();
553
889
  state.text = text ?? '';
890
+ state.updatedAt = Date.now();
554
891
  break;
555
892
  }
556
893
  }
557
894
  } catch {
558
895
  state.text = '';
896
+ state.updatedAt = Date.now();
559
897
  }
560
898
  return Promise.resolve();
561
899
  };
@@ -595,22 +933,37 @@ function buildCopyExpression(meta) {
595
933
 
596
934
  const readIntercepted = () => {
597
935
  const markdown = interception.state.text ?? '';
598
- return { success: Boolean(markdown.trim()), markdown };
936
+ const updatedAt = interception.state.updatedAt ?? 0;
937
+ return { success: Boolean(markdown.trim()), markdown, updatedAt };
938
+ };
939
+
940
+ let lastText = '';
941
+ let stableTicks = 0;
942
+ const requiredStableTicks = 3;
943
+ const requiredStableMs = 250;
944
+ const maybeFinish = () => {
945
+ const payload = readIntercepted();
946
+ if (!payload.success) return;
947
+ if (payload.markdown !== lastText) {
948
+ lastText = payload.markdown;
949
+ stableTicks = 0;
950
+ return;
951
+ }
952
+ stableTicks += 1;
953
+ const ageMs = Date.now() - (payload.updatedAt || 0);
954
+ if (stableTicks >= requiredStableTicks && ageMs >= requiredStableMs) {
955
+ finish(payload);
956
+ }
599
957
  };
600
958
 
601
959
  const handleCopy = () => {
602
- finish(readIntercepted());
960
+ maybeFinish();
603
961
  };
604
962
 
605
963
  button.addEventListener('copy', handleCopy, true);
606
964
  button.scrollIntoView({ block: 'center', behavior: 'instant' });
607
965
  dispatchClickSequence(button);
608
- pollId = setInterval(() => {
609
- const payload = readIntercepted();
610
- if (payload.success) {
611
- finish(payload);
612
- }
613
- }, 100);
966
+ pollId = setInterval(maybeFinish, 120);
614
967
  timeoutId = setTimeout(() => {
615
968
  button.removeEventListener('copy', handleCopy, true);
616
969
  finish({ success: false, status: 'timeout' });