@steipete/oracle 0.8.3 → 0.8.5
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.
- package/LICENSE +1 -1
- package/README.md +7 -0
- package/dist/bin/oracle-cli.js +102 -9
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +13 -5
- package/dist/src/browser/actions/attachments.js +44 -20
- package/dist/src/browser/chromeLifecycle.js +62 -9
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/index.js +55 -2
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/engine.js +17 -1
- package/dist/src/cli/options.js +14 -0
- package/dist/src/cli/runOptions.js +4 -0
- package/dist/src/mcp/tools/consult.js +80 -15
- package/dist/src/mcp/tools/sessions.js +15 -6
- package/dist/src/mcp/types.js +4 -0
- package/dist/src/mcp/utils.js +12 -2
- package/dist/src/oracle/background.js +1 -2
- package/dist/src/oracle/client.js +5 -2
- package/dist/src/oracle/files.js +2 -2
- package/dist/src/oracle/run.js +1 -0
- package/dist/src/remote/client.js +6 -5
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +28 -1
- package/dist/src/sessionManager.js +63 -5
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +16 -15
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { getOracleHomeDir } from '../../oracleHome.js';
|
|
7
|
+
import { parseHostPort, normalizeHostPort, formatBridgeConnectionString } from '../../bridge/connection.js';
|
|
8
|
+
import { serveRemote } from '../../remote/server.js';
|
|
9
|
+
export async function runBridgeHost(options) {
|
|
10
|
+
const bindRaw = options.bind?.trim() || '127.0.0.1:9473';
|
|
11
|
+
const { hostname: bindHost, port: bindPort } = parseHostPort(bindRaw);
|
|
12
|
+
const tokenRaw = options.token?.trim() || 'auto';
|
|
13
|
+
const token = tokenRaw === 'auto' ? randomBytes(16).toString('hex') : tokenRaw;
|
|
14
|
+
if (!token.trim()) {
|
|
15
|
+
throw new Error('Token is required (use --token auto to generate one).');
|
|
16
|
+
}
|
|
17
|
+
const writeConnectionPath = options.writeConnection?.trim() || path.join(getOracleHomeDir(), 'bridge-connection.json');
|
|
18
|
+
const sshTarget = options.ssh?.trim();
|
|
19
|
+
const sshRemotePort = typeof options.sshRemotePort === 'number' ? options.sshRemotePort : bindPort;
|
|
20
|
+
if (sshRemotePort <= 0 || sshRemotePort > 65_535) {
|
|
21
|
+
throw new Error(`Invalid --ssh-remote-port: ${sshRemotePort}. Expected 1-65535.`);
|
|
22
|
+
}
|
|
23
|
+
const connectionHostForClient = sshTarget ? normalizeHostPort('127.0.0.1', sshRemotePort) : normalizeHostPort(bindHost === '0.0.0.0' || bindHost === '::' ? '127.0.0.1' : bindHost, bindPort);
|
|
24
|
+
const artifact = await upsertConnectionArtifact(writeConnectionPath, {
|
|
25
|
+
remoteHost: connectionHostForClient,
|
|
26
|
+
remoteToken: token,
|
|
27
|
+
tunnel: sshTarget
|
|
28
|
+
? {
|
|
29
|
+
ssh: sshTarget,
|
|
30
|
+
remotePort: sshRemotePort,
|
|
31
|
+
localPort: bindPort,
|
|
32
|
+
identity: options.sshIdentity?.trim() || undefined,
|
|
33
|
+
extraArgs: options.sshExtraArgs?.trim() || undefined,
|
|
34
|
+
}
|
|
35
|
+
: undefined,
|
|
36
|
+
});
|
|
37
|
+
if (options.printToken) {
|
|
38
|
+
console.log(token);
|
|
39
|
+
}
|
|
40
|
+
if (options.print) {
|
|
41
|
+
console.log(formatBridgeConnectionString({ remoteHost: artifact.remoteHost, remoteToken: token }, { includeToken: true }));
|
|
42
|
+
}
|
|
43
|
+
if (options.background) {
|
|
44
|
+
await spawnBridgeHostInBackground({
|
|
45
|
+
bind: bindRaw,
|
|
46
|
+
token,
|
|
47
|
+
writeConnectionPath,
|
|
48
|
+
sshTarget,
|
|
49
|
+
sshRemotePort,
|
|
50
|
+
sshIdentity: options.sshIdentity?.trim(),
|
|
51
|
+
sshExtraArgs: options.sshExtraArgs?.trim(),
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
console.log(chalk.cyanBright('Bridge host starting...'));
|
|
56
|
+
console.log(chalk.dim(`- Local bind: ${normalizeHostPort(bindHost, bindPort)}`));
|
|
57
|
+
console.log(chalk.dim(`- Connection artifact: ${writeConnectionPath}`));
|
|
58
|
+
console.log(chalk.dim(`- Client remoteHost: ${artifact.remoteHost}`));
|
|
59
|
+
console.log(chalk.dim('Token stored in connection artifact (not printed). Use --print or --print-token if needed.'));
|
|
60
|
+
let tunnel = null;
|
|
61
|
+
if (sshTarget) {
|
|
62
|
+
tunnel = startReverseTunnel({
|
|
63
|
+
sshTarget,
|
|
64
|
+
remotePort: sshRemotePort,
|
|
65
|
+
localPort: bindPort,
|
|
66
|
+
identity: options.sshIdentity?.trim() || undefined,
|
|
67
|
+
extraArgs: options.sshExtraArgs?.trim() || undefined,
|
|
68
|
+
log: (msg) => console.log(chalk.dim(msg)),
|
|
69
|
+
});
|
|
70
|
+
console.log(chalk.dim(`Reverse SSH tunnel active (remote 127.0.0.1:${sshRemotePort} -> local 127.0.0.1:${bindPort})`));
|
|
71
|
+
}
|
|
72
|
+
const filteredServeLogger = (message) => {
|
|
73
|
+
if (message.includes('Access token:')) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(message);
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
await serveRemote({
|
|
80
|
+
host: bindHost,
|
|
81
|
+
port: bindPort,
|
|
82
|
+
token,
|
|
83
|
+
logger: filteredServeLogger,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
tunnel?.stop();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function upsertConnectionArtifact(filePath, input) {
|
|
91
|
+
const dir = path.dirname(filePath);
|
|
92
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const existing = await fs.readFile(filePath, 'utf8').catch(() => null);
|
|
95
|
+
let createdAt = now;
|
|
96
|
+
if (existing) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(existing);
|
|
99
|
+
if (typeof parsed.createdAt === 'string' && parsed.createdAt.trim().length > 0) {
|
|
100
|
+
createdAt = parsed.createdAt;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// ignore invalid previous content
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const artifact = {
|
|
108
|
+
remoteHost: input.remoteHost,
|
|
109
|
+
remoteToken: input.remoteToken,
|
|
110
|
+
createdAt,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
tunnel: input.tunnel,
|
|
113
|
+
};
|
|
114
|
+
const contents = `${JSON.stringify(artifact, null, 2)}\n`;
|
|
115
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
116
|
+
await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
|
|
117
|
+
await fs.rename(tempPath, filePath);
|
|
118
|
+
if (process.platform !== 'win32') {
|
|
119
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
return artifact;
|
|
122
|
+
}
|
|
123
|
+
function startReverseTunnel({ sshTarget, remotePort, localPort, identity, extraArgs, log, }) {
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let child = null;
|
|
126
|
+
let attempt = 0;
|
|
127
|
+
let timer = null;
|
|
128
|
+
const spawnOnce = () => {
|
|
129
|
+
if (stopped)
|
|
130
|
+
return;
|
|
131
|
+
const args = [
|
|
132
|
+
'-N',
|
|
133
|
+
'-R',
|
|
134
|
+
`${remotePort}:127.0.0.1:${localPort}`,
|
|
135
|
+
'-o',
|
|
136
|
+
'ExitOnForwardFailure=yes',
|
|
137
|
+
'-o',
|
|
138
|
+
'ServerAliveInterval=30',
|
|
139
|
+
'-o',
|
|
140
|
+
'ServerAliveCountMax=3',
|
|
141
|
+
];
|
|
142
|
+
if (identity) {
|
|
143
|
+
args.push('-i', identity);
|
|
144
|
+
}
|
|
145
|
+
if (extraArgs) {
|
|
146
|
+
args.push(...splitArgs(extraArgs));
|
|
147
|
+
}
|
|
148
|
+
args.push(sshTarget);
|
|
149
|
+
child = spawn('ssh', args, { stdio: 'ignore' });
|
|
150
|
+
const pid = child.pid;
|
|
151
|
+
log(`[bridge host] ssh tunnel started${pid ? ` (pid ${pid})` : ''}: ${sshTarget}`);
|
|
152
|
+
child.once('exit', (code, signal) => {
|
|
153
|
+
child = null;
|
|
154
|
+
if (stopped)
|
|
155
|
+
return;
|
|
156
|
+
const label = signal ? `signal ${signal}` : `code ${code ?? 0}`;
|
|
157
|
+
const delayMs = Math.min(30_000, 1_000 * 2 ** attempt);
|
|
158
|
+
attempt += 1;
|
|
159
|
+
log(`[bridge host] ssh tunnel exited (${label}); restarting in ${delayMs}ms`);
|
|
160
|
+
timer = setTimeout(spawnOnce, delayMs);
|
|
161
|
+
timer.unref?.();
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
spawnOnce();
|
|
165
|
+
return {
|
|
166
|
+
stop: () => {
|
|
167
|
+
stopped = true;
|
|
168
|
+
if (timer) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
timer = null;
|
|
171
|
+
}
|
|
172
|
+
if (child) {
|
|
173
|
+
child.removeAllListeners();
|
|
174
|
+
child.kill();
|
|
175
|
+
child = null;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function splitArgs(input) {
|
|
181
|
+
const args = [];
|
|
182
|
+
let current = '';
|
|
183
|
+
let quote = null;
|
|
184
|
+
const push = () => {
|
|
185
|
+
const trimmed = current.trim();
|
|
186
|
+
if (trimmed.length)
|
|
187
|
+
args.push(trimmed);
|
|
188
|
+
current = '';
|
|
189
|
+
};
|
|
190
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
191
|
+
const ch = input[i] ?? '';
|
|
192
|
+
if (quote) {
|
|
193
|
+
if (ch === quote) {
|
|
194
|
+
quote = null;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
current += ch;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (ch === '"' || ch === "'") {
|
|
202
|
+
quote = ch;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (/\s/.test(ch)) {
|
|
206
|
+
push();
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current += ch;
|
|
210
|
+
}
|
|
211
|
+
push();
|
|
212
|
+
return args;
|
|
213
|
+
}
|
|
214
|
+
async function spawnBridgeHostInBackground({ bind, token, writeConnectionPath, sshTarget, sshRemotePort, sshIdentity, sshExtraArgs, }) {
|
|
215
|
+
const oracleHome = getOracleHomeDir();
|
|
216
|
+
await fs.mkdir(oracleHome, { recursive: true, mode: 0o700 });
|
|
217
|
+
const logPath = path.join(oracleHome, 'bridge-host.log');
|
|
218
|
+
const pidPath = path.join(oracleHome, 'bridge-host.pid');
|
|
219
|
+
const logHandle = await fs.open(logPath, 'a');
|
|
220
|
+
const stdio = ['ignore', logHandle.fd, logHandle.fd];
|
|
221
|
+
const scriptPath = process.argv[1];
|
|
222
|
+
if (!scriptPath) {
|
|
223
|
+
throw new Error('Unable to determine CLI entrypoint for background mode.');
|
|
224
|
+
}
|
|
225
|
+
const args = [
|
|
226
|
+
scriptPath,
|
|
227
|
+
'bridge',
|
|
228
|
+
'host',
|
|
229
|
+
'--foreground',
|
|
230
|
+
'--bind',
|
|
231
|
+
bind,
|
|
232
|
+
'--token',
|
|
233
|
+
token,
|
|
234
|
+
'--write-connection',
|
|
235
|
+
writeConnectionPath,
|
|
236
|
+
];
|
|
237
|
+
if (sshTarget) {
|
|
238
|
+
args.push('--ssh', sshTarget);
|
|
239
|
+
}
|
|
240
|
+
if (typeof sshRemotePort === 'number') {
|
|
241
|
+
args.push('--ssh-remote-port', String(sshRemotePort));
|
|
242
|
+
}
|
|
243
|
+
if (sshIdentity) {
|
|
244
|
+
args.push('--ssh-identity', sshIdentity);
|
|
245
|
+
}
|
|
246
|
+
if (sshExtraArgs) {
|
|
247
|
+
args.push('--ssh-extra-args', sshExtraArgs);
|
|
248
|
+
}
|
|
249
|
+
const child = spawn(process.execPath, args, { detached: true, stdio });
|
|
250
|
+
child.unref();
|
|
251
|
+
await fs.writeFile(pidPath, `${child.pid ?? ''}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
252
|
+
if (process.platform !== 'win32') {
|
|
253
|
+
await fs.chmod(pidPath, 0o600).catch(() => undefined);
|
|
254
|
+
}
|
|
255
|
+
await logHandle.close();
|
|
256
|
+
console.log(chalk.green(`Bridge host running in background (pid ${child.pid ?? '?'})`));
|
|
257
|
+
console.log(chalk.dim(`- Log: ${logPath}`));
|
|
258
|
+
console.log(chalk.dim(`- PID: ${pidPath}`));
|
|
259
|
+
}
|
package/dist/src/cli/engine.js
CHANGED
|
@@ -12,7 +12,8 @@ export function defaultWaitPreference(model, engine) {
|
|
|
12
12
|
* Precedence:
|
|
13
13
|
* 1) Legacy --browser flag forces browser.
|
|
14
14
|
* 2) Explicit --engine value.
|
|
15
|
-
* 3)
|
|
15
|
+
* 3) ORACLE_ENGINE environment override (api|browser).
|
|
16
|
+
* 4) OPENAI_API_KEY decides: api when set, otherwise browser.
|
|
16
17
|
*/
|
|
17
18
|
export function resolveEngine({ engine, browserFlag, env, }) {
|
|
18
19
|
if (browserFlag) {
|
|
@@ -21,5 +22,20 @@ export function resolveEngine({ engine, browserFlag, env, }) {
|
|
|
21
22
|
if (engine) {
|
|
22
23
|
return engine;
|
|
23
24
|
}
|
|
25
|
+
const envEngine = normalizeEngineMode(env.ORACLE_ENGINE);
|
|
26
|
+
if (envEngine) {
|
|
27
|
+
return envEngine;
|
|
28
|
+
}
|
|
24
29
|
return env.OPENAI_API_KEY ? 'api' : 'browser';
|
|
25
30
|
}
|
|
31
|
+
function normalizeEngineMode(raw) {
|
|
32
|
+
if (typeof raw !== 'string') {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const normalized = raw.trim().toLowerCase();
|
|
36
|
+
if (normalized === 'api')
|
|
37
|
+
return 'api';
|
|
38
|
+
if (normalized === 'browser')
|
|
39
|
+
return 'browser';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
package/dist/src/cli/options.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { InvalidArgumentError } from 'commander';
|
|
2
|
+
import { parseDuration } from '../browserMode.js';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import fg from 'fast-glob';
|
|
4
5
|
import { DEFAULT_MODEL, MODEL_CONFIGS } from '../oracle.js';
|
|
@@ -138,6 +139,19 @@ export function parseTimeoutOption(value) {
|
|
|
138
139
|
}
|
|
139
140
|
return parsed;
|
|
140
141
|
}
|
|
142
|
+
export function parseDurationOption(value, label) {
|
|
143
|
+
if (value == null)
|
|
144
|
+
return undefined;
|
|
145
|
+
const trimmed = value.trim();
|
|
146
|
+
if (!trimmed) {
|
|
147
|
+
throw new InvalidArgumentError(`${label} must be a duration like 30m, 10s, 500ms, or 2h.`);
|
|
148
|
+
}
|
|
149
|
+
const parsed = parseDuration(trimmed, Number.NaN);
|
|
150
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
151
|
+
throw new InvalidArgumentError(`${label} must be a positive duration like 30m, 10s, 500ms, or 2h.`);
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
141
155
|
export function resolveApiModel(modelValue) {
|
|
142
156
|
const normalized = normalizeModelOption(modelValue).toLowerCase();
|
|
143
157
|
if (normalized in MODEL_CONFIGS) {
|
|
@@ -61,6 +61,10 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
|
|
|
61
61
|
function resolveEngineWithConfig({ engine, configEngine, env, }) {
|
|
62
62
|
if (engine)
|
|
63
63
|
return engine;
|
|
64
|
+
const envOverride = (env.ORACLE_ENGINE ?? '').trim().toLowerCase();
|
|
65
|
+
if (envOverride === 'api' || envOverride === 'browser') {
|
|
66
|
+
return envOverride;
|
|
67
|
+
}
|
|
64
68
|
if (configEngine)
|
|
65
69
|
return configEngine;
|
|
66
70
|
return resolveEngine({ engine: undefined, env });
|
|
@@ -3,6 +3,8 @@ import { getCliVersion } from '../../version.js';
|
|
|
3
3
|
import { LoggingMessageNotificationParamsSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { ensureBrowserAvailable, mapConsultToRunOptions } from '../utils.js';
|
|
5
5
|
import { sessionStore } from '../../sessionStore.js';
|
|
6
|
+
import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
|
|
7
|
+
import { createRemoteBrowserExecutor } from '../../remote/client.js';
|
|
6
8
|
async function readSessionLogTail(sessionId, maxBytes) {
|
|
7
9
|
try {
|
|
8
10
|
const log = await sessionStore.readLog(sessionId);
|
|
@@ -23,14 +25,54 @@ import { resolveNotificationSettings } from '../../cli/notifier.js';
|
|
|
23
25
|
import { mapModelToBrowserLabel, resolveBrowserModelLabel } from '../../cli/browserConfig.js';
|
|
24
26
|
// Use raw shapes so the MCP SDK (with its bundled Zod) wraps them and emits valid JSON Schema.
|
|
25
27
|
const consultInputShape = {
|
|
26
|
-
prompt: z
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
prompt: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1, 'Prompt is required.')
|
|
31
|
+
.describe('User prompt to run.'),
|
|
32
|
+
files: z
|
|
33
|
+
.array(z.string())
|
|
34
|
+
.default([])
|
|
35
|
+
.describe('Optional file paths or glob patterns (like the CLI `--file`). Resolved relative to the MCP server working directory.'),
|
|
36
|
+
model: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Single model name/label. Prefer setting `engine` explicitly to avoid default surprises.'),
|
|
40
|
+
models: z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.optional()
|
|
43
|
+
.describe('Multi-model fan-out (API engine only). Cannot be combined with browser automation.'),
|
|
44
|
+
engine: z
|
|
45
|
+
.enum(['api', 'browser'])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Execution engine. `api` uses OpenAI/other providers. `browser` automates the ChatGPT web UI (supports attachments and ChatGPT-only model labels).'),
|
|
48
|
+
browserModelLabel: z
|
|
49
|
+
.string()
|
|
50
|
+
.optional()
|
|
51
|
+
.describe('Browser-only: explicit ChatGPT UI label to select (overrides model mapping). Example: "GPT-5.2 Thinking".'),
|
|
52
|
+
browserAttachments: z
|
|
53
|
+
.enum(['auto', 'never', 'always'])
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Browser-only: how to deliver `files`. Use "always" for real ChatGPT file uploads (including images/PDFs). Use "never" to paste file contents inline. "auto" chooses based on prompt size.'),
|
|
56
|
+
browserBundleFiles: z
|
|
57
|
+
.boolean()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe('Browser-only: bundle many files into a single upload (helps with upload limits).'),
|
|
60
|
+
browserThinkingTime: z
|
|
61
|
+
.enum(['light', 'standard', 'extended', 'heavy'])
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('Browser-only: set ChatGPT thinking time when supported by the chosen model.'),
|
|
64
|
+
browserKeepBrowser: z
|
|
65
|
+
.boolean()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('Browser-only: keep Chrome running after completion (useful for debugging).'),
|
|
68
|
+
search: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe('API-only: enable/disable the provider search tool (browser engine ignores this).'),
|
|
72
|
+
slug: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe('Optional human-friendly session id (used for later `oracle sessions` lookups).'),
|
|
34
76
|
};
|
|
35
77
|
const consultModelSummaryShape = z.object({
|
|
36
78
|
model: z.string(),
|
|
@@ -100,13 +142,13 @@ export function summarizeModelRunsForConsult(runs) {
|
|
|
100
142
|
export function registerConsultTool(server) {
|
|
101
143
|
server.registerTool('consult', {
|
|
102
144
|
title: 'Run an oracle session',
|
|
103
|
-
description: 'Run a one-shot Oracle session (API or browser).
|
|
145
|
+
description: 'Run a one-shot Oracle session (API or ChatGPT browser automation). Use `files` to attach project context. For browser-based image/file uploads, set `browserAttachments:"always"`. Sessions are stored under `ORACLE_HOME_DIR` (shared with the CLI).',
|
|
104
146
|
// Cast to any to satisfy SDK typings across differing Zod versions.
|
|
105
147
|
inputSchema: consultInputShape,
|
|
106
148
|
outputSchema: consultOutputShape,
|
|
107
149
|
}, async (input) => {
|
|
108
150
|
const textContent = (text) => [{ type: 'text', text }];
|
|
109
|
-
const { prompt, files, model, models, engine, search, browserModelLabel, slug } = consultInputSchema.parse(input);
|
|
151
|
+
const { prompt, files, model, models, engine, search, browserModelLabel, browserAttachments, browserBundleFiles, browserThinkingTime, browserKeepBrowser, slug, } = consultInputSchema.parse(input);
|
|
110
152
|
const { config: userConfig } = await loadUserConfig();
|
|
111
153
|
const { runOptions, resolvedEngine } = mapConsultToRunOptions({
|
|
112
154
|
prompt,
|
|
@@ -115,31 +157,53 @@ export function registerConsultTool(server) {
|
|
|
115
157
|
models,
|
|
116
158
|
engine,
|
|
117
159
|
search,
|
|
160
|
+
browserAttachments,
|
|
161
|
+
browserBundleFiles,
|
|
118
162
|
userConfig,
|
|
119
163
|
env: process.env,
|
|
120
164
|
});
|
|
121
165
|
const cwd = process.cwd();
|
|
122
|
-
const
|
|
166
|
+
const resolvedRemote = resolveRemoteServiceConfig({ userConfig, env: process.env });
|
|
167
|
+
const browserGuard = ensureBrowserAvailable(resolvedEngine, { remoteHost: resolvedRemote.host });
|
|
123
168
|
if (resolvedEngine === 'browser' && browserGuard) {
|
|
124
169
|
return {
|
|
125
170
|
isError: true,
|
|
126
171
|
content: textContent(browserGuard),
|
|
127
172
|
};
|
|
128
173
|
}
|
|
174
|
+
let browserDeps;
|
|
175
|
+
if (resolvedEngine === 'browser' && resolvedRemote.host) {
|
|
176
|
+
if (!resolvedRemote.token) {
|
|
177
|
+
return {
|
|
178
|
+
isError: true,
|
|
179
|
+
content: textContent(`Remote host configured (${resolvedRemote.host}) but remote token is missing. Run \`oracle bridge client --connect <...>\` or set ORACLE_REMOTE_TOKEN.`),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
browserDeps = {
|
|
183
|
+
executeBrowser: createRemoteBrowserExecutor({ host: resolvedRemote.host, token: resolvedRemote.token }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
129
186
|
let browserConfig;
|
|
130
187
|
if (resolvedEngine === 'browser') {
|
|
188
|
+
const envProfileDir = (process.env.ORACLE_BROWSER_PROFILE_DIR ?? '').trim();
|
|
189
|
+
const hasProfileDir = envProfileDir.length > 0;
|
|
131
190
|
const preferredLabel = (browserModelLabel ?? model)?.trim();
|
|
132
191
|
const isChatGptModel = runOptions.model.startsWith('gpt-') && !runOptions.model.includes('codex');
|
|
133
192
|
const desiredModelLabel = isChatGptModel
|
|
134
193
|
? mapModelToBrowserLabel(runOptions.model)
|
|
135
194
|
: resolveBrowserModelLabel(preferredLabel, runOptions.model);
|
|
136
|
-
|
|
195
|
+
const configuredUrl = userConfig.browser?.chatgptUrl ?? userConfig.browser?.url ?? undefined;
|
|
196
|
+
// Default to manual-login when a persistent profile dir is provided (common for Codex/Claude).
|
|
197
|
+
const manualLogin = hasProfileDir;
|
|
137
198
|
browserConfig = {
|
|
138
|
-
url: CHATGPT_URL,
|
|
139
|
-
cookieSync:
|
|
199
|
+
url: configuredUrl ?? CHATGPT_URL,
|
|
200
|
+
cookieSync: !manualLogin,
|
|
140
201
|
headless: false,
|
|
141
202
|
hideWindow: false,
|
|
142
|
-
keepBrowser: false,
|
|
203
|
+
keepBrowser: browserKeepBrowser ?? false,
|
|
204
|
+
manualLogin,
|
|
205
|
+
manualLoginProfileDir: manualLogin ? envProfileDir : null,
|
|
206
|
+
thinkingTime: browserThinkingTime,
|
|
143
207
|
desiredModel: desiredModelLabel || mapModelToBrowserLabel(runOptions.model),
|
|
144
208
|
};
|
|
145
209
|
}
|
|
@@ -187,6 +251,7 @@ export function registerConsultTool(server) {
|
|
|
187
251
|
version: getCliVersion(),
|
|
188
252
|
notifications,
|
|
189
253
|
muteStdout: true,
|
|
254
|
+
browserDeps,
|
|
190
255
|
});
|
|
191
256
|
}
|
|
192
257
|
catch (error) {
|
|
@@ -2,11 +2,20 @@ import { z } from 'zod';
|
|
|
2
2
|
import { sessionStore } from '../../sessionStore.js';
|
|
3
3
|
import { sessionsInputSchema } from '../types.js';
|
|
4
4
|
const sessionsInputShape = {
|
|
5
|
-
id: z
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
id: z
|
|
6
|
+
.string()
|
|
7
|
+
.optional()
|
|
8
|
+
.describe('Session id or slug. If set, returns a single session (use detail:true to include metadata/request).'),
|
|
9
|
+
hours: z.number().optional().describe('Look back this many hours when listing sessions (default: 24).'),
|
|
10
|
+
limit: z.number().optional().describe('Maximum sessions to return when listing (default: 100).'),
|
|
11
|
+
includeAll: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe('Include sessions outside the time window when listing (mirrors `oracle status --all`).'),
|
|
15
|
+
detail: z
|
|
16
|
+
.boolean()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('When id is set, include session metadata + stored request + full log text.'),
|
|
10
19
|
};
|
|
11
20
|
const sessionsOutputShape = {
|
|
12
21
|
entries: z
|
|
@@ -31,7 +40,7 @@ const sessionsOutputShape = {
|
|
|
31
40
|
export function registerSessionsTool(server) {
|
|
32
41
|
server.registerTool('sessions', {
|
|
33
42
|
title: 'List or fetch oracle sessions',
|
|
34
|
-
description: '
|
|
43
|
+
description: 'Inspect Oracle session history stored under `ORACLE_HOME_DIR` (shared with the CLI). List recent sessions or fetch one by id/slug (optionally including metadata + request + log).',
|
|
35
44
|
inputSchema: sessionsInputShape,
|
|
36
45
|
outputSchema: sessionsOutputShape,
|
|
37
46
|
}, async (input) => {
|
package/dist/src/mcp/types.js
CHANGED
|
@@ -6,6 +6,10 @@ export const consultInputSchema = z.object({
|
|
|
6
6
|
models: z.array(z.string()).optional(),
|
|
7
7
|
engine: z.enum(['api', 'browser']).optional(),
|
|
8
8
|
browserModelLabel: z.string().optional(),
|
|
9
|
+
browserAttachments: z.enum(['auto', 'never', 'always']).optional(),
|
|
10
|
+
browserBundleFiles: z.boolean().optional(),
|
|
11
|
+
browserThinkingTime: z.enum(['light', 'standard', 'extended', 'heavy']).optional(),
|
|
12
|
+
browserKeepBrowser: z.boolean().optional(),
|
|
9
13
|
search: z.boolean().optional(),
|
|
10
14
|
slug: z.string().optional(),
|
|
11
15
|
});
|
package/dist/src/mcp/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveRunOptionsFromConfig } from '../cli/runOptions.js';
|
|
2
2
|
import { Launcher } from 'chrome-launcher';
|
|
3
|
-
export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, userConfig, env = process.env, }) {
|
|
3
|
+
export function mapConsultToRunOptions({ prompt, files, model, models, engine, search, browserAttachments, browserBundleFiles, userConfig, env = process.env, }) {
|
|
4
4
|
// Normalize CLI-style inputs through the shared resolver so config/env defaults apply,
|
|
5
5
|
// then overlay MCP-only overrides such as explicit search toggles.
|
|
6
6
|
const mergedModels = Array.isArray(models) && models.length > 0
|
|
@@ -10,12 +10,22 @@ export function mapConsultToRunOptions({ prompt, files, model, models, engine, s
|
|
|
10
10
|
if (typeof search === 'boolean') {
|
|
11
11
|
result.runOptions.search = search;
|
|
12
12
|
}
|
|
13
|
+
if (browserAttachments) {
|
|
14
|
+
result.runOptions.browserAttachments = browserAttachments;
|
|
15
|
+
}
|
|
16
|
+
if (typeof browserBundleFiles === 'boolean') {
|
|
17
|
+
result.runOptions.browserBundleFiles = browserBundleFiles;
|
|
18
|
+
}
|
|
13
19
|
return result;
|
|
14
20
|
}
|
|
15
|
-
export function ensureBrowserAvailable(engine) {
|
|
21
|
+
export function ensureBrowserAvailable(engine, options) {
|
|
16
22
|
if (engine !== 'browser') {
|
|
17
23
|
return null;
|
|
18
24
|
}
|
|
25
|
+
const remoteHost = options?.remoteHost?.trim() || process.env.ORACLE_REMOTE_HOST?.trim();
|
|
26
|
+
if (remoteHost) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
19
29
|
if (process.env.CHROME_PATH) {
|
|
20
30
|
return null;
|
|
21
31
|
}
|
|
@@ -3,7 +3,6 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { formatElapsed } from './format.js';
|
|
4
4
|
import { startHeartbeat } from '../heartbeat.js';
|
|
5
5
|
import { OracleResponseError, OracleTransportError, describeTransportError, toTransportError, } from './errors.js';
|
|
6
|
-
const BACKGROUND_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
7
6
|
const BACKGROUND_POLL_INTERVAL_MS = 5000;
|
|
8
7
|
const BACKGROUND_RETRY_BASE_MS = 3000;
|
|
9
8
|
const BACKGROUND_RETRY_MAX_MS = 15000;
|
|
@@ -22,7 +21,7 @@ export async function executeBackgroundResponse(params) {
|
|
|
22
21
|
throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
|
|
23
22
|
}
|
|
24
23
|
const responseId = initialResponse.id;
|
|
25
|
-
log(chalk.dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(
|
|
24
|
+
log(chalk.dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(maxWaitMs / 60000)} minutes for completion...`));
|
|
26
25
|
let heartbeatActive = false;
|
|
27
26
|
let stopHeartbeat = null;
|
|
28
27
|
const stopHeartbeatNow = () => {
|
|
@@ -19,19 +19,22 @@ export function createDefaultClientFactory() {
|
|
|
19
19
|
let instance;
|
|
20
20
|
const openRouter = isOpenRouterBaseUrl(options?.baseUrl);
|
|
21
21
|
const defaultHeaders = openRouter ? buildOpenRouterHeaders() : undefined;
|
|
22
|
+
const httpTimeoutMs = typeof options?.httpTimeoutMs === 'number' && Number.isFinite(options.httpTimeoutMs) && options.httpTimeoutMs > 0
|
|
23
|
+
? options.httpTimeoutMs
|
|
24
|
+
: 20 * 60 * 1000;
|
|
22
25
|
if (options?.azure?.endpoint) {
|
|
23
26
|
instance = new AzureOpenAI({
|
|
24
27
|
apiKey: key,
|
|
25
28
|
endpoint: options.azure.endpoint,
|
|
26
29
|
apiVersion: options.azure.apiVersion,
|
|
27
30
|
deployment: options.azure.deployment,
|
|
28
|
-
timeout:
|
|
31
|
+
timeout: httpTimeoutMs,
|
|
29
32
|
});
|
|
30
33
|
}
|
|
31
34
|
else {
|
|
32
35
|
instance = new OpenAI({
|
|
33
36
|
apiKey: key,
|
|
34
|
-
timeout:
|
|
37
|
+
timeout: httpTimeoutMs,
|
|
35
38
|
baseURL: options?.baseUrl,
|
|
36
39
|
defaultHeaders,
|
|
37
40
|
});
|
package/dist/src/oracle/files.js
CHANGED
|
@@ -5,7 +5,7 @@ import { FileValidationError } from './errors.js';
|
|
|
5
5
|
const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
6
6
|
const DEFAULT_FS = fs;
|
|
7
7
|
const DEFAULT_IGNORED_DIRS = ['node_modules', 'dist', 'coverage', '.git', '.turbo', '.next', 'build', 'tmp'];
|
|
8
|
-
export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES } = {}) {
|
|
8
|
+
export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES, readContents = true, } = {}) {
|
|
9
9
|
if (!filePaths || filePaths.length === 0) {
|
|
10
10
|
return [];
|
|
11
11
|
}
|
|
@@ -83,7 +83,7 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
|
|
|
83
83
|
}
|
|
84
84
|
const files = [];
|
|
85
85
|
for (const filePath of accepted) {
|
|
86
|
-
const content = await fsModule.readFile(filePath, 'utf8');
|
|
86
|
+
const content = readContents ? await fsModule.readFile(filePath, 'utf8') : '';
|
|
87
87
|
files.push({ path: filePath, content });
|
|
88
88
|
}
|
|
89
89
|
return files;
|
package/dist/src/oracle/run.js
CHANGED
|
@@ -297,6 +297,7 @@ export async function runOracle(options, deps = {}) {
|
|
|
297
297
|
: modelConfig.model.startsWith('gemini')
|
|
298
298
|
? resolveGeminiModelId(effectiveModelId)
|
|
299
299
|
: effectiveModelId,
|
|
300
|
+
httpTimeoutMs: options.httpTimeoutMs,
|
|
300
301
|
});
|
|
301
302
|
logVerbose('Dispatching request to API...');
|
|
302
303
|
if (options.verbose) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { parseHostPort } from '../bridge/connection.js';
|
|
4
5
|
export function createRemoteBrowserExecutor({ host, token }) {
|
|
5
6
|
// Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
|
|
6
7
|
return async function remoteBrowserExecutor(options) {
|
|
@@ -79,12 +80,12 @@ async function serializeAttachments(attachments) {
|
|
|
79
80
|
return serialized;
|
|
80
81
|
}
|
|
81
82
|
function parseHost(input) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
try {
|
|
84
|
+
return parseHostPort(input);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Invalid remote host: ${input} (${error instanceof Error ? error.message : String(error)})`);
|
|
86
88
|
}
|
|
87
|
-
return { hostname, port };
|
|
88
89
|
}
|
|
89
90
|
function handleEvent(line, options, onResult, onError) {
|
|
90
91
|
let event;
|