@steipete/oracle 0.9.0 → 0.10.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 (177) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +61 -48
  3. package/dist/bin/oracle-cli.js +455 -402
  4. package/dist/bin/oracle-mcp.js +2 -2
  5. package/dist/bin/oracle.js +165 -279
  6. package/dist/scripts/agent-send.js +31 -31
  7. package/dist/scripts/check.js +6 -6
  8. package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
  9. package/dist/scripts/docs-list.js +30 -30
  10. package/dist/scripts/git-policy.js +25 -23
  11. package/dist/scripts/run-cli.js +8 -8
  12. package/dist/scripts/runner.js +203 -195
  13. package/dist/scripts/test-browser.js +21 -18
  14. package/dist/scripts/test-remote-chrome.js +20 -20
  15. package/dist/src/bridge/connection.js +18 -18
  16. package/dist/src/bridge/userConfigFile.js +7 -7
  17. package/dist/src/browser/actions/assistantResponse.js +149 -101
  18. package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
  19. package/dist/src/browser/actions/attachments.js +246 -150
  20. package/dist/src/browser/actions/domEvents.js +2 -2
  21. package/dist/src/browser/actions/modelSelection.js +275 -117
  22. package/dist/src/browser/actions/navigation.js +161 -137
  23. package/dist/src/browser/actions/promptComposer.js +100 -64
  24. package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
  25. package/dist/src/browser/actions/thinkingTime.js +207 -110
  26. package/dist/src/browser/chromeLifecycle.js +62 -60
  27. package/dist/src/browser/config.js +34 -15
  28. package/dist/src/browser/constants.js +17 -12
  29. package/dist/src/browser/cookies.js +19 -19
  30. package/dist/src/browser/detect.js +62 -62
  31. package/dist/src/browser/domDebug.js +1 -1
  32. package/dist/src/browser/index.js +390 -295
  33. package/dist/src/browser/modelStrategy.js +1 -1
  34. package/dist/src/browser/pageActions.js +5 -5
  35. package/dist/src/browser/policies.js +16 -13
  36. package/dist/src/browser/profileState.js +44 -39
  37. package/dist/src/browser/prompt.js +72 -42
  38. package/dist/src/browser/promptSummary.js +5 -5
  39. package/dist/src/browser/providerDomFlow.js +1 -1
  40. package/dist/src/browser/providers/chatgptDomProvider.js +9 -9
  41. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +51 -42
  42. package/dist/src/browser/providers/index.js +2 -2
  43. package/dist/src/browser/reattach.js +67 -34
  44. package/dist/src/browser/reattachHelpers.js +31 -26
  45. package/dist/src/browser/sessionRunner.js +37 -25
  46. package/dist/src/browser/utils.js +9 -9
  47. package/dist/src/browserMode.js +1 -1
  48. package/dist/src/cli/bridge/claudeConfig.js +16 -16
  49. package/dist/src/cli/bridge/client.js +28 -20
  50. package/dist/src/cli/bridge/codexConfig.js +16 -16
  51. package/dist/src/cli/bridge/doctor.js +47 -39
  52. package/dist/src/cli/bridge/host.js +58 -56
  53. package/dist/src/cli/browserConfig.js +62 -48
  54. package/dist/src/cli/browserDefaults.js +27 -26
  55. package/dist/src/cli/bundleWarnings.js +1 -1
  56. package/dist/src/cli/clipboard.js +11 -2
  57. package/dist/src/cli/detach.js +2 -2
  58. package/dist/src/cli/dryRun.js +29 -25
  59. package/dist/src/cli/duplicatePromptGuard.js +3 -3
  60. package/dist/src/cli/engine.js +9 -9
  61. package/dist/src/cli/errorUtils.js +1 -1
  62. package/dist/src/cli/fileSize.js +3 -3
  63. package/dist/src/cli/format.js +2 -2
  64. package/dist/src/cli/help.js +28 -28
  65. package/dist/src/cli/hiddenAliases.js +3 -3
  66. package/dist/src/cli/markdownBundle.js +7 -7
  67. package/dist/src/cli/markdownRenderer.js +15 -15
  68. package/dist/src/cli/notifier.js +77 -67
  69. package/dist/src/cli/options.js +127 -106
  70. package/dist/src/cli/oscUtils.js +1 -1
  71. package/dist/src/cli/promptRequirement.js +2 -2
  72. package/dist/src/cli/renderOutput.js +1 -1
  73. package/dist/src/cli/rootAlias.js +1 -1
  74. package/dist/src/cli/runOptions.js +32 -28
  75. package/dist/src/cli/sessionCommand.js +31 -21
  76. package/dist/src/cli/sessionDisplay.js +95 -81
  77. package/dist/src/cli/sessionLineage.js +6 -2
  78. package/dist/src/cli/sessionRunner.js +103 -93
  79. package/dist/src/cli/sessionTable.js +26 -23
  80. package/dist/src/cli/stdin.js +22 -0
  81. package/dist/src/cli/tagline.js +121 -124
  82. package/dist/src/cli/tui/index.js +139 -128
  83. package/dist/src/cli/writeOutputPath.js +5 -5
  84. package/dist/src/config.js +7 -7
  85. package/dist/src/gemini-web/browserSessionManager.js +19 -15
  86. package/dist/src/gemini-web/client.js +76 -70
  87. package/dist/src/gemini-web/executionMode.js +6 -8
  88. package/dist/src/gemini-web/executor.js +98 -93
  89. package/dist/src/gemini-web/index.js +1 -1
  90. package/dist/src/mcp/server.js +16 -12
  91. package/dist/src/mcp/tools/consult.js +51 -47
  92. package/dist/src/mcp/tools/sessionResources.js +12 -12
  93. package/dist/src/mcp/tools/sessions.js +26 -17
  94. package/dist/src/mcp/types.js +5 -5
  95. package/dist/src/mcp/utils.js +15 -7
  96. package/dist/src/oracle/background.js +15 -15
  97. package/dist/src/oracle/claude.js +53 -25
  98. package/dist/src/oracle/client.js +50 -41
  99. package/dist/src/oracle/config.js +96 -66
  100. package/dist/src/oracle/errors.js +38 -38
  101. package/dist/src/oracle/files.js +55 -46
  102. package/dist/src/oracle/finishLine.js +10 -8
  103. package/dist/src/oracle/format.js +3 -3
  104. package/dist/src/oracle/gemini.js +37 -33
  105. package/dist/src/oracle/logging.js +7 -7
  106. package/dist/src/oracle/markdown.js +28 -28
  107. package/dist/src/oracle/modelResolver.js +16 -16
  108. package/dist/src/oracle/multiModelRunner.js +12 -12
  109. package/dist/src/oracle/oscProgress.js +8 -8
  110. package/dist/src/oracle/promptAssembly.js +6 -3
  111. package/dist/src/oracle/request.js +16 -13
  112. package/dist/src/oracle/run.js +156 -134
  113. package/dist/src/oracle/runUtils.js +8 -5
  114. package/dist/src/oracle/tokenEstimate.js +6 -6
  115. package/dist/src/oracle/tokenStats.js +5 -5
  116. package/dist/src/oracle/tokenStringifier.js +5 -5
  117. package/dist/src/oracle.js +12 -12
  118. package/dist/src/oracleHome.js +3 -3
  119. package/dist/src/remote/client.js +25 -25
  120. package/dist/src/remote/health.js +20 -20
  121. package/dist/src/remote/remoteServiceConfig.js +9 -9
  122. package/dist/src/remote/server.js +129 -118
  123. package/dist/src/sessionManager.js +77 -75
  124. package/dist/src/sessionStore.js +3 -3
  125. package/dist/src/version.js +10 -10
  126. package/dist/vendor/oracle-notifier/README.md +2 -0
  127. package/package.json +66 -62
  128. package/vendor/oracle-notifier/README.md +2 -0
  129. package/dist/markdansi/types/index.js +0 -4
  130. package/dist/oracle/bin/oracle-cli.js +0 -472
  131. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  132. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  133. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  134. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  135. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  136. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  137. package/dist/oracle/src/browser/config.js +0 -33
  138. package/dist/oracle/src/browser/constants.js +0 -40
  139. package/dist/oracle/src/browser/cookies.js +0 -210
  140. package/dist/oracle/src/browser/domDebug.js +0 -36
  141. package/dist/oracle/src/browser/index.js +0 -331
  142. package/dist/oracle/src/browser/pageActions.js +0 -5
  143. package/dist/oracle/src/browser/prompt.js +0 -88
  144. package/dist/oracle/src/browser/promptSummary.js +0 -20
  145. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  146. package/dist/oracle/src/browser/types.js +0 -1
  147. package/dist/oracle/src/browser/utils.js +0 -62
  148. package/dist/oracle/src/browserMode.js +0 -1
  149. package/dist/oracle/src/cli/browserConfig.js +0 -44
  150. package/dist/oracle/src/cli/dryRun.js +0 -59
  151. package/dist/oracle/src/cli/engine.js +0 -17
  152. package/dist/oracle/src/cli/errorUtils.js +0 -9
  153. package/dist/oracle/src/cli/help.js +0 -70
  154. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  155. package/dist/oracle/src/cli/options.js +0 -103
  156. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  157. package/dist/oracle/src/cli/rootAlias.js +0 -30
  158. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  159. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  160. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  161. package/dist/oracle/src/heartbeat.js +0 -43
  162. package/dist/oracle/src/oracle/client.js +0 -48
  163. package/dist/oracle/src/oracle/config.js +0 -29
  164. package/dist/oracle/src/oracle/errors.js +0 -101
  165. package/dist/oracle/src/oracle/files.js +0 -220
  166. package/dist/oracle/src/oracle/format.js +0 -33
  167. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  168. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  169. package/dist/oracle/src/oracle/request.js +0 -48
  170. package/dist/oracle/src/oracle/run.js +0 -444
  171. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  172. package/dist/oracle/src/oracle/types.js +0 -1
  173. package/dist/oracle/src/oracle.js +0 -9
  174. package/dist/oracle/src/sessionManager.js +0 -205
  175. package/dist/oracle/src/version.js +0 -39
  176. package/dist/scripts/chrome/browser-tools.js +0 -295
  177. package/dist/src/browser/profileSync.js +0 -141
@@ -1,14 +1,14 @@
1
- import path from 'node:path';
2
- import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from '../constants.js';
3
- import { delay } from '../utils.js';
4
- import { logDomFailure } from '../domDebug.js';
5
- import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
1
+ import path from "node:path";
2
+ import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS, } from "../constants.js";
3
+ import { delay } from "../utils.js";
4
+ import { logDomFailure } from "../domDebug.js";
5
+ import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
6
6
  export async function uploadAttachmentFile(deps, attachment, logger, options) {
7
7
  const { runtime, dom, input } = deps;
8
8
  if (!dom) {
9
- throw new Error('DOM domain unavailable while uploading attachments.');
9
+ throw new Error("DOM domain unavailable while uploading attachments.");
10
10
  }
11
- const expectedCount = typeof options?.expectedCount === 'number' && Number.isFinite(options.expectedCount)
11
+ const expectedCount = typeof options?.expectedCount === "number" && Number.isFinite(options.expectedCount)
12
12
  ? Math.max(0, Math.floor(options.expectedCount))
13
13
  : 0;
14
14
  const readAttachmentSignals = async (name) => {
@@ -266,17 +266,17 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
266
266
  return {
267
267
  ui: Boolean(value?.ui),
268
268
  input: Boolean(value?.input),
269
- inputCount: typeof value?.inputCount === 'number' ? value?.inputCount : 0,
270
- chipCount: typeof value?.chipCount === 'number' ? value?.chipCount : 0,
271
- chipSignature: typeof value?.chipSignature === 'string' ? value?.chipSignature : '',
269
+ inputCount: typeof value?.inputCount === "number" ? value?.inputCount : 0,
270
+ chipCount: typeof value?.chipCount === "number" ? value?.chipCount : 0,
271
+ chipSignature: typeof value?.chipSignature === "string" ? value?.chipSignature : "",
272
272
  uploading: Boolean(value?.uploading),
273
- fileCount: typeof value?.fileCount === 'number' ? value?.fileCount : 0,
273
+ fileCount: typeof value?.fileCount === "number" ? value?.fileCount : 0,
274
274
  };
275
275
  };
276
276
  // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
277
277
  // Learned: synthetic `.click()` is sometimes ignored (isTrusted checks). Prefer a CDP mouse click when possible.
278
278
  const clickPlusTrusted = async () => {
279
- if (!input || typeof input.dispatchMouseEvent !== 'function')
279
+ if (!input || typeof input.dispatchMouseEvent !== "function")
280
280
  return false;
281
281
  const locate = await runtime
282
282
  .evaluate({
@@ -304,13 +304,13 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
304
304
  })
305
305
  .then((res) => res?.result?.value)
306
306
  .catch(() => undefined);
307
- if (!locate?.ok || typeof locate.x !== 'number' || typeof locate.y !== 'number')
307
+ if (!locate?.ok || typeof locate.x !== "number" || typeof locate.y !== "number")
308
308
  return false;
309
309
  const x = locate.x;
310
310
  const y = locate.y;
311
- await input.dispatchMouseEvent({ type: 'mouseMoved', x, y });
312
- await input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
313
- await input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
311
+ await input.dispatchMouseEvent({ type: "mouseMoved", x, y });
312
+ await input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
313
+ await input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
314
314
  return true;
315
315
  };
316
316
  const clickedTrusted = await clickPlusTrusted().catch(() => false);
@@ -338,13 +338,13 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
338
338
  })).catch(() => undefined);
339
339
  }
340
340
  await delay(350);
341
- const normalizeForMatch = (value) => String(value || '')
341
+ const normalizeForMatch = (value) => String(value || "")
342
342
  .toLowerCase()
343
- .replace(/\s+/g, ' ')
343
+ .replace(/\s+/g, " ")
344
344
  .trim();
345
345
  const expectedName = path.basename(attachment.path);
346
346
  const expectedNameLower = normalizeForMatch(expectedName);
347
- const expectedNameNoExt = expectedNameLower.replace(/\.[a-z0-9]{1,10}$/i, '');
347
+ const expectedNameNoExt = expectedNameLower.replace(/\.[a-z0-9]{1,10}$/i, "");
348
348
  const matchesExpectedName = (value) => {
349
349
  const normalized = normalizeForMatch(value);
350
350
  if (!normalized)
@@ -367,16 +367,17 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
367
367
  const isExpectedSatisfied = (signals) => {
368
368
  if (expectedCount <= 0)
369
369
  return false;
370
- const fileCount = typeof signals.fileCount === 'number' ? signals.fileCount : 0;
371
- const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
370
+ const fileCount = typeof signals.fileCount === "number" ? signals.fileCount : 0;
371
+ const chipCount = typeof signals.chipCount === "number" ? signals.chipCount : 0;
372
372
  if (fileCount >= expectedCount)
373
373
  return true;
374
374
  return Boolean(signals.ui && chipCount >= expectedCount);
375
375
  };
376
376
  const initialInputSatisfied = expectedCount > 0 ? initialSignals.inputCount >= expectedCount : Boolean(initialSignals.input);
377
- if (expectedCount > 0 && (initialSignals.fileCount >= expectedCount || initialSignals.inputCount >= expectedCount)) {
377
+ if (expectedCount > 0 &&
378
+ (initialSignals.fileCount >= expectedCount || initialSignals.inputCount >= expectedCount)) {
378
379
  const satisfiedCount = Math.max(initialSignals.fileCount, initialSignals.inputCount);
379
- logger(`Attachment already present: composer shows ${satisfiedCount} file${satisfiedCount === 1 ? '' : 's'}`);
380
+ logger(`Attachment already present: composer shows ${satisfiedCount} file${satisfiedCount === 1 ? "" : "s"}`);
380
381
  return true;
381
382
  }
382
383
  if (initialInputSatisfied || initialSignals.input) {
@@ -611,33 +612,38 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
611
612
  });
612
613
  const candidateValue = candidateSetup?.result?.value;
613
614
  const candidateOrder = Array.isArray(candidateValue?.order) ? candidateValue.order : [];
614
- const baselineChipCount = typeof candidateValue?.baselineChipCount === 'number' ? candidateValue.baselineChipCount : 0;
615
- const baselineChips = Array.isArray(candidateValue?.baselineChips) ? candidateValue.baselineChips : [];
615
+ const baselineChipCount = typeof candidateValue?.baselineChipCount === "number" ? candidateValue.baselineChipCount : 0;
616
+ const baselineChips = Array.isArray(candidateValue?.baselineChips)
617
+ ? candidateValue.baselineChips
618
+ : [];
616
619
  const baselineUploading = Boolean(candidateValue?.baselineUploading);
617
- const baselineFileCount = typeof candidateValue?.baselineFileCount === 'number' ? candidateValue.baselineFileCount : 0;
618
- const baselineInputCount = typeof candidateValue?.baselineInputCount === 'number' ? candidateValue.baselineInputCount : 0;
620
+ const baselineFileCount = typeof candidateValue?.baselineFileCount === "number" ? candidateValue.baselineFileCount : 0;
621
+ const baselineInputCount = typeof candidateValue?.baselineInputCount === "number" ? candidateValue.baselineInputCount : 0;
619
622
  const serializeChips = (chips) => chips
620
623
  .map((chip) => [chip.text, chip.aria, chip.title, chip.testid]
621
- .map((value) => String(value || '').toLowerCase().replace(/\s+/g, ' ').trim())
622
- .join('|'))
623
- .join('||');
624
+ .map((value) => String(value || "")
625
+ .toLowerCase()
626
+ .replace(/\s+/g, " ")
627
+ .trim())
628
+ .join("|"))
629
+ .join("||");
624
630
  const baselineChipSignature = serializeChips(baselineChips);
625
631
  if (!candidateValue?.ok || candidateOrder.length === 0) {
626
- await logDomFailure(runtime, logger, 'file-input-missing');
627
- throw new Error('Unable to locate ChatGPT file attachment input.');
632
+ await logDomFailure(runtime, logger, "file-input-missing");
633
+ throw new Error("Unable to locate ChatGPT file attachment input.");
628
634
  }
629
635
  const hasChipDelta = (signals) => {
630
- const chipCount = typeof signals.chipCount === 'number' ? signals.chipCount : 0;
631
- const chipSignature = typeof signals.chipSignature === 'string' ? signals.chipSignature : '';
636
+ const chipCount = typeof signals.chipCount === "number" ? signals.chipCount : 0;
637
+ const chipSignature = typeof signals.chipSignature === "string" ? signals.chipSignature : "";
632
638
  if (chipCount > baselineChipCount)
633
639
  return true;
634
640
  if (baselineChipSignature && chipSignature && chipSignature !== baselineChipSignature)
635
641
  return true;
636
642
  return false;
637
643
  };
638
- const hasInputDelta = (signals) => (typeof signals.inputCount === 'number' ? signals.inputCount : 0) > baselineInputCount;
644
+ const hasInputDelta = (signals) => (typeof signals.inputCount === "number" ? signals.inputCount : 0) > baselineInputCount;
639
645
  const hasUploadDelta = (signals) => Boolean(signals.uploading && !baselineUploading);
640
- const hasFileCountDelta = (signals) => (typeof signals.fileCount === 'number' ? signals.fileCount : 0) > baselineFileCount;
646
+ const hasFileCountDelta = (signals) => (typeof signals.fileCount === "number" ? signals.fileCount : 0) > baselineFileCount;
641
647
  const waitForAttachmentUiSignal = async (timeoutMs) => {
642
648
  const deadline = Date.now() + timeoutMs;
643
649
  let sawInputSignal = false;
@@ -652,7 +658,14 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
652
658
  if (inputDelta) {
653
659
  sawInputSignal = true;
654
660
  }
655
- latest = { signals, chipDelta, inputDelta: sawInputSignal, uploadDelta, fileCountDelta, expectedSatisfied };
661
+ latest = {
662
+ signals,
663
+ chipDelta,
664
+ inputDelta: sawInputSignal,
665
+ uploadDelta,
666
+ fileCountDelta,
667
+ expectedSatisfied,
668
+ };
656
669
  if (signals.ui || chipDelta || uploadDelta || fileCountDelta || expectedSatisfied) {
657
670
  return latest;
658
671
  }
@@ -673,9 +686,9 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
673
686
  })()`;
674
687
  const parseInputSnapshot = (value) => {
675
688
  const snapshot = value;
676
- const names = Array.isArray(snapshot?.names) ? snapshot?.names ?? [] : [];
677
- const valueText = typeof snapshot?.value === 'string' ? snapshot.value : '';
678
- const count = typeof snapshot?.count === 'number' ? snapshot.count : names.length;
689
+ const names = Array.isArray(snapshot?.names) ? (snapshot?.names ?? []) : [];
690
+ const valueText = typeof snapshot?.value === "string" ? snapshot.value : "";
691
+ const count = typeof snapshot?.count === "number" ? snapshot.count : names.length;
679
692
  return {
680
693
  names,
681
694
  value: valueText,
@@ -804,7 +817,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
804
817
  })()`;
805
818
  let confirmedAttachment = false;
806
819
  let lastInputNames = [];
807
- let lastInputValue = '';
820
+ let lastInputValue = "";
808
821
  let finalSnapshot = null;
809
822
  const resolveInputNameCandidates = () => {
810
823
  const snapshot = finalSnapshot;
@@ -851,7 +864,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
851
864
  chipCount: Number(snapshot.chipCount ?? 0),
852
865
  chips: Array.isArray(snapshot.chips) ? snapshot.chips : [],
853
866
  inputNames: Array.isArray(snapshot.inputNames) ? snapshot.inputNames : [],
854
- composerText: typeof snapshot.composerText === 'string' ? snapshot.composerText : '',
867
+ composerText: typeof snapshot.composerText === "string" ? snapshot.composerText : "",
855
868
  uploading: Boolean(snapshot.uploading),
856
869
  };
857
870
  }
@@ -871,15 +884,20 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
871
884
  Boolean(signalResult?.inputDelta) ||
872
885
  inputHasFile;
873
886
  const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
874
- const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta) || Boolean(signalResult?.fileCountDelta);
887
+ const uiDelta = Boolean(signalResult?.chipDelta) ||
888
+ Boolean(signalResult?.uploadDelta) ||
889
+ Boolean(signalResult?.fileCountDelta);
875
890
  if (uiDirect || (uiDelta && inputEvidence)) {
876
- return { status: 'ui' };
891
+ return { status: "ui" };
877
892
  }
878
893
  const postSignals = await readAttachmentSignals(expectedName);
879
894
  if (postSignals.ui ||
880
895
  isExpectedSatisfied(postSignals) ||
881
- ((hasChipDelta(postSignals) || hasUploadDelta(postSignals) || hasFileCountDelta(postSignals)) && inputEvidence)) {
882
- return { status: 'ui' };
896
+ ((hasChipDelta(postSignals) ||
897
+ hasUploadDelta(postSignals) ||
898
+ hasFileCountDelta(postSignals)) &&
899
+ inputEvidence)) {
900
+ return { status: "ui" };
883
901
  }
884
902
  const inputSignal = immediateInputMatch ||
885
903
  postInputSignals.touched ||
@@ -889,15 +907,15 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
889
907
  postSignals.input ||
890
908
  hasInputDelta(postSignals);
891
909
  if (inputSignal) {
892
- return { status: 'input' };
910
+ return { status: "input" };
893
911
  }
894
- return { status: 'none' };
912
+ return { status: "none" };
895
913
  };
896
914
  const runInputAttempt = async (mode) => {
897
915
  let immediateInputSnapshot = await readInputSnapshot(idx);
898
916
  let hasExpectedFile = snapshotMatchesExpected(immediateInputSnapshot);
899
917
  if (!hasExpectedFile) {
900
- if (mode === 'set') {
918
+ if (mode === "set") {
901
919
  await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
902
920
  }
903
921
  else {
@@ -941,35 +959,37 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
941
959
  })
942
960
  .catch(() => undefined);
943
961
  };
944
- let result = await runInputAttempt('set');
945
- if (result.evaluation.status === 'ui') {
962
+ let result = await runInputAttempt("set");
963
+ if (result.evaluation.status === "ui") {
946
964
  confirmedAttachment = true;
947
965
  break;
948
966
  }
949
- if (result.evaluation.status === 'input') {
967
+ if (result.evaluation.status === "input") {
950
968
  await dispatchInputEvents();
951
969
  await delay(150);
952
970
  const forcedState = await gatherSignals(1_500);
953
971
  const forcedEvaluation = await evaluateSignals(forcedState.signalResult, forcedState.postInputSignals, result.immediateInputMatch);
954
- if (forcedEvaluation.status === 'ui') {
972
+ if (forcedEvaluation.status === "ui") {
955
973
  confirmedAttachment = true;
956
974
  break;
957
975
  }
958
- if (forcedEvaluation.status === 'input') {
959
- logger('Attachment input set; proceeding without UI confirmation.');
976
+ if (forcedEvaluation.status === "input") {
977
+ logger("Attachment input set; proceeding without UI confirmation.");
960
978
  inputConfirmed = true;
961
979
  break;
962
980
  }
963
- logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
964
- await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
981
+ logger("Attachment input set; retrying with data transfer to trigger ChatGPT upload.");
982
+ await dom
983
+ .setFileInputFiles({ nodeId: resultNode.nodeId, files: [] })
984
+ .catch(() => undefined);
965
985
  await delay(150);
966
- result = await runInputAttempt('transfer');
967
- if (result.evaluation.status === 'ui') {
986
+ result = await runInputAttempt("transfer");
987
+ if (result.evaluation.status === "ui") {
968
988
  confirmedAttachment = true;
969
989
  break;
970
990
  }
971
- if (result.evaluation.status === 'input') {
972
- logger('Attachment input set; proceeding without UI confirmation.');
991
+ if (result.evaluation.status === "input") {
992
+ logger("Attachment input set; proceeding without UI confirmation.");
973
993
  inputConfirmed = true;
974
994
  break;
975
995
  }
@@ -984,23 +1004,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
984
1004
  break;
985
1005
  }
986
1006
  if (lateSignals.input || hasInputDelta(lateSignals)) {
987
- logger('Attachment input set; proceeding without UI confirmation.');
1007
+ logger("Attachment input set; proceeding without UI confirmation.");
988
1008
  inputConfirmed = true;
989
1009
  break;
990
1010
  }
991
- logger('Attachment not acknowledged after file input set; retrying with data transfer.');
992
- result = await runInputAttempt('transfer');
993
- if (result.evaluation.status === 'ui') {
1011
+ logger("Attachment not acknowledged after file input set; retrying with data transfer.");
1012
+ result = await runInputAttempt("transfer");
1013
+ if (result.evaluation.status === "ui") {
994
1014
  confirmedAttachment = true;
995
1015
  break;
996
1016
  }
997
- if (result.evaluation.status === 'input') {
998
- logger('Attachment input set; proceeding without UI confirmation.');
1017
+ if (result.evaluation.status === "input") {
1018
+ logger("Attachment input set; proceeding without UI confirmation.");
999
1019
  inputConfirmed = true;
1000
1020
  break;
1001
1021
  }
1002
1022
  if (orderIndex < candidateOrder.length - 1) {
1003
- await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
1023
+ await dom
1024
+ .setFileInputFiles({ nodeId: resultNode.nodeId, files: [] })
1025
+ .catch(() => undefined);
1004
1026
  await delay(150);
1005
1027
  }
1006
1028
  }
@@ -1010,7 +1032,9 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
1010
1032
  const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
1011
1033
  (lastInputValue && matchesExpectedName(lastInputValue));
1012
1034
  await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1013
- logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
1035
+ logger(inputHasFile
1036
+ ? "Attachment queued (UI anchored, file input confirmed)"
1037
+ : "Attachment queued (UI anchored)");
1014
1038
  return true;
1015
1039
  }
1016
1040
  const inputNameCandidates = resolveInputNameCandidates();
@@ -1018,15 +1042,17 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
1018
1042
  (lastInputValue && matchesExpectedName(lastInputValue));
1019
1043
  if (await waitForAttachmentAnchored(runtime, expectedName, attachmentUiTimeoutMs)) {
1020
1044
  await waitForAttachmentVisible(runtime, expectedName, attachmentUiTimeoutMs, logger);
1021
- logger(inputHasFile ? 'Attachment queued (UI anchored, file input confirmed)' : 'Attachment queued (UI anchored)');
1045
+ logger(inputHasFile
1046
+ ? "Attachment queued (UI anchored, file input confirmed)"
1047
+ : "Attachment queued (UI anchored)");
1022
1048
  return true;
1023
1049
  }
1024
1050
  if (inputConfirmed || inputHasFile) {
1025
- logger('Attachment input accepted the file but UI did not acknowledge it; continuing with input confirmation only.');
1051
+ logger("Attachment input accepted the file but UI did not acknowledge it; continuing with input confirmation only.");
1026
1052
  return true;
1027
1053
  }
1028
- await logDomFailure(runtime, logger, 'file-upload-missing');
1029
- throw new Error('Attachment did not register with the ChatGPT composer in time.');
1054
+ await logDomFailure(runtime, logger, "file-upload-missing");
1055
+ throw new Error("Attachment did not register with the ChatGPT composer in time.");
1030
1056
  }
1031
1057
  export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1032
1058
  const deadline = Date.now() + Math.max(0, timeoutMs);
@@ -1131,8 +1157,8 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1131
1157
  if (value?.hadAttachments) {
1132
1158
  sawAttachments = true;
1133
1159
  }
1134
- const chipCount = typeof value?.chipCount === 'number' ? value.chipCount : 0;
1135
- const inputCount = typeof value?.inputCount === 'number' ? value.inputCount : 0;
1160
+ const chipCount = typeof value?.chipCount === "number" ? value.chipCount : 0;
1161
+ const inputCount = typeof value?.inputCount === "number" ? value.inputCount : 0;
1136
1162
  lastState = { chipCount, inputCount };
1137
1163
  if (chipCount === 0 && inputCount === 0) {
1138
1164
  return;
@@ -1141,7 +1167,7 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
1141
1167
  }
1142
1168
  if (sawAttachments) {
1143
1169
  logger?.(`Attachment cleanup timed out; still saw ${lastState?.chipCount ?? 0} chips and ${lastState?.inputCount ?? 0} inputs.`);
1144
- throw new Error('Existing attachments still present in composer; aborting to avoid duplicate uploads.');
1170
+ throw new Error("Existing attachments still present in composer; aborting to avoid duplicate uploads.");
1145
1171
  }
1146
1172
  }
1147
1173
  export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
@@ -1363,13 +1389,12 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1363
1389
  const { result } = response;
1364
1390
  const value = result?.value;
1365
1391
  if (!value && logger?.verbose) {
1366
- const exception = response
1367
- ?.exceptionDetails;
1392
+ const exception = response?.exceptionDetails;
1368
1393
  if (exception) {
1369
1394
  const details = [exception.text, exception.exception?.description]
1370
1395
  .filter((part) => Boolean(part))
1371
- .join(' - ');
1372
- logger(`Attachment wait eval failed: ${details || 'unknown error'}`);
1396
+ .join(" - ");
1397
+ logger(`Attachment wait eval failed: ${details || "unknown error"}`);
1373
1398
  }
1374
1399
  }
1375
1400
  if (value) {
@@ -1388,24 +1413,24 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1388
1413
  }
1389
1414
  }
1390
1415
  const attachedNames = (value.attachedNames ?? [])
1391
- .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
1416
+ .map((name) => name.toLowerCase().replace(/\s+/g, " ").trim())
1392
1417
  .filter(Boolean);
1393
1418
  const inputNames = (value.inputNames ?? [])
1394
- .map((name) => name.toLowerCase().replace(/\s+/g, ' ').trim())
1419
+ .map((name) => name.toLowerCase().replace(/\s+/g, " ").trim())
1395
1420
  .filter(Boolean);
1396
- const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1421
+ const fileCount = typeof value.fileCount === "number" ? value.fileCount : 0;
1397
1422
  const fileCountSatisfied = expectedNormalized.length > 0 && fileCount >= expectedNormalized.length;
1398
1423
  const matchesExpected = (expected) => {
1399
- const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1400
- const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1401
- const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1424
+ const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1425
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1426
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1402
1427
  return attachedNames.some((raw) => {
1403
1428
  if (raw.includes(normalizedExpected))
1404
1429
  return true;
1405
1430
  if (expectedNoExt.length >= 6 && raw.includes(expectedNoExt))
1406
1431
  return true;
1407
- if (raw.includes('') || raw.includes('...')) {
1408
- const marker = raw.includes('') ? '' : '...';
1432
+ if (raw.includes("") || raw.includes("...")) {
1433
+ const marker = raw.includes("") ? "" : "...";
1409
1434
  const [prefixRaw, suffixRaw] = raw.split(marker);
1410
1435
  const prefix = prefixRaw.trim();
1411
1436
  const suffix = suffixRaw.trim();
@@ -1424,12 +1449,12 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1424
1449
  attachmentMatchSince = Date.now();
1425
1450
  }
1426
1451
  const stable = Date.now() - attachmentMatchSince > stableThresholdMs;
1427
- if (stable && value.state === 'ready') {
1452
+ if (stable && value.state === "ready") {
1428
1453
  return;
1429
1454
  }
1430
1455
  // Don't treat disabled button as complete - wait for it to become 'ready'.
1431
1456
  // The spinner detection is unreliable, so a disabled button likely means upload is in progress.
1432
- if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
1457
+ if (value.state === "missing" && (value.filesAttached || fileCountSatisfied)) {
1433
1458
  return;
1434
1459
  }
1435
1460
  // If files are attached but button isn't ready yet, give it more time but don't fail immediately.
@@ -1444,13 +1469,14 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1444
1469
  // Fallback: if the file input has the expected names, allow progress once that condition is stable.
1445
1470
  // Some ChatGPT surfaces only render the filename after sending the message.
1446
1471
  const inputMissing = expectedNormalized.filter((expected) => {
1447
- const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1448
- const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1449
- const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1450
- return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1472
+ const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1473
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1474
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1475
+ return !inputNames.some((raw) => raw.includes(normalizedExpected) ||
1476
+ (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
1451
1477
  });
1452
1478
  // Don't include 'disabled' - a disabled button likely means upload is still in progress.
1453
- const inputStateOk = value.state === 'ready' || value.state === 'missing';
1479
+ const inputStateOk = value.state === "ready" || value.state === "missing";
1454
1480
  const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
1455
1481
  const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
1456
1482
  const stableThresholdMs = value.uploading ? 3000 : 1500;
@@ -1460,7 +1486,10 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1460
1486
  }
1461
1487
  sawInputMatch = true;
1462
1488
  }
1463
- if (inputMatchSince !== null && inputStateOk && inputEvidenceOk && Date.now() - inputMatchSince > stableThresholdMs) {
1489
+ if (inputMatchSince !== null &&
1490
+ inputStateOk &&
1491
+ inputEvidenceOk &&
1492
+ Date.now() - inputMatchSince > stableThresholdMs) {
1464
1493
  return;
1465
1494
  }
1466
1495
  if (!inputSeenNow && !sawInputMatch) {
@@ -1469,28 +1498,113 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
1469
1498
  }
1470
1499
  await delay(250);
1471
1500
  }
1472
- logger?.('Attachment upload timed out while waiting for ChatGPT composer to become ready.');
1473
- await logDomFailure(Runtime, logger ?? (() => { }), 'file-upload-timeout');
1474
- throw new Error('Attachments did not finish uploading before timeout.');
1501
+ logger?.("Attachment upload timed out while waiting for ChatGPT composer to become ready.");
1502
+ await logDomFailure(Runtime, logger ?? (() => { }), "file-upload-timeout");
1503
+ throw new Error("Attachments did not finish uploading before timeout.");
1475
1504
  }
1476
- export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger) {
1505
+ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeoutMs, logger, options) {
1477
1506
  if (!expectedNames || expectedNames.length === 0) {
1478
1507
  return true;
1479
1508
  }
1480
1509
  const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
1510
+ const minTurnIndex = typeof options?.minTurnIndex === "number" && Number.isFinite(options.minTurnIndex)
1511
+ ? Math.max(0, Math.floor(options.minTurnIndex))
1512
+ : null;
1513
+ const expectedPromptPrefix = options?.expectedPrompt
1514
+ ? options.expectedPrompt.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 80)
1515
+ : "";
1516
+ const expectedConversationId = typeof options?.expectedConversationId === "string" &&
1517
+ options.expectedConversationId.trim().length > 0
1518
+ ? options.expectedConversationId.trim()
1519
+ : null;
1520
+ const expression = buildUserTurnAttachmentExpression({
1521
+ minTurnIndex,
1522
+ expectedPromptPrefix,
1523
+ expectedConversationId,
1524
+ });
1525
+ const deadline = Date.now() + timeoutMs;
1526
+ let sawAttachmentUi = false;
1527
+ while (Date.now() < deadline) {
1528
+ const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1529
+ const value = result?.value;
1530
+ if (!value?.ok) {
1531
+ if (value?.conversationMismatch && logger?.verbose) {
1532
+ logger("User-turn attachment verification ignored mismatched conversation.");
1533
+ }
1534
+ await delay(200);
1535
+ continue;
1536
+ }
1537
+ if (value.hasAttachmentUi) {
1538
+ sawAttachmentUi = true;
1539
+ }
1540
+ const haystack = [value.text ?? "", ...(value.attrs ?? [])].join("\n");
1541
+ const fileCount = typeof value.fileCount === "number" ? value.fileCount : 0;
1542
+ const attachmentUiCount = typeof value.attachmentUiCount === "number" ? value.attachmentUiCount : 0;
1543
+ const promptMatches = expectedPromptPrefix ? value.promptMatches !== false : true;
1544
+ const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1545
+ const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1546
+ const missing = expectedNormalized.filter((expected) => {
1547
+ const baseName = expected.split("/").pop()?.split("\\").pop() ?? expected;
1548
+ const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, " ").trim();
1549
+ const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, "");
1550
+ if (haystack.includes(normalizedExpected))
1551
+ return false;
1552
+ if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
1553
+ return false;
1554
+ return true;
1555
+ });
1556
+ if (promptMatches && (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied)) {
1557
+ return true;
1558
+ }
1559
+ await delay(250);
1560
+ }
1561
+ if (!sawAttachmentUi) {
1562
+ logger?.("Sent user message did not expose attachment UI; skipping attachment verification.");
1563
+ return false;
1564
+ }
1565
+ logger?.("Sent user message did not show expected attachment names in time.");
1566
+ await logDomFailure(Runtime, logger ?? (() => { }), "attachment-missing-user-turn");
1567
+ throw new Error("Attachment was not present on the sent user message.");
1568
+ }
1569
+ function buildUserTurnAttachmentExpression(options) {
1481
1570
  const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1482
- const expression = `(() => {
1571
+ const minTurnLiteral = options.minTurnIndex === null ? "null" : String(options.minTurnIndex);
1572
+ const expectedPromptLiteral = JSON.stringify(options.expectedPromptPrefix);
1573
+ const expectedConversationLiteral = options.expectedConversationId
1574
+ ? JSON.stringify(options.expectedConversationId)
1575
+ : "null";
1576
+ return `(() => {
1483
1577
  const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
1578
+ const MIN_TURN_INDEX = ${minTurnLiteral};
1579
+ const EXPECTED_PROMPT_PREFIX = ${expectedPromptLiteral};
1580
+ const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
1581
+ const currentHref = typeof location === 'object' && location.href ? location.href : '';
1582
+ const currentConversationId = currentHref.match(/\\/c\\/([a-zA-Z0-9-]+)/)?.[1] ?? null;
1583
+ if (
1584
+ EXPECTED_CONVERSATION_ID &&
1585
+ currentConversationId &&
1586
+ currentConversationId !== EXPECTED_CONVERSATION_ID
1587
+ ) {
1588
+ return { ok: false, conversationMismatch: true };
1589
+ }
1484
1590
  const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1485
- const userTurns = turns.filter((node) => {
1591
+ const userTurns = turns.map((node, index) => ({ node, index })).filter(({ node }) => {
1486
1592
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
1487
1593
  if (attr === 'user') return true;
1488
1594
  return Boolean(node.querySelector('[data-message-author-role="user"]'));
1489
1595
  });
1490
- const lastUser = userTurns[userTurns.length - 1];
1596
+ const eligibleTurns =
1597
+ MIN_TURN_INDEX === null ? userTurns : userTurns.filter(({ index }) => index >= MIN_TURN_INDEX);
1598
+ const lastUser = eligibleTurns[eligibleTurns.length - 1];
1491
1599
  if (!lastUser) return { ok: false };
1492
- const text = (lastUser.innerText || '').toLowerCase();
1493
- const attrs = Array.from(lastUser.querySelectorAll('[aria-label],[title]')).map((el) => {
1600
+ const text = (lastUser.node.innerText || '').toLowerCase().replace(/\\s+/g, ' ').trim();
1601
+ const textPrefix = text.slice(0, Math.min(text.length, EXPECTED_PROMPT_PREFIX.length));
1602
+ const promptMatches =
1603
+ !EXPECTED_PROMPT_PREFIX ||
1604
+ (text.length > 0 &&
1605
+ (text.includes(EXPECTED_PROMPT_PREFIX) ||
1606
+ (textPrefix.length > 0 && EXPECTED_PROMPT_PREFIX.includes(textPrefix))));
1607
+ const attrs = Array.from(lastUser.node.querySelectorAll('[aria-label],[title]')).map((el) => {
1494
1608
  const aria = el.getAttribute('aria-label') || '';
1495
1609
  const title = el.getAttribute('title') || '';
1496
1610
  return (aria + ' ' + title).trim().toLowerCase();
@@ -1504,11 +1618,11 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1504
1618
  '[title*="file"]',
1505
1619
  '[title*="attachment"]',
1506
1620
  ];
1507
- const attachmentUiCount = lastUser.querySelectorAll(attachmentSelectors.join(',')).length;
1621
+ const attachmentUiCount = lastUser.node.querySelectorAll(attachmentSelectors.join(',')).length;
1508
1622
  const hasAttachmentUi =
1509
1623
  attachmentUiCount > 0 || attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
1510
1624
  const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
1511
- const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
1625
+ const fileCountNodes = Array.from(lastUser.node.querySelectorAll('button,span,div,[aria-label],[title]'));
1512
1626
  let fileCount = 0;
1513
1627
  for (const node of fileCountNodes) {
1514
1628
  if (!(node instanceof HTMLElement)) continue;
@@ -1541,47 +1655,29 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1541
1655
  }
1542
1656
  }
1543
1657
  }
1544
- return { ok: true, text, attrs, fileCount, hasAttachmentUi, attachmentUiCount };
1658
+ return {
1659
+ ok: true,
1660
+ text,
1661
+ attrs,
1662
+ fileCount,
1663
+ hasAttachmentUi,
1664
+ attachmentUiCount,
1665
+ promptMatches,
1666
+ turnIndex: lastUser.index,
1667
+ };
1545
1668
  })()`;
1546
- const deadline = Date.now() + timeoutMs;
1547
- let sawAttachmentUi = false;
1548
- while (Date.now() < deadline) {
1549
- const { result } = await Runtime.evaluate({ expression, returnByValue: true });
1550
- const value = result?.value;
1551
- if (!value?.ok) {
1552
- await delay(200);
1553
- continue;
1554
- }
1555
- if (value.hasAttachmentUi) {
1556
- sawAttachmentUi = true;
1557
- }
1558
- const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
1559
- const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
1560
- const attachmentUiCount = typeof value.attachmentUiCount === 'number' ? value.attachmentUiCount : 0;
1561
- const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
1562
- const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
1563
- const missing = expectedNormalized.filter((expected) => {
1564
- const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
1565
- const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
1566
- const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
1567
- if (haystack.includes(normalizedExpected))
1568
- return false;
1569
- if (expectedNoExt.length >= 6 && haystack.includes(expectedNoExt))
1570
- return false;
1571
- return true;
1572
- });
1573
- if (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied) {
1574
- return true;
1575
- }
1576
- await delay(250);
1577
- }
1578
- if (!sawAttachmentUi) {
1579
- logger?.('Sent user message did not expose attachment UI; skipping attachment verification.');
1580
- return false;
1581
- }
1582
- logger?.('Sent user message did not show expected attachment names in time.');
1583
- await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-missing-user-turn');
1584
- throw new Error('Attachment was not present on the sent user message.');
1669
+ }
1670
+ export function buildUserTurnAttachmentExpressionForTest(options) {
1671
+ return buildUserTurnAttachmentExpression({
1672
+ minTurnIndex: typeof options?.minTurnIndex === "number" && Number.isFinite(options.minTurnIndex)
1673
+ ? Math.max(0, Math.floor(options.minTurnIndex))
1674
+ : null,
1675
+ expectedPromptPrefix: options?.expectedPromptPrefix ?? "",
1676
+ expectedConversationId: typeof options?.expectedConversationId === "string" &&
1677
+ options.expectedConversationId.trim().length > 0
1678
+ ? options.expectedConversationId.trim()
1679
+ : null,
1680
+ });
1585
1681
  }
1586
1682
  export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
1587
1683
  // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
@@ -1763,9 +1859,9 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
1763
1859
  }
1764
1860
  await delay(200);
1765
1861
  }
1766
- logger?.('Attachment not visible in composer; giving up.');
1767
- await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
1768
- throw new Error('Attachment did not appear in ChatGPT composer.');
1862
+ logger?.("Attachment not visible in composer; giving up.");
1863
+ await logDomFailure(Runtime, logger ?? (() => { }), "attachment-visible");
1864
+ throw new Error("Attachment did not appear in ChatGPT composer.");
1769
1865
  }
1770
1866
  async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
1771
1867
  const deadline = Date.now() + timeoutMs;