@steipete/oracle 0.8.5 → 0.8.6

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.
@@ -18,6 +18,13 @@ export async function submitPrompt(deps, prompt, logger) {
18
18
  expression: `(() => {
19
19
  ${buildClickDispatcher()}
20
20
  const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
21
+ const isVisible = (node) => {
22
+ if (!node || typeof node.getBoundingClientRect !== 'function') {
23
+ return false;
24
+ }
25
+ const rect = node.getBoundingClientRect();
26
+ return rect.width > 0 && rect.height > 0;
27
+ };
21
28
  const focusNode = (node) => {
22
29
  if (!node) {
23
30
  return false;
@@ -39,13 +46,17 @@ export async function submitPrompt(deps, prompt, logger) {
39
46
  return true;
40
47
  };
41
48
 
49
+ const candidates = [];
42
50
  for (const selector of SELECTORS) {
43
51
  const node = document.querySelector(selector);
44
- if (!node) continue;
45
- if (focusNode(node)) {
46
- return { focused: true };
52
+ if (node) {
53
+ candidates.push(node);
47
54
  }
48
55
  }
56
+ const preferred = candidates.find((node) => isVisible(node)) || candidates[0];
57
+ if (preferred && focusNode(preferred)) {
58
+ return { focused: true };
59
+ }
49
60
  return { focused: false };
50
61
  })()`,
51
62
  returnByValue: true,
@@ -65,18 +76,36 @@ export async function submitPrompt(deps, prompt, logger) {
65
76
  expression: `(() => {
66
77
  const editor = document.querySelector(${primarySelectorLiteral});
67
78
  const fallback = document.querySelector(${fallbackSelectorLiteral});
79
+ const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
80
+ const readValue = (node) => {
81
+ if (!node) return '';
82
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
83
+ return node.innerText ?? '';
84
+ };
85
+ const isVisible = (node) => {
86
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
87
+ const rect = node.getBoundingClientRect();
88
+ return rect.width > 0 && rect.height > 0;
89
+ };
90
+ const candidates = inputSelectors
91
+ .map((selector) => document.querySelector(selector))
92
+ .filter((node) => Boolean(node));
93
+ const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
68
94
  return {
69
95
  editorText: editor?.innerText ?? '',
70
96
  fallbackValue: fallback?.value ?? '',
97
+ activeValue: active ? readValue(active) : '',
71
98
  };
72
99
  })()`,
73
100
  returnByValue: true,
74
101
  });
75
102
  const editorTextRaw = verification.result?.value?.editorText ?? '';
76
103
  const fallbackValueRaw = verification.result?.value?.fallbackValue ?? '';
104
+ const activeValueRaw = verification.result?.value?.activeValue ?? '';
77
105
  const editorTextTrimmed = editorTextRaw?.trim?.() ?? '';
78
106
  const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? '';
79
- if (!editorTextTrimmed && !fallbackValueTrimmed) {
107
+ const activeValueTrimmed = activeValueRaw?.trim?.() ?? '';
108
+ if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
80
109
  // Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
81
110
  await runtime.evaluate({
82
111
  expression: `(() => {
@@ -100,16 +129,33 @@ export async function submitPrompt(deps, prompt, logger) {
100
129
  expression: `(() => {
101
130
  const editor = document.querySelector(${primarySelectorLiteral});
102
131
  const fallback = document.querySelector(${fallbackSelectorLiteral});
132
+ const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
133
+ const readValue = (node) => {
134
+ if (!node) return '';
135
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
136
+ return node.innerText ?? '';
137
+ };
138
+ const isVisible = (node) => {
139
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
140
+ const rect = node.getBoundingClientRect();
141
+ return rect.width > 0 && rect.height > 0;
142
+ };
143
+ const candidates = inputSelectors
144
+ .map((selector) => document.querySelector(selector))
145
+ .filter((node) => Boolean(node));
146
+ const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
103
147
  return {
104
148
  editorText: editor?.innerText ?? '',
105
149
  fallbackValue: fallback?.value ?? '',
150
+ activeValue: active ? readValue(active) : '',
106
151
  };
107
152
  })()`,
108
153
  returnByValue: true,
109
154
  });
110
155
  const observedEditor = postVerification.result?.value?.editorText ?? '';
111
156
  const observedFallback = postVerification.result?.value?.fallbackValue ?? '';
112
- const observedLength = Math.max(observedEditor.length, observedFallback.length);
157
+ const observedActive = postVerification.result?.value?.activeValue ?? '';
158
+ const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
113
159
  if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
114
160
  // Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
115
161
  await logDomFailure(runtime, logger, 'prompt-too-large');
@@ -144,10 +190,12 @@ export async function submitPrompt(deps, prompt, logger) {
144
190
  export async function clearPromptComposer(Runtime, logger) {
145
191
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
146
192
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
193
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
147
194
  const result = await Runtime.evaluate({
148
195
  expression: `(() => {
149
196
  const fallback = document.querySelector(${fallbackSelectorLiteral});
150
197
  const editor = document.querySelector(${primarySelectorLiteral});
198
+ const inputSelectors = ${inputSelectorsLiteral};
151
199
  let cleared = false;
152
200
  if (fallback) {
153
201
  fallback.value = '';
@@ -160,6 +208,24 @@ export async function clearPromptComposer(Runtime, logger) {
160
208
  editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
161
209
  cleared = true;
162
210
  }
211
+ const nodes = inputSelectors
212
+ .map((selector) => document.querySelector(selector))
213
+ .filter((node) => Boolean(node));
214
+ for (const node of nodes) {
215
+ if (!node) continue;
216
+ if (node instanceof HTMLTextAreaElement) {
217
+ node.value = '';
218
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
219
+ node.dispatchEvent(new Event('change', { bubbles: true }));
220
+ cleared = true;
221
+ continue;
222
+ }
223
+ if (node.isContentEditable || node.getAttribute('contenteditable') === 'true') {
224
+ node.textContent = '';
225
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
226
+ cleared = true;
227
+ }
228
+ }
163
229
  return { cleared };
164
230
  })()`,
165
231
  returnByValue: true,
@@ -281,15 +347,34 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
281
347
  const encodedPrompt = JSON.stringify(prompt.trim());
282
348
  const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
283
349
  const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
350
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
284
351
  const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
285
352
  const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
286
- const baselineLiteral = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
353
+ const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
354
+ let baseline = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
287
355
  ? Math.floor(baselineTurns)
288
- : -1;
356
+ : null;
357
+ if (baseline === null) {
358
+ try {
359
+ const { result } = await Runtime.evaluate({
360
+ expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
361
+ returnByValue: true,
362
+ });
363
+ const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
364
+ if (Number.isFinite(raw)) {
365
+ baseline = Math.max(0, Math.floor(raw));
366
+ }
367
+ }
368
+ catch {
369
+ // ignore; baseline stays unknown
370
+ }
371
+ }
372
+ const baselineLiteral = baseline ?? -1;
289
373
  // Learned: ChatGPT can echo/format text; normalize markdown and use prefix matches to detect the sent prompt.
290
374
  const script = `(() => {
291
- const editor = document.querySelector(${primarySelectorLiteral});
292
- const fallback = document.querySelector(${fallbackSelectorLiteral});
375
+ const editor = document.querySelector(${primarySelectorLiteral});
376
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
377
+ const inputSelectors = ${inputSelectorsLiteral};
293
378
  const normalize = (value) => {
294
379
  let text = value?.toLowerCase?.() ?? '';
295
380
  // Strip markdown *markers* but keep content (ChatGPT renders fence markers differently).
@@ -303,35 +388,53 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
303
388
  const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
304
389
  const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
305
390
  const normalizedTurns = articles.map((node) => normalize(node?.innerText));
391
+ const readValue = (node) => {
392
+ if (!node) return '';
393
+ if (node instanceof HTMLTextAreaElement) return node.value ?? '';
394
+ return node.innerText ?? '';
395
+ };
396
+ const isVisible = (node) => {
397
+ if (!node || typeof node.getBoundingClientRect !== 'function') return false;
398
+ const rect = node.getBoundingClientRect();
399
+ return rect.width > 0 && rect.height > 0;
400
+ };
401
+ const inputs = inputSelectors
402
+ .map((selector) => document.querySelector(selector))
403
+ .filter((node) => Boolean(node));
404
+ const visibleInputs = inputs.filter((node) => isVisible(node));
405
+ const activeInputs = visibleInputs.length > 0 ? visibleInputs : inputs;
306
406
  const userMatched =
307
407
  normalizedPrompt.length > 0 && normalizedTurns.some((text) => text.includes(normalizedPrompt));
308
408
  const prefixMatched =
309
409
  normalizedPromptPrefix.length > 30 &&
310
410
  normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
311
- const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
312
- const lastMatched =
313
- normalizedPrompt.length > 0 &&
314
- (lastTurn.includes(normalizedPrompt) ||
315
- (normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
316
- const baseline = ${baselineLiteral};
317
- const hasNewTurn = baseline < 0 ? true : normalizedTurns.length > baseline;
318
- const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
319
- const assistantVisible = Boolean(
320
- document.querySelector(${assistantSelectorLiteral}) ||
321
- document.querySelector('[data-testid*="assistant"]'),
322
- );
323
- // Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
411
+ const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
412
+ const lastMatched =
413
+ normalizedPrompt.length > 0 &&
414
+ (lastTurn.includes(normalizedPrompt) ||
415
+ (normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
416
+ const baseline = ${baselineLiteral};
417
+ const hasNewTurn = baseline < 0 ? false : normalizedTurns.length > baseline;
418
+ const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
419
+ const assistantVisible = Boolean(
420
+ document.querySelector(${assistantSelectorLiteral}) ||
421
+ document.querySelector('[data-testid*="assistant"]'),
422
+ );
423
+ // Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
324
424
  const editorValue = editor?.innerText ?? '';
325
425
  const fallbackValue = fallback?.value ?? '';
326
- const composerCleared = !(String(editorValue).trim() || String(fallbackValue).trim());
426
+ const activeEmpty =
427
+ activeInputs.length === 0 ? null : activeInputs.every((node) => !String(readValue(node)).trim());
428
+ const composerCleared = activeEmpty ?? !(String(editorValue).trim() || String(fallbackValue).trim());
327
429
  const href = typeof location === 'object' && location.href ? location.href : '';
328
430
  const inConversation = /\\/c\\//.test(href);
329
- return {
330
- userMatched,
331
- prefixMatched,
332
- lastMatched,
333
- hasNewTurn,
334
- stopVisible,
431
+ return {
432
+ baseline,
433
+ userMatched,
434
+ prefixMatched,
435
+ lastMatched,
436
+ hasNewTurn,
437
+ stopVisible,
335
438
  assistantVisible,
336
439
  composerCleared,
337
440
  inConversation,
@@ -346,12 +449,14 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
346
449
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
347
450
  const info = result.value;
348
451
  const turnsCount = result.value?.turnsCount;
349
- if (info?.hasNewTurn && (info?.lastMatched || info?.userMatched || info?.prefixMatched)) {
452
+ const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
453
+ const baselineUnknown = typeof info?.baseline === 'number' ? info.baseline < 0 : baselineLiteral < 0;
454
+ if (matchesPrompt && (baselineUnknown || info?.hasNewTurn)) {
350
455
  return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
351
456
  }
352
457
  const fallbackCommit = info?.composerCleared &&
353
- ((info?.stopVisible ?? false) ||
354
- (info?.hasNewTurn && (info?.assistantVisible || info?.inConversation)));
458
+ Boolean(info?.hasNewTurn) &&
459
+ ((info?.stopVisible ?? false) || info?.assistantVisible || info?.inConversation);
355
460
  if (fallbackCommit) {
356
461
  return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
357
462
  }
@@ -374,3 +479,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
374
479
  }
375
480
  throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
376
481
  }
482
+ // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
483
+ export const __test__ = {
484
+ verifyPromptCommitted,
485
+ };
@@ -7,6 +7,7 @@ import { promisify } from 'node:util';
7
7
  import CDP from 'chrome-remote-interface';
8
8
  import { launch, Launcher } from 'chrome-launcher';
9
9
  import { cleanupStaleProfileState } from './profileState.js';
10
+ import { delay } from './utils.js';
10
11
  const execFileAsync = promisify(execFile);
11
12
  export async function launchChrome(config, userDataDir, logger) {
12
13
  const connectHost = resolveRemoteDebugHost();
@@ -181,17 +182,32 @@ async function connectToNewTarget(host, port, url, logger, messages) {
181
182
  }
182
183
  return null;
183
184
  }
184
- export async function connectWithNewTab(port, logger, initialUrl, host) {
185
+ export async function connectWithNewTab(port, logger, initialUrl, host, options) {
185
186
  const effectiveHost = host ?? '127.0.0.1';
186
187
  const url = initialUrl ?? 'about:blank';
187
- const targetConnection = await connectToNewTarget(effectiveHost, port, url, logger, {
188
- opened: (targetId) => `Opened isolated browser tab (target=${targetId})`,
189
- openFailed: (message) => `Failed to open isolated browser tab (${message}); falling back to default target.`,
190
- attachFailed: (targetId, message) => `Failed to attach to isolated browser tab ${targetId} (${message}); falling back to default target.`,
191
- closeFailed: (targetId, message) => `Failed to close unused browser tab ${targetId}: ${message}`,
192
- });
193
- if (targetConnection) {
194
- return targetConnection;
188
+ const fallbackToDefault = options?.fallbackToDefault ?? true;
189
+ const retries = Math.max(0, options?.retries ?? 0);
190
+ const retryDelayMs = Math.max(0, options?.retryDelayMs ?? 250);
191
+ const fallbackLabel = fallbackToDefault ? 'falling back to default target.' : 'strict mode: not falling back.';
192
+ let attempt = 0;
193
+ while (attempt <= retries) {
194
+ const targetConnection = await connectToNewTarget(effectiveHost, port, url, logger, {
195
+ opened: (targetId) => `Opened isolated browser tab (target=${targetId})`,
196
+ openFailed: (message) => `Failed to open isolated browser tab (${message}); ${fallbackLabel}`,
197
+ attachFailed: (targetId, message) => `Failed to attach to isolated browser tab ${targetId} (${message}); ${fallbackLabel}`,
198
+ closeFailed: (targetId, message) => `Failed to close unused browser tab ${targetId}: ${message}`,
199
+ });
200
+ if (targetConnection) {
201
+ return targetConnection;
202
+ }
203
+ if (attempt >= retries) {
204
+ break;
205
+ }
206
+ attempt += 1;
207
+ await delay(retryDelayMs * attempt);
208
+ }
209
+ if (!fallbackToDefault) {
210
+ throw new Error('Failed to open isolated browser tab; refusing to attach to default target.');
195
211
  }
196
212
  const client = await connectToChrome(port, logger, effectiveHost);
197
213
  return { client };
@@ -12,6 +12,13 @@ export const DEFAULT_BROWSER_CONFIG = {
12
12
  timeoutMs: 1_200_000,
13
13
  debugPort: null,
14
14
  inputTimeoutMs: 60_000,
15
+ assistantRecheckDelayMs: 0,
16
+ assistantRecheckTimeoutMs: 120_000,
17
+ reuseChromeWaitMs: 10_000,
18
+ profileLockTimeoutMs: 300_000,
19
+ autoReattachDelayMs: 0,
20
+ autoReattachIntervalMs: 0,
21
+ autoReattachTimeoutMs: 120_000,
15
22
  cookieSync: true,
16
23
  cookieNames: null,
17
24
  cookieSyncWaitMs: 0,
@@ -57,6 +64,13 @@ export function resolveBrowserConfig(config) {
57
64
  timeoutMs: config?.timeoutMs ?? DEFAULT_BROWSER_CONFIG.timeoutMs,
58
65
  debugPort: config?.debugPort ?? debugPortEnv ?? DEFAULT_BROWSER_CONFIG.debugPort,
59
66
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
67
+ assistantRecheckDelayMs: config?.assistantRecheckDelayMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckDelayMs,
68
+ assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
69
+ reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
70
+ profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
71
+ autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
72
+ autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
73
+ autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
60
74
  cookieSync: config?.cookieSync ?? cookieSyncDefault,
61
75
  cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
62
76
  cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,