@steipete/oracle 1.1.0 → 1.3.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 (69) hide show
  1. package/README.md +40 -7
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/.DS_Store +0 -0
  4. package/dist/bin/oracle-cli.js +315 -47
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/src/browser/actions/modelSelection.js +117 -29
  7. package/dist/src/browser/config.js +6 -0
  8. package/dist/src/browser/cookies.js +50 -12
  9. package/dist/src/browser/index.js +19 -5
  10. package/dist/src/browser/prompt.js +6 -5
  11. package/dist/src/browser/sessionRunner.js +14 -3
  12. package/dist/src/cli/browserConfig.js +109 -2
  13. package/dist/src/cli/detach.js +12 -0
  14. package/dist/src/cli/dryRun.js +60 -8
  15. package/dist/src/cli/engine.js +7 -0
  16. package/dist/src/cli/help.js +3 -1
  17. package/dist/src/cli/hiddenAliases.js +17 -0
  18. package/dist/src/cli/markdownRenderer.js +79 -0
  19. package/dist/src/cli/notifier.js +223 -0
  20. package/dist/src/cli/options.js +22 -0
  21. package/dist/src/cli/promptRequirement.js +3 -0
  22. package/dist/src/cli/runOptions.js +43 -0
  23. package/dist/src/cli/sessionCommand.js +1 -1
  24. package/dist/src/cli/sessionDisplay.js +94 -7
  25. package/dist/src/cli/sessionRunner.js +32 -2
  26. package/dist/src/cli/tui/index.js +457 -0
  27. package/dist/src/config.js +27 -0
  28. package/dist/src/mcp/server.js +36 -0
  29. package/dist/src/mcp/tools/consult.js +158 -0
  30. package/dist/src/mcp/tools/sessionResources.js +64 -0
  31. package/dist/src/mcp/tools/sessions.js +106 -0
  32. package/dist/src/mcp/types.js +17 -0
  33. package/dist/src/mcp/utils.js +24 -0
  34. package/dist/src/oracle/client.js +24 -6
  35. package/dist/src/oracle/config.js +10 -0
  36. package/dist/src/oracle/files.js +151 -8
  37. package/dist/src/oracle/format.js +2 -7
  38. package/dist/src/oracle/fsAdapter.js +4 -1
  39. package/dist/src/oracle/gemini.js +161 -0
  40. package/dist/src/oracle/logging.js +36 -0
  41. package/dist/src/oracle/oscProgress.js +7 -1
  42. package/dist/src/oracle/run.js +148 -64
  43. package/dist/src/oracle/tokenEstimate.js +34 -0
  44. package/dist/src/oracle.js +1 -0
  45. package/dist/src/sessionManager.js +50 -3
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  48. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  49. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  50. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  51. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  52. package/dist/vendor/oracle-notifier/README.md +24 -0
  53. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  54. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  55. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  56. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  57. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  58. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  59. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  60. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  61. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  62. package/package.json +22 -6
  63. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  64. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  65. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  68. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  69. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -9,7 +9,8 @@ export async function ensureModelSelection(Runtime, desiredModel, logger) {
9
9
  const result = outcome.result?.value;
10
10
  switch (result?.status) {
11
11
  case 'already-selected':
12
- case 'switched': {
12
+ case 'switched':
13
+ case 'switched-best-effort': {
13
14
  const label = result.label ?? desiredModel;
14
15
  logger(`Model picker: ${label}`);
15
16
  return;
@@ -24,18 +25,26 @@ export async function ensureModelSelection(Runtime, desiredModel, logger) {
24
25
  }
25
26
  }
26
27
  }
28
+ /**
29
+ * Builds the DOM expression that runs inside the ChatGPT tab to select a model.
30
+ * The string is evaluated inside Chrome, so keep it self-contained and well-commented.
31
+ */
27
32
  function buildModelSelectionExpression(targetModel) {
28
33
  const matchers = buildModelMatchersLiteral(targetModel);
29
34
  const labelLiteral = JSON.stringify(matchers.labelTokens);
30
35
  const idLiteral = JSON.stringify(matchers.testIdTokens);
36
+ const primaryLabelLiteral = JSON.stringify(targetModel);
31
37
  const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
32
38
  const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
33
39
  return `(() => {
40
+ // Capture the selectors and matcher literals up front so the browser expression stays pure.
34
41
  const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}';
35
42
  const LABEL_TOKENS = ${labelLiteral};
36
43
  const TEST_IDS = ${idLiteral};
37
- const CLICK_INTERVAL_MS = 50;
38
- const MAX_WAIT_MS = 12000;
44
+ const PRIMARY_LABEL = ${primaryLabelLiteral};
45
+ const INITIAL_WAIT_MS = 150;
46
+ const REOPEN_INTERVAL_MS = 400;
47
+ const MAX_WAIT_MS = 20000;
39
48
  const normalizeText = (value) => {
40
49
  if (!value) {
41
50
  return '';
@@ -46,6 +55,12 @@ function buildModelSelectionExpression(targetModel) {
46
55
  .replace(/\\s+/g, ' ')
47
56
  .trim();
48
57
  };
58
+ // Normalize every candidate token to keep fuzzy matching deterministic.
59
+ const normalizedTarget = normalizeText(PRIMARY_LABEL);
60
+ const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
61
+ .map((token) => normalizeText(token))
62
+ .filter(Boolean);
63
+ const targetWords = normalizedTarget.split(' ').filter(Boolean);
49
64
 
50
65
  const button = document.querySelector(BUTTON_SELECTOR);
51
66
  if (!button) {
@@ -54,6 +69,7 @@ function buildModelSelectionExpression(targetModel) {
54
69
 
55
70
  let lastPointerClick = 0;
56
71
  const pointerClick = () => {
72
+ // Some menus ignore synthetic click events.
57
73
  const down = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
58
74
  const up = new PointerEvent('pointerup', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
59
75
  const click = new MouseEvent('click', { bubbles: true });
@@ -86,64 +102,109 @@ function buildModelSelectionExpression(targetModel) {
86
102
  return false;
87
103
  };
88
104
 
89
- const findOption = () => {
105
+ const scoreOption = (normalizedText, testid) => {
106
+ // Assign a score to every node so we can pick the most likely match without brittle equality checks.
107
+ if (!normalizedText && !testid) {
108
+ return 0;
109
+ }
110
+ let score = 0;
111
+ const normalizedTestId = (testid ?? '').toLowerCase();
112
+ if (normalizedTestId && TEST_IDS.some((id) => normalizedTestId.includes(id))) {
113
+ score += 1000;
114
+ }
115
+ if (normalizedText && normalizedTarget) {
116
+ if (normalizedText === normalizedTarget) {
117
+ score += 500;
118
+ } else if (normalizedText.startsWith(normalizedTarget)) {
119
+ score += 420;
120
+ } else if (normalizedText.includes(normalizedTarget)) {
121
+ score += 380;
122
+ }
123
+ }
124
+ for (const token of normalizedTokens) {
125
+ // Reward partial matches to the expanded label/token set.
126
+ if (token && normalizedText.includes(token)) {
127
+ const tokenWeight = Math.min(120, Math.max(10, token.length * 4));
128
+ score += tokenWeight;
129
+ }
130
+ }
131
+ if (targetWords.length > 1) {
132
+ let missing = 0;
133
+ for (const word of targetWords) {
134
+ if (!normalizedText.includes(word)) {
135
+ missing += 1;
136
+ }
137
+ }
138
+ score -= missing * 12;
139
+ }
140
+ return Math.max(score, 0);
141
+ };
142
+
143
+ const findBestOption = () => {
144
+ // Walk through every menu item and keep whichever earns the highest score.
145
+ let bestMatch = null;
90
146
  const menus = Array.from(document.querySelectorAll(${menuContainerLiteral}));
91
147
  for (const menu of menus) {
92
148
  const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
93
149
  for (const option of buttons) {
94
- const testid = (option.getAttribute('data-testid') ?? '').toLowerCase();
95
150
  const text = option.textContent ?? '';
96
151
  const normalizedText = normalizeText(text);
97
- const matchesTestId = testid && TEST_IDS.some((id) => testid.includes(id));
98
- const matchesText = LABEL_TOKENS.some((token) => {
99
- const normalizedToken = normalizeText(token);
100
- if (!normalizedToken) {
101
- return false;
102
- }
103
- return normalizedText.includes(normalizedToken);
104
- });
105
- if (matchesTestId || matchesText) {
106
- return option;
152
+ const testid = option.getAttribute('data-testid') ?? '';
153
+ const score = scoreOption(normalizedText, testid);
154
+ if (score <= 0) {
155
+ continue;
156
+ }
157
+ const label = getOptionLabel(option);
158
+ if (!bestMatch || score > bestMatch.score) {
159
+ bestMatch = { node: option, label, score };
107
160
  }
108
161
  }
109
162
  }
110
- return null;
163
+ return bestMatch;
111
164
  };
112
165
 
113
- pointerClick();
114
166
  return new Promise((resolve) => {
115
167
  const start = performance.now();
116
168
  const ensureMenuOpen = () => {
117
169
  const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
118
- if (!menuOpen && performance.now() - lastPointerClick > 300) {
170
+ if (!menuOpen && performance.now() - lastPointerClick > REOPEN_INTERVAL_MS) {
119
171
  pointerClick();
120
172
  }
121
173
  };
122
- const attempt = () => {
174
+
175
+ // Open once and wait a tick before first scan.
176
+ pointerClick();
177
+ const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
178
+ let initialized = false;
179
+ const attempt = async () => {
180
+ if (!initialized) {
181
+ initialized = true;
182
+ await openDelay();
183
+ }
123
184
  ensureMenuOpen();
124
- const option = findOption();
125
- if (option) {
126
- if (optionIsSelected(option)) {
127
- resolve({ status: 'already-selected', label: getOptionLabel(option) });
185
+ const match = findBestOption();
186
+ if (match) {
187
+ if (optionIsSelected(match.node)) {
188
+ resolve({ status: 'already-selected', label: match.label });
128
189
  return;
129
190
  }
130
- option.click();
131
- resolve({ status: 'switched', label: getOptionLabel(option) });
191
+ match.node.click();
192
+ resolve({ status: 'switched', label: match.label });
132
193
  return;
133
194
  }
134
195
  if (performance.now() - start > MAX_WAIT_MS) {
135
196
  resolve({ status: 'option-not-found' });
136
197
  return;
137
198
  }
138
- if (performance.now() - lastPointerClick > 500) {
139
- pointerClick();
140
- }
141
- setTimeout(attempt, CLICK_INTERVAL_MS);
199
+ setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
142
200
  };
143
201
  attempt();
144
202
  });
145
203
  })()`;
146
204
  }
205
+ export function buildModelMatchersLiteralForTest(targetModel) {
206
+ return buildModelMatchersLiteral(targetModel);
207
+ }
147
208
  function buildModelMatchersLiteral(targetModel) {
148
209
  const base = targetModel.trim().toLowerCase();
149
210
  const labelTokens = new Set();
@@ -164,6 +225,28 @@ function buildModelMatchersLiteral(targetModel) {
164
225
  push(`chatgpt ${dotless}`, labelTokens);
165
226
  push(`gpt ${base}`, labelTokens);
166
227
  push(`gpt ${dotless}`, labelTokens);
228
+ // Numeric variations (5.1 ↔ 51 ↔ gpt-5-1)
229
+ if (base.includes('5.1') || base.includes('5-1') || base.includes('51')) {
230
+ push('5.1', labelTokens);
231
+ push('gpt-5.1', labelTokens);
232
+ push('gpt5.1', labelTokens);
233
+ push('gpt-5-1', labelTokens);
234
+ push('gpt5-1', labelTokens);
235
+ push('gpt51', labelTokens);
236
+ push('chatgpt 5.1', labelTokens);
237
+ testIdTokens.add('gpt-5-1');
238
+ testIdTokens.add('gpt5-1');
239
+ testIdTokens.add('gpt51');
240
+ }
241
+ // Pro / research variants
242
+ if (base.includes('pro')) {
243
+ push('proresearch', labelTokens);
244
+ push('research grade', labelTokens);
245
+ push('advanced reasoning', labelTokens);
246
+ testIdTokens.add('gpt-5-pro');
247
+ testIdTokens.add('pro');
248
+ testIdTokens.add('proresearch');
249
+ }
167
250
  base
168
251
  .split(/\s+/)
169
252
  .map((token) => token.trim())
@@ -175,8 +258,10 @@ function buildModelMatchersLiteral(targetModel) {
175
258
  push(hyphenated, testIdTokens);
176
259
  push(collapsed, testIdTokens);
177
260
  push(dotless, testIdTokens);
261
+ // data-testid values observed in the ChatGPT picker (e.g., model-switcher-gpt-5-pro)
178
262
  push(`model-switcher-${hyphenated}`, testIdTokens);
179
263
  push(`model-switcher-${collapsed}`, testIdTokens);
264
+ push(`model-switcher-${dotless}`, testIdTokens);
180
265
  if (!labelTokens.size) {
181
266
  labelTokens.add(base);
182
267
  }
@@ -188,3 +273,6 @@ function buildModelMatchersLiteral(targetModel) {
188
273
  testIdTokens: Array.from(testIdTokens).filter(Boolean),
189
274
  };
190
275
  }
276
+ export function buildModelSelectionExpressionForTest(targetModel) {
277
+ return buildModelSelectionExpression(targetModel);
278
+ }
@@ -6,6 +6,9 @@ export const DEFAULT_BROWSER_CONFIG = {
6
6
  timeoutMs: 900_000,
7
7
  inputTimeoutMs: 30_000,
8
8
  cookieSync: true,
9
+ cookieNames: null,
10
+ inlineCookies: null,
11
+ inlineCookiesSource: null,
9
12
  headless: false,
10
13
  keepBrowser: false,
11
14
  hideWindow: false,
@@ -21,6 +24,9 @@ export function resolveBrowserConfig(config) {
21
24
  timeoutMs: config?.timeoutMs ?? DEFAULT_BROWSER_CONFIG.timeoutMs,
22
25
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
23
26
  cookieSync: config?.cookieSync ?? DEFAULT_BROWSER_CONFIG.cookieSync,
27
+ cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
28
+ inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
29
+ inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
24
30
  headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
25
31
  keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
26
32
  hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
@@ -4,21 +4,18 @@ import { fileURLToPath } from 'node:url';
4
4
  import { COOKIE_URLS } from './constants.js';
5
5
  export class ChromeCookieSyncError extends Error {
6
6
  }
7
- export async function syncCookies(Network, url, profile, logger, allowErrors = false) {
7
+ export async function syncCookies(Network, url, profile, logger, options = {}) {
8
+ const { allowErrors = false, filterNames, inlineCookies } = options;
8
9
  try {
9
- const cookies = await readChromeCookies(url, profile);
10
+ const cookies = inlineCookies?.length
11
+ ? normalizeInlineCookies(inlineCookies, new URL(url).hostname)
12
+ : await readChromeCookies(url, profile, filterNames ?? undefined);
10
13
  if (!cookies.length) {
11
14
  return 0;
12
15
  }
13
16
  let applied = 0;
14
17
  for (const cookie of cookies) {
15
- const cookieWithUrl = { ...cookie };
16
- if (!cookieWithUrl.domain || cookieWithUrl.domain === 'localhost') {
17
- cookieWithUrl.url = url;
18
- }
19
- else if (!cookieWithUrl.domain.startsWith('.')) {
20
- cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
21
- }
18
+ const cookieWithUrl = attachUrl(cookie, url);
22
19
  try {
23
20
  const result = await Network.setCookie(cookieWithUrl);
24
21
  if (result?.success) {
@@ -41,10 +38,11 @@ export async function syncCookies(Network, url, profile, logger, allowErrors = f
41
38
  throw error instanceof ChromeCookieSyncError ? error : new ChromeCookieSyncError(message);
42
39
  }
43
40
  }
44
- async function readChromeCookies(url, profile) {
41
+ async function readChromeCookies(url, profile, filterNames) {
45
42
  const chromeModule = await loadChromeCookiesModule();
46
43
  const urlsToCheck = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
47
44
  const merged = new Map();
45
+ const allowlist = normalizeCookieNames(filterNames);
48
46
  for (const candidateUrl of urlsToCheck) {
49
47
  let rawCookies;
50
48
  rawCookies = await chromeModule.getCookiesPromised(candidateUrl, 'puppeteer', profile ?? undefined);
@@ -54,7 +52,7 @@ async function readChromeCookies(url, profile) {
54
52
  const fallbackHostname = new URL(candidateUrl).hostname;
55
53
  for (const cookie of rawCookies) {
56
54
  const normalized = normalizeCookie(cookie, fallbackHostname);
57
- if (!normalized) {
55
+ if (!normalized || (allowlist && !allowlist.has(normalized.name))) {
58
56
  continue;
59
57
  }
60
58
  const key = `${normalized.domain ?? fallbackHostname}:${normalized.name}`;
@@ -83,6 +81,46 @@ function normalizeCookie(cookie, fallbackHost) {
83
81
  httpOnly,
84
82
  };
85
83
  }
84
+ function normalizeInlineCookies(rawCookies, fallbackHost) {
85
+ const merged = new Map();
86
+ for (const cookie of rawCookies) {
87
+ if (!cookie?.name)
88
+ continue;
89
+ const normalized = {
90
+ ...cookie,
91
+ name: cookie.name,
92
+ value: cookie.value ?? '',
93
+ domain: cookie.domain ?? fallbackHost,
94
+ path: cookie.path ?? '/',
95
+ expires: normalizeExpiration(cookie.expires),
96
+ secure: cookie.secure ?? true,
97
+ httpOnly: cookie.httpOnly ?? false,
98
+ };
99
+ const key = `${normalized.domain ?? fallbackHost}:${normalized.name}`;
100
+ if (!merged.has(key)) {
101
+ merged.set(key, normalized);
102
+ }
103
+ }
104
+ return Array.from(merged.values());
105
+ }
106
+ function normalizeCookieNames(names) {
107
+ if (!names || names.length === 0) {
108
+ return null;
109
+ }
110
+ return new Set(names.map((name) => name.trim()).filter(Boolean));
111
+ }
112
+ function attachUrl(cookie, fallbackUrl) {
113
+ const cookieWithUrl = { ...cookie };
114
+ if (!cookieWithUrl.url) {
115
+ if (!cookieWithUrl.domain || cookieWithUrl.domain === 'localhost') {
116
+ cookieWithUrl.url = fallbackUrl;
117
+ }
118
+ else if (!cookieWithUrl.domain.startsWith('.')) {
119
+ cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
120
+ }
121
+ }
122
+ return cookieWithUrl;
123
+ }
86
124
  function stripQuery(url) {
87
125
  try {
88
126
  const parsed = new URL(url);
@@ -177,7 +215,7 @@ async function attemptSqliteRebuild() {
177
215
  }
178
216
  attemptedSqliteRebuild = true;
179
217
  if (process.env.ORACLE_ALLOW_SQLITE_REBUILD !== '1') {
180
- console.warn('[oracle] sqlite3 bindings missing. Set ORACLE_ALLOW_SQLITE_REBUILD=1 if you want Oracle to attempt an automatic rebuild, or run `pnpm rebuild chrome-cookies-secure sqlite3 keytar --workspace-root` manually.');
218
+ console.warn('[oracle] sqlite3 bindings missing. Set ORACLE_ALLOW_SQLITE_REBUILD=1 if you want oracle to attempt an automatic rebuild, or run `pnpm rebuild chrome-cookies-secure sqlite3 keytar --workspace-root` manually.');
181
219
  return false;
182
220
  }
183
221
  const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
@@ -64,10 +64,24 @@ export async function runBrowserMode(options) {
64
64
  await Promise.all(domainEnablers);
65
65
  await Network.clearBrowserCookies();
66
66
  if (config.cookieSync) {
67
- const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, config.allowCookieErrors ?? false);
67
+ if (!config.inlineCookies) {
68
+ logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; approve it to stay signed in or rerun with --browser-no-cookie-sync / --browser-allow-cookie-errors / --browser-inline-cookies[(-file)]. Inline cookies skip Chrome + Keychain entirely.');
69
+ }
70
+ else {
71
+ logger('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
72
+ }
73
+ const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
74
+ allowErrors: config.allowCookieErrors ?? false,
75
+ filterNames: config.cookieNames ?? undefined,
76
+ inlineCookies: config.inlineCookies ?? undefined,
77
+ });
68
78
  logger(cookieCount > 0
69
- ? `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
70
- : 'No Chrome cookies found; continuing without session reuse');
79
+ ? config.inlineCookies
80
+ ? `Applied ${cookieCount} inline cookies`
81
+ : `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
82
+ : config.inlineCookies
83
+ ? 'No inline cookies applied; continuing without session reuse'
84
+ : 'No Chrome cookies found; continuing without session reuse');
71
85
  }
72
86
  else {
73
87
  logger('Skipping Chrome cookie sync (--browser-no-cookie-sync)');
@@ -155,7 +169,7 @@ export async function runBrowserMode(options) {
155
169
  logger(`Chrome window closed before completion: ${normalizedError.message}`);
156
170
  logger(normalizedError.stack);
157
171
  }
158
- throw new Error('Chrome window closed before Oracle finished. Please keep it open until completion.', {
172
+ throw new Error('Chrome window closed before oracle finished. Please keep it open until completion.', {
159
173
  cause: normalizedError,
160
174
  });
161
175
  }
@@ -211,7 +225,7 @@ export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
211
225
  .toString()
212
226
  .padStart(3, ' ');
213
227
  const statusLabel = message ? ` — ${message}` : '';
214
- return `[${elapsedText} / ~10m] ${bar} ${pct}%${statusLabel}${locatorSuffix}`;
228
+ return `${bar} ${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
215
229
  }
216
230
  function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
217
231
  let stopped = false;
@@ -1,14 +1,14 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { readFiles, createFileSections, DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from '../oracle.js';
4
+ import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS } from '../oracle.js';
5
5
  export async function assembleBrowserPrompt(runOptions, deps = {}) {
6
6
  const cwd = deps.cwd ?? process.cwd();
7
7
  const readFilesFn = deps.readFilesImpl ?? readFiles;
8
8
  const files = await readFilesFn(runOptions.file ?? [], { cwd });
9
9
  const basePrompt = (runOptions.prompt ?? '').trim();
10
10
  const userPrompt = basePrompt;
11
- const systemPrompt = runOptions.system?.trim() || DEFAULT_SYSTEM_PROMPT;
11
+ const systemPrompt = runOptions.system?.trim() || '';
12
12
  const sections = createFileSections(files, cwd);
13
13
  const lines = ['[SYSTEM]', systemPrompt, '', '[USER]', userPrompt, ''];
14
14
  sections.forEach((section) => {
@@ -43,7 +43,8 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
43
43
  sizeBytes: Buffer.byteLength(section.content, 'utf8'),
44
44
  }));
45
45
  const MAX_BROWSER_ATTACHMENTS = 10;
46
- if (!inlineFiles && attachments.length > MAX_BROWSER_ATTACHMENTS) {
46
+ const shouldBundle = !inlineFiles && (runOptions.browserBundleFiles || attachments.length > MAX_BROWSER_ATTACHMENTS);
47
+ if (shouldBundle) {
47
48
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
48
49
  const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
49
50
  const bundleLines = [];
@@ -57,7 +58,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
57
58
  attachments.length = 0;
58
59
  attachments.push({
59
60
  path: bundlePath,
60
- displayPath: 'attachments-bundle.txt',
61
+ displayPath: bundlePath,
61
62
  sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
62
63
  });
63
64
  }
@@ -81,7 +82,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
81
82
  attachments,
82
83
  inlineFileCount,
83
84
  tokenEstimateIncludesInlineFiles,
84
- bundled: !inlineFiles && attachments.length === 1 && sections.length > MAX_BROWSER_ATTACHMENTS && attachments[0]?.displayPath
85
+ bundled: shouldBundle && attachments.length === 1 && attachments[0]?.displayPath
85
86
  ? { originalCount: sections.length, bundlePath: attachments[0].displayPath }
86
87
  : null,
87
88
  };
@@ -4,6 +4,11 @@ import { runBrowserMode } from '../browserMode.js';
4
4
  import { assembleBrowserPrompt } from './prompt.js';
5
5
  import { BrowserAutomationError } from '../oracle/errors.js';
6
6
  export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log, cliVersion }, deps = {}) {
7
+ if (runOptions.model.startsWith('gemini')) {
8
+ throw new BrowserAutomationError('Gemini models are not available in browser mode. Re-run with --engine api.', {
9
+ stage: 'preflight',
10
+ });
11
+ }
7
12
  const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
8
13
  const executeBrowser = deps.executeBrowser ?? runBrowserMode;
9
14
  const promptArtifacts = await assemblePrompt(runOptions, { cwd });
@@ -16,14 +21,17 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
16
21
  const attachmentList = promptArtifacts.attachments.map((attachment) => attachment.displayPath).join(', ');
17
22
  log(chalk.dim(`[verbose] Browser attachments: ${attachmentList}`));
18
23
  if (promptArtifacts.bundled) {
19
- log(chalk.yellow(`[browser] More than 10 files provided; bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath} to satisfy ChatGPT upload limits.`));
24
+ log(chalk.yellow(`[browser] Bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}.`));
20
25
  }
21
26
  }
22
27
  else if (runOptions.file && runOptions.file.length > 0 && runOptions.browserInlineFiles) {
23
28
  log(chalk.dim('[verbose] Browser inline file fallback enabled (pasting file contents).'));
24
29
  }
25
30
  }
26
- const headerLine = `Oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
31
+ const headerLine = `oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
32
+ if (promptArtifacts.bundled) {
33
+ log(chalk.yellow(`[browser] Packed ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}. If automation fails, you can drag this file into ChatGPT manually.`));
34
+ }
27
35
  const automationLogger = ((message) => {
28
36
  if (typeof message === 'string') {
29
37
  log(message);
@@ -56,6 +64,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
56
64
  log(browserResult.answerMarkdown || browserResult.answerText || chalk.dim('(no text output)'));
57
65
  log('');
58
66
  }
67
+ const answerText = browserResult.answerMarkdown || browserResult.answerText || '';
59
68
  const usage = {
60
69
  inputTokens: promptArtifacts.estimatedInputTokens,
61
70
  outputTokens: browserResult.answerTokens,
@@ -63,7 +72,8 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
63
72
  totalTokens: promptArtifacts.estimatedInputTokens + browserResult.answerTokens,
64
73
  };
65
74
  const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
66
- const statsParts = [`${runOptions.model}[browser]`, `tok(i/o/r/t)=${tokensDisplay}`];
75
+ const tokensLabel = runOptions.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
76
+ const statsParts = [`${runOptions.model}[browser]`, `${tokensLabel}=${tokensDisplay}`];
67
77
  if (runOptions.file && runOptions.file.length > 0) {
68
78
  statsParts.push(`files=${runOptions.file.length}`);
69
79
  }
@@ -76,5 +86,6 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
76
86
  chromePort: browserResult.chromePort,
77
87
  userDataDir: browserResult.userDataDir,
78
88
  },
89
+ answerText,
79
90
  };
80
91
  }
@@ -1,16 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
1
4
  import { DEFAULT_MODEL_TARGET, parseDuration } from '../browserMode.js';
2
5
  const DEFAULT_BROWSER_TIMEOUT_MS = 900_000;
3
6
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 30_000;
4
7
  const DEFAULT_CHROME_PROFILE = 'Default';
5
8
  const BROWSER_MODEL_LABELS = {
6
9
  'gpt-5-pro': 'GPT-5 Pro',
7
- 'gpt-5.1': 'ChatGPT 5.1',
10
+ 'gpt-5.1': 'GPT-5.1',
11
+ 'gemini-3-pro': 'Gemini 3 Pro',
8
12
  };
9
- export function buildBrowserConfig(options) {
13
+ export async function buildBrowserConfig(options) {
10
14
  const desiredModelOverride = options.browserModelLabel?.trim();
11
15
  const normalizedOverride = desiredModelOverride?.toLowerCase() ?? '';
12
16
  const baseModel = options.model.toLowerCase();
13
17
  const shouldUseOverride = normalizedOverride.length > 0 && normalizedOverride !== baseModel;
18
+ const cookieNames = parseCookieNames(options.browserCookieNames ?? process.env.ORACLE_BROWSER_COOKIE_NAMES);
19
+ const inline = await resolveInlineCookies({
20
+ inlineArg: options.browserInlineCookies,
21
+ inlineFileArg: options.browserInlineCookiesFile,
22
+ envPayload: process.env.ORACLE_BROWSER_COOKIES_JSON,
23
+ envFile: process.env.ORACLE_BROWSER_COOKIES_FILE,
24
+ cwd: process.cwd(),
25
+ });
14
26
  return {
15
27
  chromeProfile: options.browserChromeProfile ?? DEFAULT_CHROME_PROFILE,
16
28
  chromePath: options.browserChromePath ?? null,
@@ -20,6 +32,9 @@ export function buildBrowserConfig(options) {
20
32
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
21
33
  : undefined,
22
34
  cookieSync: options.browserNoCookieSync ? false : undefined,
35
+ cookieNames,
36
+ inlineCookies: inline?.cookies,
37
+ inlineCookiesSource: inline?.source ?? null,
23
38
  headless: options.browserHeadless ? true : undefined,
24
39
  keepBrowser: options.browserKeepBrowser ? true : undefined,
25
40
  hideWindow: options.browserHideWindow ? true : undefined,
@@ -42,3 +57,95 @@ export function resolveBrowserModelLabel(input, model) {
42
57
  }
43
58
  return trimmed;
44
59
  }
60
+ function parseCookieNames(raw) {
61
+ if (!raw)
62
+ return undefined;
63
+ const names = raw
64
+ .split(',')
65
+ .map((entry) => entry.trim())
66
+ .filter(Boolean);
67
+ return names.length ? names : undefined;
68
+ }
69
+ async function resolveInlineCookies({ inlineArg, inlineFileArg, envPayload, envFile, cwd, }) {
70
+ const tryLoad = async (source, allowPathResolution) => {
71
+ if (!source)
72
+ return undefined;
73
+ const trimmed = source.trim();
74
+ if (!trimmed)
75
+ return undefined;
76
+ if (allowPathResolution) {
77
+ const resolved = path.isAbsolute(trimmed) ? trimmed : path.join(cwd, trimmed);
78
+ try {
79
+ const stat = await fs.stat(resolved);
80
+ if (stat.isFile()) {
81
+ const fileContent = await fs.readFile(resolved, 'utf8');
82
+ const parsed = parseInlineCookiesPayload(fileContent);
83
+ if (parsed)
84
+ return parsed;
85
+ }
86
+ }
87
+ catch {
88
+ // not a file; treat as payload below
89
+ }
90
+ }
91
+ return parseInlineCookiesPayload(trimmed);
92
+ };
93
+ const sources = [
94
+ { value: inlineFileArg, allowPath: true, source: 'inline-file' },
95
+ { value: inlineArg, allowPath: true, source: 'inline-arg' },
96
+ { value: envFile, allowPath: true, source: 'env-file' },
97
+ { value: envPayload, allowPath: false, source: 'env-payload' },
98
+ ];
99
+ for (const { value, allowPath, source } of sources) {
100
+ const parsed = await tryLoad(value, allowPath);
101
+ if (parsed)
102
+ return { cookies: parsed, source };
103
+ }
104
+ // fallback: ~/.oracle/cookies.{json,base64}
105
+ const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
106
+ const candidates = ['cookies.json', 'cookies.base64'];
107
+ for (const file of candidates) {
108
+ const fullPath = path.join(oracleHome, file);
109
+ try {
110
+ const stat = await fs.stat(fullPath);
111
+ if (!stat.isFile())
112
+ continue;
113
+ const content = await fs.readFile(fullPath, 'utf8');
114
+ const parsed = parseInlineCookiesPayload(content);
115
+ if (parsed)
116
+ return { cookies: parsed, source: `home:${file}` };
117
+ }
118
+ catch {
119
+ // ignore missing/invalid
120
+ }
121
+ }
122
+ return undefined;
123
+ }
124
+ function parseInlineCookiesPayload(raw) {
125
+ if (!raw)
126
+ return undefined;
127
+ const text = raw.trim();
128
+ if (!text)
129
+ return undefined;
130
+ let jsonPayload = text;
131
+ // Attempt base64 decode first; fall back to raw text on failure.
132
+ try {
133
+ const decoded = Buffer.from(text, 'base64').toString('utf8');
134
+ if (decoded.trim().startsWith('[')) {
135
+ jsonPayload = decoded;
136
+ }
137
+ }
138
+ catch {
139
+ // not base64; continue with raw text
140
+ }
141
+ try {
142
+ const parsed = JSON.parse(jsonPayload);
143
+ if (Array.isArray(parsed)) {
144
+ return parsed;
145
+ }
146
+ }
147
+ catch {
148
+ // invalid json; skip silently to keep this hidden flag non-fatal
149
+ }
150
+ return undefined;
151
+ }
@@ -0,0 +1,12 @@
1
+ export function shouldDetachSession({
2
+ // Params kept for future policy tweaks; currently only model/disableDetachEnv matter.
3
+ engine: _engine, model, waitPreference: _waitPreference, disableDetachEnv, }) {
4
+ if (disableDetachEnv)
5
+ return false;
6
+ // Gemini runs must stay inline: forcing detachment can launch the background session runner,
7
+ // which previously led to silent hangs when Gemini picked the browser path. Keep it simple: no detach.
8
+ if (model.startsWith('gemini'))
9
+ return false;
10
+ // For other models, keep legacy behavior (detach if allowed, then reattach when waitPreference=true).
11
+ return true;
12
+ }