@steipete/oracle 0.19.0 → 0.20.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 (35) hide show
  1. package/dist/bin/oracle-cli.js +11 -13
  2. package/dist/src/browser/actions/deepResearch.js +168 -44
  3. package/dist/src/browser/actions/modelSelection.js +78 -1
  4. package/dist/src/browser/actions/promptComposer.js +3 -0
  5. package/dist/src/browser/actions/thinkingTime.js +38 -1
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +5 -4
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +111 -43
  10. package/dist/src/browser/liveTabs.js +3 -4
  11. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  12. package/dist/src/browser/reattach.js +21 -1
  13. package/dist/src/browser/recoveryTarget.js +131 -0
  14. package/dist/src/browser/sessionRunner.js +3 -1
  15. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  16. package/dist/src/browser/targetClaim.js +54 -0
  17. package/dist/src/cli/browserConfig.js +33 -3
  18. package/dist/src/cli/browserTabs.js +3 -0
  19. package/dist/src/cli/options.js +15 -0
  20. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  21. package/dist/src/cli/runOptions.js +19 -4
  22. package/dist/src/cli/sessionDisplay.js +4 -5
  23. package/dist/src/cli/sessionRunner.js +4 -5
  24. package/dist/src/mcp/tools/consult.js +2 -2
  25. package/dist/src/mcp/types.js +1 -1
  26. package/dist/src/oracle/config.js +14 -0
  27. package/dist/src/oracle/geminiModels.js +1 -0
  28. package/dist/src/oracle/run.js +27 -4
  29. package/dist/src/remote/client.js +3 -0
  30. package/dist/src/remote/server.js +1 -0
  31. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/package.json +3 -3
  34. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  35. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -13,6 +13,7 @@ import { CHATGPT_URL } from "../src/browser/constants.js";
13
13
  import { applyHelpStyling } from "../src/cli/help.js";
14
14
  import { collectPaths, collectModelList, collectTextValues, parseFloatOption, parseIntOption, parseSearchOption, parseThinkingTimeOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from "../src/cli/options.js";
15
15
  import { copyToClipboard } from "../src/cli/clipboard.js";
16
+ import { isGpt6ProAlias } from "../src/cli/browserConfig.js";
16
17
  import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
17
18
  import { shouldDetachSession, stopDetachedWorker } from "../src/cli/detach.js";
18
19
  import { launchDetachedSession } from "../src/cli/detachedSession.js";
@@ -225,15 +226,8 @@ program
225
226
  .addOption(new Option("--models <models>", 'Comma-separated API model list to query in parallel (e.g., "gpt-5.5-pro,gemini-3-pro").')
226
227
  .argParser(collectModelList)
227
228
  .default([]))
228
- .addOption(new Option("--reasoning-effort <effort>", "Reasoning effort for GPT-5.6 API models.").choices([
229
- "none",
230
- "low",
231
- "medium",
232
- "high",
233
- "xhigh",
234
- "max",
235
- ]))
236
- .addOption(new Option("--reasoning-mode <mode>", 'Responses API reasoning execution mode for GPT-5.6 models ("standard" or "pro").').choices(["standard", "pro"]))
229
+ .addOption(new Option("--reasoning-effort <effort>", "Reasoning effort for GPT-6 Astra and GPT-5.6 API models (Astra requires low or higher).").choices(["none", "low", "medium", "high", "xhigh", "max"]))
230
+ .addOption(new Option("--reasoning-mode <mode>", 'Responses API reasoning execution mode for GPT-6 Astra and GPT-5.6 models ("standard" or "pro").').choices(["standard", "pro"]))
237
231
  .addOption(new Option("-e, --engine <mode>", "Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.").choices(["api", "browser"]))
238
232
  .addOption(new Option("--mode <mode>", "Alias for --engine (api | browser).")
239
233
  .choices(["api", "browser"])
@@ -339,7 +333,7 @@ program
339
333
  .addOption(new Option("--browser-thinking-time <level>", "Thinking time intensity for Thinking/Pro models: light, standard, extended, extra-high (Extra High), pro (Pro tier of the active model), heavy, or ChatGPT UI aliases.")
340
334
  .argParser(parseThinkingTimeOption)
341
335
  .hideHelp())
342
- .addOption(new Option("--browser-research <mode>", "Browser research mode: deep activates ChatGPT Deep Research.").choices(["off", "deep"]))
336
+ .addOption(new Option("--browser-research <mode>", "Browser research mode: search activates Web Search; deep activates Deep Research.").choices(["off", "search", "deep"]))
343
337
  .addOption(new Option("--browser-archive <mode>", "Archive completed ChatGPT browser conversations after local artifacts are saved (auto archives successful non-project one-shots only).").choices(["auto", "always", "never"]))
344
338
  .addOption(new Option("--browser-follow-up <prompt>", "Submit an additional prompt in the same ChatGPT browser conversation after the initial answer; repeat for multi-turn consults.")
345
339
  .argParser(collectTextValues)
@@ -1064,9 +1058,13 @@ async function runRootCommand(options) {
1064
1058
  options.baseUrl = userConfig.apiBaseUrl;
1065
1059
  }
1066
1060
  const providerMode = resolveApiProviderMode(options);
1067
- const engineModels = multiModelProvided
1068
- ? Array.from(new Set(options.models.map((entry) => resolveApiModel(entry))))
1069
- : [resolveApiModel(normalizeModelOption(options.model) || DEFAULT_MODEL)];
1061
+ // Engine discovery must not apply API-only validation to browser aliases.
1062
+ const engineModelInputs = multiModelProvided
1063
+ ? options.models
1064
+ : [normalizeModelOption(options.model) || DEFAULT_MODEL];
1065
+ const engineModels = Array.from(new Set(engineModelInputs.map((entry) => isGpt6ProAlias(entry) && !options.route && !options.preflight
1066
+ ? "gpt-6-pro"
1067
+ : resolveApiModel(entry))));
1070
1068
  if (options.route || options.preflight) {
1071
1069
  const routeAzureEndpoint = firstNonEmpty(options.azureEndpoint, process.env.AZURE_OPENAI_ENDPOINT, userConfig.azure?.endpoint);
1072
1070
  const configuredAzureForRoute = routeAzureEndpoint
@@ -79,65 +79,131 @@ async function waitForDeepResearchPill(Runtime, timeoutMs = 5000) {
79
79
  * After prompt submission, waits for the research plan to appear and
80
80
  * auto-confirm (~60s countdown + 10s safety margin).
81
81
  */
82
- export async function waitForResearchPlanAutoConfirm(Runtime, logger, autoConfirmWaitMs = DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS) {
82
+ export async function waitForResearchPlanAutoConfirm(Runtime, logger, autoConfirmWaitMs = DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, options) {
83
+ const ignoredTargetKeys = new Set(options?.ignoredTargetKeys ?? []);
84
+ const minTurnIndex = typeof options?.minTurnIndex === "number" && Number.isFinite(options.minTurnIndex)
85
+ ? Math.floor(options.minTurnIndex)
86
+ : -1;
87
+ let capturedPlan = null;
88
+ let loggedPlan = false;
89
+ const targetOwnerMinTurnIndex = minTurnIndex >= 0 && options?.targetBaselineCaptured !== true ? minTurnIndex : -1;
90
+ const readPlanStatus = async () => {
91
+ const targetRead = options?.client
92
+ ? ((await readDeepResearchTargetResult(options.client, ignoredTargetKeys, targetOwnerMinTurnIndex).catch(() => null))?.read ?? null)
93
+ : null;
94
+ if (targetRead?.planTitle || targetRead?.researchStarted) {
95
+ return targetRead;
96
+ }
97
+ const inPageRead = options?.Page
98
+ ? await readDeepResearchFrameResult(Runtime, options.Page, options.client, minTurnIndex).catch(() => null)
99
+ : null;
100
+ return inPageRead?.read ?? targetRead;
101
+ };
102
+ const capturePlan = async (status) => {
103
+ if (!status.planTitle || !status.planSteps || status.planSteps.length === 0) {
104
+ return capturedPlan;
105
+ }
106
+ const next = {
107
+ title: status.planTitle,
108
+ steps: status.planSteps,
109
+ phase: status.researchStarted ? "researching" : "planning",
110
+ ...(status.planActionText ? { actionText: status.planActionText } : {}),
111
+ capturedAt: capturedPlan?.capturedAt ?? new Date().toISOString(),
112
+ };
113
+ const changed = !capturedPlan ||
114
+ capturedPlan.phase !== next.phase ||
115
+ capturedPlan.title !== next.title ||
116
+ capturedPlan.steps.join("\n") !== next.steps.join("\n") ||
117
+ capturedPlan.actionText !== next.actionText;
118
+ capturedPlan = next;
119
+ if (!loggedPlan) {
120
+ logger(`[browser] Deep Research plan detected:\n${[next.title, ...next.steps.map((step, index) => `${index + 1}. ${step}`)].join("\n")}`);
121
+ loggedPlan = true;
122
+ }
123
+ if (changed) {
124
+ await options?.onPlan?.(next);
125
+ }
126
+ return next;
127
+ };
128
+ const reportResearchStarted = async () => {
129
+ if (capturedPlan?.phase === "planning") {
130
+ capturedPlan = { ...capturedPlan, phase: "researching" };
131
+ await options?.onPlan?.(capturedPlan);
132
+ }
133
+ logger("[browser] Deep Research execution started; plan countdown is complete.");
134
+ return capturedPlan;
135
+ };
83
136
  // Phase A: Detect research plan appearance (up to 60s)
84
137
  const planDeadline = Date.now() + 60_000;
85
138
  let planDetected = false;
86
139
  while (Date.now() < planDeadline) {
140
+ const frameStatus = await readPlanStatus();
141
+ if (frameStatus) {
142
+ await capturePlan(frameStatus);
143
+ if (frameStatus.researchStarted) {
144
+ return reportResearchStarted();
145
+ }
146
+ if (capturedPlan) {
147
+ planDetected = true;
148
+ break;
149
+ }
150
+ }
151
+ // Legacy/inline fallback. Do not treat an arbitrary large iframe as a plan:
152
+ // ChatGPT projects, attachments, and other tools also render large iframes.
87
153
  const { result } = await Runtime.evaluate({
88
154
  expression: `(() => {
89
- const iframes = document.querySelectorAll('iframe');
90
- const hasResearchIframe = Array.from(iframes).some(f => {
91
- const rect = f.getBoundingClientRect();
92
- return rect.width > 200 && rect.height > 200;
93
- });
94
- const assistantText = (document.querySelector('[data-message-author-role="assistant"]')?.textContent || '').toLowerCase();
155
+ const turns = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]'));
156
+ const assistantText = String(turns.at(-1)?.textContent || '').toLowerCase();
95
157
  const hasResearchText = assistantText.includes('researching') ||
96
158
  assistantText.includes('research plan') ||
97
- assistantText.includes('survey') ||
98
- assistantText.includes('analyze');
99
- return { hasResearchIframe, hasResearchText };
159
+ assistantText.includes('正在研究') ||
160
+ assistantText.includes('研究计划');
161
+ return { hasResearchText };
100
162
  })()`,
101
163
  returnByValue: true,
102
164
  });
103
165
  const val = result?.value;
104
- if (val?.hasResearchIframe || val?.hasResearchText) {
166
+ if (val?.hasResearchText) {
105
167
  planDetected = true;
106
- logger("Research plan detected, waiting for auto-confirm countdown...");
168
+ logger("[browser] Deep Research activity detected; waiting for plan auto-confirm...");
107
169
  break;
108
170
  }
109
171
  await delay(2_000);
110
172
  }
111
173
  if (!planDetected) {
112
174
  logger("Warning: Research plan not detected within 60s; continuing (may have auto-confirmed already)");
113
- return;
175
+ return capturedPlan;
114
176
  }
115
- // Phase B: Wait for auto-confirm countdown
177
+ // Phase B: Wait for the OOPIF's real execution state instead of sleeping for
178
+ // the full countdown. The main page cannot see this sandboxed iframe's text.
116
179
  const confirmStart = Date.now();
117
180
  while (Date.now() - confirmStart < autoConfirmWaitMs) {
181
+ const frameStatus = await readPlanStatus();
182
+ if (frameStatus) {
183
+ await capturePlan(frameStatus);
184
+ if (frameStatus.researchStarted) {
185
+ return reportResearchStarted();
186
+ }
187
+ }
118
188
  const { result } = await Runtime.evaluate({
119
189
  expression: `(() => {
120
- const iframes = document.querySelectorAll('iframe');
121
- const hasLargeIframe = Array.from(iframes).some(f => {
122
- const rect = f.getBoundingClientRect();
123
- return rect.width > 200 && rect.height > 200;
124
- });
125
190
  const text = (document.body?.innerText || '').toLowerCase();
126
191
  const isResearching = text.includes('researching...') ||
127
192
  text.includes('reading sources') ||
128
- text.includes('considering');
129
- return { hasLargeIframe, isResearching };
193
+ text.includes('正在研究') ||
194
+ text.includes('正在阅读来源');
195
+ return { isResearching };
130
196
  })()`,
131
197
  returnByValue: true,
132
198
  });
133
199
  const val = result?.value;
134
200
  if (val?.isResearching) {
135
- logger("Research plan confirmed, execution started");
136
- return;
201
+ return reportResearchStarted();
137
202
  }
138
- await delay(5_000);
203
+ await delay(2_000);
139
204
  }
140
- logger("Auto-confirm wait complete, proceeding to monitor research progress");
205
+ logger("[browser] Deep Research plan wait elapsed; proceeding to monitor research progress.");
206
+ return capturedPlan;
141
207
  }
142
208
  /**
143
209
  * Polls for Deep Research completion over 5-30+ minutes.
@@ -600,6 +666,44 @@ function buildDeepResearchFrameStatusExpression() {
600
666
  return `(() => {
601
667
  const rawText = document.body?.innerText || '';
602
668
  const html = document.body?.innerHTML || '';
669
+ const cleanText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
670
+ const sections = typeof document.querySelectorAll === 'function'
671
+ ? Array.from(document.querySelectorAll('section'))
672
+ : [];
673
+ const planSection = sections.find((section) => {
674
+ if (typeof section.querySelector !== 'function' ||
675
+ typeof section.querySelectorAll !== 'function' ||
676
+ !cleanText(section.querySelector('h2')?.textContent) ||
677
+ section.querySelectorAll('ul li').length === 0) {
678
+ return false;
679
+ }
680
+ const buttons = Array.from(section.querySelectorAll('button'));
681
+ const hasPlanAction = buttons.some((button) =>
682
+ /^(edit|update|编辑|更新)$/i.test(cleanText(button.textContent))
683
+ );
684
+ const hasResearchStatus = Boolean(section.querySelector('p.loading-shimmer')) ||
685
+ buttons.some((button) =>
686
+ /stop research|停止研究/i.test(cleanText(button.getAttribute?.('aria-label')))
687
+ );
688
+ return hasPlanAction || hasResearchStatus;
689
+ });
690
+ const planTitle = cleanText(planSection?.querySelector?.('h2')?.textContent);
691
+ const planSteps = planSection && typeof planSection.querySelectorAll === 'function'
692
+ ? Array.from(planSection.querySelectorAll('ul li'))
693
+ .map((item) => cleanText(item.textContent))
694
+ .filter(Boolean)
695
+ : [];
696
+ const planActionText = planSection && typeof planSection.querySelectorAll === 'function'
697
+ ? Array.from(planSection.querySelectorAll('button'))
698
+ .map((button) => cleanText(button.textContent))
699
+ .find((text) => /^(edit|update|编辑|更新)$/i.test(text)) || ''
700
+ : '';
701
+ const hasResearchShimmer = Boolean(planSection?.querySelector?.('p.loading-shimmer'));
702
+ const hasStopResearchControl = typeof planSection?.querySelectorAll === 'function' &&
703
+ Array.from(planSection.querySelectorAll('button')).some((button) =>
704
+ /stop research|停止研究/i.test(cleanText(button.getAttribute?.('aria-label')))
705
+ );
706
+ const researchStarted = planSteps.length > 0 && (hasResearchShimmer || hasStopResearchControl);
603
707
  const isPlaceholder = (line) => /^(called tool|used tool|użyto narzędzia|narzędzie wywołane)$/i.test(line);
604
708
  const isCompletionLine = (line) =>
605
709
  /^(research completed|badanie ukończone)\\b/i.test(line);
@@ -641,6 +745,10 @@ function buildDeepResearchFrameStatusExpression() {
641
745
  return {
642
746
  completed,
643
747
  inProgress,
748
+ researchStarted,
749
+ planTitle: planTitle || undefined,
750
+ planSteps: planSteps.length > 0 ? planSteps : undefined,
751
+ planActionText: planActionText || undefined,
644
752
  textLength: reportText.length || rawText.trim().length,
645
753
  text: completed ? reportText : undefined,
646
754
  html: completed ? html : undefined,
@@ -773,9 +881,9 @@ export function buildDeepResearchCompletionPollExpressionForTest(minTurnIndex =
773
881
  return buildDeepResearchCompletionPollExpression(minTurnIndex);
774
882
  }
775
883
  function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPill") {
776
- const pillLabel = JSON.stringify(DEEP_RESEARCH_PILL_LABEL);
884
+ const pillLabels = JSON.stringify([DEEP_RESEARCH_PILL_LABEL, "深度研究"]);
777
885
  return `const ${functionName} = () => {
778
- const label = ${pillLabel}.toLowerCase();
886
+ const labels = ${pillLabels}.map(label => label.toLowerCase());
779
887
  const selectors = [
780
888
  '.__composer-pill-composite',
781
889
  '.__composer-pill',
@@ -798,7 +906,7 @@ function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPil
798
906
  pill.querySelector('button')?.getAttribute('aria-label') ||
799
907
  ''
800
908
  ).toLowerCase();
801
- if (text.includes(label) || aria.includes(label)) {
909
+ if (labels.some(label => text.includes(label) || aria.includes(label))) {
802
910
  return pill;
803
911
  }
804
912
  }
@@ -819,6 +927,18 @@ function buildWaitForDeepResearchPillExpression(timeoutMs) {
819
927
  function buildActivateDeepResearchExpression() {
820
928
  const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
821
929
  const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
930
+ const targetLabels = JSON.stringify([DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, "深度研究"]);
931
+ const descriptionLabels = JSON.stringify(["Get a detailed report", "获取详细报告"]);
932
+ const addFilesLabels = JSON.stringify(["add files", "添加文件"]);
933
+ const dropdownReadyLabels = JSON.stringify([
934
+ "add photos",
935
+ "create image",
936
+ "web search",
937
+ "deep research",
938
+ "get a detailed report",
939
+ "深度研究",
940
+ "获取详细报告",
941
+ ]);
822
942
  return `(async () => {
823
943
  ${buildClickDispatcher()}
824
944
  ${buildFindDeepResearchPillExpression()}
@@ -864,8 +984,12 @@ function buildActivateDeepResearchExpression() {
864
984
  '[data-radix-popper-content-wrapper]',
865
985
  '[data-floating-ui-portal]',
866
986
  ].join(',');
867
- const target = ${targetText}.toLowerCase();
868
987
  const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
988
+ const compactText = (value) => normalizeText(value).replace(/\\s+/g, '');
989
+ const targetLabels = ${targetLabels}.map(normalizeText);
990
+ const descriptionLabels = ${descriptionLabels}.map(normalizeText);
991
+ const addFilesLabels = ${addFilesLabels}.map(normalizeText);
992
+ const dropdownReadyLabels = ${dropdownReadyLabels}.map(normalizeText);
869
993
  const getText = (item) => normalizeText(item.textContent || item.getAttribute?.('aria-label') || '');
870
994
  const isInPopover = (item) => Boolean(item.closest?.(popoverSelector));
871
995
  const isVisible = (item) => {
@@ -891,14 +1015,16 @@ function buildActivateDeepResearchExpression() {
891
1015
  input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
892
1016
  input.dispatchEvent(new Event('change', { bubbles: true }));
893
1017
  };
894
- const isDeepResearchText = (text) => (
895
- text === target ||
896
- text.startsWith(target + ' ') ||
897
- text === 'get a detailed report' ||
898
- text.startsWith('get a detailed report ') ||
899
- (text.includes(target) && text.includes('detailed report')) ||
900
- text.replace(/\\s+/g, '').startsWith('deepresearch')
901
- );
1018
+ const isDeepResearchText = (text) => {
1019
+ const compact = compactText(text);
1020
+ const exactLabel = targetLabels.some(label => compact === compactText(label) || text.startsWith(label + ' '));
1021
+ const exactDescription = descriptionLabels.some(
1022
+ label => text === label || text.startsWith(label + ' ')
1023
+ );
1024
+ const combinedLabel = targetLabels.some(label => compact.startsWith(compactText(label))) &&
1025
+ descriptionLabels.some(label => compact.includes(compactText(label)));
1026
+ return exactLabel || exactDescription || combinedLabel;
1027
+ };
902
1028
  const getClickableItem = (item) => item.closest?.(
903
1029
  '[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"], .__menu-item, [class*="__menu-item"], [class*="menu-item"]'
904
1030
  ) || item;
@@ -915,7 +1041,7 @@ function buildActivateDeepResearchExpression() {
915
1041
  .map(item => {
916
1042
  const text = getText(item);
917
1043
  const clickable = getClickableItem(item);
918
- const exact = text === target ? 0 : 1;
1044
+ const exact = targetLabels.includes(text) ? 0 : 1;
919
1045
  const menuRow = /(^|\\s)__menu-item(\\s|$)/.test(clickable.className || '') ? 0 : 1;
920
1046
  return { item: clickable, score: exact + menuRow, textLength: text.length };
921
1047
  })
@@ -946,7 +1072,9 @@ function buildActivateDeepResearchExpression() {
946
1072
  // mutate the main composer and can be submitted as normal prompt text.
947
1073
  const plusBtn = document.querySelector(${plusBtnSelector}) ||
948
1074
  Array.from(document.querySelectorAll('button')).find(
949
- b => (b.getAttribute('aria-label') || '').toLowerCase().includes('add files')
1075
+ b => addFilesLabels.some(label =>
1076
+ normalizeText(b.getAttribute('aria-label') || '').includes(label)
1077
+ )
950
1078
  );
951
1079
  if (!plusBtn) return { status: 'plus-button-missing' };
952
1080
  dispatchClickSequence(plusBtn);
@@ -958,11 +1086,7 @@ function buildActivateDeepResearchExpression() {
958
1086
  const items = collectAvailableItems({ requirePopover: true });
959
1087
  if (findDeepResearchItem({ requirePopover: true }) || items.some(text => {
960
1088
  const normalized = normalizeText(text);
961
- return normalized.includes('add photos') ||
962
- normalized.includes('create image') ||
963
- normalized.includes('web search') ||
964
- normalized.includes('deep research') ||
965
- normalized.includes('get a detailed report');
1089
+ return dropdownReadyLabels.some(label => normalized.includes(label));
966
1090
  })) { resolve(items); return; }
967
1091
  elapsed += 150;
968
1092
  if (elapsed > 3000) { resolve(items.length ? items : null); return; }
@@ -81,6 +81,15 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
81
81
  const resolved = resolvedLabel.toLowerCase();
82
82
  const normalizedDesired = normalizeResolvedModelLabel(desired);
83
83
  const normalizedResolved = normalizeResolvedModelLabel(resolved);
84
+ if (desired === "latest") {
85
+ // The advanced radio is localized, but only the documented exact labels are
86
+ // evidence of GPT-6 Astra. Do not let a generic picker result verify Latest.
87
+ if (resolvedLabel.normalize("NFC").trim() === "Latest" ||
88
+ resolvedLabel.normalize("NFC").trim() === "最新") {
89
+ return;
90
+ }
91
+ throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-6 Astra (Latest).`);
92
+ }
84
93
  const wantsGpt56Sol = /(?:^| )5 6(?: |$)/.test(normalizedDesired) && normalizedDesired.split(" ").includes("sol");
85
94
  if (wantsGpt56Sol) {
86
95
  const resolvedTokens = normalizedResolved.split(" ");
@@ -169,6 +178,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
169
178
  const hasToken = (value, token) => normalizeText(value).split(' ').includes(token);
170
179
  // Normalize every candidate token to keep fuzzy matching deterministic.
171
180
  const normalizedTarget = normalizeText(PRIMARY_LABEL);
181
+ // "Latest" (GPT-6 since 2026-09) is a radio in the advanced view whose composer pill reads
182
+ // "6 Pro" / "6 High"…, while GPT-5.6 Sol's reads "5.6 Pro". Declared up front: getResolvedLabel
183
+ // runs on the picker-less path before the selection helpers below are initialized.
184
+ const targetIsLatest = normalizedTarget === 'latest';
185
+ // ChatGPT localizes the Latest radio itself (for example, Japanese "最新") but
186
+ // keeps the GPT-6 composer pill numeric. Keep this allow-list exact so GPT-5.6
187
+ // Sol or an arbitrary localized menu row can never satisfy a Latest request.
188
+ const isLatestModelLabel = (value) => {
189
+ const label = String(value ?? '').normalize('NFC').trim();
190
+ return label === 'Latest' || label === '最新';
191
+ };
172
192
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
173
193
  .map((token) => normalizeText(token))
174
194
  .filter(Boolean);
@@ -310,6 +330,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
310
330
  };
311
331
 
312
332
  const getButtonLabel = () => (findModelButton()?.textContent ?? '').trim();
333
+ // With the picker closed the only evidence for "Latest" is the composer pill, so a version-less
334
+ // "latest" target must be decided on it: the blank composer signal would otherwise pass as
335
+ // "already selected" while GPT-5.6 Sol is active. Defined here, before getResolvedLabel, because
336
+ // the "current" strategy resolves the label before the selection helpers further down exist.
337
+ const latestButtonSelected = () => {
338
+ const label = normalizeText(getButtonLabel());
339
+ return /^(chatgpt |gpt )?6(?![0-9 .]*[0-9])/.test(label) && !/(^| )5 6/.test(label);
340
+ };
313
341
  const getComposerModelLabel = () =>
314
342
  (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim();
315
343
  const readComposerModelSignal = () => normalizeText(getComposerModelLabel());
@@ -481,15 +509,37 @@ function buildModelSelectionExpression(targetModel, strategy) {
481
509
  if (wantsInstant) return label.includes('instant');
482
510
  if (wantsThinking) return Boolean(desiredVersion) && !labelHasProWord(label);
483
511
  if (desiredVersion) return true;
512
+ // A version-less target ("Latest") must match the radio that is actually checked in the
513
+ // advanced view: the opener's text lists every radio label, so a substring test would
514
+ // report "Latest" as selected while GPT-5.6 Sol is the checked model.
515
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio(parentMenu);
516
+ if (checkedAdvancedRadio) {
517
+ const checkedLabel = checkedAdvancedRadio.textContent ?? '';
518
+ return targetIsLatest
519
+ ? isLatestModelLabel(checkedLabel)
520
+ : normalizedTokens.some((token) => token && normalizeText(checkedLabel) === token);
521
+ }
484
522
  return normalizedTokens.some((token) => token && label.includes(token));
485
523
  };
524
+ const findCheckedAdvancedModelRadio = (menu = null) => {
525
+ const scope = menu || findUnifiedPickerMenu() || document;
526
+ return (
527
+ scope?.querySelector?.(
528
+ '[data-testid="composer-model-picker-slider-advanced-view"] [role="menuitemradio"][aria-checked="true"]',
529
+ ) ?? null
530
+ );
531
+ };
486
532
  const getAdvancedModelLabel = () => {
487
533
  const opener = findModelSubmenuOpener(findUnifiedPickerMenu());
488
534
  if (!opener) return '';
489
535
  const raw = (opener.textContent ?? '').trim();
490
536
  const normalized = normalizeText(pickerNodeLabel(opener));
491
537
  const version = versionFromLabel(normalized);
492
- if (!version) return raw;
538
+ if (!version) {
539
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio(findUnifiedPickerMenu());
540
+ const checkedLabel = (checkedAdvancedRadio?.textContent ?? '').trim();
541
+ return checkedLabel || raw;
542
+ }
493
543
  const [major, minor] = version.split('-');
494
544
  const suffix = normalized.split(' ').includes('sol') ? ' Sol' : '';
495
545
  return 'GPT-' + major + '.' + minor + suffix;
@@ -503,6 +553,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
503
553
  );
504
554
  };
505
555
  const getResolvedLabel = (observedOptionLabel = '') => {
556
+ if (targetIsLatest) {
557
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio();
558
+ if (checkedAdvancedRadio) return (checkedAdvancedRadio.textContent ?? '').trim();
559
+ // Picker closed: the pill ("6 Pro") is the evidence; report the radio's name so callers
560
+ // can compare against the requested target instead of the tier-suffixed pill text.
561
+ if (latestButtonSelected()) return 'Latest';
562
+ const currentButtonLabel = getButtonLabel();
563
+ if (currentButtonLabel) return currentButtonLabel;
564
+ // No picker button at all (e.g. the "current" strategy on a page that hides it): fall back
565
+ // to the generic composer/observed label resolution below.
566
+ }
506
567
  if (configuredSelectionMatchesTarget()) {
507
568
  const variant = getConfiguredVariantLabel();
508
569
  const version = formatModelOptionLabel(getConfiguredVersionLabel());
@@ -644,6 +705,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
644
705
  return COMPOSER_SIGNAL_INCLUDES.some((token) => token && signal.includes(token));
645
706
  };
646
707
  const activeSelectionMatchesTarget = () => {
708
+ if (targetIsLatest) {
709
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio();
710
+ if (checkedAdvancedRadio) {
711
+ return isLatestModelLabel(checkedAdvancedRadio.textContent ?? '');
712
+ }
713
+ return latestButtonSelected();
714
+ }
647
715
  if (advancedModelSignalMatchesTarget()) {
648
716
  return true;
649
717
  }
@@ -718,6 +786,11 @@ function buildModelSelectionExpression(targetModel, strategy) {
718
786
 
719
787
  const scoreOption = (normalizedText, testid, node) => {
720
788
  // Assign a score to every node so we can pick the most likely match without brittle equality checks.
789
+ // Latest is localized in the advanced radio list. Match the documented labels
790
+ // exactly instead of falling through to generic scoring, which could select Sol.
791
+ if (targetIsLatest) {
792
+ return isLatestModelLabel(node?.textContent ?? '') ? 2000 : 0;
793
+ }
721
794
  if (!normalizedText && !testid) {
722
795
  return 0;
723
796
  }
@@ -1368,6 +1441,10 @@ function buildModelMatchersLiteral(targetModel) {
1368
1441
  testIdTokens.add("gpt5-6");
1369
1442
  testIdTokens.add("gpt56");
1370
1443
  }
1444
+ if (base === "latest") {
1445
+ // ChatGPT's Japanese advanced-model radio is named exactly "最新".
1446
+ push("最新", labelTokens);
1447
+ }
1371
1448
  // Numeric variations (5.5 <-> 55 <-> gpt-5-5)
1372
1449
  if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
1373
1450
  push("5.5", labelTokens);
@@ -11,6 +11,7 @@ import { stageAttachmentPrompt } from "./attachmentPrompt.js";
11
11
  import { BrowserAutomationError } from "../../oracle/errors.js";
12
12
  import { buildAttachmentEvidenceExpression } from "./attachmentEvidence.js";
13
13
  import { buildAttachmentProgressExpression } from "./attachmentProgress.js";
14
+ import { activateWebSearch } from "./webSearch.js";
14
15
  const ENTER_KEY_EVENT = {
15
16
  key: "Enter",
16
17
  code: "Enter",
@@ -196,6 +197,8 @@ export async function submitPrompt(deps, prompt, logger) {
196
197
  observedLength,
197
198
  });
198
199
  }
200
+ if (deps.webSearch)
201
+ await activateWebSearch(runtime, input, prompt, logger);
199
202
  const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs, deps?.page, deps?.attachmentNavigationUrl);
200
203
  if (!clicked) {
201
204
  await dispatchEnterKey(input);
@@ -192,6 +192,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
192
192
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
193
193
  const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
194
194
  const targetIsGpt56ModelLiteral = JSON.stringify(/(?:^|[^0-9])5[._ -]6(?:[^0-9]|$)/i.test(desiredModel ?? ""));
195
+ // Astra resolves the gpt-6-pro request to this exact model-picker alias. Keep
196
+ // this deliberately narrow: a generic unknown model must not claim its
197
+ // version-prefixed effort pill whose ownership we cannot establish.
198
+ const targetIsAstraLatestLiteral = JSON.stringify((desiredModel ?? "").trim().toLowerCase() === "latest");
195
199
  return `(async () => {
196
200
  ${buildClickDispatcher()}
197
201
 
@@ -201,13 +205,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
201
205
  const TARGET_LEVEL = ${targetLevelLiteral};
202
206
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
203
207
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
208
+ const TARGET_IS_ASTRA_LATEST = ${targetIsAstraLatestLiteral};
204
209
 
205
210
  // Multilingual matchers: English level token + observed localized variants.
206
211
  const LEVEL_TOKENS = {
207
212
  light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速', '즉시'],
208
213
  standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中', '중간'],
209
214
  extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高', '높음'],
210
- 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高', '매우 높음'],
215
+ 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '極高', '极高', '매우 높음'],
211
216
  heavy: ['heavy', 'schwer', '重度', '加重'],
212
217
  };
213
218
  // Pro is a tier you can request, but it is also a MODEL name, so it must never
@@ -216,6 +221,29 @@ function buildThinkingTimeExpression(level, desiredModel) {
216
221
  // "Instant"/"Pro" would look like a tier list. Keep it to target matching only.
217
222
  const TARGET_LEVEL_TOKENS = { ...LEVEL_TOKENS, pro: ['pro'] };
218
223
  const targetTokens = TARGET_LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
224
+ const normalizeAstraLabel = (value) =>
225
+ String(value ?? '')
226
+ .normalize('NFC')
227
+ .toLowerCase()
228
+ .split(String.fromCharCode(9)).join(' ')
229
+ .split(String.fromCharCode(10)).join(' ')
230
+ .split(String.fromCharCode(13)).join(' ')
231
+ .split(String.fromCharCode(12)).join(' ')
232
+ .split(' ').filter(Boolean).join(' ');
233
+ const isAstraLatestEffortPill = (label) =>
234
+ TARGET_IS_ASTRA_LATEST &&
235
+ Object.values(TARGET_LEVEL_TOKENS).some((tokens) =>
236
+ tokens.some((token) => {
237
+ // Do not use the generic matcher here: ownership needs an exact
238
+ // version prefix plus a known localized tier, not token containment.
239
+ const tier = normalizeAstraLabel(token);
240
+ const observed = normalizeAstraLabel(label);
241
+ return observed === '6 ' + tier || observed === '6' + tier;
242
+ }),
243
+ );
244
+ const isSolModelPillForLatest = (label) =>
245
+ TARGET_IS_ASTRA_LATEST &&
246
+ ['5.6 pro', '5.6pro', '5 6 pro'].includes(normalizeAstraLabel(label));
219
247
 
220
248
  const INITIAL_WAIT_MS = 150;
221
249
  const STEP_WAIT_MS = 200;
@@ -1062,6 +1090,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
1062
1090
  if (seen.has(button) || !isVisible(button)) continue;
1063
1091
  seen.add(button);
1064
1092
  if (button.getAttribute?.('data-testid') === 'model-switcher-dropdown-button') continue;
1093
+ // A 5.6 Pro model pill is not Astra Latest's 6-prefixed effort owner.
1094
+ // Keep this rejection ahead of the generic compatibility matcher.
1095
+ if (isSolModelPillForLatest(button.textContent ?? '')) continue;
1065
1096
  const label = normalize(
1066
1097
  (button.getAttribute?.('aria-label') ?? '') + ' ' +
1067
1098
  (button.getAttribute?.('data-testid') ?? '') + ' ' +
@@ -1071,6 +1102,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
1071
1102
  (TARGET_MODEL_KIND === 'pro' && hasToken(label, 'pro') && !hasToken(label, 'thinking')) ||
1072
1103
  (TARGET_MODEL_KIND === 'thinking' && hasToken(label, 'thinking') && !hasToken(label, 'pro')) ||
1073
1104
  (!TARGET_MODEL_KIND && hasToken(label, 'thinking')) ||
1105
+ // Astra Latest prefixes a supported effort label with "6" (for
1106
+ // example, "6 Pro" or textContent-concatenated "6Pro"). This is
1107
+ // recognized only for the exact Latest target; selection still
1108
+ // requires the direct slider's leading label and numeric ARIA proof.
1109
+ isAstraLatestEffortPill(button.textContent ?? '') ||
1074
1110
  (button.matches?.('button.__composer-pill') && matchesAnyEffortLevel(label))
1075
1111
  ) {
1076
1112
  return button;
@@ -1157,6 +1193,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
1157
1193
  tokens.some((token) => normalize(token) === pillLabel),
1158
1194
  );
1159
1195
  const pillNamesEffortNotModel =
1196
+ TARGET_IS_ASTRA_LATEST ||
1160
1197
  TARGET_IS_GPT56_MODEL ||
1161
1198
  (pillIsBareEffortTier && Boolean(document.querySelector(INTELLIGENCE_MENU_SELECTOR)));
1162
1199
  const composerModelKind =