@steipete/oracle 0.4.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 (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +954 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/bin/oracle.js +683 -0
  7. package/dist/markdansi/types/index.js +4 -0
  8. package/dist/oracle/bin/oracle-cli.js +472 -0
  9. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  10. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  11. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  12. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  13. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  14. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  15. package/dist/oracle/src/browser/config.js +33 -0
  16. package/dist/oracle/src/browser/constants.js +40 -0
  17. package/dist/oracle/src/browser/cookies.js +210 -0
  18. package/dist/oracle/src/browser/domDebug.js +36 -0
  19. package/dist/oracle/src/browser/index.js +331 -0
  20. package/dist/oracle/src/browser/pageActions.js +5 -0
  21. package/dist/oracle/src/browser/prompt.js +88 -0
  22. package/dist/oracle/src/browser/promptSummary.js +20 -0
  23. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  24. package/dist/oracle/src/browser/types.js +1 -0
  25. package/dist/oracle/src/browser/utils.js +62 -0
  26. package/dist/oracle/src/browserMode.js +1 -0
  27. package/dist/oracle/src/cli/browserConfig.js +44 -0
  28. package/dist/oracle/src/cli/dryRun.js +59 -0
  29. package/dist/oracle/src/cli/engine.js +17 -0
  30. package/dist/oracle/src/cli/errorUtils.js +9 -0
  31. package/dist/oracle/src/cli/help.js +70 -0
  32. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  33. package/dist/oracle/src/cli/options.js +103 -0
  34. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  35. package/dist/oracle/src/cli/rootAlias.js +30 -0
  36. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  37. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  38. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  39. package/dist/oracle/src/heartbeat.js +43 -0
  40. package/dist/oracle/src/oracle/client.js +48 -0
  41. package/dist/oracle/src/oracle/config.js +29 -0
  42. package/dist/oracle/src/oracle/errors.js +101 -0
  43. package/dist/oracle/src/oracle/files.js +220 -0
  44. package/dist/oracle/src/oracle/format.js +33 -0
  45. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  46. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  47. package/dist/oracle/src/oracle/request.js +48 -0
  48. package/dist/oracle/src/oracle/run.js +444 -0
  49. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  50. package/dist/oracle/src/oracle/types.js +1 -0
  51. package/dist/oracle/src/oracle.js +9 -0
  52. package/dist/oracle/src/sessionManager.js +205 -0
  53. package/dist/oracle/src/version.js +39 -0
  54. package/dist/scripts/browser-tools.js +536 -0
  55. package/dist/scripts/check.js +21 -0
  56. package/dist/scripts/chrome/browser-tools.js +295 -0
  57. package/dist/scripts/run-cli.js +14 -0
  58. package/dist/src/browser/actions/assistantResponse.js +555 -0
  59. package/dist/src/browser/actions/attachments.js +82 -0
  60. package/dist/src/browser/actions/modelSelection.js +300 -0
  61. package/dist/src/browser/actions/navigation.js +175 -0
  62. package/dist/src/browser/actions/promptComposer.js +167 -0
  63. package/dist/src/browser/actions/remoteFileTransfer.js +154 -0
  64. package/dist/src/browser/chromeCookies.js +274 -0
  65. package/dist/src/browser/chromeLifecycle.js +107 -0
  66. package/dist/src/browser/config.js +49 -0
  67. package/dist/src/browser/constants.js +42 -0
  68. package/dist/src/browser/cookies.js +130 -0
  69. package/dist/src/browser/domDebug.js +36 -0
  70. package/dist/src/browser/index.js +541 -0
  71. package/dist/src/browser/keytarShim.js +56 -0
  72. package/dist/src/browser/pageActions.js +5 -0
  73. package/dist/src/browser/policies.js +43 -0
  74. package/dist/src/browser/prompt.js +82 -0
  75. package/dist/src/browser/promptSummary.js +20 -0
  76. package/dist/src/browser/sessionRunner.js +96 -0
  77. package/dist/src/browser/types.js +1 -0
  78. package/dist/src/browser/utils.js +112 -0
  79. package/dist/src/browser/windowsCookies.js +218 -0
  80. package/dist/src/browserMode.js +1 -0
  81. package/dist/src/cli/browserConfig.js +193 -0
  82. package/dist/src/cli/bundleWarnings.js +9 -0
  83. package/dist/src/cli/clipboard.js +10 -0
  84. package/dist/src/cli/detach.js +11 -0
  85. package/dist/src/cli/dryRun.js +103 -0
  86. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  87. package/dist/src/cli/engine.js +25 -0
  88. package/dist/src/cli/errorUtils.js +9 -0
  89. package/dist/src/cli/format.js +13 -0
  90. package/dist/src/cli/help.js +77 -0
  91. package/dist/src/cli/hiddenAliases.js +22 -0
  92. package/dist/src/cli/markdownBundle.js +17 -0
  93. package/dist/src/cli/markdownRenderer.js +97 -0
  94. package/dist/src/cli/notifier.js +300 -0
  95. package/dist/src/cli/options.js +193 -0
  96. package/dist/src/cli/oscUtils.js +20 -0
  97. package/dist/src/cli/promptRequirement.js +17 -0
  98. package/dist/src/cli/renderFlags.js +9 -0
  99. package/dist/src/cli/renderOutput.js +26 -0
  100. package/dist/src/cli/rootAlias.js +30 -0
  101. package/dist/src/cli/runOptions.js +62 -0
  102. package/dist/src/cli/sessionCommand.js +111 -0
  103. package/dist/src/cli/sessionDisplay.js +540 -0
  104. package/dist/src/cli/sessionRunner.js +419 -0
  105. package/dist/src/cli/tagline.js +258 -0
  106. package/dist/src/cli/tui/index.js +520 -0
  107. package/dist/src/cli/writeOutputPath.js +21 -0
  108. package/dist/src/config.js +27 -0
  109. package/dist/src/heartbeat.js +43 -0
  110. package/dist/src/mcp/server.js +36 -0
  111. package/dist/src/mcp/tools/consult.js +221 -0
  112. package/dist/src/mcp/tools/sessionResources.js +75 -0
  113. package/dist/src/mcp/tools/sessions.js +96 -0
  114. package/dist/src/mcp/types.js +18 -0
  115. package/dist/src/mcp/utils.js +27 -0
  116. package/dist/src/oracle/background.js +134 -0
  117. package/dist/src/oracle/claude.js +95 -0
  118. package/dist/src/oracle/client.js +87 -0
  119. package/dist/src/oracle/config.js +92 -0
  120. package/dist/src/oracle/errors.js +104 -0
  121. package/dist/src/oracle/files.js +371 -0
  122. package/dist/src/oracle/format.js +30 -0
  123. package/dist/src/oracle/fsAdapter.js +10 -0
  124. package/dist/src/oracle/gemini.js +185 -0
  125. package/dist/src/oracle/logging.js +36 -0
  126. package/dist/src/oracle/markdown.js +46 -0
  127. package/dist/src/oracle/multiModelRunner.js +164 -0
  128. package/dist/src/oracle/oscProgress.js +66 -0
  129. package/dist/src/oracle/promptAssembly.js +13 -0
  130. package/dist/src/oracle/request.js +49 -0
  131. package/dist/src/oracle/run.js +492 -0
  132. package/dist/src/oracle/runUtils.js +27 -0
  133. package/dist/src/oracle/tokenEstimate.js +37 -0
  134. package/dist/src/oracle/tokenStats.js +39 -0
  135. package/dist/src/oracle/tokenStringifier.js +24 -0
  136. package/dist/src/oracle/types.js +1 -0
  137. package/dist/src/oracle.js +12 -0
  138. package/dist/src/remote/client.js +128 -0
  139. package/dist/src/remote/server.js +294 -0
  140. package/dist/src/remote/types.js +1 -0
  141. package/dist/src/sessionManager.js +462 -0
  142. package/dist/src/sessionStore.js +56 -0
  143. package/dist/src/version.js +39 -0
  144. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  145. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  146. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  147. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  148. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  149. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  150. package/dist/vendor/oracle-notifier/README.md +24 -0
  151. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  152. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  153. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  154. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  155. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  156. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  157. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  158. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  159. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  160. package/package.json +102 -0
  161. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  162. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  163. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  164. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  165. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  166. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  167. package/vendor/oracle-notifier/README.md +24 -0
  168. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,300 @@
1
+ import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from '../constants.js';
2
+ import { logDomFailure } from '../domDebug.js';
3
+ export async function ensureModelSelection(Runtime, desiredModel, logger) {
4
+ const outcome = await Runtime.evaluate({
5
+ expression: buildModelSelectionExpression(desiredModel),
6
+ awaitPromise: true,
7
+ returnByValue: true,
8
+ });
9
+ const result = outcome.result?.value;
10
+ switch (result?.status) {
11
+ case 'already-selected':
12
+ case 'switched':
13
+ case 'switched-best-effort': {
14
+ const label = result.label ?? desiredModel;
15
+ logger(`Model picker: ${label}`);
16
+ return;
17
+ }
18
+ case 'option-not-found': {
19
+ await logDomFailure(Runtime, logger, 'model-switcher-option');
20
+ throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.`);
21
+ }
22
+ default: {
23
+ await logDomFailure(Runtime, logger, 'model-switcher-button');
24
+ throw new Error('Unable to locate the ChatGPT model selector button.');
25
+ }
26
+ }
27
+ }
28
+ /**
29
+ * Builds the DOM expression that runs inside the ChatGPT tab to select a model.
30
+ * The string is evaluated inside Chrome, so keep it self-contained and well-commented.
31
+ */
32
+ function buildModelSelectionExpression(targetModel) {
33
+ const matchers = buildModelMatchersLiteral(targetModel);
34
+ const labelLiteral = JSON.stringify(matchers.labelTokens);
35
+ const idLiteral = JSON.stringify(matchers.testIdTokens);
36
+ const primaryLabelLiteral = JSON.stringify(targetModel);
37
+ const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
38
+ const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
39
+ return `(() => {
40
+ // Capture the selectors and matcher literals up front so the browser expression stays pure.
41
+ const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}';
42
+ const LABEL_TOKENS = ${labelLiteral};
43
+ const TEST_IDS = ${idLiteral};
44
+ const PRIMARY_LABEL = ${primaryLabelLiteral};
45
+ const INITIAL_WAIT_MS = 150;
46
+ const REOPEN_INTERVAL_MS = 400;
47
+ const MAX_WAIT_MS = 20000;
48
+ const normalizeText = (value) => {
49
+ if (!value) {
50
+ return '';
51
+ }
52
+ return value
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, ' ')
55
+ .replace(/\\s+/g, ' ')
56
+ .trim();
57
+ };
58
+ // Normalize every candidate token to keep fuzzy matching deterministic.
59
+ const normalizedTarget = normalizeText(PRIMARY_LABEL);
60
+ const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
61
+ .map((token) => normalizeText(token))
62
+ .filter(Boolean);
63
+ const targetWords = normalizedTarget.split(' ').filter(Boolean);
64
+
65
+ const button = document.querySelector(BUTTON_SELECTOR);
66
+ if (!button) {
67
+ return { status: 'button-missing' };
68
+ }
69
+
70
+ let lastPointerClick = 0;
71
+ const pointerClick = () => {
72
+ // Some menus ignore synthetic click events.
73
+ const down = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
74
+ const up = new PointerEvent('pointerup', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
75
+ const click = new MouseEvent('click', { bubbles: true });
76
+ button.dispatchEvent(down);
77
+ button.dispatchEvent(up);
78
+ button.dispatchEvent(click);
79
+ lastPointerClick = performance.now();
80
+ };
81
+
82
+ const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
83
+ const optionIsSelected = (node) => {
84
+ if (!(node instanceof HTMLElement)) {
85
+ return false;
86
+ }
87
+ const ariaChecked = node.getAttribute('aria-checked');
88
+ const ariaSelected = node.getAttribute('aria-selected');
89
+ const ariaCurrent = node.getAttribute('aria-current');
90
+ const dataSelected = node.getAttribute('data-selected');
91
+ const dataState = (node.getAttribute('data-state') ?? '').toLowerCase();
92
+ const selectedStates = ['checked', 'selected', 'on', 'true'];
93
+ if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') {
94
+ return true;
95
+ }
96
+ if (dataSelected === 'true' || selectedStates.includes(dataState)) {
97
+ return true;
98
+ }
99
+ if (node.querySelector('[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"]')) {
100
+ return true;
101
+ }
102
+ return false;
103
+ };
104
+
105
+ const scoreOption = (normalizedText, testid) => {
106
+ // Assign a score to every node so we can pick the most likely match without brittle equality checks.
107
+ if (!normalizedText && !testid) {
108
+ return 0;
109
+ }
110
+ let score = 0;
111
+ const normalizedTestId = (testid ?? '').toLowerCase();
112
+ if (normalizedTestId && TEST_IDS.some((id) => normalizedTestId.includes(id))) {
113
+ score += 1000;
114
+ }
115
+ if (normalizedText && normalizedTarget) {
116
+ if (normalizedText === normalizedTarget) {
117
+ score += 500;
118
+ } else if (normalizedText.startsWith(normalizedTarget)) {
119
+ score += 420;
120
+ } else if (normalizedText.includes(normalizedTarget)) {
121
+ score += 380;
122
+ }
123
+ }
124
+ for (const token of normalizedTokens) {
125
+ // Reward partial matches to the expanded label/token set.
126
+ if (token && normalizedText.includes(token)) {
127
+ const tokenWeight = Math.min(120, Math.max(10, token.length * 4));
128
+ score += tokenWeight;
129
+ }
130
+ }
131
+ if (targetWords.length > 1) {
132
+ let missing = 0;
133
+ for (const word of targetWords) {
134
+ if (!normalizedText.includes(word)) {
135
+ missing += 1;
136
+ }
137
+ }
138
+ score -= missing * 12;
139
+ }
140
+ return Math.max(score, 0);
141
+ };
142
+
143
+ const findBestOption = () => {
144
+ // Walk through every menu item and keep whichever earns the highest score.
145
+ let bestMatch = null;
146
+ const menus = Array.from(document.querySelectorAll(${menuContainerLiteral}));
147
+ for (const menu of menus) {
148
+ const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
149
+ for (const option of buttons) {
150
+ const text = option.textContent ?? '';
151
+ const normalizedText = normalizeText(text);
152
+ const testid = option.getAttribute('data-testid') ?? '';
153
+ const score = scoreOption(normalizedText, testid);
154
+ if (score <= 0) {
155
+ continue;
156
+ }
157
+ const label = getOptionLabel(option);
158
+ if (!bestMatch || score > bestMatch.score) {
159
+ bestMatch = { node: option, label, score };
160
+ }
161
+ }
162
+ }
163
+ return bestMatch;
164
+ };
165
+
166
+ return new Promise((resolve) => {
167
+ const start = performance.now();
168
+ const ensureMenuOpen = () => {
169
+ const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
170
+ if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
171
+ pointerClick();
172
+ }
173
+ };
174
+
175
+ // Open once and wait a tick before first scan.
176
+ pointerClick();
177
+ const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
178
+ let initialized = false;
179
+ const attempt = async () => {
180
+ if (!initialized) {
181
+ initialized = true;
182
+ await openDelay();
183
+ }
184
+ ensureMenuOpen();
185
+ const match = findBestOption();
186
+ if (match) {
187
+ if (optionIsSelected(match.node)) {
188
+ resolve({ status: 'already-selected', label: match.label });
189
+ return;
190
+ }
191
+ match.node.click();
192
+ resolve({ status: 'switched', label: match.label });
193
+ return;
194
+ }
195
+ if (performance.now() - start > MAX_WAIT_MS) {
196
+ resolve({ status: 'option-not-found' });
197
+ return;
198
+ }
199
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
200
+ };
201
+ attempt();
202
+ });
203
+ })()`;
204
+ }
205
+ export function buildModelMatchersLiteralForTest(targetModel) {
206
+ return buildModelMatchersLiteral(targetModel);
207
+ }
208
+ function buildModelMatchersLiteral(targetModel) {
209
+ const base = targetModel.trim().toLowerCase();
210
+ const labelTokens = new Set();
211
+ const testIdTokens = new Set();
212
+ const push = (value, set) => {
213
+ const normalized = value?.trim();
214
+ if (normalized) {
215
+ set.add(normalized);
216
+ }
217
+ };
218
+ push(base, labelTokens);
219
+ push(base.replace(/\s+/g, ' '), labelTokens);
220
+ const collapsed = base.replace(/\s+/g, '');
221
+ push(collapsed, labelTokens);
222
+ const dotless = base.replace(/[.]/g, '');
223
+ push(dotless, labelTokens);
224
+ push(`chatgpt ${base}`, labelTokens);
225
+ push(`chatgpt ${dotless}`, labelTokens);
226
+ push(`gpt ${base}`, labelTokens);
227
+ push(`gpt ${dotless}`, labelTokens);
228
+ // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
229
+ if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
230
+ push('5.1', labelTokens);
231
+ push('gpt-5.1', labelTokens);
232
+ push('gpt5.1', labelTokens);
233
+ push('gpt-5-1', labelTokens);
234
+ push('gpt5-1', labelTokens);
235
+ push('gpt51', labelTokens);
236
+ push('chatgpt 5.1', labelTokens);
237
+ testIdTokens.add('gpt-5-1');
238
+ testIdTokens.add('gpt5-1');
239
+ testIdTokens.add('gpt51');
240
+ }
241
+ // Numeric variations (5.0 ↔ 50 ↔ gpt-5-0)
242
+ if (base.includes('5.0') || base.includes('5-0') || base.includes('50')) {
243
+ push('5.0', labelTokens);
244
+ push('gpt-5.0', labelTokens);
245
+ push('gpt5.0', labelTokens);
246
+ push('gpt-5-0', labelTokens);
247
+ push('gpt5-0', labelTokens);
248
+ push('gpt50', labelTokens);
249
+ push('chatgpt 5.0', labelTokens);
250
+ testIdTokens.add('gpt-5-0');
251
+ testIdTokens.add('gpt5-0');
252
+ testIdTokens.add('gpt50');
253
+ }
254
+ // Pro / research variants
255
+ if (base.includes('pro')) {
256
+ push('proresearch', labelTokens);
257
+ push('research grade', labelTokens);
258
+ push('advanced reasoning', labelTokens);
259
+ if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
260
+ testIdTokens.add('gpt-5.1-pro');
261
+ testIdTokens.add('gpt-5-1-pro');
262
+ testIdTokens.add('gpt51pro');
263
+ }
264
+ if (base.includes('5.0') || base.includes('5-0') || base.includes('50')) {
265
+ testIdTokens.add('gpt-5.0-pro');
266
+ testIdTokens.add('gpt-5-0-pro');
267
+ testIdTokens.add('gpt50pro');
268
+ }
269
+ testIdTokens.add('pro');
270
+ testIdTokens.add('proresearch');
271
+ }
272
+ base
273
+ .split(/\s+/)
274
+ .map((token) => token.trim())
275
+ .filter(Boolean)
276
+ .forEach((token) => {
277
+ push(token, labelTokens);
278
+ });
279
+ const hyphenated = base.replace(/\s+/g, '-');
280
+ push(hyphenated, testIdTokens);
281
+ push(collapsed, testIdTokens);
282
+ push(dotless, testIdTokens);
283
+ // data-testid values observed in the ChatGPT picker (e.g., model-switcher-gpt-5.1-pro)
284
+ push(`model-switcher-${hyphenated}`, testIdTokens);
285
+ push(`model-switcher-${collapsed}`, testIdTokens);
286
+ push(`model-switcher-${dotless}`, testIdTokens);
287
+ if (!labelTokens.size) {
288
+ labelTokens.add(base);
289
+ }
290
+ if (!testIdTokens.size) {
291
+ testIdTokens.add(base.replace(/\s+/g, '-'));
292
+ }
293
+ return {
294
+ labelTokens: Array.from(labelTokens).filter(Boolean),
295
+ testIdTokens: Array.from(testIdTokens).filter(Boolean),
296
+ };
297
+ }
298
+ export function buildModelSelectionExpressionForTest(targetModel) {
299
+ return buildModelSelectionExpression(targetModel);
300
+ }
@@ -0,0 +1,175 @@
1
+ import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS, } from '../constants.js';
2
+ import { delay } from '../utils.js';
3
+ import { logDomFailure } from '../domDebug.js';
4
+ export async function navigateToChatGPT(Page, Runtime, url, logger) {
5
+ logger(`Navigating to ${url}`);
6
+ await Page.navigate({ url });
7
+ await waitForDocumentReady(Runtime, 45_000);
8
+ }
9
+ export async function ensureNotBlocked(Runtime, headless, logger) {
10
+ if (await isCloudflareInterstitial(Runtime)) {
11
+ const message = headless
12
+ ? 'Cloudflare challenge detected in headless mode. Re-run with --headful so you can solve the challenge.'
13
+ : 'Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.';
14
+ logger('Cloudflare anti-bot page detected');
15
+ throw new Error(message);
16
+ }
17
+ }
18
+ const LOGIN_CHECK_TIMEOUT_MS = 5_000;
19
+ export async function ensureLoggedIn(Runtime, logger, options = {}) {
20
+ const outcome = await Runtime.evaluate({
21
+ expression: buildLoginProbeExpression(LOGIN_CHECK_TIMEOUT_MS),
22
+ awaitPromise: true,
23
+ returnByValue: true,
24
+ });
25
+ const probe = normalizeLoginProbe(outcome.result?.value);
26
+ if (probe.ok && !probe.domLoginCta && !probe.onAuthPage) {
27
+ logger('Login check passed (no login button detected on page)');
28
+ return;
29
+ }
30
+ const domLabel = probe.domLoginCta ? ' Login button detected on page.' : '';
31
+ const cookieHint = options.remoteSession
32
+ ? 'The remote Chrome session is not signed into ChatGPT. Sign in there, then rerun.'
33
+ : (options.appliedCookies ?? 0) === 0
34
+ ? 'No ChatGPT cookies were applied; sign in to chatgpt.com in Chrome or pass inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).'
35
+ : 'ChatGPT login appears missing; open chatgpt.com in Chrome to refresh the session or provide inline cookies (--browser-inline-cookies[(-file)] / ORACLE_BROWSER_COOKIES_JSON).';
36
+ throw new Error(`ChatGPT session not detected.${domLabel} ${cookieHint}`);
37
+ }
38
+ export async function ensurePromptReady(Runtime, timeoutMs, logger) {
39
+ const ready = await waitForPrompt(Runtime, timeoutMs);
40
+ if (!ready) {
41
+ await logDomFailure(Runtime, logger, 'prompt-textarea');
42
+ throw new Error('Prompt textarea did not appear before timeout');
43
+ }
44
+ }
45
+ async function waitForDocumentReady(Runtime, timeoutMs) {
46
+ const start = Date.now();
47
+ while (Date.now() - start < timeoutMs) {
48
+ const { result } = await Runtime.evaluate({
49
+ expression: `document.readyState`,
50
+ returnByValue: true,
51
+ });
52
+ if (result?.value === 'complete' || result?.value === 'interactive') {
53
+ return;
54
+ }
55
+ await delay(100);
56
+ }
57
+ throw new Error('Page did not reach ready state in time');
58
+ }
59
+ async function waitForPrompt(Runtime, timeoutMs) {
60
+ const deadline = Date.now() + timeoutMs;
61
+ while (Date.now() < deadline) {
62
+ const { result } = await Runtime.evaluate({
63
+ expression: `(() => {
64
+ const selectors = ${JSON.stringify(INPUT_SELECTORS)};
65
+ for (const selector of selectors) {
66
+ const node = document.querySelector(selector);
67
+ if (node && !node.hasAttribute('disabled')) {
68
+ return true;
69
+ }
70
+ }
71
+ return false;
72
+ })()`,
73
+ returnByValue: true,
74
+ });
75
+ if (result?.value) {
76
+ return true;
77
+ }
78
+ await delay(200);
79
+ }
80
+ return false;
81
+ }
82
+ async function isCloudflareInterstitial(Runtime) {
83
+ const { result: titleResult } = await Runtime.evaluate({ expression: 'document.title', returnByValue: true });
84
+ const title = typeof titleResult.value === 'string' ? titleResult.value : '';
85
+ const challengeTitle = CLOUDFLARE_TITLE.toLowerCase();
86
+ if (title.toLowerCase().includes(challengeTitle)) {
87
+ return true;
88
+ }
89
+ const { result } = await Runtime.evaluate({
90
+ expression: `Boolean(document.querySelector('${CLOUDFLARE_SCRIPT_SELECTOR}'))`,
91
+ returnByValue: true,
92
+ });
93
+ return Boolean(result.value);
94
+ }
95
+ function buildLoginProbeExpression(timeoutMs) {
96
+ return `(() => {
97
+ const timer = setTimeout(() => {}, ${timeoutMs});
98
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
99
+ const onAuthPage =
100
+ typeof location === 'object' &&
101
+ typeof location.pathname === 'string' &&
102
+ /^\\/(auth|login|signin)/i.test(location.pathname);
103
+
104
+ const hasLoginCta = () => {
105
+ const candidates = Array.from(
106
+ document.querySelectorAll(
107
+ [
108
+ 'a[href*="/auth/login"]',
109
+ 'a[href*="/auth/signin"]',
110
+ 'button[type="submit"]',
111
+ 'button[data-testid*="login"]',
112
+ 'button[data-testid*="log-in"]',
113
+ 'button[data-testid*="sign-in"]',
114
+ 'button[data-testid*="signin"]',
115
+ 'button',
116
+ 'a',
117
+ ].join(','),
118
+ ),
119
+ );
120
+ const textMatches = (text) => {
121
+ if (!text) return false;
122
+ const normalized = text.toLowerCase().trim();
123
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
124
+ normalized.startsWith(needle),
125
+ );
126
+ };
127
+ for (const node of candidates) {
128
+ if (!(node instanceof HTMLElement)) continue;
129
+ const label =
130
+ node.textContent?.trim() ||
131
+ node.getAttribute('aria-label') ||
132
+ node.getAttribute('title') ||
133
+ '';
134
+ if (textMatches(label)) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ };
140
+
141
+ const domLoginCta = hasLoginCta();
142
+ clearTimeout(timer);
143
+ return {
144
+ ok: !domLoginCta && !onAuthPage,
145
+ status: 0,
146
+ redirected: false,
147
+ url: pageUrl,
148
+ pageUrl,
149
+ domLoginCta,
150
+ onAuthPage,
151
+ };
152
+ })()`;
153
+ }
154
+ function normalizeLoginProbe(raw) {
155
+ if (!raw || typeof raw !== 'object') {
156
+ return { ok: false, status: 0 };
157
+ }
158
+ const value = raw;
159
+ const statusRaw = value.status;
160
+ const status = typeof statusRaw === 'number'
161
+ ? statusRaw
162
+ : typeof statusRaw === 'string' && !Number.isNaN(Number(statusRaw))
163
+ ? Number(statusRaw)
164
+ : 0;
165
+ return {
166
+ ok: Boolean(value.ok),
167
+ status: Number.isFinite(status) ? status : 0,
168
+ url: typeof value.url === 'string' ? value.url : null,
169
+ redirected: Boolean(value.redirected),
170
+ error: typeof value.error === 'string' ? value.error : null,
171
+ pageUrl: typeof value.pageUrl === 'string' ? value.pageUrl : null,
172
+ domLoginCta: Boolean(value.domLoginCta),
173
+ onAuthPage: Boolean(value.onAuthPage),
174
+ };
175
+ }
@@ -0,0 +1,167 @@
1
+ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from '../constants.js';
2
+ import { delay } from '../utils.js';
3
+ import { logDomFailure } from '../domDebug.js';
4
+ export async function submitPrompt(deps, prompt, logger) {
5
+ const { runtime, input } = deps;
6
+ const encodedPrompt = JSON.stringify(prompt);
7
+ const focusResult = await runtime.evaluate({
8
+ expression: `(() => {
9
+ const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
10
+ const dispatchPointer = (target) => {
11
+ if (!(target instanceof HTMLElement)) {
12
+ return;
13
+ }
14
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
15
+ target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
16
+ }
17
+ };
18
+ const focusNode = (node) => {
19
+ if (!node) {
20
+ return false;
21
+ }
22
+ dispatchPointer(node);
23
+ if (typeof node.focus === 'function') {
24
+ node.focus();
25
+ }
26
+ const doc = node.ownerDocument;
27
+ const selection = doc?.getSelection?.();
28
+ if (selection) {
29
+ const range = doc.createRange();
30
+ range.selectNodeContents(node);
31
+ range.collapse(false);
32
+ selection.removeAllRanges();
33
+ selection.addRange(range);
34
+ }
35
+ return true;
36
+ };
37
+
38
+ for (const selector of SELECTORS) {
39
+ const node = document.querySelector(selector);
40
+ if (!node) continue;
41
+ if (focusNode(node)) {
42
+ return { focused: true };
43
+ }
44
+ }
45
+ return { focused: false };
46
+ })()`,
47
+ returnByValue: true,
48
+ awaitPromise: true,
49
+ });
50
+ if (!focusResult.result?.value?.focused) {
51
+ await logDomFailure(runtime, logger, 'focus-textarea');
52
+ throw new Error('Failed to focus prompt textarea');
53
+ }
54
+ await input.insertText({ text: prompt });
55
+ const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
56
+ const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
57
+ const verification = await runtime.evaluate({
58
+ expression: `(() => {
59
+ const editor = document.querySelector(${primarySelectorLiteral});
60
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
61
+ return {
62
+ editorText: editor?.innerText ?? '',
63
+ fallbackValue: fallback?.value ?? '',
64
+ };
65
+ })()`,
66
+ returnByValue: true,
67
+ });
68
+ const editorText = verification.result?.value?.editorText?.trim?.() ?? '';
69
+ const fallbackValue = verification.result?.value?.fallbackValue?.trim?.() ?? '';
70
+ if (!editorText && !fallbackValue) {
71
+ await runtime.evaluate({
72
+ expression: `(() => {
73
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
74
+ if (fallback) {
75
+ fallback.value = ${encodedPrompt};
76
+ fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
77
+ fallback.dispatchEvent(new Event('change', { bubbles: true }));
78
+ }
79
+ const editor = document.querySelector(${primarySelectorLiteral});
80
+ if (editor) {
81
+ editor.textContent = ${encodedPrompt};
82
+ }
83
+ })()`,
84
+ });
85
+ }
86
+ const clicked = await attemptSendButton(runtime);
87
+ if (!clicked) {
88
+ await input.dispatchKeyEvent({
89
+ type: 'rawKeyDown',
90
+ key: 'Enter',
91
+ code: 'Enter',
92
+ windowsVirtualKeyCode: 13,
93
+ nativeVirtualKeyCode: 13,
94
+ });
95
+ await input.dispatchKeyEvent({
96
+ type: 'keyUp',
97
+ key: 'Enter',
98
+ code: 'Enter',
99
+ windowsVirtualKeyCode: 13,
100
+ nativeVirtualKeyCode: 13,
101
+ });
102
+ logger('Submitted prompt via Enter key');
103
+ }
104
+ else {
105
+ logger('Clicked send button');
106
+ }
107
+ await verifyPromptCommitted(runtime, prompt, 30_000, logger);
108
+ }
109
+ async function attemptSendButton(Runtime) {
110
+ const script = `(() => {
111
+ const button = document.querySelector('${SEND_BUTTON_SELECTOR}');
112
+ if (!button) {
113
+ return 'missing';
114
+ }
115
+ const ariaDisabled = button.getAttribute('aria-disabled');
116
+ const disabled = button.hasAttribute('disabled') || ariaDisabled === 'true';
117
+ if (disabled || window.getComputedStyle(button).display === 'none') {
118
+ return 'disabled';
119
+ }
120
+ button.click();
121
+ return 'clicked';
122
+ })()`;
123
+ const deadline = Date.now() + 2_000;
124
+ while (Date.now() < deadline) {
125
+ const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
126
+ if (result.value === 'clicked') {
127
+ return true;
128
+ }
129
+ if (result.value === 'missing') {
130
+ break;
131
+ }
132
+ await delay(100);
133
+ }
134
+ return false;
135
+ }
136
+ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
137
+ const deadline = Date.now() + timeoutMs;
138
+ const encodedPrompt = JSON.stringify(prompt.trim());
139
+ const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
140
+ const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
141
+ const script = `(() => {
142
+ const editor = document.querySelector(${primarySelectorLiteral});
143
+ const fallback = document.querySelector(${fallbackSelectorLiteral});
144
+ const normalize = (value) => value?.toLowerCase?.().replace(/\\s+/g, ' ').trim() ?? '';
145
+ const normalizedPrompt = normalize(${encodedPrompt});
146
+ const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
147
+ const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
148
+ const userMatched = articles.some((node) => normalize(node?.innerText).includes(normalizedPrompt));
149
+ return {
150
+ userMatched,
151
+ fallbackValue: fallback?.value ?? '',
152
+ editorValue: editor?.innerText ?? '',
153
+ };
154
+ })()`;
155
+ while (Date.now() < deadline) {
156
+ const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
157
+ const info = result.value;
158
+ if (info?.userMatched) {
159
+ return;
160
+ }
161
+ await delay(100);
162
+ }
163
+ if (logger) {
164
+ await logDomFailure(Runtime, logger, 'prompt-commit');
165
+ }
166
+ throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
167
+ }