@indykish/oracle 0.9.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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +1252 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/scripts/agent-send.js +147 -0
  7. package/dist/scripts/browser-tools.js +536 -0
  8. package/dist/scripts/check.js +21 -0
  9. package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
  10. package/dist/scripts/docs-list.js +110 -0
  11. package/dist/scripts/git-policy.js +125 -0
  12. package/dist/scripts/run-cli.js +14 -0
  13. package/dist/scripts/runner.js +1378 -0
  14. package/dist/scripts/test-browser.js +103 -0
  15. package/dist/scripts/test-remote-chrome.js +68 -0
  16. package/dist/src/bridge/connection.js +103 -0
  17. package/dist/src/bridge/userConfigFile.js +28 -0
  18. package/dist/src/browser/actions/assistantResponse.js +1067 -0
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
  20. package/dist/src/browser/actions/attachments.js +1910 -0
  21. package/dist/src/browser/actions/domEvents.js +19 -0
  22. package/dist/src/browser/actions/modelSelection.js +485 -0
  23. package/dist/src/browser/actions/navigation.js +445 -0
  24. package/dist/src/browser/actions/promptComposer.js +485 -0
  25. package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
  26. package/dist/src/browser/actions/thinkingTime.js +206 -0
  27. package/dist/src/browser/chromeLifecycle.js +344 -0
  28. package/dist/src/browser/config.js +103 -0
  29. package/dist/src/browser/constants.js +71 -0
  30. package/dist/src/browser/cookies.js +191 -0
  31. package/dist/src/browser/detect.js +164 -0
  32. package/dist/src/browser/domDebug.js +36 -0
  33. package/dist/src/browser/index.js +1741 -0
  34. package/dist/src/browser/modelStrategy.js +13 -0
  35. package/dist/src/browser/pageActions.js +5 -0
  36. package/dist/src/browser/policies.js +43 -0
  37. package/dist/src/browser/profileState.js +280 -0
  38. package/dist/src/browser/prompt.js +152 -0
  39. package/dist/src/browser/promptSummary.js +20 -0
  40. package/dist/src/browser/reattach.js +186 -0
  41. package/dist/src/browser/reattachHelpers.js +382 -0
  42. package/dist/src/browser/sessionRunner.js +119 -0
  43. package/dist/src/browser/types.js +1 -0
  44. package/dist/src/browser/utils.js +122 -0
  45. package/dist/src/browserMode.js +1 -0
  46. package/dist/src/cli/bridge/claudeConfig.js +54 -0
  47. package/dist/src/cli/bridge/client.js +73 -0
  48. package/dist/src/cli/bridge/codexConfig.js +43 -0
  49. package/dist/src/cli/bridge/doctor.js +107 -0
  50. package/dist/src/cli/bridge/host.js +259 -0
  51. package/dist/src/cli/browserConfig.js +278 -0
  52. package/dist/src/cli/browserDefaults.js +81 -0
  53. package/dist/src/cli/bundleWarnings.js +9 -0
  54. package/dist/src/cli/clipboard.js +10 -0
  55. package/dist/src/cli/detach.js +11 -0
  56. package/dist/src/cli/dryRun.js +105 -0
  57. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  58. package/dist/src/cli/engine.js +41 -0
  59. package/dist/src/cli/errorUtils.js +9 -0
  60. package/dist/src/cli/format.js +13 -0
  61. package/dist/src/cli/help.js +77 -0
  62. package/dist/src/cli/hiddenAliases.js +22 -0
  63. package/dist/src/cli/markdownBundle.js +17 -0
  64. package/dist/src/cli/markdownRenderer.js +97 -0
  65. package/dist/src/cli/notifier.js +306 -0
  66. package/dist/src/cli/options.js +281 -0
  67. package/dist/src/cli/oscUtils.js +2 -0
  68. package/dist/src/cli/promptRequirement.js +17 -0
  69. package/dist/src/cli/renderFlags.js +9 -0
  70. package/dist/src/cli/renderOutput.js +26 -0
  71. package/dist/src/cli/rootAlias.js +30 -0
  72. package/dist/src/cli/runOptions.js +78 -0
  73. package/dist/src/cli/sessionCommand.js +111 -0
  74. package/dist/src/cli/sessionDisplay.js +567 -0
  75. package/dist/src/cli/sessionRunner.js +602 -0
  76. package/dist/src/cli/sessionTable.js +92 -0
  77. package/dist/src/cli/tagline.js +258 -0
  78. package/dist/src/cli/tui/index.js +486 -0
  79. package/dist/src/cli/writeOutputPath.js +21 -0
  80. package/dist/src/config.js +26 -0
  81. package/dist/src/gemini-web/client.js +328 -0
  82. package/dist/src/gemini-web/executor.js +285 -0
  83. package/dist/src/gemini-web/index.js +1 -0
  84. package/dist/src/gemini-web/types.js +1 -0
  85. package/dist/src/heartbeat.js +43 -0
  86. package/dist/src/mcp/server.js +40 -0
  87. package/dist/src/mcp/tools/consult.js +290 -0
  88. package/dist/src/mcp/tools/sessionResources.js +75 -0
  89. package/dist/src/mcp/tools/sessions.js +105 -0
  90. package/dist/src/mcp/types.js +22 -0
  91. package/dist/src/mcp/utils.js +37 -0
  92. package/dist/src/oracle/background.js +141 -0
  93. package/dist/src/oracle/claude.js +101 -0
  94. package/dist/src/oracle/client.js +197 -0
  95. package/dist/src/oracle/config.js +227 -0
  96. package/dist/src/oracle/errors.js +132 -0
  97. package/dist/src/oracle/files.js +378 -0
  98. package/dist/src/oracle/finishLine.js +32 -0
  99. package/dist/src/oracle/format.js +30 -0
  100. package/dist/src/oracle/fsAdapter.js +10 -0
  101. package/dist/src/oracle/gemini.js +195 -0
  102. package/dist/src/oracle/logging.js +36 -0
  103. package/dist/src/oracle/markdown.js +46 -0
  104. package/dist/src/oracle/modelResolver.js +183 -0
  105. package/dist/src/oracle/multiModelRunner.js +153 -0
  106. package/dist/src/oracle/oscProgress.js +24 -0
  107. package/dist/src/oracle/promptAssembly.js +13 -0
  108. package/dist/src/oracle/request.js +50 -0
  109. package/dist/src/oracle/run.js +596 -0
  110. package/dist/src/oracle/runUtils.js +31 -0
  111. package/dist/src/oracle/tokenEstimate.js +37 -0
  112. package/dist/src/oracle/tokenStats.js +39 -0
  113. package/dist/src/oracle/tokenStringifier.js +24 -0
  114. package/dist/src/oracle/types.js +1 -0
  115. package/dist/src/oracle.js +12 -0
  116. package/dist/src/oracleHome.js +13 -0
  117. package/dist/src/remote/client.js +129 -0
  118. package/dist/src/remote/health.js +113 -0
  119. package/dist/src/remote/remoteServiceConfig.js +31 -0
  120. package/dist/src/remote/server.js +533 -0
  121. package/dist/src/remote/types.js +1 -0
  122. package/dist/src/sessionManager.js +637 -0
  123. package/dist/src/sessionStore.js +56 -0
  124. package/dist/src/version.js +39 -0
  125. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  126. package/dist/vendor/oracle-notifier/README.md +24 -0
  127. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  128. package/package.json +115 -0
  129. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  130. package/vendor/oracle-notifier/README.md +24 -0
  131. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,13 @@
1
+ export function normalizeBrowserModelStrategy(value) {
2
+ if (value == null) {
3
+ return undefined;
4
+ }
5
+ const normalized = value.trim().toLowerCase();
6
+ if (!normalized) {
7
+ return undefined;
8
+ }
9
+ if (normalized === 'select' || normalized === 'current' || normalized === 'ignore') {
10
+ return normalized;
11
+ }
12
+ throw new Error(`Invalid browser model strategy: "${value}". Expected "select", "current", or "ignore".`);
13
+ }
@@ -0,0 +1,5 @@
1
+ export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, } from './actions/navigation.js';
2
+ export { ensureModelSelection } from './actions/modelSelection.js';
3
+ export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
4
+ export { clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, } from './actions/attachments.js';
5
+ export { waitForAssistantResponse, readAssistantSnapshot, captureAssistantMarkdown, buildAssistantExtractorForTest, buildConversationDebugExpressionForTest, buildMarkdownFallbackExtractorForTest, buildCopyExpressionForTest, } from './actions/assistantResponse.js';
@@ -0,0 +1,43 @@
1
+ import { formatFileSection } from '../oracle/markdown.js';
2
+ export function buildAttachmentPlan(sections, { inlineFiles, bundleRequested, maxAttachments = 10, }) {
3
+ if (inlineFiles) {
4
+ const inlineLines = [];
5
+ sections.forEach((section) => {
6
+ inlineLines.push(formatFileSection(section.displayPath, section.content).trimEnd(), '');
7
+ });
8
+ const inlineBlock = inlineLines.join('\n').trim();
9
+ return {
10
+ mode: 'inline',
11
+ inlineBlock,
12
+ inlineFileCount: sections.length,
13
+ attachments: [],
14
+ shouldBundle: false,
15
+ };
16
+ }
17
+ const attachments = sections.map((section) => ({
18
+ path: section.absolutePath,
19
+ displayPath: section.displayPath,
20
+ sizeBytes: Buffer.byteLength(section.content, 'utf8'),
21
+ }));
22
+ const shouldBundle = bundleRequested || attachments.length > maxAttachments;
23
+ return {
24
+ mode: shouldBundle ? 'bundle' : 'upload',
25
+ inlineBlock: '',
26
+ inlineFileCount: 0,
27
+ attachments,
28
+ shouldBundle,
29
+ };
30
+ }
31
+ export function buildCookiePlan(config) {
32
+ if (config?.inlineCookies && config.inlineCookies.length > 0) {
33
+ const source = config.inlineCookiesSource ?? 'inline';
34
+ return { type: 'inline', description: `Cookies: inline payload (${config.inlineCookies.length}) via ${source}.` };
35
+ }
36
+ if (config?.cookieSync === false) {
37
+ return { type: 'disabled', description: 'Cookies: sync disabled (--browser-no-cookie-sync).' };
38
+ }
39
+ const allowlist = config?.cookieNames && config.cookieNames.length > 0
40
+ ? config.cookieNames.join(', ')
41
+ : 'all from Chrome profile';
42
+ return { type: 'copy', description: `Cookies: copy from Chrome (${allowlist}).` };
43
+ }
@@ -0,0 +1,280 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { delay } from './utils.js';
7
+ const DEVTOOLS_ACTIVE_PORT_FILENAME = 'DevToolsActivePort';
8
+ const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
9
+ DEVTOOLS_ACTIVE_PORT_FILENAME,
10
+ path.join('Default', DEVTOOLS_ACTIVE_PORT_FILENAME),
11
+ ];
12
+ const CHROME_PID_FILENAME = 'chrome.pid';
13
+ const ORACLE_PROFILE_LOCK_FILENAME = 'oracle-automation.lock';
14
+ const execFileAsync = promisify(execFile);
15
+ export function getDevToolsActivePortPaths(userDataDir) {
16
+ return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
17
+ }
18
+ export async function readDevToolsPort(userDataDir) {
19
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
20
+ try {
21
+ const raw = await readFile(candidate, 'utf8');
22
+ const firstLine = raw.split(/\r?\n/u)[0]?.trim();
23
+ const port = Number.parseInt(firstLine ?? '', 10);
24
+ if (Number.isFinite(port)) {
25
+ return port;
26
+ }
27
+ }
28
+ catch {
29
+ // ignore missing/unreadable candidates
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ export async function writeDevToolsActivePort(userDataDir, port) {
35
+ const contents = `${port}\n/devtools/browser`;
36
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
37
+ try {
38
+ await mkdir(path.dirname(candidate), { recursive: true });
39
+ await writeFile(candidate, contents, 'utf8');
40
+ }
41
+ catch {
42
+ // best effort
43
+ }
44
+ }
45
+ }
46
+ export async function readChromePid(userDataDir) {
47
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
48
+ try {
49
+ const raw = (await readFile(pidPath, 'utf8')).trim();
50
+ const pid = Number.parseInt(raw, 10);
51
+ if (!Number.isFinite(pid) || pid <= 0) {
52
+ return null;
53
+ }
54
+ return pid;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export async function writeChromePid(userDataDir, pid) {
61
+ if (!Number.isFinite(pid) || pid <= 0)
62
+ return;
63
+ const pidPath = path.join(userDataDir, CHROME_PID_FILENAME);
64
+ try {
65
+ await mkdir(path.dirname(pidPath), { recursive: true });
66
+ await writeFile(pidPath, `${Math.trunc(pid)}\n`, 'utf8');
67
+ }
68
+ catch {
69
+ // best effort
70
+ }
71
+ }
72
+ export function isProcessAlive(pid) {
73
+ if (!Number.isFinite(pid) || pid <= 0)
74
+ return false;
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ }
79
+ catch (error) {
80
+ // EPERM means "exists but no permission"; treat as alive.
81
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
82
+ return true;
83
+ }
84
+ return false;
85
+ }
86
+ }
87
+ function parseProfileRunLock(payload) {
88
+ if (!payload)
89
+ return null;
90
+ try {
91
+ const parsed = JSON.parse(payload);
92
+ if (!Number.isFinite(parsed.pid) || parsed.pid <= 0)
93
+ return null;
94
+ if (!parsed.lockId || typeof parsed.lockId !== 'string')
95
+ return null;
96
+ return parsed;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ export async function acquireProfileRunLock(userDataDir, options) {
103
+ const timeoutMs = options.timeoutMs;
104
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
105
+ return null;
106
+ }
107
+ const pollMs = typeof options.pollMs === 'number' && Number.isFinite(options.pollMs) && options.pollMs > 0
108
+ ? options.pollMs
109
+ : 1000;
110
+ const lockPath = path.join(userDataDir, ORACLE_PROFILE_LOCK_FILENAME);
111
+ const lockId = randomUUID();
112
+ const startedAt = Date.now();
113
+ let warned = false;
114
+ for (;;) {
115
+ try {
116
+ const payload = {
117
+ pid: process.pid,
118
+ lockId,
119
+ createdAt: new Date().toISOString(),
120
+ sessionId: options.sessionId,
121
+ };
122
+ await mkdir(path.dirname(lockPath), { recursive: true });
123
+ await writeFile(lockPath, JSON.stringify(payload), { encoding: 'utf8', flag: 'wx' });
124
+ options.logger?.(`Acquired Oracle profile lock at ${lockPath}`);
125
+ return {
126
+ path: lockPath,
127
+ lockId,
128
+ release: async () => releaseProfileRunLock(lockPath, lockId, options.logger),
129
+ };
130
+ }
131
+ catch (error) {
132
+ const code = error.code;
133
+ if (code !== 'EEXIST') {
134
+ throw error;
135
+ }
136
+ let existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
137
+ if (!existing) {
138
+ // Likely partial write / corruption; re-read once, then delete (user preference: delete unreadable lockfiles).
139
+ await delay(200);
140
+ existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
141
+ if (!existing) {
142
+ options.logger?.('Oracle profile lock unreadable; deleting lockfile.');
143
+ await rm(lockPath, { force: true }).catch(() => undefined);
144
+ continue;
145
+ }
146
+ }
147
+ if (!existing || !isProcessAlive(existing.pid)) {
148
+ await rm(lockPath, { force: true }).catch(() => undefined);
149
+ continue;
150
+ }
151
+ if (!warned) {
152
+ const waited = Math.round(timeoutMs / 1000);
153
+ options.logger?.(`Oracle profile lock held by pid ${existing.pid}; waiting up to ${waited}s.`);
154
+ warned = true;
155
+ }
156
+ const elapsed = Date.now() - startedAt;
157
+ if (elapsed >= timeoutMs) {
158
+ throw new Error(`Oracle profile lock still held by pid ${existing.pid} after ${Math.round(elapsed / 1000)}s`);
159
+ }
160
+ await delay(Math.min(pollMs, timeoutMs - elapsed));
161
+ }
162
+ }
163
+ }
164
+ export async function releaseProfileRunLock(lockPath, lockId, logger) {
165
+ try {
166
+ const existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
167
+ if (!existing || existing.lockId !== lockId) {
168
+ return;
169
+ }
170
+ await rm(lockPath, { force: true });
171
+ logger?.(`Released Oracle profile lock ${lockPath}`);
172
+ }
173
+ catch {
174
+ // best effort
175
+ }
176
+ }
177
+ export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attempts = 3, timeoutMs = 3000, }) {
178
+ const versionUrl = `http://${host}:${port}/json/version`;
179
+ for (let attempt = 0; attempt < attempts; attempt++) {
180
+ try {
181
+ const controller = new AbortController();
182
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
183
+ const response = await fetch(versionUrl, { signal: controller.signal });
184
+ clearTimeout(timeout);
185
+ if (!response.ok) {
186
+ throw new Error(`HTTP ${response.status}`);
187
+ }
188
+ return { ok: true };
189
+ }
190
+ catch (error) {
191
+ if (attempt < attempts - 1) {
192
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
193
+ continue;
194
+ }
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ return { ok: false, error: message };
197
+ }
198
+ }
199
+ return { ok: false, error: 'unreachable' };
200
+ }
201
+ export async function shouldCleanupManualLoginProfileState(userDataDir, logger, options = {}) {
202
+ if (!options.connectionClosedUnexpectedly) {
203
+ return true;
204
+ }
205
+ const port = await readDevToolsPort(userDataDir);
206
+ if (!port) {
207
+ return true;
208
+ }
209
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port, host: options.host });
210
+ if (probe.ok) {
211
+ logger?.(`DevTools port ${port} still reachable; preserving manual-login profile state`);
212
+ return false;
213
+ }
214
+ logger?.(`DevTools port ${port} unreachable (${probe.error}); clearing stale profile state`);
215
+ return true;
216
+ }
217
+ export async function cleanupStaleProfileState(userDataDir, logger, options = {}) {
218
+ for (const candidate of getDevToolsActivePortPaths(userDataDir)) {
219
+ try {
220
+ await rm(candidate, { force: true });
221
+ logger?.(`Removed stale DevToolsActivePort: ${candidate}`);
222
+ }
223
+ catch {
224
+ // ignore cleanup errors
225
+ }
226
+ }
227
+ const lockRemovalMode = options.lockRemovalMode ?? 'never';
228
+ if (lockRemovalMode === 'never') {
229
+ return;
230
+ }
231
+ const pid = await readChromePid(userDataDir);
232
+ if (!pid) {
233
+ return;
234
+ }
235
+ if (isProcessAlive(pid)) {
236
+ logger?.(`Chrome pid ${pid} still alive; skipping profile lock cleanup`);
237
+ return;
238
+ }
239
+ // Extra safety: if Chrome is running with this profile (but with a different PID, e.g. user relaunched
240
+ // without remote debugging), never delete lock files.
241
+ if (await isChromeUsingUserDataDir(userDataDir)) {
242
+ logger?.('Detected running Chrome using this profile; skipping profile lock cleanup');
243
+ return;
244
+ }
245
+ const lockFiles = [
246
+ path.join(userDataDir, 'lockfile'),
247
+ path.join(userDataDir, 'SingletonLock'),
248
+ path.join(userDataDir, 'SingletonSocket'),
249
+ path.join(userDataDir, 'SingletonCookie'),
250
+ ];
251
+ for (const lock of lockFiles) {
252
+ await rm(lock, { force: true }).catch(() => undefined);
253
+ }
254
+ logger?.('Cleaned up stale Chrome profile locks');
255
+ }
256
+ async function isChromeUsingUserDataDir(userDataDir) {
257
+ if (process.platform === 'win32') {
258
+ // On Windows, lockfiles are typically held open and removal should fail anyway; avoid expensive process scans.
259
+ return false;
260
+ }
261
+ try {
262
+ const { stdout } = await execFileAsync('ps', ['-ax', '-o', 'command='], { maxBuffer: 10 * 1024 * 1024 });
263
+ const lines = String(stdout ?? '').split('\n');
264
+ const needle = userDataDir;
265
+ for (const line of lines) {
266
+ if (!line)
267
+ continue;
268
+ const lower = line.toLowerCase();
269
+ if (!lower.includes('chrome') && !lower.includes('chromium'))
270
+ continue;
271
+ if (line.includes(needle) && lower.includes('user-data-dir')) {
272
+ return true;
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ // best effort
278
+ }
279
+ return false;
280
+ }
@@ -0,0 +1,152 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSection, } from '../oracle.js';
5
+ import { isKnownModel } from '../oracle/modelResolver.js';
6
+ import { buildPromptMarkdown } from '../oracle/promptAssembly.js';
7
+ import { buildAttachmentPlan } from './policies.js';
8
+ const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
9
+ const MEDIA_EXTENSIONS = new Set([
10
+ '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v',
11
+ '.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a',
12
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.heic', '.heif',
13
+ '.pdf',
14
+ ]);
15
+ export function isMediaFile(filePath) {
16
+ const ext = path.extname(filePath).toLowerCase();
17
+ return MEDIA_EXTENSIONS.has(ext);
18
+ }
19
+ export async function assembleBrowserPrompt(runOptions, deps = {}) {
20
+ const cwd = deps.cwd ?? process.cwd();
21
+ const readFilesFn = deps.readFilesImpl ?? readFiles;
22
+ const allFilePaths = runOptions.file ?? [];
23
+ const textFilePaths = allFilePaths.filter((f) => !isMediaFile(f));
24
+ const mediaFilePaths = allFilePaths.filter((f) => isMediaFile(f));
25
+ const mediaAttachments = await Promise.all(mediaFilePaths.map(async (filePath) => {
26
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
27
+ const stats = await fs.stat(resolvedPath);
28
+ return {
29
+ path: resolvedPath,
30
+ displayPath: path.relative(cwd, resolvedPath) || path.basename(resolvedPath),
31
+ sizeBytes: stats.size,
32
+ };
33
+ }));
34
+ const files = await readFilesFn(textFilePaths, { cwd });
35
+ const basePrompt = (runOptions.prompt ?? '').trim();
36
+ const userPrompt = basePrompt;
37
+ const systemPrompt = runOptions.system?.trim() || '';
38
+ const sections = createFileSections(files, cwd);
39
+ const markdown = buildPromptMarkdown(systemPrompt, userPrompt, sections);
40
+ const attachmentsPolicy = runOptions.browserInlineFiles
41
+ ? 'never'
42
+ : runOptions.browserAttachments ?? 'auto';
43
+ const bundleRequested = Boolean(runOptions.browserBundleFiles);
44
+ const inlinePlan = buildAttachmentPlan(sections, { inlineFiles: true, bundleRequested });
45
+ const uploadPlan = buildAttachmentPlan(sections, { inlineFiles: false, bundleRequested });
46
+ const baseComposerSections = [];
47
+ if (systemPrompt)
48
+ baseComposerSections.push(systemPrompt);
49
+ if (userPrompt)
50
+ baseComposerSections.push(userPrompt);
51
+ const inlineComposerText = [...baseComposerSections, inlinePlan.inlineBlock].filter(Boolean).join('\n\n').trim();
52
+ const selectedPlan = attachmentsPolicy === 'always'
53
+ ? uploadPlan
54
+ : attachmentsPolicy === 'never'
55
+ ? inlinePlan
56
+ : inlineComposerText.length <= DEFAULT_BROWSER_INLINE_CHAR_BUDGET || sections.length === 0
57
+ ? inlinePlan
58
+ : uploadPlan;
59
+ const composerText = (selectedPlan.inlineBlock
60
+ ? [...baseComposerSections, selectedPlan.inlineBlock]
61
+ : baseComposerSections)
62
+ .filter(Boolean)
63
+ .join('\n\n')
64
+ .trim();
65
+ const attachments = [...selectedPlan.attachments, ...mediaAttachments];
66
+ const shouldBundle = selectedPlan.shouldBundle;
67
+ let bundleText = null;
68
+ let bundled = null;
69
+ if (shouldBundle) {
70
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
71
+ const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
72
+ const bundleLines = [];
73
+ sections.forEach((section) => {
74
+ bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
75
+ bundleLines.push('');
76
+ });
77
+ bundleText = `${bundleLines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
78
+ await fs.writeFile(bundlePath, bundleText, 'utf8');
79
+ attachments.length = 0;
80
+ attachments.push({
81
+ path: bundlePath,
82
+ displayPath: bundlePath,
83
+ sizeBytes: Buffer.byteLength(bundleText, 'utf8'),
84
+ });
85
+ attachments.push(...mediaAttachments);
86
+ bundled = { originalCount: sections.length, bundlePath };
87
+ }
88
+ const inlineFileCount = selectedPlan.inlineFileCount;
89
+ const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
90
+ const tokenizer = deps.tokenizeImpl ?? modelConfig.tokenizer;
91
+ const tokenizerUserContent = inlineFileCount > 0 && selectedPlan.inlineBlock
92
+ ? [userPrompt, selectedPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
93
+ : userPrompt;
94
+ const tokenizerMessages = [
95
+ systemPrompt ? { role: 'system', content: systemPrompt } : null,
96
+ tokenizerUserContent ? { role: 'user', content: tokenizerUserContent } : null,
97
+ ].filter(Boolean);
98
+ let estimatedInputTokens = tokenizer(tokenizerMessages.length > 0
99
+ ? tokenizerMessages
100
+ : [{ role: 'user', content: '' }], TOKENIZER_OPTIONS);
101
+ const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(selectedPlan.inlineBlock);
102
+ if (!tokenEstimateIncludesInlineFiles && sections.length > 0) {
103
+ const attachmentText = bundleText ??
104
+ sections
105
+ .map((section) => formatFileSection(section.displayPath, section.content).trimEnd())
106
+ .join('\n\n');
107
+ const attachmentTokens = tokenizer([{ role: 'user', content: attachmentText }], TOKENIZER_OPTIONS);
108
+ estimatedInputTokens += attachmentTokens;
109
+ }
110
+ let fallback = null;
111
+ if (attachmentsPolicy === 'auto' && selectedPlan.mode === 'inline' && sections.length > 0) {
112
+ const fallbackComposerText = baseComposerSections.join('\n\n').trim();
113
+ const fallbackAttachments = [...uploadPlan.attachments, ...mediaAttachments];
114
+ let fallbackBundled = null;
115
+ if (uploadPlan.shouldBundle) {
116
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oracle-browser-bundle-'));
117
+ const bundlePath = path.join(bundleDir, 'attachments-bundle.txt');
118
+ const bundleLines = [];
119
+ sections.forEach((section) => {
120
+ bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
121
+ bundleLines.push('');
122
+ });
123
+ const fallbackBundleText = `${bundleLines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
124
+ await fs.writeFile(bundlePath, fallbackBundleText, 'utf8');
125
+ fallbackAttachments.length = 0;
126
+ fallbackAttachments.push({
127
+ path: bundlePath,
128
+ displayPath: bundlePath,
129
+ sizeBytes: Buffer.byteLength(fallbackBundleText, 'utf8'),
130
+ });
131
+ fallbackAttachments.push(...mediaAttachments);
132
+ fallbackBundled = { originalCount: sections.length, bundlePath };
133
+ }
134
+ fallback = {
135
+ composerText: fallbackComposerText,
136
+ attachments: fallbackAttachments,
137
+ bundled: fallbackBundled,
138
+ };
139
+ }
140
+ return {
141
+ markdown,
142
+ composerText,
143
+ estimatedInputTokens,
144
+ attachments,
145
+ inlineFileCount,
146
+ tokenEstimateIncludesInlineFiles,
147
+ attachmentsPolicy,
148
+ attachmentMode: selectedPlan.mode,
149
+ fallback,
150
+ bundled,
151
+ };
152
+ }
@@ -0,0 +1,20 @@
1
+ import { formatBytes } from './utils.js';
2
+ export function buildTokenEstimateSuffix(artifacts) {
3
+ if (artifacts.tokenEstimateIncludesInlineFiles && artifacts.inlineFileCount > 0) {
4
+ const count = artifacts.inlineFileCount;
5
+ const plural = count === 1 ? '' : 's';
6
+ return ` (includes ${count} inline file${plural})`;
7
+ }
8
+ if (artifacts.attachments.length > 0) {
9
+ const count = artifacts.attachments.length;
10
+ const plural = count === 1 ? '' : 's';
11
+ return ` (prompt only; ${count} attachment${plural} excluded)`;
12
+ }
13
+ return '';
14
+ }
15
+ export function formatAttachmentLabel(attachment) {
16
+ if (typeof attachment.sizeBytes !== 'number' || Number.isNaN(attachment.sizeBytes)) {
17
+ return attachment.displayPath;
18
+ }
19
+ return `${attachment.displayPath} (${formatBytes(attachment.sizeBytes)})`;
20
+ }