@steipete/oracle 0.7.0 → 0.7.2

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.
@@ -1,24 +1,45 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { CHATGPT_URL, DEFAULT_MODEL_TARGET, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
3
+ import { CHATGPT_URL, DEFAULT_MODEL_TARGET, isTemporaryChatUrl, normalizeChatgptUrl, parseDuration } from '../browserMode.js';
4
4
  import { getOracleHomeDir } from '../oracleHome.js';
5
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
6
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
7
7
  const DEFAULT_CHROME_PROFILE = 'Default';
8
8
  const BROWSER_MODEL_LABELS = {
9
- 'gpt-5-pro': 'GPT-5 Pro',
10
- 'gpt-5.1-pro': 'GPT-5.1 Pro',
11
- 'gpt-5.1': 'GPT-5.1',
12
- 'gpt-5.2': 'GPT-5.2 Thinking',
13
- 'gpt-5.2-instant': 'GPT-5.2 Instant',
9
+ // Browser engine supports GPT-5.2 and GPT-5.2 Pro (legacy/Pro aliases normalize to those targets).
10
+ 'gpt-5-pro': 'GPT-5.2 Pro',
11
+ 'gpt-5.1-pro': 'GPT-5.2 Pro',
12
+ 'gpt-5.1': 'GPT-5.2',
13
+ 'gpt-5.2': 'GPT-5.2',
14
+ // ChatGPT UI doesn't expose "instant" as a separate picker option; treat it as GPT-5.2 for browser automation.
15
+ 'gpt-5.2-instant': 'GPT-5.2',
14
16
  'gpt-5.2-pro': 'GPT-5.2 Pro',
15
17
  'gemini-3-pro': 'Gemini 3 Pro',
16
18
  };
19
+ export function normalizeChatGptModelForBrowser(model) {
20
+ const normalized = model.toLowerCase();
21
+ if (!normalized.startsWith('gpt-') || normalized.includes('codex')) {
22
+ return model;
23
+ }
24
+ // Pro variants: always resolve to the latest Pro model in ChatGPT.
25
+ if (normalized === 'gpt-5-pro' || normalized === 'gpt-5.1-pro' || normalized.endsWith('-pro')) {
26
+ return 'gpt-5.2-pro';
27
+ }
28
+ // Legacy / UI-mismatch variants: map to the closest ChatGPT picker target.
29
+ if (normalized === 'gpt-5.2-instant') {
30
+ return 'gpt-5.2';
31
+ }
32
+ if (normalized === 'gpt-5.1') {
33
+ return 'gpt-5.2';
34
+ }
35
+ return model;
36
+ }
17
37
  export async function buildBrowserConfig(options) {
18
38
  const desiredModelOverride = options.browserModelLabel?.trim();
19
39
  const normalizedOverride = desiredModelOverride?.toLowerCase() ?? '';
20
40
  const baseModel = options.model.toLowerCase();
21
- const shouldUseOverride = normalizedOverride.length > 0 && normalizedOverride !== baseModel;
41
+ const isChatGptModel = baseModel.startsWith('gpt-') && !baseModel.includes('codex');
42
+ const shouldUseOverride = !isChatGptModel && normalizedOverride.length > 0 && normalizedOverride !== baseModel;
22
43
  const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES);
23
44
  const inline = await resolveInlineCookies({
24
45
  inlineArg: options.browserInlineCookies,
@@ -33,6 +54,15 @@ export async function buildBrowserConfig(options) {
33
54
  }
34
55
  const rawUrl = options.chatgptUrl ?? options.browserUrl;
35
56
  const url = rawUrl ? normalizeChatgptUrl(rawUrl, CHATGPT_URL) : undefined;
57
+ const desiredModel = isChatGptModel
58
+ ? mapModelToBrowserLabel(options.model)
59
+ : shouldUseOverride
60
+ ? desiredModelOverride
61
+ : mapModelToBrowserLabel(options.model);
62
+ if (url && isTemporaryChatUrl(url) && /\bpro\b/i.test(desiredModel ?? '')) {
63
+ throw new Error('Temporary Chat mode does not expose Pro models in the ChatGPT model picker. ' +
64
+ 'Remove "temporary-chat=true" from --chatgpt-url (or omit --chatgpt-url), or use a non-Pro model (e.g. --model gpt-5.2).');
65
+ }
36
66
  return {
37
67
  chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
38
68
  chromePath: options.browserChromePath ?? null,
@@ -51,7 +81,7 @@ export async function buildBrowserConfig(options) {
51
81
  keepBrowser: options.browserKeepBrowser ? true : undefined,
52
82
  manualLogin: options.browserManualLogin ? true : undefined,
53
83
  hideWindow: options.browserHideWindow ? true : undefined,
54
- desiredModel: shouldUseOverride ? desiredModelOverride : mapModelToBrowserLabel(options.model),
84
+ desiredModel,
55
85
  debug: options.verbose ? true : undefined,
56
86
  // Allow cookie failures by default so runs can continue without Chrome/Keychain secrets.
57
87
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
@@ -69,7 +99,8 @@ function selectBrowserPort(options) {
69
99
  return candidate;
70
100
  }
71
101
  export function mapModelToBrowserLabel(model) {
72
- return BROWSER_MODEL_LABELS[model] ?? DEFAULT_MODEL_TARGET;
102
+ const normalized = normalizeChatGptModelForBrowser(model);
103
+ return BROWSER_MODEL_LABELS[normalized] ?? DEFAULT_MODEL_TARGET;
73
104
  }
74
105
  export function resolveBrowserModelLabel(input, model) {
75
106
  const trimmed = input?.trim?.() ?? '';
@@ -38,7 +38,7 @@ export function applyHelpStyling(program, version, isTty) {
38
38
  program.addHelpText('after', () => renderHelpFooter(program, colors));
39
39
  }
40
40
  function renderHelpBanner(version, colors) {
41
- const subtitle = 'Prompt + files required — GPT-5.1 Pro/GPT-5.1 for tough questions with code/file context.';
41
+ const subtitle = 'Prompt + files required — GPT-5.2 Pro/GPT-5.2 for tough questions with code/file context.';
42
42
  return `${colors.banner(`Oracle CLI v${version}`)} ${colors.subtitle(`— ${subtitle}`)}\n`;
43
43
  }
44
44
  function renderHelpFooter(program, colors) {
@@ -51,7 +51,7 @@ function renderHelpFooter(program, colors) {
51
51
  `${colors.bullet('•')} Best results: 6–30 sentences plus key source files; very short prompts often yield generic answers.`,
52
52
  `${colors.bullet('•')} Oracle is one-shot: it does not remember prior runs, so start fresh each time with full context.`,
53
53
  `${colors.bullet('•')} Run ${colors.accent('--files-report')} to inspect token spend before hitting the API.`,
54
- `${colors.bullet('•')} Non-preview runs spawn detached sessions (especially gpt-5.1-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent('oracle session <slug>')} to resume/inspect the existing run.`,
54
+ `${colors.bullet('•')} Non-preview runs spawn detached sessions (especially gpt-5.2-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent('oracle session <slug>')} to resume/inspect the existing run.`,
55
55
  `${colors.bullet('•')} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
56
56
  `${colors.bullet('•')} Finished sessions auto-hide preamble logs when reattached; raw timestamps remain in the saved log file.`,
57
57
  `${colors.bullet('•')} Need hidden flags? Run ${colors.accent(`${program.name()} --help --verbose`)} to list search/token/browser overrides.`,
@@ -61,7 +61,7 @@ function renderHelpFooter(program, colors) {
61
61
  const formatExample = (command, description) => `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
62
62
  const examples = [
63
63
  formatExample(`${program.name()} --render --copy --prompt "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"`, 'Build the bundle, print it, and copy it for manual paste into ChatGPT.'),
64
- formatExample(`${program.name()} --prompt "Cross-check the data layer assumptions" --models gpt-5.1-pro,gemini-3-pro --file "src/**/*.ts"`, 'Run multiple API models in one go and aggregate cost/usage.'),
64
+ formatExample(`${program.name()} --prompt "Cross-check the data layer assumptions" --models gpt-5.2-pro,gemini-3-pro --file "src/**/*.ts"`, 'Run multiple API models in one go and aggregate cost/usage.'),
65
65
  formatExample(`${program.name()} status --hours 72 --limit 50`, 'Show sessions from the last 72h (capped at 50 entries).'),
66
66
  formatExample(`${program.name()} session <sessionId>`, 'Attach to a running/completed session and stream the saved transcript.'),
67
67
  formatExample(`${program.name()} --prompt "Ship review" --slug "release-readiness-audit"`, 'Encourage the model to hand you a 3–5 word slug and pass it along with --slug.'),
@@ -137,6 +137,9 @@ export function resolveApiModel(modelValue) {
137
137
  if (normalized.includes('5-pro') && !normalized.includes('5.1')) {
138
138
  return 'gpt-5-pro';
139
139
  }
140
+ if (normalized.includes('5.2') && normalized.includes('pro')) {
141
+ return 'gpt-5.2-pro';
142
+ }
140
143
  if (normalized.includes('5.1') && normalized.includes('pro')) {
141
144
  return 'gpt-5.1-pro';
142
145
  }
@@ -149,6 +152,9 @@ export function resolveApiModel(modelValue) {
149
152
  if (normalized.includes('gemini')) {
150
153
  return 'gemini-3-pro';
151
154
  }
155
+ if (normalized.includes('pro')) {
156
+ return 'gpt-5.2-pro';
157
+ }
152
158
  // Passthrough for custom/OpenRouter model IDs.
153
159
  return normalized;
154
160
  }
@@ -169,12 +175,6 @@ export function inferModelFromLabel(modelValue) {
169
175
  if (normalized.includes('claude') && normalized.includes('opus')) {
170
176
  return 'claude-4.1-opus';
171
177
  }
172
- if (normalized.includes('5.0') || normalized.includes('5-pro')) {
173
- return 'gpt-5-pro';
174
- }
175
- if (normalized.includes('gpt-5') && normalized.includes('pro') && !normalized.includes('5.1')) {
176
- return 'gpt-5-pro';
177
- }
178
178
  if (normalized.includes('codex')) {
179
179
  return 'gpt-5.1-codex';
180
180
  }
@@ -182,13 +182,25 @@ export function inferModelFromLabel(modelValue) {
182
182
  return 'gemini-3-pro';
183
183
  }
184
184
  if (normalized.includes('classic')) {
185
- return 'gpt-5.1-pro';
185
+ return 'gpt-5-pro';
186
+ }
187
+ if ((normalized.includes('5.2') || normalized.includes('5_2')) && normalized.includes('pro')) {
188
+ return 'gpt-5.2-pro';
189
+ }
190
+ if (normalized.includes('5.0') || normalized.includes('5-pro')) {
191
+ return 'gpt-5-pro';
192
+ }
193
+ if (normalized.includes('gpt-5') &&
194
+ normalized.includes('pro') &&
195
+ !normalized.includes('5.1') &&
196
+ !normalized.includes('5.2')) {
197
+ return 'gpt-5-pro';
186
198
  }
187
199
  if ((normalized.includes('5.1') || normalized.includes('5_1')) && normalized.includes('pro')) {
188
200
  return 'gpt-5.1-pro';
189
201
  }
190
202
  if (normalized.includes('pro')) {
191
- return 'gpt-5.1-pro';
203
+ return 'gpt-5.2-pro';
192
204
  }
193
205
  if (normalized.includes('5.1') || normalized.includes('5_1')) {
194
206
  return 'gpt-5.1';
@@ -3,6 +3,7 @@ import { resolveEngine } from './engine.js';
3
3
  import { normalizeModelOption, inferModelFromLabel, resolveApiModel, normalizeBaseUrl } from './options.js';
4
4
  import { resolveGeminiModelId } from '../oracle/gemini.js';
5
5
  import { PromptValidationError } from '../oracle/errors.js';
6
+ import { normalizeChatGptModelForBrowser } from './browserConfig.js';
6
7
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, models, engine, userConfig, env = process.env, }) {
7
8
  const resolvedEngine = resolveEngineWithConfig({ engine, configEngine: userConfig?.engine, env });
8
9
  const browserRequested = engine === 'browser';
@@ -10,9 +11,11 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
10
11
  const requestedModelList = Array.isArray(models) ? models : [];
11
12
  const normalizedRequestedModels = requestedModelList.map((entry) => normalizeModelOption(entry)).filter(Boolean);
12
13
  const cliModelArg = normalizeModelOption(model ?? userConfig?.model) || DEFAULT_MODEL;
13
- const resolvedModel = resolvedEngine === 'browser' && normalizedRequestedModels.length === 0
14
+ const inferredModel = resolvedEngine === 'browser' && normalizedRequestedModels.length === 0
14
15
  ? inferModelFromLabel(cliModelArg)
15
16
  : resolveApiModel(cliModelArg);
17
+ // Browser engine maps Pro/legacy aliases to the latest ChatGPT picker targets (GPT-5.2 / GPT-5.2 Pro).
18
+ const resolvedModel = resolvedEngine === 'browser' ? normalizeChatGptModelForBrowser(inferredModel) : inferredModel;
16
19
  const isCodex = resolvedModel.startsWith('gpt-5.1-codex');
17
20
  const isClaude = resolvedModel.startsWith('claude');
18
21
  const isGrok = resolvedModel.startsWith('grok');
@@ -35,10 +35,11 @@ function buildCookieHeader(cookieMap) {
35
35
  .map(([name, value]) => `${name}=${value}`)
36
36
  .join('; ');
37
37
  }
38
- export async function fetchGeminiAccessToken(cookieMap) {
38
+ export async function fetchGeminiAccessToken(cookieMap, signal) {
39
39
  const cookieHeader = buildCookieHeader(cookieMap);
40
40
  const res = await fetch(GEMINI_APP_URL, {
41
41
  redirect: 'follow',
42
+ signal,
42
43
  headers: {
43
44
  cookie: cookieHeader,
44
45
  'user-agent': USER_AGENT,
@@ -84,10 +85,10 @@ function ensureFullSizeImageUrl(url) {
84
85
  return url;
85
86
  return `${url}=s2048`;
86
87
  }
87
- async function fetchWithCookiePreservingRedirects(url, init, maxRedirects = 10) {
88
+ async function fetchWithCookiePreservingRedirects(url, init, signal, maxRedirects = 10) {
88
89
  let current = url;
89
90
  for (let i = 0; i <= maxRedirects; i += 1) {
90
- const res = await fetch(current, { ...init, redirect: 'manual' });
91
+ const res = await fetch(current, { ...init, redirect: 'manual', signal });
91
92
  if (res.status >= 300 && res.status < 400) {
92
93
  const location = res.headers.get('location');
93
94
  if (!location)
@@ -99,14 +100,14 @@ async function fetchWithCookiePreservingRedirects(url, init, maxRedirects = 10)
99
100
  }
100
101
  throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
101
102
  }
102
- async function downloadGeminiImage(url, cookieMap, outputPath) {
103
+ async function downloadGeminiImage(url, cookieMap, outputPath, signal) {
103
104
  const cookieHeader = buildCookieHeader(cookieMap);
104
105
  const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
105
106
  headers: {
106
107
  cookie: cookieHeader,
107
108
  'user-agent': USER_AGENT,
108
109
  },
109
- });
110
+ }, signal);
110
111
  if (!res.ok) {
111
112
  throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
112
113
  }
@@ -114,7 +115,7 @@ async function downloadGeminiImage(url, cookieMap, outputPath) {
114
115
  await mkdir(path.dirname(outputPath), { recursive: true });
115
116
  await writeFile(outputPath, data);
116
117
  }
117
- async function uploadGeminiFile(filePath) {
118
+ async function uploadGeminiFile(filePath, signal) {
118
119
  const absPath = path.resolve(process.cwd(), filePath);
119
120
  const data = await readFile(absPath);
120
121
  const fileName = path.basename(absPath);
@@ -123,6 +124,7 @@ async function uploadGeminiFile(filePath) {
123
124
  const res = await fetch(GEMINI_UPLOAD_URL, {
124
125
  method: 'POST',
125
126
  redirect: 'follow',
127
+ signal,
126
128
  headers: {
127
129
  'push-id': GEMINI_UPLOAD_PUSH_ID,
128
130
  'user-agent': USER_AGENT,
@@ -234,10 +236,13 @@ export function isGeminiModelUnavailable(errorCode) {
234
236
  }
235
237
  export async function runGeminiWebOnce(input) {
236
238
  const cookieHeader = buildCookieHeader(input.cookieMap);
237
- const at = await fetchGeminiAccessToken(input.cookieMap);
239
+ const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
238
240
  const uploaded = [];
239
241
  for (const file of input.files ?? []) {
240
- uploaded.push(await uploadGeminiFile(file));
242
+ if (input.signal?.aborted) {
243
+ throw new Error('Gemini web run aborted before upload.');
244
+ }
245
+ uploaded.push(await uploadGeminiFile(file, input.signal));
241
246
  }
242
247
  const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
243
248
  const params = new URLSearchParams();
@@ -246,6 +251,7 @@ export async function runGeminiWebOnce(input) {
246
251
  const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
247
252
  method: 'POST',
248
253
  redirect: 'follow',
254
+ signal: input.signal,
249
255
  headers: {
250
256
  'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
251
257
  origin: 'https://gemini.google.com',
@@ -307,15 +313,15 @@ export async function runGeminiWebWithFallback(input) {
307
313
  }
308
314
  return { ...attempt, effectiveModel: input.model };
309
315
  }
310
- export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath) {
316
+ export async function saveFirstGeminiImageFromOutput(output, cookieMap, outputPath, signal) {
311
317
  const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
312
318
  if (generatedOrWeb?.url) {
313
- await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath);
319
+ await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
314
320
  return { saved: true, imageCount: output.images.length };
315
321
  }
316
322
  const ggdl = extractGgdlUrls(output.rawResponseText);
317
323
  if (ggdl[0]) {
318
- await downloadGeminiImage(ggdl[0], cookieMap, outputPath);
324
+ await downloadGeminiImage(ggdl[0], cookieMap, outputPath, signal);
319
325
  return { saved: true, imageCount: ggdl.length };
320
326
  }
321
327
  return { saved: false, imageCount: 0 };
@@ -100,6 +100,17 @@ export function createGeminiWebExecutor(geminiOptions) {
100
100
  if (!cookieMap['__Secure-1PSID'] || !cookieMap['__Secure-1PSIDTS']) {
101
101
  throw new Error('Gemini browser mode requires Chrome cookies for google.com (missing __Secure-1PSID/__Secure-1PSIDTS).');
102
102
  }
103
+ const configTimeout = typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
104
+ ? Math.max(1_000, runOptions.config.timeoutMs)
105
+ : null;
106
+ const defaultTimeoutMs = geminiOptions.youtube
107
+ ? 240_000
108
+ : geminiOptions.generateImage || geminiOptions.editImage
109
+ ? 300_000
110
+ : 120_000;
111
+ const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, 600_000);
112
+ const controller = new AbortController();
113
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
103
114
  const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
104
115
  const editImagePath = resolveInvocationPath(geminiOptions.editImage);
105
116
  const outputPath = resolveInvocationPath(geminiOptions.outputPath);
@@ -116,71 +127,80 @@ export function createGeminiWebExecutor(geminiOptions) {
116
127
  }
117
128
  const model = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
118
129
  let response;
119
- if (editImagePath) {
120
- const intro = await runGeminiWebWithFallback({
121
- prompt: 'Here is an image to edit',
122
- files: [editImagePath],
123
- model,
124
- cookieMap,
125
- chatMetadata: null,
126
- });
127
- const editPrompt = `Use image generation tool to ${prompt}`;
128
- const out = await runGeminiWebWithFallback({
129
- prompt: editPrompt,
130
- files: attachmentPaths,
131
- model,
132
- cookieMap,
133
- chatMetadata: intro.metadata,
134
- });
135
- response = {
136
- text: out.text ?? null,
137
- thoughts: geminiOptions.showThoughts ? out.thoughts : null,
138
- has_images: false,
139
- image_count: 0,
140
- };
141
- const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
142
- const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath);
143
- response.has_images = imageSave.saved;
144
- response.image_count = imageSave.imageCount;
145
- if (!imageSave.saved) {
146
- throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
130
+ try {
131
+ if (editImagePath) {
132
+ const intro = await runGeminiWebWithFallback({
133
+ prompt: 'Here is an image to edit',
134
+ files: [editImagePath],
135
+ model,
136
+ cookieMap,
137
+ chatMetadata: null,
138
+ signal: controller.signal,
139
+ });
140
+ const editPrompt = `Use image generation tool to ${prompt}`;
141
+ const out = await runGeminiWebWithFallback({
142
+ prompt: editPrompt,
143
+ files: attachmentPaths,
144
+ model,
145
+ cookieMap,
146
+ chatMetadata: intro.metadata,
147
+ signal: controller.signal,
148
+ });
149
+ response = {
150
+ text: out.text ?? null,
151
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
152
+ has_images: false,
153
+ image_count: 0,
154
+ };
155
+ const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
156
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
157
+ response.has_images = imageSave.saved;
158
+ response.image_count = imageSave.imageCount;
159
+ if (!imageSave.saved) {
160
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
161
+ }
147
162
  }
148
- }
149
- else if (generateImagePath) {
150
- const out = await runGeminiWebWithFallback({
151
- prompt,
152
- files: attachmentPaths,
153
- model,
154
- cookieMap,
155
- chatMetadata: null,
156
- });
157
- response = {
158
- text: out.text ?? null,
159
- thoughts: geminiOptions.showThoughts ? out.thoughts : null,
160
- has_images: false,
161
- image_count: 0,
162
- };
163
- const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath);
164
- response.has_images = imageSave.saved;
165
- response.image_count = imageSave.imageCount;
166
- if (!imageSave.saved) {
167
- throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
163
+ else if (generateImagePath) {
164
+ const out = await runGeminiWebWithFallback({
165
+ prompt,
166
+ files: attachmentPaths,
167
+ model,
168
+ cookieMap,
169
+ chatMetadata: null,
170
+ signal: controller.signal,
171
+ });
172
+ response = {
173
+ text: out.text ?? null,
174
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
175
+ has_images: false,
176
+ image_count: 0,
177
+ };
178
+ const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal);
179
+ response.has_images = imageSave.saved;
180
+ response.image_count = imageSave.imageCount;
181
+ if (!imageSave.saved) {
182
+ throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
183
+ }
184
+ }
185
+ else {
186
+ const out = await runGeminiWebWithFallback({
187
+ prompt,
188
+ files: attachmentPaths,
189
+ model,
190
+ cookieMap,
191
+ chatMetadata: null,
192
+ signal: controller.signal,
193
+ });
194
+ response = {
195
+ text: out.text ?? null,
196
+ thoughts: geminiOptions.showThoughts ? out.thoughts : null,
197
+ has_images: out.images.length > 0,
198
+ image_count: out.images.length,
199
+ };
168
200
  }
169
201
  }
170
- else {
171
- const out = await runGeminiWebWithFallback({
172
- prompt,
173
- files: attachmentPaths,
174
- model,
175
- cookieMap,
176
- chatMetadata: null,
177
- });
178
- response = {
179
- text: out.text ?? null,
180
- thoughts: geminiOptions.showThoughts ? out.thoughts : null,
181
- has_images: out.images.length > 0,
182
- image_count: out.images.length,
183
- };
202
+ finally {
203
+ clearTimeout(timeout);
184
204
  }
185
205
  const answerText = response.text ?? '';
186
206
  let answerMarkdown = answerText;
@@ -129,7 +129,10 @@ export function registerConsultTool(server) {
129
129
  let browserConfig;
130
130
  if (resolvedEngine === 'browser') {
131
131
  const preferredLabel = (browserModelLabel ?? model)?.trim();
132
- const desiredModelLabel = resolveBrowserModelLabel(preferredLabel, runOptions.model);
132
+ const isChatGptModel = runOptions.model.startsWith('gpt-') && !runOptions.model.includes('codex');
133
+ const desiredModelLabel = isChatGptModel
134
+ ? mapModelToBrowserLabel(runOptions.model)
135
+ : resolveBrowserModelLabel(preferredLabel, runOptions.model);
133
136
  // Keep the browser path minimal; only forward a desired model label for the ChatGPT picker.
134
137
  browserConfig = {
135
138
  url: CHATGPT_URL,
@@ -2,7 +2,7 @@ import { countTokens as countTokensGpt5 } from 'gpt-tokenizer/model/gpt-5';
2
2
  import { countTokens as countTokensGpt5Pro } from 'gpt-tokenizer/model/gpt-5-pro';
3
3
  import { countTokens as countTokensAnthropicRaw } from '@anthropic-ai/tokenizer';
4
4
  import { stringifyTokenizerInput } from './tokenStringifier.js';
5
- export const DEFAULT_MODEL = 'gpt-5.1-pro';
5
+ export const DEFAULT_MODEL = 'gpt-5.2-pro';
6
6
  export const PRO_MODELS = new Set(['gpt-5.1-pro', 'gpt-5-pro', 'gpt-5.2-pro', 'claude-4.5-sonnet', 'claude-4.1-opus']);
7
7
  const countTokensAnthropic = (input) => countTokensAnthropicRaw(stringifyTokenizerInput(input));
8
8
  export const MODEL_CONFIGS = {
@@ -191,7 +191,6 @@ export async function runOracle(options, deps = {}) {
191
191
  (options.model.startsWith('gemini')
192
192
  ? resolveGeminiModelId(options.model)
193
193
  : (modelConfig.apiModel ?? modelConfig.model));
194
- const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
195
194
  const requestBody = buildRequestBody({
196
195
  modelConfig,
197
196
  systemPrompt,
@@ -205,7 +204,13 @@ export async function runOracle(options, deps = {}) {
205
204
  const tokenLabel = formatTokenEstimate(estimatedInputTokens, (text) => (richTty ? chalk.green(text) : text));
206
205
  const fileLabel = richTty ? chalk.magenta(fileCount.toString()) : fileCount.toString();
207
206
  const filesPhrase = fileCount === 0 ? 'no files' : `${fileLabel} files`;
208
- const headerLine = `Calling ${headerModelLabel} ${tokenLabel} tokens, ${filesPhrase}.`;
207
+ const headerModelLabelBase = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
208
+ const headerModelSuffix = effectiveModelId !== modelConfig.model
209
+ ? richTty
210
+ ? chalk.gray(` (API: ${effectiveModelId})`)
211
+ : ` (API: ${effectiveModelId})`
212
+ : '';
213
+ const headerLine = `Calling ${headerModelLabelBase}${headerModelSuffix} — ${tokenLabel} tokens, ${filesPhrase}.`;
209
214
  const shouldReportFiles = (options.filesReport || fileTokenInfo.totalTokens > inputTokenBudget) && fileTokenInfo.stats.length > 0;
210
215
  if (!isPreview) {
211
216
  if (!options.suppressHeader) {
@@ -213,9 +218,14 @@ export async function runOracle(options, deps = {}) {
213
218
  }
214
219
  const maskedKey = maskApiKey(apiKey);
215
220
  if (maskedKey && options.verbose) {
216
- const resolvedSuffix = effectiveModelId !== modelConfig.model ? ` (resolved: ${effectiveModelId})` : '';
221
+ const resolvedSuffix = effectiveModelId !== modelConfig.model ? ` (API: ${effectiveModelId})` : '';
217
222
  log(dim(`Using ${envVar}=${maskedKey} for model ${modelConfig.model}${resolvedSuffix}`));
218
223
  }
224
+ if (!options.suppressHeader &&
225
+ modelConfig.model === 'gpt-5.1-pro' &&
226
+ effectiveModelId === 'gpt-5.2-pro') {
227
+ log(dim('Note: `gpt-5.1-pro` is a stable CLI alias; OpenAI API uses `gpt-5.2-pro`.'));
228
+ }
219
229
  if (baseUrl) {
220
230
  log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
221
231
  }
@@ -442,10 +452,11 @@ export async function runOracle(options, deps = {}) {
442
452
  if (response.id && response.status === 'in_progress') {
443
453
  const polishingStart = now();
444
454
  const pollIntervalMs = 2_000;
445
- const maxWaitMs = 60_000;
455
+ const maxWaitMs = 180_000;
446
456
  log(chalk.dim('Response still in_progress; polling until completion...'));
447
457
  // Short polling loop — we don't want to hang forever, just catch late finalization.
448
458
  while (now() - polishingStart < maxWaitMs) {
459
+ throwIfTimedOut();
449
460
  await wait(pollIntervalMs);
450
461
  const refreshed = await clientInstance.responses.retrieve(response.id);
451
462
  if (refreshed.status === 'completed') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -41,44 +41,44 @@
41
41
  "homepage": "https://github.com/steipete/oracle#readme",
42
42
  "dependencies": {
43
43
  "@anthropic-ai/tokenizer": "^0.0.4",
44
- "@google/genai": "^1.31.0",
44
+ "@google/genai": "^1.34.0",
45
45
  "@google/generative-ai": "^0.24.1",
46
- "@modelcontextprotocol/sdk": "^1.24.3",
46
+ "@modelcontextprotocol/sdk": "^1.25.1",
47
47
  "chalk": "^5.6.2",
48
48
  "chrome-cookies-secure": "3.0.0",
49
49
  "chrome-launcher": "^1.2.1",
50
50
  "chrome-remote-interface": "^0.33.3",
51
- "clipboardy": "^5.0.1",
51
+ "clipboardy": "^5.0.2",
52
52
  "commander": "^14.0.2",
53
53
  "dotenv": "^17.2.3",
54
54
  "fast-glob": "^3.3.3",
55
55
  "gpt-tokenizer": "^3.4.0",
56
- "inquirer": "13.0.2",
56
+ "inquirer": "13.1.0",
57
57
  "json5": "^2.2.3",
58
58
  "keytar": "^7.9.0",
59
59
  "kleur": "^4.1.5",
60
60
  "markdansi": "^0.1.3",
61
- "openai": "^6.10.0",
62
- "shiki": "^3.19.0",
61
+ "openai": "^6.14.0",
62
+ "shiki": "^3.20.0",
63
63
  "sqlite3": "^5.1.7",
64
64
  "toasted-notifier": "^10.1.0",
65
- "zod": "^4.1.13"
65
+ "zod": "^4.2.1"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@anthropic-ai/tokenizer": "^0.0.4",
69
- "@biomejs/biome": "^2.3.8",
69
+ "@biomejs/biome": "^2.3.9",
70
70
  "@cdktf/node-pty-prebuilt-multiarch": "0.10.2",
71
71
  "@types/chrome-remote-interface": "^0.33.0",
72
72
  "@types/inquirer": "^9.0.9",
73
- "@types/node": "^24.10.1",
74
- "@vitest/coverage-v8": "4.0.15",
75
- "devtools-protocol": "^0.0.1551306",
76
- "es-toolkit": "^1.42.0",
77
- "esbuild": "^0.27.1",
78
- "puppeteer-core": "^24.32.0",
73
+ "@types/node": "^25.0.3",
74
+ "@vitest/coverage-v8": "4.0.16",
75
+ "devtools-protocol": "0.0.1559729",
76
+ "es-toolkit": "^1.43.0",
77
+ "esbuild": "^0.27.2",
78
+ "puppeteer-core": "^24.33.0",
79
79
  "tsx": "^4.21.0",
80
80
  "typescript": "^5.9.3",
81
- "vitest": "^4.0.15"
81
+ "vitest": "^4.0.16"
82
82
  },
83
83
  "optionalDependencies": {
84
84
  "win-dpapi": "npm:@primno/dpapi@2.0.1"