ask-pro 0.1.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 (55) hide show
  1. package/.codex-plugin/plugin.json +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +231 -0
  4. package/assets/ask-pro_logo.png +0 -0
  5. package/dist/bin/ask-pro-cli.js +507 -0
  6. package/dist/scripts/run-cli.js +27 -0
  7. package/dist/src/ask-pro/atomicWrite.js +26 -0
  8. package/dist/src/ask-pro/browserRunner.js +796 -0
  9. package/dist/src/ask-pro/responseZip.js +349 -0
  10. package/dist/src/ask-pro/session.js +662 -0
  11. package/dist/src/ask-pro/sessionControllerLease.js +64 -0
  12. package/dist/src/ask-pro/toon.js +26 -0
  13. package/dist/src/ask-pro/zip.js +85 -0
  14. package/dist/src/browser/actions/assistantResponse.js +1245 -0
  15. package/dist/src/browser/actions/attachmentDataTransfer.js +140 -0
  16. package/dist/src/browser/actions/attachments.js +1720 -0
  17. package/dist/src/browser/actions/composerSendReadiness.js +369 -0
  18. package/dist/src/browser/actions/domEvents.js +31 -0
  19. package/dist/src/browser/actions/inputGuard.js +52 -0
  20. package/dist/src/browser/actions/modelPickerDom.js +68 -0
  21. package/dist/src/browser/actions/modelSelection.js +576 -0
  22. package/dist/src/browser/actions/navigation.js +510 -0
  23. package/dist/src/browser/actions/promptComposer.js +824 -0
  24. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  25. package/dist/src/browser/actions/thinkingStatus.js +408 -0
  26. package/dist/src/browser/actions/thinkingTime.js +635 -0
  27. package/dist/src/browser/actions/windowState.js +47 -0
  28. package/dist/src/browser/attachRunning.js +31 -0
  29. package/dist/src/browser/chatgptModelCatalog.js +321 -0
  30. package/dist/src/browser/chromeLifecycle.js +807 -0
  31. package/dist/src/browser/config.js +110 -0
  32. package/dist/src/browser/constants.js +85 -0
  33. package/dist/src/browser/cookies.js +191 -0
  34. package/dist/src/browser/detect.js +337 -0
  35. package/dist/src/browser/domDebug.js +72 -0
  36. package/dist/src/browser/errors.js +20 -0
  37. package/dist/src/browser/format.js +16 -0
  38. package/dist/src/browser/index.js +2631 -0
  39. package/dist/src/browser/language.js +97 -0
  40. package/dist/src/browser/liveTabs.js +434 -0
  41. package/dist/src/browser/modelStrategy.js +13 -0
  42. package/dist/src/browser/pageActions.js +5 -0
  43. package/dist/src/browser/profilePaths.js +282 -0
  44. package/dist/src/browser/profileState.js +413 -0
  45. package/dist/src/browser/providerDomFlow.js +17 -0
  46. package/dist/src/browser/providers/chatgptDomProvider.js +50 -0
  47. package/dist/src/browser/reattach.js +534 -0
  48. package/dist/src/browser/reattachHelpers.js +387 -0
  49. package/dist/src/browser/utils.js +122 -0
  50. package/dist/src/browserMode.js +1 -0
  51. package/dist/src/version.js +39 -0
  52. package/package.json +114 -0
  53. package/scripts/refresh-local-plugin.mjs +179 -0
  54. package/scripts/refresh-local-plugin.ps1 +93 -0
  55. package/skills/ask-pro/SKILL.md +181 -0
@@ -0,0 +1,576 @@
1
+ import { INPUT_SELECTORS, 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
+ import { buildChatGptModelMatchers } from "../chatgptModelCatalog.js";
5
+ import { buildModelPickerDomHelpers } from "./modelPickerDom.js";
6
+ export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
7
+ const outcome = await Runtime.evaluate({
8
+ expression: buildModelSelectionExpression(desiredModel, strategy),
9
+ awaitPromise: true,
10
+ returnByValue: true,
11
+ });
12
+ const result = outcome.result?.value;
13
+ switch (result?.status) {
14
+ case "already-selected":
15
+ case "switched": {
16
+ const label = result.label ?? desiredModel;
17
+ logger(`Model picker: ${label}`);
18
+ return;
19
+ }
20
+ case "option-not-found": {
21
+ await logDomFailure(Runtime, logger, "model-switcher-option");
22
+ const isTemporary = result.hint?.temporaryChat ?? false;
23
+ const available = (result.hint?.availableOptions ?? []).filter(Boolean);
24
+ const availableHint = available.length > 0 ? ` Available: ${available.join(", ")}.` : "";
25
+ const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
26
+ ? " Temporary Chat mode is active; verify the model picker exposes Pro in the current account/UI."
27
+ : "";
28
+ throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.${availableHint}${tempHint}`);
29
+ }
30
+ default: {
31
+ await logDomFailure(Runtime, logger, "model-switcher-button");
32
+ const isTemporary = result?.hint?.temporaryChat ?? false;
33
+ const tempHint = isTemporary && /\bpro\b/i.test(desiredModel)
34
+ ? " Temporary Chat mode is active; verify the model picker exposes Pro in the current account/UI."
35
+ : "";
36
+ throw new Error(`Unable to locate the ChatGPT model selector button.${tempHint}`);
37
+ }
38
+ }
39
+ }
40
+ /**
41
+ * Builds the DOM expression that runs inside the ChatGPT tab to select a model.
42
+ * The string is evaluated inside Chrome, so keep it self-contained and well-commented.
43
+ */
44
+ function buildModelSelectionExpression(targetModel, strategy) {
45
+ const matchers = buildModelMatchersLiteral(targetModel);
46
+ const labelLiteral = JSON.stringify(matchers.labelTokens);
47
+ const idLiteral = JSON.stringify(matchers.testIdTokens);
48
+ const targetVersionLiteral = JSON.stringify(matchers.targetVersion);
49
+ const targetKindLiteral = JSON.stringify(matchers.targetKind);
50
+ const visibleAliasesLiteral = JSON.stringify(matchers.visibleAliases);
51
+ const versionPatternsLiteral = JSON.stringify(matchers.versionPatterns);
52
+ const primaryLabelLiteral = JSON.stringify(targetModel);
53
+ const strategyLiteral = JSON.stringify(strategy);
54
+ const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
55
+ const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
56
+ const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
57
+ const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
58
+ return `(async () => {
59
+ ${buildClickDispatcher()}
60
+ // Capture the selectors and matcher literals up front so the browser expression stays pure.
61
+ const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
62
+ const INPUT_SELECTORS = ${inputSelectorsLiteral};
63
+ const LABEL_TOKENS = ${labelLiteral};
64
+ const TEST_IDS = ${idLiteral};
65
+ const TARGET_VERSION = ${targetVersionLiteral};
66
+ const TARGET_KIND = ${targetKindLiteral};
67
+ const VISIBLE_ALIASES = ${visibleAliasesLiteral};
68
+ const VERSION_PATTERNS = ${versionPatternsLiteral};
69
+ const PRIMARY_LABEL = ${primaryLabelLiteral};
70
+ const MODEL_STRATEGY = ${strategyLiteral};
71
+ const INITIAL_WAIT_MS = 150;
72
+ const REOPEN_INTERVAL_MS = 400;
73
+ const MAX_WAIT_MS = 20000;
74
+ const MODEL_BUTTON_MOUNT_WAIT_MS = 6000;
75
+ const normalize = (value) => {
76
+ if (!value) {
77
+ return '';
78
+ }
79
+ return value
80
+ .toLowerCase()
81
+ .normalize('NFD')
82
+ .replace(/[\\u0300-\\u036f]/g, '')
83
+ .replace(/[^a-z0-9]+/g, ' ')
84
+ .replace(/\\s+/g, ' ')
85
+ .trim();
86
+ };
87
+ // Normalize every candidate token to keep fuzzy matching deterministic.
88
+ const normalizedTarget = normalize(PRIMARY_LABEL);
89
+ const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
90
+ .map((token) => normalize(token))
91
+ .filter(Boolean);
92
+ const targetWords = normalizedTarget.split(' ').filter(Boolean);
93
+ const desiredVersion = TARGET_VERSION;
94
+ const wantsPro = TARGET_KIND === 'pro' || normalizedTokens.includes('pro');
95
+ const wantsInstant = TARGET_KIND === 'instant' || normalizedTokens.includes('instant');
96
+ const wantsThinking = TARGET_KIND === 'thinking' || normalizedTokens.includes('thinking');
97
+ const labelHasToken = (label, token) => {
98
+ if (!token) return true;
99
+ const normalizedToken = normalize(token);
100
+ if (!normalizedToken) return true;
101
+ if (/^[a-z0-9]+$/.test(normalizedToken)) {
102
+ return label
103
+ .split(' ')
104
+ .some((word) => word === normalizedToken || (word.startsWith(normalizedToken) && /^\\d/.test(word.slice(normalizedToken.length))));
105
+ }
106
+ return label.includes(normalizedToken);
107
+ };
108
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
109
+ const matchesVisibleAlias = (value) => {
110
+ const label = normalize(value);
111
+ return VISIBLE_ALIASES.some((alias) => {
112
+ const includes = alias.includes || [];
113
+ const excludes = alias.excludes || [];
114
+ return includes.every((token) => labelHasToken(label, token)) && excludes.every((token) => !labelHasToken(label, token));
115
+ });
116
+ };
117
+ const versionFromText = (value) => {
118
+ if (!value) return null;
119
+ for (const pattern of VERSION_PATTERNS) {
120
+ if ((pattern.textTokens || []).some((token) => value.includes(token))) {
121
+ return pattern.version;
122
+ }
123
+ }
124
+ return null;
125
+ };
126
+ const versionFromTestId = (value) => {
127
+ if (!value) return null;
128
+ for (const pattern of VERSION_PATTERNS) {
129
+ if ((pattern.testIdTokens || []).some((token) => value.includes(token))) {
130
+ return pattern.version;
131
+ }
132
+ }
133
+ return null;
134
+ };
135
+ ${buildModelPickerDomHelpers()}
136
+ const readComposerValue = (node) => {
137
+ if (!node) return '';
138
+ if (typeof HTMLTextAreaElement !== 'undefined' && node instanceof HTMLTextAreaElement) {
139
+ return node.value ?? '';
140
+ }
141
+ return node.innerText ?? node.textContent ?? '';
142
+ };
143
+ const writeComposerValue = (node, value, inputType, data) => {
144
+ if (!node) return;
145
+ if (typeof HTMLTextAreaElement !== 'undefined' && node instanceof HTMLTextAreaElement) {
146
+ node.value = value;
147
+ } else {
148
+ node.textContent = value;
149
+ }
150
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType, data }));
151
+ node.dispatchEvent(new Event('change', { bubbles: true }));
152
+ };
153
+ const findComposerInput = () => {
154
+ const candidates = INPUT_SELECTORS
155
+ .map((selector) => document.querySelector(selector))
156
+ .filter(Boolean);
157
+ return candidates.find((node) => {
158
+ if (!(node instanceof HTMLElement)) return false;
159
+ const rect = node.getBoundingClientRect();
160
+ return rect.width > 0 && rect.height > 0;
161
+ }) || candidates[0] || null;
162
+ };
163
+ const wakeHiddenModelButton = async () => {
164
+ const input = findComposerInput();
165
+ if (!(input instanceof HTMLElement)) return null;
166
+ dispatchClickSequence(input);
167
+ input.focus?.();
168
+ const before = readComposerValue(input);
169
+ if (before.trim()) {
170
+ await sleep(250);
171
+ return { button: findModelButton(), restore: null };
172
+ }
173
+ const draft = 'ask-pro model selection';
174
+ writeComposerValue(input, draft, 'insertText', draft);
175
+ await sleep(500);
176
+ return {
177
+ button: findModelButton(),
178
+ restore: async () => {
179
+ writeComposerValue(input, before, 'deleteByCut', null);
180
+ await sleep(150);
181
+ },
182
+ };
183
+ };
184
+ const detectTemporaryChat = () => {
185
+ try {
186
+ const url = new URL(window.location.href);
187
+ const flag = (url.searchParams.get('temporary-chat') ?? '').toLowerCase();
188
+ if (flag === 'true' || flag === '1' || flag === 'yes') return true;
189
+ } catch {}
190
+ const temporaryControls = Array.from(document.querySelectorAll('button, [role="button"], input[type="checkbox"]'));
191
+ return temporaryControls.some((node) => {
192
+ const label = [
193
+ node.getAttribute?.('aria-label') ?? '',
194
+ node.getAttribute?.('title') ?? '',
195
+ node.textContent ?? '',
196
+ ].join(' ').toLowerCase();
197
+ const pressed = (node.getAttribute?.('aria-pressed') ?? '').toLowerCase();
198
+ const checked = (node.getAttribute?.('aria-checked') ?? '').toLowerCase();
199
+ const inputChecked =
200
+ typeof HTMLInputElement !== 'undefined' &&
201
+ node instanceof HTMLInputElement &&
202
+ node.type === 'checkbox' &&
203
+ node.checked;
204
+ if (label.includes('turn off temporary chat')) return true;
205
+ if (label.includes('temporary chat') && (pressed === 'true' || checked === 'true' || inputChecked)) return true;
206
+ return false;
207
+ });
208
+ };
209
+
210
+ const waitForModelButton = async (timeoutMs = MODEL_BUTTON_MOUNT_WAIT_MS, wake = false) => {
211
+ const start = performance.now();
212
+ do {
213
+ const candidate = findModelButton();
214
+ if (candidate) return candidate;
215
+ if (wake) {
216
+ const wakeResult = await wakeHiddenModelButton();
217
+ if (wakeResult?.restore && !wakeRestore) {
218
+ wakeRestore = wakeResult.restore;
219
+ }
220
+ if (wakeResult?.button) return wakeResult.button;
221
+ }
222
+ await sleep(REOPEN_INTERVAL_MS / 2);
223
+ } while (performance.now() - start <= timeoutMs);
224
+ return null;
225
+ };
226
+
227
+ let wakeRestore = null;
228
+ let button = findModelButton();
229
+ if (!button) {
230
+ const wake = await wakeHiddenModelButton();
231
+ button = wake?.button ?? null;
232
+ wakeRestore = wake?.restore ?? null;
233
+ }
234
+ if (!button && MODEL_STRATEGY !== 'current' && !detectTemporaryChat()) {
235
+ button = await waitForModelButton(MODEL_BUTTON_MOUNT_WAIT_MS, true);
236
+ }
237
+ if (!button) {
238
+ await wakeRestore?.().catch?.(() => undefined);
239
+ if (MODEL_STRATEGY === 'current') {
240
+ return { status: 'already-selected', label: 'current model' };
241
+ }
242
+ return { status: 'button-missing', hint: { temporaryChat: detectTemporaryChat() } };
243
+ }
244
+
245
+ const closeMenu = () => {
246
+ try {
247
+ if (dispatchClickSequence(button)) {
248
+ lastPointerClick = performance.now();
249
+ return;
250
+ }
251
+ } catch {}
252
+ try {
253
+ document.dispatchEvent(
254
+ new KeyboardEvent('keydown', {
255
+ key: 'Escape',
256
+ code: 'Escape',
257
+ keyCode: 27,
258
+ which: 27,
259
+ bubbles: true,
260
+ }),
261
+ );
262
+ } catch {}
263
+ };
264
+
265
+ const getButtonLabel = () => (button.textContent ?? '').trim();
266
+ if (MODEL_STRATEGY === 'current') {
267
+ const label = getButtonLabel();
268
+ await wakeRestore?.().catch?.(() => undefined);
269
+ return { status: 'already-selected', label };
270
+ }
271
+ const buttonMatchesTarget = () => {
272
+ // The numbered pill cannot prove that the rolling Latest option is selected.
273
+ if (normalizedTarget === 'latest') return false;
274
+ const normalizedLabel = normalize(getButtonLabel());
275
+ if (!normalizedLabel) return false;
276
+ if (matchesVisibleAlias(normalizedLabel)) return true;
277
+ if (desiredVersion) {
278
+ if (versionFromText(normalizedLabel) !== desiredVersion) return false;
279
+ }
280
+ if (wantsPro && !labelHasToken(normalizedLabel, 'pro')) return false;
281
+ if (wantsInstant && !normalizedLabel.includes('instant')) return false;
282
+ if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
283
+ // Also reject if button has variants we DON'T want
284
+ if (!wantsPro && labelHasToken(normalizedLabel, 'pro')) return false;
285
+ if (!wantsInstant && normalizedLabel.includes('instant')) return false;
286
+ if (!wantsThinking && normalizedLabel.includes('thinking')) return false;
287
+ return true;
288
+ };
289
+
290
+ if (buttonMatchesTarget()) {
291
+ const label = getButtonLabel();
292
+ await wakeRestore?.().catch?.(() => undefined);
293
+ return { status: 'already-selected', label };
294
+ }
295
+
296
+ let lastPointerClick = 0;
297
+ const pointerClick = () => {
298
+ if (dispatchClickSequence(button)) {
299
+ lastPointerClick = performance.now();
300
+ }
301
+ };
302
+
303
+ const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
304
+ const optionIsSelected = (node) => {
305
+ if (!(node instanceof HTMLElement)) {
306
+ return false;
307
+ }
308
+ const ariaChecked = node.getAttribute('aria-checked');
309
+ const ariaSelected = node.getAttribute('aria-selected');
310
+ const ariaCurrent = node.getAttribute('aria-current');
311
+ const dataSelected = node.getAttribute('data-selected');
312
+ const dataState = (node.getAttribute('data-state') ?? '').toLowerCase();
313
+ const selectedStates = ['checked', 'selected', 'on', 'true'];
314
+ if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') {
315
+ return true;
316
+ }
317
+ if (dataSelected === 'true' || selectedStates.includes(dataState)) {
318
+ return true;
319
+ }
320
+ if (node.querySelector('[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"]')) {
321
+ return true;
322
+ }
323
+ return false;
324
+ };
325
+
326
+ const scoreOption = (normalizedText, testid) => {
327
+ if (normalizedTarget === 'latest') return normalizedText === 'latest' ? 1000 : 0;
328
+ // Assign a score to every node so we can pick the most likely match without brittle equality checks.
329
+ if (!normalizedText && !testid) {
330
+ return 0;
331
+ }
332
+ let score = 0;
333
+ const normalizedTestId = (testid ?? '').toLowerCase();
334
+ const candidateTextVersion = versionFromText(normalizedText);
335
+ const candidateTestIdVersion = versionFromTestId(normalizedTestId);
336
+ const candidateVisibleAlias = matchesVisibleAlias(normalizedText);
337
+ if (desiredVersion) {
338
+ if (candidateTextVersion && candidateTextVersion !== desiredVersion) {
339
+ return 0;
340
+ }
341
+ if (candidateTestIdVersion && candidateTestIdVersion !== desiredVersion) {
342
+ return 0;
343
+ }
344
+ const versionLikeLabel =
345
+ normalizedText.includes('gpt') ||
346
+ normalizedText.includes('pro') ||
347
+ normalizedText.includes('thinking') ||
348
+ /\\b5\\b/.test(normalizedText);
349
+ if (
350
+ versionLikeLabel &&
351
+ !candidateTextVersion &&
352
+ !candidateTestIdVersion &&
353
+ !candidateVisibleAlias
354
+ ) {
355
+ return 0;
356
+ }
357
+ // When targeting an explicit version, avoid selecting submenu wrappers that can contain legacy models.
358
+ if (normalizedTestId.includes('submenu') && candidateTestIdVersion === null) {
359
+ return 0;
360
+ }
361
+ }
362
+ if (candidateVisibleAlias) {
363
+ score += 900;
364
+ }
365
+ if (normalizedTestId) {
366
+ // Exact testid matches take priority over substring matches
367
+ const exactMatch = TEST_IDS.find((id) => id && normalizedTestId === id);
368
+ if (exactMatch) {
369
+ score += 1500;
370
+ if (exactMatch.startsWith('model-switcher-')) score += 200;
371
+ } else {
372
+ const matches = TEST_IDS.filter((id) => id && normalizedTestId.includes(id));
373
+ if (matches.length > 0) {
374
+ // Prefer the most specific match (longest token) instead of treating any hit as equal.
375
+ // This prevents generic tokens (e.g. "pro") from outweighing version-specific targets.
376
+ const best = matches.reduce((acc, token) => (token.length > acc.length ? token : acc), '');
377
+ score += 200 + Math.min(900, best.length * 25);
378
+ if (best.startsWith('model-switcher-')) score += 120;
379
+ if (best.includes('gpt-')) score += 60;
380
+ }
381
+ }
382
+ }
383
+ if (normalizedText && normalizedTarget) {
384
+ if (normalizedText === normalizedTarget) {
385
+ score += 500;
386
+ } else if (normalizedTarget.length > 3 && normalizedText.startsWith(normalizedTarget)) {
387
+ score += 420;
388
+ } else if (normalizedTarget.length > 3 && normalizedText.includes(normalizedTarget)) {
389
+ score += 380;
390
+ }
391
+ }
392
+ for (const token of normalizedTokens) {
393
+ // Reward partial matches to the expanded label/token set.
394
+ if (token && labelHasToken(normalizedText, token)) {
395
+ const tokenWeight = Math.min(120, Math.max(10, token.length * 4));
396
+ score += tokenWeight;
397
+ }
398
+ }
399
+ if (targetWords.length > 1) {
400
+ let missing = 0;
401
+ for (const word of targetWords) {
402
+ if (!normalizedText.includes(word)) {
403
+ missing += 1;
404
+ }
405
+ }
406
+ score -= missing * 12;
407
+ }
408
+ // If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
409
+ if (wantsPro) {
410
+ if (!labelHasToken(normalizedText, 'pro')) {
411
+ score -= 80;
412
+ }
413
+ } else if (labelHasToken(normalizedText, 'pro')) {
414
+ score -= 40;
415
+ }
416
+ // Similarly for Thinking variant
417
+ if (wantsThinking) {
418
+ if (!normalizedText.includes('thinking') && !normalizedTestId.includes('thinking')) {
419
+ score -= 80;
420
+ }
421
+ } else if (normalizedText.includes('thinking') || normalizedTestId.includes('thinking')) {
422
+ score -= 40;
423
+ }
424
+ // Similarly for Instant variant
425
+ if (wantsInstant) {
426
+ if (!normalizedText.includes('instant') && !normalizedTestId.includes('instant')) {
427
+ score -= 80;
428
+ }
429
+ } else if (normalizedText.includes('instant') || normalizedTestId.includes('instant')) {
430
+ score -= 40;
431
+ }
432
+ return Math.max(score, 0);
433
+ };
434
+
435
+ const findBestOption = () => {
436
+ // Walk through every menu item and keep whichever earns the highest score.
437
+ let bestMatch = null;
438
+ const menus = Array.from(document.querySelectorAll(${menuContainerLiteral}));
439
+ for (const menu of menus) {
440
+ const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
441
+ for (const option of buttons) {
442
+ const text = option.textContent ?? '';
443
+ const normalizedText = normalize(text);
444
+ const testid = option.getAttribute('data-testid') ?? '';
445
+ const isSubmenu =
446
+ testid.toLowerCase().includes('submenu') ||
447
+ option.getAttribute('data-has-submenu') !== null ||
448
+ option.getAttribute('aria-haspopup') === 'menu';
449
+ const baseScore = scoreOption(normalizedText, testid);
450
+ if (baseScore <= 0) {
451
+ continue;
452
+ }
453
+ const score = baseScore + (isSubmenu ? 0 : 1);
454
+ const label = getOptionLabel(option);
455
+ if (!bestMatch || score > bestMatch.score) {
456
+ bestMatch = { node: option, label, score, testid, normalizedText, isSubmenu };
457
+ }
458
+ }
459
+ }
460
+ return bestMatch;
461
+ };
462
+
463
+ return new Promise((resolve) => {
464
+ const start = performance.now();
465
+ const collectAvailableOptions = () => {
466
+ const menuRoots = Array.from(document.querySelectorAll(${menuContainerLiteral}));
467
+ const nodes = menuRoots.length > 0
468
+ ? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(${menuItemLiteral})))
469
+ : Array.from(document.querySelectorAll(${menuItemLiteral}));
470
+ const labels = nodes
471
+ .map((node) => (node?.textContent ?? '').trim())
472
+ .filter(Boolean)
473
+ .filter((label, index, arr) => arr.indexOf(label) === index);
474
+ return labels.slice(0, 12);
475
+ };
476
+ const ensureMenuOpen = () => {
477
+ const menuOpen = document.querySelector(${menuContainerLiteral});
478
+ if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
479
+ pointerClick();
480
+ }
481
+ };
482
+
483
+ // Open once and wait a tick before first scan.
484
+ pointerClick();
485
+ const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
486
+ const restoreWakeDraft = async () => {
487
+ if (!wakeRestore) return;
488
+ const restore = wakeRestore;
489
+ wakeRestore = null;
490
+ await restore().catch(() => undefined);
491
+ };
492
+ let initialized = false;
493
+ const attempt = async () => {
494
+ if (!initialized) {
495
+ initialized = true;
496
+ await openDelay();
497
+ }
498
+ ensureMenuOpen();
499
+ if (normalizedTarget === 'latest') {
500
+ const modelView = document.querySelector('[role="menuitem"][aria-label="Select model"][aria-expanded="false"]');
501
+ if (modelView) {
502
+ dispatchClickSequence(modelView);
503
+ await openDelay();
504
+ }
505
+ }
506
+ const match = findBestOption();
507
+ if (match) {
508
+ if (optionIsSelected(match.node)) {
509
+ await restoreWakeDraft();
510
+ closeMenu();
511
+ resolve({
512
+ status: 'already-selected',
513
+ label: buttonMatchesTarget() ? getButtonLabel() : match.label,
514
+ });
515
+ return;
516
+ }
517
+ dispatchClickSequence(match.node);
518
+ // Submenus (e.g. "Legacy models") need a second pass to pick the actual model option.
519
+ // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
520
+ if (match.isSubmenu) {
521
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
522
+ return;
523
+ }
524
+ // Newer ChatGPT builds may keep the composer pill as just "Standard",
525
+ // "Extended", or "Pro" after selecting a terminal Pro row. Require
526
+ // selected-state evidence, or a closed picker, when the composer
527
+ // remains effort-only.
528
+ setTimeout(async () => {
529
+ if (
530
+ buttonMatchesTarget() ||
531
+ optionIsSelected(match.node) ||
532
+ !document.querySelector(${menuContainerLiteral})
533
+ ) {
534
+ await restoreWakeDraft();
535
+ closeMenu();
536
+ resolve({
537
+ status: 'switched',
538
+ label: buttonMatchesTarget() ? getButtonLabel() : match.label,
539
+ });
540
+ return;
541
+ }
542
+ if (performance.now() - start > MAX_WAIT_MS) {
543
+ await restoreWakeDraft();
544
+ resolve({
545
+ status: 'option-not-found',
546
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
547
+ });
548
+ return;
549
+ }
550
+ attempt();
551
+ }, Math.max(120, INITIAL_WAIT_MS));
552
+ return;
553
+ }
554
+ if (performance.now() - start > MAX_WAIT_MS) {
555
+ await restoreWakeDraft();
556
+ resolve({
557
+ status: 'option-not-found',
558
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
559
+ });
560
+ return;
561
+ }
562
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
563
+ };
564
+ attempt();
565
+ });
566
+ })()`;
567
+ }
568
+ export function buildModelMatchersLiteralForTest(targetModel) {
569
+ return buildModelMatchersLiteral(targetModel);
570
+ }
571
+ function buildModelMatchersLiteral(targetModel) {
572
+ return buildChatGptModelMatchers(targetModel);
573
+ }
574
+ export function buildModelSelectionExpressionForTest(targetModel, strategy = "select") {
575
+ return buildModelSelectionExpression(targetModel, strategy);
576
+ }