@steipete/oracle 1.0.8 → 1.2.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 (106) hide show
  1. package/README.md +32 -4
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/bin/oracle-cli.js +178 -21
  4. package/dist/bin/oracle-mcp.js +6 -0
  5. package/dist/markdansi/types/index.js +4 -0
  6. package/dist/oracle/bin/oracle-cli.js +472 -0
  7. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  8. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  9. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  10. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  11. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  12. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  13. package/dist/oracle/src/browser/config.js +33 -0
  14. package/dist/oracle/src/browser/constants.js +40 -0
  15. package/dist/oracle/src/browser/cookies.js +210 -0
  16. package/dist/oracle/src/browser/domDebug.js +36 -0
  17. package/dist/oracle/src/browser/index.js +331 -0
  18. package/dist/oracle/src/browser/pageActions.js +5 -0
  19. package/dist/oracle/src/browser/prompt.js +88 -0
  20. package/dist/oracle/src/browser/promptSummary.js +20 -0
  21. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  22. package/dist/oracle/src/browser/types.js +1 -0
  23. package/dist/oracle/src/browser/utils.js +62 -0
  24. package/dist/oracle/src/browserMode.js +1 -0
  25. package/dist/oracle/src/cli/browserConfig.js +44 -0
  26. package/dist/oracle/src/cli/dryRun.js +59 -0
  27. package/dist/oracle/src/cli/engine.js +17 -0
  28. package/dist/oracle/src/cli/errorUtils.js +9 -0
  29. package/dist/oracle/src/cli/help.js +70 -0
  30. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  31. package/dist/oracle/src/cli/options.js +103 -0
  32. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  33. package/dist/oracle/src/cli/rootAlias.js +30 -0
  34. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  35. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  36. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  37. package/dist/oracle/src/heartbeat.js +43 -0
  38. package/dist/oracle/src/oracle/client.js +48 -0
  39. package/dist/oracle/src/oracle/config.js +29 -0
  40. package/dist/oracle/src/oracle/errors.js +101 -0
  41. package/dist/oracle/src/oracle/files.js +220 -0
  42. package/dist/oracle/src/oracle/format.js +33 -0
  43. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  44. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  45. package/dist/oracle/src/oracle/request.js +48 -0
  46. package/dist/oracle/src/oracle/run.js +444 -0
  47. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  48. package/dist/oracle/src/oracle/types.js +1 -0
  49. package/dist/oracle/src/oracle.js +9 -0
  50. package/dist/oracle/src/sessionManager.js +205 -0
  51. package/dist/oracle/src/version.js +39 -0
  52. package/dist/src/browser/actions/modelSelection.js +117 -29
  53. package/dist/src/browser/cookies.js +1 -1
  54. package/dist/src/browser/index.js +2 -1
  55. package/dist/src/browser/prompt.js +6 -5
  56. package/dist/src/browser/sessionRunner.js +4 -2
  57. package/dist/src/cli/dryRun.js +41 -5
  58. package/dist/src/cli/engine.js +7 -0
  59. package/dist/src/cli/help.js +1 -1
  60. package/dist/src/cli/hiddenAliases.js +17 -0
  61. package/dist/src/cli/markdownRenderer.js +97 -0
  62. package/dist/src/cli/notifier.js +223 -0
  63. package/dist/src/cli/promptRequirement.js +3 -0
  64. package/dist/src/cli/rootAlias.js +14 -0
  65. package/dist/src/cli/runOptions.js +29 -0
  66. package/dist/src/cli/sessionCommand.js +60 -2
  67. package/dist/src/cli/sessionDisplay.js +222 -10
  68. package/dist/src/cli/sessionRunner.js +21 -2
  69. package/dist/src/cli/tui/index.js +436 -0
  70. package/dist/src/config.js +27 -0
  71. package/dist/src/mcp/server.js +36 -0
  72. package/dist/src/mcp/tools/consult.js +158 -0
  73. package/dist/src/mcp/tools/sessionResources.js +64 -0
  74. package/dist/src/mcp/tools/sessions.js +106 -0
  75. package/dist/src/mcp/types.js +17 -0
  76. package/dist/src/mcp/utils.js +24 -0
  77. package/dist/src/oracle/files.js +143 -6
  78. package/dist/src/oracle/oscProgress.js +60 -0
  79. package/dist/src/oracle/run.js +104 -71
  80. package/dist/src/oracle/tokenEstimate.js +34 -0
  81. package/dist/src/sessionManager.js +65 -3
  82. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  83. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  84. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  85. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  86. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  88. package/dist/vendor/oracle-notifier/README.md +24 -0
  89. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  90. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  91. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  92. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  93. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  94. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  95. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  96. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  97. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  98. package/package.json +27 -9
  99. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  100. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  101. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  102. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  103. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  104. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  105. package/vendor/oracle-notifier/README.md +24 -0
  106. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,205 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import { createWriteStream } from 'node:fs';
5
+ const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
6
+ const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
7
+ const MAX_STATUS_LIMIT = 1000;
8
+ const DEFAULT_SLUG = 'session';
9
+ const MAX_SLUG_WORDS = 5;
10
+ const MIN_CUSTOM_SLUG_WORDS = 3;
11
+ async function ensureDir(dirPath) {
12
+ await fs.mkdir(dirPath, { recursive: true });
13
+ }
14
+ export async function ensureSessionStorage() {
15
+ await ensureDir(SESSIONS_DIR);
16
+ }
17
+ function slugify(text, maxWords = MAX_SLUG_WORDS) {
18
+ const normalized = text?.toLowerCase() ?? '';
19
+ const words = normalized.match(/[a-z0-9]+/g) ?? [];
20
+ const trimmed = words.slice(0, maxWords);
21
+ return trimmed.length > 0 ? trimmed.join('-') : DEFAULT_SLUG;
22
+ }
23
+ function countSlugWords(slug) {
24
+ return slug.split('-').filter(Boolean).length;
25
+ }
26
+ function normalizeCustomSlug(candidate) {
27
+ const slug = slugify(candidate, MAX_SLUG_WORDS);
28
+ const wordCount = countSlugWords(slug);
29
+ if (wordCount < MIN_CUSTOM_SLUG_WORDS || wordCount > MAX_SLUG_WORDS) {
30
+ throw new Error(`Custom slug must include between ${MIN_CUSTOM_SLUG_WORDS} and ${MAX_SLUG_WORDS} words.`);
31
+ }
32
+ return slug;
33
+ }
34
+ export function createSessionId(prompt, customSlug) {
35
+ if (customSlug) {
36
+ return normalizeCustomSlug(customSlug);
37
+ }
38
+ return slugify(prompt);
39
+ }
40
+ function sessionDir(id) {
41
+ return path.join(SESSIONS_DIR, id);
42
+ }
43
+ function metaPath(id) {
44
+ return path.join(sessionDir(id), 'session.json');
45
+ }
46
+ function logPath(id) {
47
+ return path.join(sessionDir(id), 'output.log');
48
+ }
49
+ function requestPath(id) {
50
+ return path.join(sessionDir(id), 'request.json');
51
+ }
52
+ async function fileExists(targetPath) {
53
+ try {
54
+ await fs.access(targetPath);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function ensureUniqueSessionId(baseSlug) {
62
+ let candidate = baseSlug;
63
+ let suffix = 2;
64
+ while (await fileExists(sessionDir(candidate))) {
65
+ candidate = `${baseSlug}-${suffix}`;
66
+ suffix += 1;
67
+ }
68
+ return candidate;
69
+ }
70
+ export async function initializeSession(options, cwd) {
71
+ await ensureSessionStorage();
72
+ const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
73
+ const sessionId = await ensureUniqueSessionId(baseSlug);
74
+ const dir = sessionDir(sessionId);
75
+ await ensureDir(dir);
76
+ const mode = options.mode ?? 'api';
77
+ const browserConfig = options.browserConfig;
78
+ const metadata = {
79
+ id: sessionId,
80
+ createdAt: new Date().toISOString(),
81
+ status: 'pending',
82
+ promptPreview: (options.prompt || '').slice(0, 160),
83
+ model: options.model,
84
+ cwd,
85
+ mode,
86
+ browser: browserConfig ? { config: browserConfig } : undefined,
87
+ options: {
88
+ prompt: options.prompt,
89
+ file: options.file ?? [],
90
+ model: options.model,
91
+ maxInput: options.maxInput,
92
+ system: options.system,
93
+ maxOutput: options.maxOutput,
94
+ silent: options.silent,
95
+ filesReport: options.filesReport,
96
+ slug: sessionId,
97
+ mode,
98
+ browserConfig,
99
+ verbose: options.verbose,
100
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
101
+ browserInlineFiles: options.browserInlineFiles,
102
+ background: options.background,
103
+ },
104
+ };
105
+ await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
106
+ await fs.writeFile(requestPath(sessionId), JSON.stringify(metadata.options, null, 2), 'utf8');
107
+ await fs.writeFile(logPath(sessionId), '', 'utf8');
108
+ return metadata;
109
+ }
110
+ export async function readSessionMetadata(sessionId) {
111
+ try {
112
+ const raw = await fs.readFile(metaPath(sessionId), 'utf8');
113
+ return JSON.parse(raw);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ export async function updateSessionMetadata(sessionId, updates) {
120
+ const existing = (await readSessionMetadata(sessionId)) ?? { id: sessionId };
121
+ const next = { ...existing, ...updates };
122
+ await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), 'utf8');
123
+ return next;
124
+ }
125
+ export function createSessionLogWriter(sessionId) {
126
+ const stream = createWriteStream(logPath(sessionId), { flags: 'a' });
127
+ const logLine = (line = '') => {
128
+ stream.write(`${line}\n`);
129
+ };
130
+ const writeChunk = (chunk) => {
131
+ stream.write(chunk);
132
+ return true;
133
+ };
134
+ return { stream, logLine, writeChunk, logPath: logPath(sessionId) };
135
+ }
136
+ export async function listSessionsMetadata() {
137
+ await ensureSessionStorage();
138
+ const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
139
+ const metas = [];
140
+ for (const entry of entries) {
141
+ const meta = await readSessionMetadata(entry);
142
+ if (meta) {
143
+ metas.push(meta);
144
+ }
145
+ }
146
+ return metas.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
147
+ }
148
+ export function filterSessionsByRange(metas, { hours = 24, includeAll = false, limit = 100 }) {
149
+ const maxLimit = Math.min(limit, MAX_STATUS_LIMIT);
150
+ let filtered = metas;
151
+ if (!includeAll) {
152
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
153
+ filtered = metas.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff);
154
+ }
155
+ const limited = filtered.slice(0, maxLimit);
156
+ const truncated = filtered.length > maxLimit;
157
+ return { entries: limited, truncated, total: filtered.length };
158
+ }
159
+ export async function readSessionLog(sessionId) {
160
+ try {
161
+ return await fs.readFile(logPath(sessionId), 'utf8');
162
+ }
163
+ catch {
164
+ return '';
165
+ }
166
+ }
167
+ export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
168
+ await ensureSessionStorage();
169
+ const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
170
+ if (!entries.length) {
171
+ return { deleted: 0, remaining: 0 };
172
+ }
173
+ const cutoff = includeAll ? Number.NEGATIVE_INFINITY : Date.now() - hours * 60 * 60 * 1000;
174
+ let deleted = 0;
175
+ for (const entry of entries) {
176
+ const dir = sessionDir(entry);
177
+ let createdMs;
178
+ const meta = await readSessionMetadata(entry);
179
+ if (meta?.createdAt) {
180
+ const parsed = Date.parse(meta.createdAt);
181
+ if (!Number.isNaN(parsed)) {
182
+ createdMs = parsed;
183
+ }
184
+ }
185
+ if (createdMs == null) {
186
+ try {
187
+ const stats = await fs.stat(dir);
188
+ createdMs = stats.birthtimeMs || stats.mtimeMs;
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ }
194
+ if (includeAll || (createdMs != null && createdMs < cutoff)) {
195
+ await fs.rm(dir, { recursive: true, force: true });
196
+ deleted += 1;
197
+ }
198
+ }
199
+ const remaining = Math.max(entries.length - deleted, 0);
200
+ return { deleted, remaining };
201
+ }
202
+ export async function wait(ms) {
203
+ return new Promise((resolve) => setTimeout(resolve, ms));
204
+ }
205
+ export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ let cachedVersion = null;
5
+ export function getCliVersion() {
6
+ if (cachedVersion) {
7
+ return cachedVersion;
8
+ }
9
+ cachedVersion = readVersionFromPackage();
10
+ return cachedVersion;
11
+ }
12
+ function readVersionFromPackage() {
13
+ const modulePath = fileURLToPath(import.meta.url);
14
+ let currentDir = path.dirname(modulePath);
15
+ const filesystemRoot = path.parse(currentDir).root;
16
+ // biome-ignore lint/nursery/noUnnecessaryConditions: deliberate sentinel loop to walk up directories
17
+ while (true) {
18
+ const candidate = path.join(currentDir, 'package.json');
19
+ try {
20
+ const raw = readFileSync(candidate, 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ const version = typeof parsed.version === 'string' && parsed.version.trim().length > 0
23
+ ? parsed.version.trim()
24
+ : '0.0.0';
25
+ return version;
26
+ }
27
+ catch (error) {
28
+ const code = error instanceof Error && 'code' in error ? error.code : undefined;
29
+ if (code && code !== 'ENOENT') {
30
+ break;
31
+ }
32
+ }
33
+ if (currentDir === filesystemRoot) {
34
+ break;
35
+ }
36
+ currentDir = path.dirname(currentDir);
37
+ }
38
+ return '0.0.0';
39
+ }
@@ -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
+ }
@@ -177,7 +177,7 @@ async function attemptSqliteRebuild() {
177
177
  }
178
178
  attemptedSqliteRebuild = true;
179
179
  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.');
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.');
181
181
  return false;
182
182
  }
183
183
  const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
@@ -64,6 +64,7 @@ export async function runBrowserMode(options) {
64
64
  await Promise.all(domainEnablers);
65
65
  await Network.clearBrowserCookies();
66
66
  if (config.cookieSync) {
67
+ 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.');
67
68
  const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, config.allowCookieErrors ?? false);
68
69
  logger(cookieCount > 0
69
70
  ? `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? 'Default'}`
@@ -155,7 +156,7 @@ export async function runBrowserMode(options) {
155
156
  logger(`Chrome window closed before completion: ${normalizedError.message}`);
156
157
  logger(normalizedError.stack);
157
158
  }
158
- throw new Error('Chrome window closed before Oracle finished. Please keep it open until completion.', {
159
+ throw new Error('Chrome window closed before oracle finished. Please keep it open until completion.', {
159
160
  cause: normalizedError,
160
161
  });
161
162
  }
@@ -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
  };
@@ -16,14 +16,14 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
16
16
  const attachmentList = promptArtifacts.attachments.map((attachment) => attachment.displayPath).join(', ');
17
17
  log(chalk.dim(`[verbose] Browser attachments: ${attachmentList}`));
18
18
  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.`));
19
+ log(chalk.yellow(`[browser] Bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}.`));
20
20
  }
21
21
  }
22
22
  else if (runOptions.file && runOptions.file.length > 0 && runOptions.browserInlineFiles) {
23
23
  log(chalk.dim('[verbose] Browser inline file fallback enabled (pasting file contents).'));
24
24
  }
25
25
  }
26
- const headerLine = `Oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
26
+ const headerLine = `oracle (${cliVersion}) launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens`;
27
27
  const automationLogger = ((message) => {
28
28
  if (typeof message === 'string') {
29
29
  log(message);
@@ -56,6 +56,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
56
56
  log(browserResult.answerMarkdown || browserResult.answerText || chalk.dim('(no text output)'));
57
57
  log('');
58
58
  }
59
+ const answerText = browserResult.answerMarkdown || browserResult.answerText || '';
59
60
  const usage = {
60
61
  inputTokens: promptArtifacts.estimatedInputTokens,
61
62
  outputTokens: browserResult.answerTokens,
@@ -76,5 +77,6 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
76
77
  chromePort: browserResult.chromePort,
77
78
  userDataDir: browserResult.userDataDir,
78
79
  },
80
+ answerText,
79
81
  };
80
82
  }
@@ -40,20 +40,56 @@ async function runBrowserDryRun({ runOptions, cwd, version, log, }, deps) {
40
40
  const suffix = buildTokenEstimateSuffix(artifacts);
41
41
  const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
42
42
  log(chalk.cyan(headerLine));
43
- logBrowserFileSummary(artifacts, log);
43
+ logBrowserFileSummary(artifacts, log, 'dry-run');
44
44
  }
45
- function logBrowserFileSummary(artifacts, log) {
45
+ function logBrowserFileSummary(artifacts, log, label) {
46
46
  if (artifacts.attachments.length > 0) {
47
- log(chalk.bold('[dry-run] Attachments to upload:'));
47
+ const prefix = artifacts.bundled ? `[${label}] Bundled upload:` : `[${label}] Attachments to upload:`;
48
+ log(chalk.bold(prefix));
48
49
  artifacts.attachments.forEach((attachment) => {
49
50
  log(` • ${formatAttachmentLabel(attachment)}`);
50
51
  });
52
+ if (artifacts.bundled) {
53
+ log(chalk.dim(` (bundled ${artifacts.bundled.originalCount} files into ${artifacts.bundled.bundlePath})`));
54
+ }
51
55
  return;
52
56
  }
53
57
  if (artifacts.inlineFileCount > 0) {
54
- log(chalk.bold('[dry-run] Inline file content:'));
58
+ log(chalk.bold(`[${label}] Inline file content:`));
55
59
  log(` • ${artifacts.inlineFileCount} file${artifacts.inlineFileCount === 1 ? '' : 's'} pasted directly into the composer.`);
56
60
  return;
57
61
  }
58
- log(chalk.dim('[dry-run] No files attached.'));
62
+ log(chalk.dim(`[${label}] No files attached.`));
63
+ }
64
+ export async function runBrowserPreview({ runOptions, cwd, version, previewMode, log, }, deps = {}) {
65
+ const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt;
66
+ const artifacts = await assemblePromptImpl(runOptions, { cwd });
67
+ const suffix = buildTokenEstimateSuffix(artifacts);
68
+ const headerLine = `[preview] Oracle (${version}) browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
69
+ log(chalk.cyan(headerLine));
70
+ logBrowserFileSummary(artifacts, log, 'preview');
71
+ if (previewMode === 'json' || previewMode === 'full') {
72
+ const attachmentSummary = artifacts.attachments.map((attachment) => ({
73
+ path: attachment.path,
74
+ displayPath: attachment.displayPath,
75
+ sizeBytes: attachment.sizeBytes,
76
+ }));
77
+ const previewPayload = {
78
+ model: runOptions.model,
79
+ engine: 'browser',
80
+ composerText: artifacts.composerText,
81
+ attachments: attachmentSummary,
82
+ inlineFileCount: artifacts.inlineFileCount,
83
+ bundled: artifacts.bundled,
84
+ tokenEstimate: artifacts.estimatedInputTokens,
85
+ };
86
+ log('');
87
+ log(chalk.bold('Preview JSON'));
88
+ log(JSON.stringify(previewPayload, null, 2));
89
+ }
90
+ if (previewMode === 'full') {
91
+ log('');
92
+ log(chalk.bold('Composer Text'));
93
+ log(artifacts.composerText || chalk.dim('(empty prompt)'));
94
+ }
59
95
  }
@@ -1,3 +1,10 @@
1
+ export function defaultWaitPreference(model, engine) {
2
+ // gpt-5-pro (API) can take up to 10 minutes; default to non-blocking
3
+ if (engine === 'api' && model === 'gpt-5-pro') {
4
+ return false;
5
+ }
6
+ return true; // browser or gpt-5.1 are fast enough to block by default
7
+ }
1
8
  /**
2
9
  * Determine which engine to use based on CLI flags and the environment.
3
10
  *