Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

### Fixed

- Browser: keep `--browser-thinking-time extra-high` as Extra High (non-Pro) on GPT-5.6 Sol; reserve `heavy` for explicit Pro selection. Fixes #353.
- Browser: keep `--browser-thinking-time extra-high` as Extra High (non-Pro) on GPT-5.6 Sol instead of selecting Pro. Fixes #353.
- Browser: match German Intelligence effort labels with whole-word Latin matching, and keep the currently selected effort when a requested tier has no matching row. Thanks @Jonasdero!

## 0.17.0 — 2026-08-02

Expand Down
2 changes: 1 addition & 1 deletion docs/browser-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Notes:
- If an assistant response still times out (common with long Pro runs), Oracle marks the session as an incomplete capture, stores reattach/runtime diagnostics, and keeps enough browser metadata for `oracle session <id>` to recover the final answer. Visible ChatGPT rate-limit, temporary-unavailable, and authentication/challenge warnings are included in the error and session metadata instead of being reduced to a generic timeout. Increase `--browser-timeout` only when the browser session is truly unrecoverable.
- `--browser-model-strategy <select|current|ignore>`: control ChatGPT model selection. `select` (default) switches to the requested model; `current` keeps the active model and logs its label; `ignore` skips the picker entirely. (Ignored for Gemini web runs.)
- Temporary Chat can reduce account-sidebar clutter for one-shot browser consults, but it is a different ChatGPT workflow: Oracle skips archive attempts there and the local transcript/artifacts are the durable record. Verify live behavior before relying on Project Sources, Deep Research reports, or multi-turn persistence.
- `--browser-thinking-time <light|standard|extended|extra-high|heavy>`: set the ChatGPT thinking-time intensity (Thinking/Pro models only). On GPT-5.6 Sol, `extra-high` selects Extra High and `heavy` selects Pro. You can also set a default in `~/.oracle/config.json` via `browser.thinkingTime`.
- `--browser-thinking-time <light|standard|extended|extra-high|heavy>`: set the ChatGPT thinking-time intensity (Thinking/Pro models only). On GPT-5.6 Sol, `extra-high` selects Extra High; a `heavy` request accepts an already-selected Pro pill but otherwise selects only a matching Heavy row. Effort rows are matched in English, German (`Sofort`/`Mittel`/`Hoch`/`Sehr hoch`), and Chinese; when the requested tier has no row in the current UI language, Oracle keeps the effort already selected in the tab instead of switching the model. You can also set a default in `~/.oracle/config.json` via `browser.thinkingTime`.
- GPT-5.5 Pro Extended is verified from the selected item in ChatGPT's standalone Pro/Thinking effort pill or compatible Intelligence/model-picker menu. A run **fails closed** if Extended cannot be confirmed rather than silently submitting at a weaker effort. Detection failures write a bounded, redacted model-picker diagnostic to the normal session log.
- `--browser-research deep`: activate ChatGPT Deep Research before submitting the prompt. Use this for broad public-web research and final cited reports, not as a replacement for GPT-5.x Pro Heavy code review or pure reasoning.
- `--browser-follow-up <prompt>`: submit another prompt in the same ChatGPT conversation after the initial answer. Repeat the flag for multi-turn reviews such as “challenge your recommendation”, “compare against this constraint”, then “give the final decision”. Deep Research has its own report lifecycle, so browser follow-ups are rejected when `--browser-research deep` is enabled.
Expand Down
158 changes: 87 additions & 71 deletions src/browser/actions/thinkingTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export async function ensureThinkingTime(
if (strictProEffort) {
throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
}
logger(formatBrowserThinkingLog(`${message}; continuing with ChatGPT default.`));
// Nothing was clicked, so the tab keeps whatever effort it already had —
// which is not necessarily ChatGPT's default.
logger(
formatBrowserThinkingLog(`${message}; keeping the effort already selected in ChatGPT.`),
);
return;
}
default: {
Expand Down Expand Up @@ -201,13 +205,13 @@ function buildThinkingTimeExpression(
const TARGET_MODEL_KIND = ${targetModelKindLiteral};
const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};

// Bilingual matchers: English level token + observed Chinese variants.
// Multilingual matchers: English level token + observed German/Chinese variants.
const LEVEL_TOKENS = {
light: ['light', 'instant', '轻', '极速'],
standard: ['standard', 'medium', '标准', '中'],
extended: ['extended', 'high', '扩展', '深度', '加强', '高'],
'extra-high': ['extra high', '极高'],
heavy: ['heavy', '重度', '加重'],
light: ['light', 'instant', 'sofort', 'leicht', '轻', '极速'],
standard: ['standard', 'medium', 'mittel', '标准', '中'],
extended: ['extended', 'high', 'hoch', 'erweitert', '扩展', '深度', '加强', '高'],
'extra-high': ['extra high', 'sehr hoch', '极高'],
heavy: ['heavy', 'schwer', '重度', '加重'],
};
const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];

Expand All @@ -222,47 +226,59 @@ function buildThinkingTimeExpression(
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
const normalize = (value) => (value || '')
// Compose first so NFD umlauts fold too, then map them onto ASCII before
// the strip below would drop them (and split the token in half).
.normalize('NFC')
.toLowerCase()
.replace(/ä/g, 'a')
.replace(/ö/g, 'o')
.replace(/ü/g, 'u')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
.replace(/\\s+/g, ' ')
.trim();
const hasToken = (text, token) => normalize(text).split(' ').includes(token);
const matchesLevel = (text) => {
// Whole-word/phrase containment. Latin effort labels are short words that also
// occur inside unrelated UI text ("Hochladen", "Ermitteln") and inside their own
// row descriptions ("Hoch – für sehr komplexe Aufgaben"), so plain substring
// matching misclassifies rows. CJK labels have no word separators, so they keep
// substring semantics.
const hasPhrase = (text, phrase) => {
const haystack = ' ' + normalize(text) + ' ';
const needle = normalize(phrase);
if (!needle) return false;
return /^[a-z0-9 ]+$/.test(needle)
? haystack.includes(' ' + needle + ' ')
: haystack.includes(needle);
};
// ChatGPT's Pro effort tiers are "Pro Extended"/"Pro Erweitert" per UI language.
const hasExtendedWord = (text) => hasPhrase(text, 'extended') || hasPhrase(text, 'erweitert');
const matchesTokens = (text, tokens) => {
const t = normalize(text);
if (!t) return false;
return targetTokens.some((tok) => {
return tokens.some((tok) => {
const token = normalize(tok);
if (!token) return false;
if (token === 'high') return hasToken(t, 'high') && !hasToken(t, 'extra');
if (token === 'extra high') return hasToken(t, 'extra') && hasToken(t, 'high');
if (token === 'high') return hasPhrase(t, 'high') && !hasPhrase(t, 'extra high');
if (token === 'extra high') return hasPhrase(t, 'extra high');
if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
if (token === '极速') {
const suffix = t.slice(token.length);
return t === token || hasToken(t, token) || /^[0-9]/.test(suffix);
}
if (['中', '高', '极高'].includes(token)) {
return t === token || hasToken(t, token);
}
if (/^[a-z0-9 ]+$/.test(token)) {
return hasPhrase(t, token);
}
return t === token || hasToken(t, token) || t.includes(token);
});
};
const matchesAnyEffortLevel = (text) => {
const normalizedText = normalize(text);
if (!normalizedText) return false;
for (const tokens of Object.values(LEVEL_TOKENS)) {
for (const rawToken of tokens) {
const token = normalize(rawToken);
if (!token) continue;
if (token.includes(' ')) {
if (token.split(' ').every((part) => hasToken(normalizedText, part))) return true;
} else if (/^[a-z0-9]+$/.test(token)) {
if (hasToken(normalizedText, token)) return true;
} else if (normalizedText.includes(token)) {
return true;
}
}
}
return false;
};
const matchesLevel = (text) => matchesTokens(text, targetTokens);
const matchesAnyEffortLevel = (text) =>
Object.values(LEVEL_TOKENS).some((tokens) => matchesTokens(text, tokens));
const optionIsSelected = (node) => {
if (!(node instanceof HTMLElement)) return false;
const ariaChecked = node.getAttribute('aria-checked');
Expand Down Expand Up @@ -414,7 +430,8 @@ function buildThinkingTimeExpression(
return true;
}
const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]');
return normalize(label?.textContent ?? '').includes('intelligence');
// 'intelligen' matches both "Intelligence" and German "Intelligenz".
return normalize(label?.textContent ?? '').includes('intelligen');
};
const failure = (status, extra = {}) => ({
status,
Expand Down Expand Up @@ -457,24 +474,10 @@ function buildThinkingTimeExpression(
return null;
}
}
if (
TARGET_IS_GPT56_MODEL &&
TARGET_LEVEL === 'heavy' &&
isIntelligenceEffortMenu(menu)
) {
for (const item of items) {
const itemText = normalize(
(item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
);
if (
hasToken(itemText, 'pro') &&
!itemText.includes('gpt') &&
!/(?:^|\\s)5[ .-]?6(?:\\s|$)/.test(itemText)
) {
return item;
}
}
}
// Generic effort-label match for every model/level. GPT-5.6 heavy used to
// short-circuit to the Pro row before reaching here; it no longer does, so
// a UI without a matching tier (e.g. German, which has no "heavy") falls
// through to null and the caller keeps the current selection.
for (const item of items) {
const itemText = normalize(
(item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
Expand All @@ -501,11 +504,15 @@ function buildThinkingTimeExpression(
}
return null;
};
// Menu-shape heuristic only. This reads the whole menu's textContent, where
// adjacent row labels concatenate without a separator ("Pro StandardPro
// Extended"), so word-boundary matching does not apply here — substring is
// deliberate. Row-level classification uses matchesLevel/matchesTokens.
const countEffortLevels = (menu) => {
const text = normalize(menu?.textContent ?? '');
let hits = 0;
for (const tokens of Object.values(LEVEL_TOKENS)) {
if (tokens.some((token) => text.includes(String(token).toLowerCase()))) hits += 1;
if (tokens.some((token) => text.includes(normalize(token)))) hits += 1;
}
return hits;
};
Expand All @@ -516,16 +523,22 @@ function buildThinkingTimeExpression(
const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
const labelText = normalize(label?.textContent ?? '');
return (
labelText.includes('intelligence') ||
labelText.includes('intelligen') ||
labelText.includes('thinking time') ||
labelText.includes('thinking effort') ||
labelText.includes('denkdauer') ||
labelText.includes('denkzeit') ||
countEffortLevels(menu) >= 2
);
};
const isProEffortMenu = (menu) => {
if (!isVisible(menu)) return false;
const text = normalize(menu?.textContent ?? '');
return text.includes('pro standard') && text.includes('pro extended');
// Aggregate menu text, so plain substring only (see countEffortLevels).
return (
text.includes('pro standard') &&
(text.includes('pro extended') || text.includes('pro erweitert'))
);
};
const controlledMenu = (trigger) => {
const id = trigger?.getAttribute?.('aria-controls');
Expand Down Expand Up @@ -560,10 +573,10 @@ function buildThinkingTimeExpression(
(node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
);
if (TARGET_LEVEL === 'standard') {
return text.includes('pro') && text.includes('standard');
return hasPhrase(text, 'pro') && hasPhrase(text, 'standard');
}
if (TARGET_LEVEL === 'extended') {
return text.includes('pro') && text.includes('extended');
return hasPhrase(text, 'pro') && hasExtendedWord(text);
}
return false;
};
Expand All @@ -588,10 +601,10 @@ function buildThinkingTimeExpression(
}
const label = normalize(button?.textContent ?? '');
if (TARGET_LEVEL === 'standard') {
return hasToken(label, 'pro') && !hasToken(label, 'extended');
return hasToken(label, 'pro') && !hasExtendedWord(label);
}
if (TARGET_LEVEL === 'extended') {
return hasToken(label, 'pro') && hasToken(label, 'extended');
return hasToken(label, 'pro') && hasExtendedWord(label);
}
return false;
};
Expand All @@ -601,13 +614,9 @@ function buildThinkingTimeExpression(
const normalizedLabel = normalize(
(button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''),
);
if (
TARGET_IS_GPT56_MODEL &&
TARGET_LEVEL === 'heavy' &&
hasToken(normalizedLabel, 'pro')
) {
return true;
}
// No 5.6-heavy "a Pro pill counts as heavy" shortcut here: that would also
// make post-click verification pass on an unchanged Pro pill. selectAndVerify
// handles the already-on-Pro case explicitly before any click.
if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') {
return false;
}
Expand All @@ -620,14 +629,21 @@ function buildThinkingTimeExpression(
modelKindFromNode(trigger) ||
effectiveTargetModelKind();
const option = findOption();
if (
!option &&
TARGET_IS_GPT56_MODEL &&
TARGET_LEVEL === 'heavy' &&
currentEffortPillMatchesTarget(trigger, triggerModelKind)
) {
closeOpenMenus();
return { status: 'already-selected', label: trigger.textContent?.trim?.() || null };
if (!option && TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy') {
// GPT-5.6 has no "heavy" tier: Pro is the closest thing. Accept a pill that
// is already on Pro as satisfying the request, but never click Pro to get
// there, and never let this stand in for post-click verification.
const pill = freshComposerTrigger(trigger) || findModelButton();
const pillLabel = normalize(
(pill?.textContent ?? '') + ' ' + (pill?.getAttribute?.('aria-label') ?? ''),
);
if (
hasToken(pillLabel, 'pro') ||
currentEffortPillMatchesTarget(trigger, triggerModelKind)
) {
closeOpenMenus();
return { status: 'already-selected', label: trigger.textContent?.trim?.() || null };
}
}
if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
const label = option.textContent?.trim?.() || null;
Expand Down Expand Up @@ -924,7 +940,7 @@ function buildThinkingTimeExpression(
const text = normalize(
(node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
);
return text.includes('pro') && text.includes('extended');
return hasPhrase(text, 'pro') && hasExtendedWord(text);
};
const findProExtendedOption = () => {
const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR);
Expand Down
6 changes: 6 additions & 0 deletions src/browser/chromeLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,12 @@ function buildChromeFlags(
"--disable-features=TranslateUI,AutomationControlled",
"--mute-audio",
"--window-size=1280,720",
// Chrome that *we* launch is pinned to English, so ChatGPT renders the labels
// our selectors were written against. This does not make English the only case
// to handle: --browser-attach-running and --remote-chrome never build these
// flags (see controlPlan.ts), so those runs inherit the user's own Chrome
// locale, and a ChatGPT account language setting can localize the UI even here.
// That is why the model/effort matchers must stay language-tolerant.
"--lang=en-US",
"--accept-lang=en-US,en",
];
Expand Down
Loading