@steipete/oracle 0.12.1 → 0.14.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.
Files changed (97) hide show
  1. package/README.md +72 -61
  2. package/dist/bin/oracle-cli.js +77 -68
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/docs-site/.nojekyll +0 -0
  5. package/dist/docs-site/CNAME +1 -0
  6. package/dist/docs-site/RELEASING.html +410 -0
  7. package/dist/docs-site/agents.html +374 -0
  8. package/dist/docs-site/anthropic.html +368 -0
  9. package/dist/docs-site/bridge.html +400 -0
  10. package/dist/docs-site/browser-mode.html +593 -0
  11. package/dist/docs-site/chromium-forks.html +347 -0
  12. package/dist/docs-site/cli-reference.html +346 -0
  13. package/dist/docs-site/configuration.html +452 -0
  14. package/dist/docs-site/favicon.svg +14 -0
  15. package/dist/docs-site/followup.html +375 -0
  16. package/dist/docs-site/gemini.html +383 -0
  17. package/dist/docs-site/grok.html +325 -0
  18. package/dist/docs-site/index.html +360 -0
  19. package/dist/docs-site/install.html +335 -0
  20. package/dist/docs-site/linux.html +321 -0
  21. package/dist/docs-site/llms.txt +43 -0
  22. package/dist/docs-site/manual-tests.html +596 -0
  23. package/dist/docs-site/mcp.html +391 -0
  24. package/dist/docs-site/multimodel.html +364 -0
  25. package/dist/docs-site/mythical-pro-agents.html +360 -0
  26. package/dist/docs-site/notifier.html +338 -0
  27. package/dist/docs-site/openai-endpoints.html +387 -0
  28. package/dist/docs-site/openrouter.html +344 -0
  29. package/dist/docs-site/quickstart.html +369 -0
  30. package/dist/docs-site/refactor/ux.html +532 -0
  31. package/dist/docs-site/sessions.html +388 -0
  32. package/dist/docs-site/social-card.png +0 -0
  33. package/dist/docs-site/social-card.svg +79 -0
  34. package/dist/docs-site/spec.html +363 -0
  35. package/dist/docs-site/testing.html +320 -0
  36. package/dist/docs-site/tui-debug.html +326 -0
  37. package/dist/docs-site/windows-work.html +323 -0
  38. package/dist/docs-site/windows.html +320 -0
  39. package/dist/src/browser/actions/assistantResponse.js +91 -9
  40. package/dist/src/browser/actions/deepResearch.js +180 -52
  41. package/dist/src/browser/actions/modelSelection.js +111 -24
  42. package/dist/src/browser/actions/navigation.js +358 -67
  43. package/dist/src/browser/actions/promptComposer.js +241 -46
  44. package/dist/src/browser/actions/thinkingTime.js +308 -89
  45. package/dist/src/browser/artifacts.js +19 -0
  46. package/dist/src/browser/chatgptFiles.js +797 -0
  47. package/dist/src/browser/chatgptImages.js +130 -3
  48. package/dist/src/browser/chromeLifecycle.js +4 -0
  49. package/dist/src/browser/config.js +4 -0
  50. package/dist/src/browser/index.js +353 -41
  51. package/dist/src/browser/pageActions.js +1 -1
  52. package/dist/src/browser/policies.js +2 -6
  53. package/dist/src/browser/projectSourcesRunner.js +14 -2
  54. package/dist/src/browser/prompt.js +11 -15
  55. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  56. package/dist/src/browser/reattach.js +6 -2
  57. package/dist/src/browser/reattachability.js +22 -10
  58. package/dist/src/browser/recoverConversation.js +73 -0
  59. package/dist/src/browser/sessionRunner.js +4 -1
  60. package/dist/src/cli/bridge/doctor.js +7 -2
  61. package/dist/src/cli/browserConfig.js +13 -2
  62. package/dist/src/cli/browserDefaults.js +3 -0
  63. package/dist/src/cli/browserTabs.js +129 -54
  64. package/dist/src/cli/engine.js +6 -2
  65. package/dist/src/cli/followup.js +72 -0
  66. package/dist/src/cli/help.js +1 -1
  67. package/dist/src/cli/options.js +20 -0
  68. package/dist/src/cli/reattachGuidance.js +8 -0
  69. package/dist/src/cli/runOptions.js +10 -31
  70. package/dist/src/cli/sessionCommand.js +4 -0
  71. package/dist/src/cli/sessionDisplay.js +17 -3
  72. package/dist/src/cli/sessionLineage.js +7 -4
  73. package/dist/src/cli/sessionRunner.js +16 -1
  74. package/dist/src/config.js +164 -9
  75. package/dist/src/gemini-web/client.js +5 -10
  76. package/dist/src/gemini-web/executor.js +1 -24
  77. package/dist/src/gemini-web/models.js +83 -0
  78. package/dist/src/mcp/server.js +2 -0
  79. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  80. package/dist/src/mcp/tools/consult.js +261 -167
  81. package/dist/src/mcp/types.js +2 -0
  82. package/dist/src/mcp/utils.js +87 -1
  83. package/dist/src/oracle/config.js +27 -1
  84. package/dist/src/oracle/files.js +4 -6
  85. package/dist/src/oracle/geminiModels.js +2 -0
  86. package/dist/src/oracle/markdown.js +36 -3
  87. package/dist/src/oracle/modelResolver.js +21 -13
  88. package/dist/src/oracle/promptAssembly.js +2 -4
  89. package/dist/src/oracle/request.js +3 -5
  90. package/dist/src/oracle/tokenStats.js +5 -1
  91. package/dist/src/oracle.js +1 -1
  92. package/dist/src/sessionManager.js +1 -0
  93. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  94. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  95. package/package.json +20 -29
  96. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  97. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -166,7 +166,7 @@ export async function submitPrompt(deps, prompt, logger) {
166
166
  observedLength,
167
167
  });
168
168
  }
169
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
169
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
170
  if (!clicked) {
171
171
  await input.dispatchKeyEvent({
172
172
  type: "keyDown",
@@ -283,18 +283,171 @@ async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
283
283
  logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
284
284
  }
285
285
  function buildAttachmentReadyExpression(attachmentNames) {
286
- const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
286
+ const attachmentExpectations = attachmentNames.map((attachment) => {
287
+ const name = typeof attachment === "string" ? attachment : attachment.name;
288
+ const normalized = name.toLowerCase().replace(/\s+/g, " ").trim();
289
+ return {
290
+ name: normalized,
291
+ stem: normalized.replace(/\.[a-z0-9]{1,10}$/i, ""),
292
+ extension: normalized.match(/(\.[a-z0-9]{1,10})$/i)?.[1] ?? "",
293
+ generatedBundle: typeof attachment === "object" && attachment.generatedBundle === true,
294
+ };
295
+ });
296
+ const namesLiteral = JSON.stringify(attachmentExpectations);
287
297
  return `(() => {
288
- const names = ${namesLiteral};
298
+ const expected = ${namesLiteral};
299
+ const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
300
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
301
+ const hasNameBoundary = (text, name) => {
302
+ if (!name) return false;
303
+ let from = 0;
304
+ while (from < text.length) {
305
+ const index = text.indexOf(name, from);
306
+ if (index === -1) return false;
307
+ const previous = text[index - 1] || '';
308
+ const next = text[index + name.length] || '';
309
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
310
+ const nextOk = !next || !/[a-z0-9._-]/.test(next);
311
+ if (previousOk && nextOk) return true;
312
+ from = index + name.length;
313
+ }
314
+ return false;
315
+ };
316
+ const hasStemFileBoundary = (text, stem) => {
317
+ if (!stem) return false;
318
+ let from = 0;
319
+ while (from < text.length) {
320
+ const index = text.indexOf(stem, from);
321
+ if (index === -1) return false;
322
+ const previous = text[index - 1] || '';
323
+ const next = text[index + stem.length] || '';
324
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
325
+ const nextOk = !next || !/[a-z0-9._-]/.test(next);
326
+ if (previousOk && nextOk) return true;
327
+ from = index + stem.length;
328
+ }
329
+ return false;
330
+ };
331
+ const hasBareStemBoundary = (text, stem) => {
332
+ if (!stem) return false;
333
+ let from = 0;
334
+ while (from < text.length) {
335
+ const index = text.indexOf(stem, from);
336
+ if (index === -1) return false;
337
+ const previous = text[index - 1] || '';
338
+ const next = text[index + stem.length] || '';
339
+ const previousOk = !previous || !/[a-z0-9._-]/.test(previous);
340
+ const nextOk = !next || !/[a-z0-9._(-]/.test(next);
341
+ if (previousOk && nextOk) return true;
342
+ from = index + stem.length;
343
+ }
344
+ return false;
345
+ };
346
+ const hasExtensionBoundary = (text, extension) => {
347
+ if (!extension) return false;
348
+ let from = 0;
349
+ while (from < text.length) {
350
+ const index = text.indexOf(extension, from);
351
+ if (index === -1) return false;
352
+ const next = text[index + extension.length] || '';
353
+ if (!next || !/[a-z0-9]/.test(next)) return true;
354
+ from = index + extension.length;
355
+ }
356
+ return false;
357
+ };
358
+ const matchesExpected = (value, item) => {
359
+ const text = normalize(value);
360
+ if (!text) return false;
361
+ if (hasNameBoundary(text, item.name)) return true;
362
+ if (item.generatedBundle && hasBareStemBoundary(text, item.stem)) return true;
363
+ if (
364
+ item.stem &&
365
+ item.stem.length >= 4 &&
366
+ item.extension &&
367
+ text.includes(item.stem + '(') &&
368
+ hasExtensionBoundary(text, item.extension)
369
+ ) {
370
+ return true;
371
+ }
372
+ if (text.includes('…') || text.includes('...')) {
373
+ const marker = text.includes('…') ? '…' : '...';
374
+ const [prefixRaw, suffixRaw] = text.split(marker);
375
+ const prefix = normalize(prefixRaw);
376
+ const suffix = normalize(suffixRaw);
377
+ const prefixParts = prefix.split(' ').filter(Boolean);
378
+ const suffixParts = suffix.split(' ').filter(Boolean);
379
+ const prefixCandidates = prefixParts.map((_, index) => prefixParts.slice(index).join(' '));
380
+ const suffixCandidates = suffixParts.map((_, index) =>
381
+ suffixParts.slice(0, suffixParts.length - index).join(' '),
382
+ );
383
+ if (prefixCandidates.length === 0 || suffixCandidates.length === 0) return false;
384
+ const targets = [item.name, item.stem && item.stem.length >= 4 ? item.stem : ''].filter(Boolean);
385
+ return targets.some((target) => {
386
+ return prefixCandidates.some((prefixPart) =>
387
+ suffixCandidates.some((suffixPart) => {
388
+ const strongEnough =
389
+ suffixPart.length >= 2 &&
390
+ (prefixPart.length >= 3 || (prefixPart.length >= 2 && suffixPart.length >= 4));
391
+ return strongEnough && target.startsWith(prefixPart) && target.endsWith(suffixPart);
392
+ }),
393
+ );
394
+ });
395
+ }
396
+ return false;
397
+ };
398
+ // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
399
+ const attachmentSelectors = [
400
+ // Current ChatGPT file tiles expose the filename through a role-group aria label.
401
+ '[role="group"][aria-label]',
402
+ '[data-testid*="chip"]',
403
+ '[data-testid*="attachment"]',
404
+ '[data-testid*="upload"]',
405
+ '[data-testid*="file"]',
406
+ '[aria-label*="Remove file"]',
407
+ 'button[aria-label*="Remove file"]',
408
+ '[aria-label*="remove file"]',
409
+ 'button[aria-label*="remove file"]',
410
+ '[aria-label*="Remove attachment"]',
411
+ 'button[aria-label*="Remove attachment"]',
412
+ '[aria-label*="remove attachment"]',
413
+ 'button[aria-label*="remove attachment"]',
414
+ ];
415
+ const sendButton = sendSelectors
416
+ .map((selector) => document.querySelector(selector))
417
+ .find(Boolean);
418
+ const isUsableComposerRoot = (node) => {
419
+ if (!(node instanceof HTMLElement)) return false;
420
+ if (String(node.tagName || '').toLowerCase() === 'button') return false;
421
+ const testId = String(node.getAttribute?.('data-testid') || '').toLowerCase();
422
+ if (!testId.includes('composer')) return false;
423
+ return !(
424
+ testId.includes('footer') ||
425
+ testId.includes('action') ||
426
+ testId.includes('plus') ||
427
+ testId.includes('send')
428
+ );
429
+ };
430
+ const closestComposerRoot = (node) => {
431
+ let current = node instanceof HTMLElement ? node : null;
432
+ while (current) {
433
+ if (isUsableComposerRoot(current)) return current;
434
+ current = current.parentElement;
435
+ }
436
+ return null;
437
+ };
438
+ const firstComposerRoot = () =>
439
+ Array.from(document.querySelectorAll('[data-testid*="composer"]')).find(isUsableComposerRoot) || null;
289
440
  const composer =
290
- document.querySelector('[data-testid*="composer"]') ||
441
+ closestComposerRoot(sendButton) ||
442
+ sendButton?.closest?.('form') ||
443
+ firstComposerRoot() ||
291
444
  document.querySelector('form') ||
292
445
  document.body ||
293
446
  document;
294
447
  // Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
295
448
  // ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
296
449
  // only the node's own textContent/aria/title misses the match.
297
- const collectLabelHaystack = (node) => {
450
+ const collectOwnLabelHaystack = (node) => {
298
451
  if (!node) return '';
299
452
  const pieces = [];
300
453
  const pushAttrs = (el) => {
@@ -311,31 +464,21 @@ function buildAttachmentReadyExpression(attachmentNames) {
311
464
  };
312
465
  pushAttrs(node);
313
466
  pushText(node);
467
+ return pieces.join(' ').toLowerCase();
468
+ };
469
+ const collectLabelHaystack = (node) => {
470
+ if (!node) return '';
471
+ const pieces = [collectOwnLabelHaystack(node)];
472
+ const push = (el) => {
473
+ const text = collectOwnLabelHaystack(el);
474
+ if (text) pieces.push(text);
475
+ };
314
476
  const parent = node.parentElement;
315
- pushAttrs(parent);
316
- pushText(parent);
477
+ push(parent);
317
478
  const grandparent = parent?.parentElement;
318
- pushAttrs(grandparent);
319
- pushText(grandparent);
479
+ push(grandparent);
320
480
  return pieces.join(' ').toLowerCase();
321
481
  };
322
- const match = (node, name) => collectLabelHaystack(node).includes(name);
323
-
324
- // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
325
- const attachmentSelectors = [
326
- '[data-testid*="chip"]',
327
- '[data-testid*="attachment"]',
328
- '[data-testid*="upload"]',
329
- '[data-testid*="file"]',
330
- '[aria-label*="Remove file"]',
331
- 'button[aria-label*="Remove file"]',
332
- '[aria-label*="remove file"]',
333
- 'button[aria-label*="remove file"]',
334
- '[aria-label*="Remove attachment"]',
335
- 'button[aria-label*="Remove attachment"]',
336
- '[aria-label*="remove attachment"]',
337
- 'button[aria-label*="remove attachment"]',
338
- ];
339
482
  const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
340
483
  const collectChipNodes = () => {
341
484
  const seen = new Set();
@@ -354,32 +497,77 @@ function buildAttachmentReadyExpression(attachmentNames) {
354
497
  return collected;
355
498
  };
356
499
  const chipNodes = collectChipNodes();
357
-
358
- const chipsReady = names.every((name) =>
359
- chipNodes.some((node) => match(node, name)),
500
+ const chipLabels = chipNodes.map((node) => collectLabelHaystack(node));
501
+ const chipOwnLabels = chipNodes.map((node) => collectOwnLabelHaystack(node));
502
+ const hasEllipsisSuffix = (label) => {
503
+ const marker = label.includes('…') ? '…' : label.includes('...') ? '...' : '';
504
+ if (!marker) return false;
505
+ return normalize(label.split(marker)[1] || '').length > 0;
506
+ };
507
+ const chipOwnLabelsWithVisibleNames = chipOwnLabels.filter((label) =>
508
+ /\\.[a-z][a-z0-9]{0,9}(?:\\b|$)/i.test(label) ||
509
+ hasEllipsisSuffix(label),
510
+ );
511
+ const visibleExtensionLabelsMatchExpected = chipOwnLabelsWithVisibleNames.every((label) =>
512
+ expected.some((item) => matchesExpected(label, item)),
513
+ );
514
+ const visibleStemOnlyMismatch = chipOwnLabels.some((label) =>
515
+ expected.some(
516
+ (item) =>
517
+ !item.generatedBundle &&
518
+ item.stem &&
519
+ hasStemFileBoundary(label, item.stem) &&
520
+ !matchesExpected(label, item),
521
+ ),
360
522
  );
361
- const inputsReady = names.every((name) =>
523
+
524
+ const chipsReady = (() => {
525
+ const used = new Set();
526
+ return expected.every((item) => {
527
+ const index = chipLabels.findIndex((label, candidateIndex) =>
528
+ !used.has(candidateIndex) && matchesExpected(label, item),
529
+ );
530
+ if (index === -1) return false;
531
+ used.add(index);
532
+ return true;
533
+ });
534
+ })();
535
+ const inputsReady = expected.every((item) =>
362
536
  attachmentRoots.some((root) =>
363
537
  Array.from(root.querySelectorAll('input[type="file"]')).some((el) =>
364
538
  Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
365
- file?.name?.toLowerCase?.().includes(name),
539
+ matchesExpected(file?.name, item),
366
540
  ),
367
541
  ),
368
542
  ),
369
543
  );
370
544
  // Count-based fallback: if we cannot match names individually (ChatGPT may strip
371
545
  // the filename out of attribute-readable text into a deeply nested span), but we
372
- // do see at least as many distinct chip-shaped nodes as attachments we uploaded,
373
- // and a sibling "Remove" affordance exists per chip, trust the upload.
374
- const removeAffordanceCount = chipNodes.filter((node) => {
375
- const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
376
- if (aria.includes('remove')) return true;
377
- const removeSibling = node.querySelector?.(
546
+ // do see at least as many distinct "Remove" affordances as attachments we
547
+ // uploaded, trust the upload without double-counting nested chip/remove nodes.
548
+ const removeAffordances = [];
549
+ const removeSeen = new Set();
550
+ for (const root of attachmentRoots) {
551
+ for (const node of Array.from(root.querySelectorAll(
378
552
  '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
379
- );
380
- return Boolean(removeSibling);
381
- }).length;
382
- const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
553
+ ))) {
554
+ if (!(node instanceof HTMLElement)) continue;
555
+ if (node.closest('textarea,[contenteditable="true"]')) continue;
556
+ const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
557
+ const fileSpecific = aria.includes('remove file') || aria.includes('remove attachment');
558
+ const attachmentOwner = node.closest(
559
+ '[data-testid*="chip"], [data-testid*="attachment"], [data-testid*="upload"], [data-testid*="file"]',
560
+ );
561
+ if (!fileSpecific && !attachmentOwner) continue;
562
+ if (removeSeen.has(node)) continue;
563
+ removeSeen.add(node);
564
+ removeAffordances.push(node);
565
+ }
566
+ }
567
+ const countReady =
568
+ !visibleStemOnlyMismatch &&
569
+ visibleExtensionLabelsMatchExpected &&
570
+ removeAffordances.length >= expected.length;
383
571
 
384
572
  return chipsReady || inputsReady || countReady;
385
573
  })()`;
@@ -387,7 +575,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
387
575
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
388
576
  return buildAttachmentReadyExpression(attachmentNames);
389
577
  }
390
- async function attemptSendButton(Runtime, _logger, attachmentNames) {
578
+ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
391
579
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
392
580
  const script = `(() => {
393
581
  ${buildClickDispatcher()}
@@ -424,7 +612,8 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
424
612
  // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
425
613
  // settle slowly for multi-file uploads, but plain text sends should keep the
426
614
  // shorter historical deadline.
427
- const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
615
+ const timeoutMs = sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs);
616
+ const deadline = Date.now() + timeoutMs;
428
617
  while (Date.now() < deadline) {
429
618
  if (needAttachment) {
430
619
  const ready = await Runtime.evaluate({
@@ -446,16 +635,22 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
446
635
  await delay(100);
447
636
  }
448
637
  if (Array.isArray(attachmentNames) && attachmentNames.length > 0) {
449
- throw new BrowserAutomationError("Attachments never reached a clickable send button before timeout.", {
638
+ throw new BrowserAutomationError(`Attachments never reached a clickable send button after ${Math.ceil(timeoutMs / 1000)}s; tune --browser-attachment-timeout.`, {
450
639
  stage: "submit-prompt",
451
640
  code: "attachment-send-not-ready",
452
641
  attachmentNames,
642
+ timeoutMs,
453
643
  });
454
644
  }
455
645
  return false;
456
646
  }
457
- function sendButtonTimeoutMs(attachmentNames) {
458
- return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
647
+ function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
648
+ if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
649
+ return 20_000;
650
+ }
651
+ return typeof attachmentTimeoutMs === "number" && Number.isFinite(attachmentTimeoutMs)
652
+ ? Math.max(1_000, attachmentTimeoutMs)
653
+ : 45_000;
459
654
  }
460
655
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
461
656
  const deadline = Date.now() + timeoutMs;