@indykish/oracle 0.9.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +1252 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/scripts/agent-send.js +147 -0
  7. package/dist/scripts/browser-tools.js +536 -0
  8. package/dist/scripts/check.js +21 -0
  9. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  10. package/dist/scripts/docs-list.js +110 -0
  11. package/dist/scripts/git-policy.js +125 -0
  12. package/dist/scripts/run-cli.js +14 -0
  13. package/dist/scripts/runner.js +1378 -0
  14. package/dist/scripts/test-browser.js +103 -0
  15. package/dist/scripts/test-remote-chrome.js +68 -0
  16. package/dist/src/bridge/connection.js +103 -0
  17. package/dist/src/bridge/userConfigFile.js +28 -0
  18. package/dist/src/browser/actions/assistantResponse.js +1067 -0
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
  20. package/dist/src/browser/actions/attachments.js +1910 -0
  21. package/dist/src/browser/actions/domEvents.js +19 -0
  22. package/dist/src/browser/actions/modelSelection.js +485 -0
  23. package/dist/src/browser/actions/navigation.js +445 -0
  24. package/dist/src/browser/actions/promptComposer.js +485 -0
  25. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  26. package/dist/src/browser/actions/thinkingTime.js +206 -0
  27. package/dist/src/browser/chromeLifecycle.js +344 -0
  28. package/dist/src/browser/config.js +103 -0
  29. package/dist/src/browser/constants.js +71 -0
  30. package/dist/src/browser/cookies.js +191 -0
  31. package/dist/src/browser/detect.js +164 -0
  32. package/dist/src/browser/domDebug.js +36 -0
  33. package/dist/src/browser/index.js +1741 -0
  34. package/dist/src/browser/modelStrategy.js +13 -0
  35. package/dist/src/browser/pageActions.js +5 -0
  36. package/dist/src/browser/policies.js +43 -0
  37. package/dist/src/browser/profileState.js +280 -0
  38. package/dist/src/browser/prompt.js +152 -0
  39. package/dist/src/browser/promptSummary.js +20 -0
  40. package/dist/src/browser/reattach.js +186 -0
  41. package/dist/src/browser/reattachHelpers.js +382 -0
  42. package/dist/src/browser/sessionRunner.js +119 -0
  43. package/dist/src/browser/types.js +1 -0
  44. package/dist/src/browser/utils.js +122 -0
  45. package/dist/src/browserMode.js +1 -0
  46. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  47. package/dist/src/cli/bridge/client.js +73 -0
  48. package/dist/src/cli/bridge/codexConfig.js +43 -0
  49. package/dist/src/cli/bridge/doctor.js +107 -0
  50. package/dist/src/cli/bridge/host.js +259 -0
  51. package/dist/src/cli/browserConfig.js +278 -0
  52. package/dist/src/cli/browserDefaults.js +81 -0
  53. package/dist/src/cli/bundleWarnings.js +9 -0
  54. package/dist/src/cli/clipboard.js +10 -0
  55. package/dist/src/cli/detach.js +11 -0
  56. package/dist/src/cli/dryRun.js +105 -0
  57. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  58. package/dist/src/cli/engine.js +41 -0
  59. package/dist/src/cli/errorUtils.js +9 -0
  60. package/dist/src/cli/format.js +13 -0
  61. package/dist/src/cli/help.js +77 -0
  62. package/dist/src/cli/hiddenAliases.js +22 -0
  63. package/dist/src/cli/markdownBundle.js +17 -0
  64. package/dist/src/cli/markdownRenderer.js +97 -0
  65. package/dist/src/cli/notifier.js +306 -0
  66. package/dist/src/cli/options.js +281 -0
  67. package/dist/src/cli/oscUtils.js +2 -0
  68. package/dist/src/cli/promptRequirement.js +17 -0
  69. package/dist/src/cli/renderFlags.js +9 -0
  70. package/dist/src/cli/renderOutput.js +26 -0
  71. package/dist/src/cli/rootAlias.js +30 -0
  72. package/dist/src/cli/runOptions.js +78 -0
  73. package/dist/src/cli/sessionCommand.js +111 -0
  74. package/dist/src/cli/sessionDisplay.js +567 -0
  75. package/dist/src/cli/sessionRunner.js +602 -0
  76. package/dist/src/cli/sessionTable.js +92 -0
  77. package/dist/src/cli/tagline.js +258 -0
  78. package/dist/src/cli/tui/index.js +486 -0
  79. package/dist/src/cli/writeOutputPath.js +21 -0
  80. package/dist/src/config.js +26 -0
  81. package/dist/src/gemini-web/client.js +328 -0
  82. package/dist/src/gemini-web/executor.js +285 -0
  83. package/dist/src/gemini-web/index.js +1 -0
  84. package/dist/src/gemini-web/types.js +1 -0
  85. package/dist/src/heartbeat.js +43 -0
  86. package/dist/src/mcp/server.js +40 -0
  87. package/dist/src/mcp/tools/consult.js +290 -0
  88. package/dist/src/mcp/tools/sessionResources.js +75 -0
  89. package/dist/src/mcp/tools/sessions.js +105 -0
  90. package/dist/src/mcp/types.js +22 -0
  91. package/dist/src/mcp/utils.js +37 -0
  92. package/dist/src/oracle/background.js +141 -0
  93. package/dist/src/oracle/claude.js +101 -0
  94. package/dist/src/oracle/client.js +197 -0
  95. package/dist/src/oracle/config.js +227 -0
  96. package/dist/src/oracle/errors.js +132 -0
  97. package/dist/src/oracle/files.js +378 -0
  98. package/dist/src/oracle/finishLine.js +32 -0
  99. package/dist/src/oracle/format.js +30 -0
  100. package/dist/src/oracle/fsAdapter.js +10 -0
  101. package/dist/src/oracle/gemini.js +195 -0
  102. package/dist/src/oracle/logging.js +36 -0
  103. package/dist/src/oracle/markdown.js +46 -0
  104. package/dist/src/oracle/modelResolver.js +183 -0
  105. package/dist/src/oracle/multiModelRunner.js +153 -0
  106. package/dist/src/oracle/oscProgress.js +24 -0
  107. package/dist/src/oracle/promptAssembly.js +13 -0
  108. package/dist/src/oracle/request.js +50 -0
  109. package/dist/src/oracle/run.js +596 -0
  110. package/dist/src/oracle/runUtils.js +31 -0
  111. package/dist/src/oracle/tokenEstimate.js +37 -0
  112. package/dist/src/oracle/tokenStats.js +39 -0
  113. package/dist/src/oracle/tokenStringifier.js +24 -0
  114. package/dist/src/oracle/types.js +1 -0
  115. package/dist/src/oracle.js +12 -0
  116. package/dist/src/oracleHome.js +13 -0
  117. package/dist/src/remote/client.js +129 -0
  118. package/dist/src/remote/health.js +113 -0
  119. package/dist/src/remote/remoteServiceConfig.js +31 -0
  120. package/dist/src/remote/server.js +533 -0
  121. package/dist/src/remote/types.js +1 -0
  122. package/dist/src/sessionManager.js +637 -0
  123. package/dist/src/sessionStore.js +56 -0
  124. package/dist/src/version.js +39 -0
  125. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  126. package/dist/vendor/oracle-notifier/README.md +24 -0
  127. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  128. package/package.json +115 -0
  129. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  130. package/vendor/oracle-notifier/README.md +24 -0
  131. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,19 @@
1
+ const CLICK_TYPES = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
2
+ export function buildClickDispatcher(functionName = 'dispatchClickSequence') {
3
+ const typesLiteral = JSON.stringify(CLICK_TYPES);
4
+ return `function ${functionName}(target){
5
+ if(!target || !(target instanceof EventTarget)) return false;
6
+ const types = ${typesLiteral};
7
+ for (const type of types) {
8
+ const common = { bubbles: true, cancelable: true, view: window };
9
+ let event;
10
+ if (type.startsWith('pointer') && 'PointerEvent' in window) {
11
+ event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
12
+ } else {
13
+ event = new MouseEvent(type, common);
14
+ }
15
+ target.dispatchEvent(event);
16
+ }
17
+ return true;
18
+ }`;
19
+ }
@@ -0,0 +1,485 @@
1
+ import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from '../constants.js';
2
+ import { logDomFailure } from '../domDebug.js';
3
+ import { buildClickDispatcher } from './domEvents.js';
4
+ export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = 'select') {
5
+ const outcome = await Runtime.evaluate({
6
+ expression: buildModelSelectionExpression(desiredModel, strategy),
7
+ awaitPromise: true,
8
+ returnByValue: true,
9
+ });
10
+ const result = outcome.result?.value;
11
+ switch (result?.status) {
12
+ case 'already-selected':
13
+ case 'switched':
14
+ case 'switched-best-effort': {
15
+ const label = result.label ?? desiredModel;
16
+ logger(`Model picker: ${label}`);
17
+ return;
18
+ }
19
+ case 'option-not-found': {
20
+ await logDomFailure(Runtime, logger, 'model-switcher-option');
21
+ const isTemporary = result.hint?.temporaryChat ?? false;
22
+ const available = (result.hint?.availableOptions ?? []).filter(Boolean);
23
+ const availableHint = available.length > 0 ? ` Available: ${available.join(', ')}.` : '';
24
+ const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
25
+ ? ' You are in Temporary Chat mode; Pro models are not available there. Remove "temporary-chat=true" from --chatgpt-url or use a non-Pro model (e.g. gpt-5.2).'
26
+ : '';
27
+ throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.${availableHint}${tempHint}`);
28
+ }
29
+ default: {
30
+ await logDomFailure(Runtime, logger, 'model-switcher-button');
31
+ throw new Error('Unable to locate the ChatGPT model selector button.');
32
+ }
33
+ }
34
+ }
35
+ /**
36
+ * Builds the DOM expression that runs inside the ChatGPT tab to select a model.
37
+ * The string is evaluated inside Chrome, so keep it self-contained and well-commented.
38
+ */
39
+ function buildModelSelectionExpression(targetModel, strategy) {
40
+ const matchers = buildModelMatchersLiteral(targetModel);
41
+ const labelLiteral = JSON.stringify(matchers.labelTokens);
42
+ const idLiteral = JSON.stringify(matchers.testIdTokens);
43
+ const primaryLabelLiteral = JSON.stringify(targetModel);
44
+ const strategyLiteral = JSON.stringify(strategy);
45
+ const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
46
+ const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
47
+ return `(() => {
48
+ ${buildClickDispatcher()}
49
+ // Capture the selectors and matcher literals up front so the browser expression stays pure.
50
+ const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}';
51
+ const LABEL_TOKENS = ${labelLiteral};
52
+ const TEST_IDS = ${idLiteral};
53
+ const PRIMARY_LABEL = ${primaryLabelLiteral};
54
+ const MODEL_STRATEGY = ${strategyLiteral};
55
+ const INITIAL_WAIT_MS = 150;
56
+ const REOPEN_INTERVAL_MS = 400;
57
+ const MAX_WAIT_MS = 20000;
58
+ const normalizeText = (value) => {
59
+ if (!value) {
60
+ return '';
61
+ }
62
+ return value
63
+ .toLowerCase()
64
+ .replace(/[^a-z0-9]+/g, ' ')
65
+ .replace(/\\s+/g, ' ')
66
+ .trim();
67
+ };
68
+ // Normalize every candidate token to keep fuzzy matching deterministic.
69
+ const normalizedTarget = normalizeText(PRIMARY_LABEL);
70
+ const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
71
+ .map((token) => normalizeText(token))
72
+ .filter(Boolean);
73
+ const targetWords = normalizedTarget.split(' ').filter(Boolean);
74
+ const desiredVersion = normalizedTarget.includes('5 2')
75
+ ? '5-2'
76
+ : normalizedTarget.includes('5 1')
77
+ ? '5-1'
78
+ : normalizedTarget.includes('5 0')
79
+ ? '5-0'
80
+ : null;
81
+ const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
82
+ const wantsInstant = normalizedTarget.includes('instant');
83
+ const wantsThinking = normalizedTarget.includes('thinking');
84
+
85
+ const button = document.querySelector(BUTTON_SELECTOR);
86
+ if (!button) {
87
+ return { status: 'button-missing' };
88
+ }
89
+
90
+ const getButtonLabel = () => (button.textContent ?? '').trim();
91
+ if (MODEL_STRATEGY === 'current') {
92
+ return { status: 'already-selected', label: getButtonLabel() };
93
+ }
94
+ const buttonMatchesTarget = () => {
95
+ const normalizedLabel = normalizeText(getButtonLabel());
96
+ if (!normalizedLabel) return false;
97
+ if (desiredVersion) {
98
+ if (desiredVersion === '5-2' && !normalizedLabel.includes('5 2')) return false;
99
+ if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
100
+ if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
101
+ }
102
+ if (wantsPro && !normalizedLabel.includes(' pro')) return false;
103
+ if (wantsInstant && !normalizedLabel.includes('instant')) return false;
104
+ if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
105
+ // Also reject if button has variants we DON'T want
106
+ if (!wantsPro && normalizedLabel.includes(' pro')) return false;
107
+ if (!wantsInstant && normalizedLabel.includes('instant')) return false;
108
+ if (!wantsThinking && normalizedLabel.includes('thinking')) return false;
109
+ return true;
110
+ };
111
+
112
+ if (buttonMatchesTarget()) {
113
+ return { status: 'already-selected', label: getButtonLabel() };
114
+ }
115
+
116
+ let lastPointerClick = 0;
117
+ const pointerClick = () => {
118
+ if (dispatchClickSequence(button)) {
119
+ lastPointerClick = performance.now();
120
+ }
121
+ };
122
+
123
+ const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
124
+ const optionIsSelected = (node) => {
125
+ if (!(node instanceof HTMLElement)) {
126
+ return false;
127
+ }
128
+ const ariaChecked = node.getAttribute('aria-checked');
129
+ const ariaSelected = node.getAttribute('aria-selected');
130
+ const ariaCurrent = node.getAttribute('aria-current');
131
+ const dataSelected = node.getAttribute('data-selected');
132
+ const dataState = (node.getAttribute('data-state') ?? '').toLowerCase();
133
+ const selectedStates = ['checked', 'selected', 'on', 'true'];
134
+ if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') {
135
+ return true;
136
+ }
137
+ if (dataSelected === 'true' || selectedStates.includes(dataState)) {
138
+ return true;
139
+ }
140
+ if (node.querySelector('[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"]')) {
141
+ return true;
142
+ }
143
+ return false;
144
+ };
145
+
146
+ const scoreOption = (normalizedText, testid) => {
147
+ // Assign a score to every node so we can pick the most likely match without brittle equality checks.
148
+ if (!normalizedText && !testid) {
149
+ return 0;
150
+ }
151
+ let score = 0;
152
+ const normalizedTestId = (testid ?? '').toLowerCase();
153
+ if (normalizedTestId) {
154
+ if (desiredVersion) {
155
+ // data-testid strings have been observed with both dotted and dashed versions (e.g. gpt-5.2-pro vs gpt-5-2-pro).
156
+ const has52 =
157
+ normalizedTestId.includes('5-2') ||
158
+ normalizedTestId.includes('5.2') ||
159
+ normalizedTestId.includes('gpt-5-2') ||
160
+ normalizedTestId.includes('gpt-5.2') ||
161
+ normalizedTestId.includes('gpt52');
162
+ const has51 =
163
+ normalizedTestId.includes('5-1') ||
164
+ normalizedTestId.includes('5.1') ||
165
+ normalizedTestId.includes('gpt-5-1') ||
166
+ normalizedTestId.includes('gpt-5.1') ||
167
+ normalizedTestId.includes('gpt51');
168
+ const has50 =
169
+ normalizedTestId.includes('5-0') ||
170
+ normalizedTestId.includes('5.0') ||
171
+ normalizedTestId.includes('gpt-5-0') ||
172
+ normalizedTestId.includes('gpt-5.0') ||
173
+ normalizedTestId.includes('gpt50');
174
+ const candidateVersion = has52 ? '5-2' : has51 ? '5-1' : has50 ? '5-0' : null;
175
+ // If a candidate advertises a different version, ignore it entirely.
176
+ if (candidateVersion && candidateVersion !== desiredVersion) {
177
+ return 0;
178
+ }
179
+ // When targeting an explicit version, avoid selecting submenu wrappers that can contain legacy models.
180
+ if (normalizedTestId.includes('submenu') && candidateVersion === null) {
181
+ return 0;
182
+ }
183
+ }
184
+ // Exact testid matches take priority over substring matches
185
+ const exactMatch = TEST_IDS.find((id) => id && normalizedTestId === id);
186
+ if (exactMatch) {
187
+ score += 1500;
188
+ if (exactMatch.startsWith('model-switcher-')) score += 200;
189
+ } else {
190
+ const matches = TEST_IDS.filter((id) => id && normalizedTestId.includes(id));
191
+ if (matches.length > 0) {
192
+ // Prefer the most specific match (longest token) instead of treating any hit as equal.
193
+ // This prevents generic tokens (e.g. "pro") from outweighing version-specific targets.
194
+ const best = matches.reduce((acc, token) => (token.length > acc.length ? token : acc), '');
195
+ score += 200 + Math.min(900, best.length * 25);
196
+ if (best.startsWith('model-switcher-')) score += 120;
197
+ if (best.includes('gpt-')) score += 60;
198
+ }
199
+ }
200
+ }
201
+ if (normalizedText && normalizedTarget) {
202
+ if (normalizedText === normalizedTarget) {
203
+ score += 500;
204
+ } else if (normalizedText.startsWith(normalizedTarget)) {
205
+ score += 420;
206
+ } else if (normalizedText.includes(normalizedTarget)) {
207
+ score += 380;
208
+ }
209
+ }
210
+ for (const token of normalizedTokens) {
211
+ // Reward partial matches to the expanded label/token set.
212
+ if (token && normalizedText.includes(token)) {
213
+ const tokenWeight = Math.min(120, Math.max(10, token.length * 4));
214
+ score += tokenWeight;
215
+ }
216
+ }
217
+ if (targetWords.length > 1) {
218
+ let missing = 0;
219
+ for (const word of targetWords) {
220
+ if (!normalizedText.includes(word)) {
221
+ missing += 1;
222
+ }
223
+ }
224
+ score -= missing * 12;
225
+ }
226
+ // If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
227
+ if (wantsPro) {
228
+ if (!normalizedText.includes(' pro')) {
229
+ score -= 80;
230
+ }
231
+ } else if (normalizedText.includes(' pro')) {
232
+ score -= 40;
233
+ }
234
+ // Similarly for Thinking variant
235
+ if (wantsThinking) {
236
+ if (!normalizedText.includes('thinking') && !normalizedTestId.includes('thinking')) {
237
+ score -= 80;
238
+ }
239
+ } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
240
+ score -= 40;
241
+ }
242
+ // Similarly for Instant variant
243
+ if (wantsInstant) {
244
+ if (!normalizedText.includes('instant') && !normalizedTestId.includes('instant')) {
245
+ score -= 80;
246
+ }
247
+ } else if (normalizedText.includes('instant') || normalizedTestId.includes('instant')) {
248
+ score -= 40;
249
+ }
250
+ return Math.max(score, 0);
251
+ };
252
+
253
+ const findBestOption = () => {
254
+ // Walk through every menu item and keep whichever earns the highest score.
255
+ let bestMatch = null;
256
+ const menus = Array.from(document.querySelectorAll(${menuContainerLiteral}));
257
+ for (const menu of menus) {
258
+ const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
259
+ for (const option of buttons) {
260
+ const text = option.textContent ?? '';
261
+ const normalizedText = normalizeText(text);
262
+ const testid = option.getAttribute('data-testid') ?? '';
263
+ const score = scoreOption(normalizedText, testid);
264
+ if (score <= 0) {
265
+ continue;
266
+ }
267
+ const label = getOptionLabel(option);
268
+ if (!bestMatch || score > bestMatch.score) {
269
+ bestMatch = { node: option, label, score, testid, normalizedText };
270
+ }
271
+ }
272
+ }
273
+ return bestMatch;
274
+ };
275
+
276
+ return new Promise((resolve) => {
277
+ const start = performance.now();
278
+ const detectTemporaryChat = () => {
279
+ try {
280
+ const url = new URL(window.location.href);
281
+ const flag = (url.searchParams.get('temporary-chat') ?? '').toLowerCase();
282
+ if (flag === 'true' || flag === '1' || flag === 'yes') return true;
283
+ } catch {}
284
+ const title = (document.title || '').toLowerCase();
285
+ if (title.includes('temporary chat')) return true;
286
+ const body = (document.body?.innerText || '').toLowerCase();
287
+ return body.includes('temporary chat');
288
+ };
289
+ const collectAvailableOptions = () => {
290
+ const menuRoots = Array.from(document.querySelectorAll(${menuContainerLiteral}));
291
+ const nodes = menuRoots.length > 0
292
+ ? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(${menuItemLiteral})))
293
+ : Array.from(document.querySelectorAll(${menuItemLiteral}));
294
+ const labels = nodes
295
+ .map((node) => (node?.textContent ?? '').trim())
296
+ .filter(Boolean)
297
+ .filter((label, index, arr) => arr.indexOf(label) === index);
298
+ return labels.slice(0, 12);
299
+ };
300
+ const ensureMenuOpen = () => {
301
+ const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
302
+ if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
303
+ pointerClick();
304
+ }
305
+ };
306
+
307
+ // Open once and wait a tick before first scan.
308
+ pointerClick();
309
+ const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
310
+ let initialized = false;
311
+ const attempt = async () => {
312
+ if (!initialized) {
313
+ initialized = true;
314
+ await openDelay();
315
+ }
316
+ ensureMenuOpen();
317
+ const match = findBestOption();
318
+ if (match) {
319
+ if (optionIsSelected(match.node)) {
320
+ resolve({ status: 'already-selected', label: getButtonLabel() || match.label });
321
+ return;
322
+ }
323
+ dispatchClickSequence(match.node);
324
+ // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
325
+ // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
326
+ const isSubmenu = (match.testid ?? '').toLowerCase().includes('submenu');
327
+ if (isSubmenu) {
328
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
329
+ return;
330
+ }
331
+ // Wait for the top bar label to reflect the requested model; otherwise keep scanning.
332
+ setTimeout(() => {
333
+ if (buttonMatchesTarget()) {
334
+ resolve({ status: 'switched', label: getButtonLabel() || match.label });
335
+ return;
336
+ }
337
+ attempt();
338
+ }, Math.max(120, INITIAL_WAIT_MS));
339
+ return;
340
+ }
341
+ if (performance.now() - start > MAX_WAIT_MS) {
342
+ resolve({
343
+ status: 'option-not-found',
344
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
345
+ });
346
+ return;
347
+ }
348
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
349
+ };
350
+ attempt();
351
+ });
352
+ })()`;
353
+ }
354
+ export function buildModelMatchersLiteralForTest(targetModel) {
355
+ return buildModelMatchersLiteral(targetModel);
356
+ }
357
+ function buildModelMatchersLiteral(targetModel) {
358
+ const base = targetModel.trim().toLowerCase();
359
+ const labelTokens = new Set();
360
+ const testIdTokens = new Set();
361
+ const push = (value, set) => {
362
+ const normalized = value?.trim();
363
+ if (normalized) {
364
+ set.add(normalized);
365
+ }
366
+ };
367
+ push(base, labelTokens);
368
+ push(base.replace(/\s+/g, ' '), labelTokens);
369
+ const collapsed = base.replace(/\s+/g, '');
370
+ push(collapsed, labelTokens);
371
+ const dotless = base.replace(/[.]/g, '');
372
+ push(dotless, labelTokens);
373
+ push(`chatgpt ${base}`, labelTokens);
374
+ push(`chatgpt ${dotless}`, labelTokens);
375
+ push(`gpt ${base}`, labelTokens);
376
+ push(`gpt ${dotless}`, labelTokens);
377
+ // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
378
+ if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
379
+ push('5.1', labelTokens);
380
+ push('gpt-5.1', labelTokens);
381
+ push('gpt5.1', labelTokens);
382
+ push('gpt-5-1', labelTokens);
383
+ push('gpt5-1', labelTokens);
384
+ push('gpt51', labelTokens);
385
+ push('chatgpt 5.1', labelTokens);
386
+ testIdTokens.add('gpt-5-1');
387
+ testIdTokens.add('gpt5-1');
388
+ testIdTokens.add('gpt51');
389
+ }
390
+ // Numeric variations (5.0 ↔ 50 ↔ gpt-5-0)
391
+ if (base.includes('5.0') || base.includes('5-0') || base.includes('50')) {
392
+ push('5.0', labelTokens);
393
+ push('gpt-5.0', labelTokens);
394
+ push('gpt5.0', labelTokens);
395
+ push('gpt-5-0', labelTokens);
396
+ push('gpt5-0', labelTokens);
397
+ push('gpt50', labelTokens);
398
+ push('chatgpt 5.0', labelTokens);
399
+ testIdTokens.add('gpt-5-0');
400
+ testIdTokens.add('gpt5-0');
401
+ testIdTokens.add('gpt50');
402
+ }
403
+ // Numeric variations (5.2 ↔ 52 ↔ gpt-5-2)
404
+ if (base.includes('5.2') || base.includes('5-2') || base.includes('52')) {
405
+ push('5.2', labelTokens);
406
+ push('gpt-5.2', labelTokens);
407
+ push('gpt5.2', labelTokens);
408
+ push('gpt-5-2', labelTokens);
409
+ push('gpt5-2', labelTokens);
410
+ push('gpt52', labelTokens);
411
+ push('chatgpt 5.2', labelTokens);
412
+ // Thinking variant: explicit testid for "Thinking" picker option
413
+ if (base.includes('thinking')) {
414
+ push('thinking', labelTokens);
415
+ testIdTokens.add('model-switcher-gpt-5-2-thinking');
416
+ testIdTokens.add('gpt-5-2-thinking');
417
+ testIdTokens.add('gpt-5.2-thinking');
418
+ }
419
+ // Instant variant: explicit testid for "Instant" picker option
420
+ if (base.includes('instant')) {
421
+ push('instant', labelTokens);
422
+ testIdTokens.add('model-switcher-gpt-5-2-instant');
423
+ testIdTokens.add('gpt-5-2-instant');
424
+ testIdTokens.add('gpt-5.2-instant');
425
+ }
426
+ // Base 5.2 testids (for "Auto" mode when no suffix specified)
427
+ if (!base.includes('thinking') && !base.includes('instant') && !base.includes('pro')) {
428
+ testIdTokens.add('model-switcher-gpt-5-2');
429
+ }
430
+ testIdTokens.add('gpt-5-2');
431
+ testIdTokens.add('gpt5-2');
432
+ testIdTokens.add('gpt52');
433
+ }
434
+ // Pro / research variants
435
+ if (base.includes('pro')) {
436
+ push('proresearch', labelTokens);
437
+ push('research grade', labelTokens);
438
+ push('advanced reasoning', labelTokens);
439
+ if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
440
+ testIdTokens.add('gpt-5.1-pro');
441
+ testIdTokens.add('gpt-5-1-pro');
442
+ testIdTokens.add('gpt51pro');
443
+ }
444
+ if (base.includes('5.0') || base.includes('5-0') || base.includes('50')) {
445
+ testIdTokens.add('gpt-5.0-pro');
446
+ testIdTokens.add('gpt-5-0-pro');
447
+ testIdTokens.add('gpt50pro');
448
+ }
449
+ if (base.includes('5.2') || base.includes('5-2') || base.includes('52')) {
450
+ testIdTokens.add('gpt-5.2-pro');
451
+ testIdTokens.add('gpt-5-2-pro');
452
+ testIdTokens.add('gpt52pro');
453
+ }
454
+ testIdTokens.add('pro');
455
+ testIdTokens.add('proresearch');
456
+ }
457
+ base
458
+ .split(/\s+/)
459
+ .map((token) => token.trim())
460
+ .filter(Boolean)
461
+ .forEach((token) => {
462
+ push(token, labelTokens);
463
+ });
464
+ const hyphenated = base.replace(/\s+/g, '-');
465
+ push(hyphenated, testIdTokens);
466
+ push(collapsed, testIdTokens);
467
+ push(dotless, testIdTokens);
468
+ // data-testid values observed in the ChatGPT picker (e.g., model-switcher-gpt-5.1-pro)
469
+ push(`model-switcher-${hyphenated}`, testIdTokens);
470
+ push(`model-switcher-${collapsed}`, testIdTokens);
471
+ push(`model-switcher-${dotless}`, testIdTokens);
472
+ if (!labelTokens.size) {
473
+ labelTokens.add(base);
474
+ }
475
+ if (!testIdTokens.size) {
476
+ testIdTokens.add(base.replace(/\s+/g, '-'));
477
+ }
478
+ return {
479
+ labelTokens: Array.from(labelTokens).filter(Boolean),
480
+ testIdTokens: Array.from(testIdTokens).filter(Boolean),
481
+ };
482
+ }
483
+ export function buildModelSelectionExpressionForTest(targetModel) {
484
+ return buildModelSelectionExpression(targetModel, 'select');
485
+ }