@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
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -119,6 +119,11 @@ npx -y @steipete/oracle oracle-mcp
|
|
|
119
119
|
| `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
|
|
120
120
|
| `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
|
|
121
121
|
| `--wait` | Block for background API runs (e.g., GPT‑5.1 Pro) instead of detaching. |
|
|
122
|
+
| `--timeout <seconds\|auto>` | Overall API deadline (auto = 60m for pro, 120s otherwise). |
|
|
123
|
+
| `--background`, `--no-background` | Force Responses API background mode (create + retrieve) for API runs. |
|
|
124
|
+
| `--http-timeout <ms\|s\|m\|h>` | HTTP client timeout (default 20m). |
|
|
125
|
+
| `--zombie-timeout <ms\|s\|m\|h>` | Override stale-session cutoff used by `oracle status`. |
|
|
126
|
+
| `--zombie-last-activity` | Use last log activity to detect stale sessions. |
|
|
122
127
|
| `--write-output <path>` | Save only the final answer (multi-model adds `.<model>`). |
|
|
123
128
|
| `--files-report` | Print per-file token usage. |
|
|
124
129
|
| `--dry-run [summary\|json\|full]` | Preview without sending. |
|
|
@@ -150,6 +155,7 @@ Advanced flags
|
|
|
150
155
|
| Area | Flags |
|
|
151
156
|
| --- | --- |
|
|
152
157
|
| Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
|
|
158
|
+
| Run control | `--background`, `--no-background`, `--http-timeout`, `--zombie-timeout`, `--zombie-last-activity` |
|
|
153
159
|
| Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
|
|
154
160
|
|
|
155
161
|
Remote browser example
|
|
@@ -171,6 +177,7 @@ oracle status --clear --hours 168
|
|
|
171
177
|
```
|
|
172
178
|
|
|
173
179
|
## More docs
|
|
180
|
+
- Bridge (Windows host → Linux client): [docs/bridge.md](docs/bridge.md)
|
|
174
181
|
- Browser mode & forks: [docs/browser-mode.md](docs/browser-mode.md) (includes `oracle serve` remote service), [docs/chromium-forks.md](docs/chromium-forks.md), [docs/linux.md](docs/linux.md)
|
|
175
182
|
- MCP: [docs/mcp.md](docs/mcp.md)
|
|
176
183
|
- OpenAI/Azure/OpenRouter endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md), [docs/openrouter.md](docs/openrouter.md)
|
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import { CHATGPT_URL } from '../src/browserMode.js';
|
|
|
20
20
|
import { createRemoteBrowserExecutor } from '../src/remote/client.js';
|
|
21
21
|
import { createGeminiWebExecutor } from '../src/gemini-web/index.js';
|
|
22
22
|
import { applyHelpStyling } from '../src/cli/help.js';
|
|
23
|
-
import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, dedupePathInputs, } from '../src/cli/options.js';
|
|
23
|
+
import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from '../src/cli/options.js';
|
|
24
24
|
import { copyToClipboard } from '../src/cli/clipboard.js';
|
|
25
25
|
import { buildMarkdownBundle } from '../src/cli/markdownBundle.js';
|
|
26
26
|
import { shouldDetachSession } from '../src/cli/detach.js';
|
|
@@ -46,9 +46,20 @@ import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, }
|
|
|
46
46
|
import { loadUserConfig } from '../src/config.js';
|
|
47
47
|
import { applyBrowserDefaultsFromConfig } from '../src/cli/browserDefaults.js';
|
|
48
48
|
import { shouldBlockDuplicatePrompt } from '../src/cli/duplicatePromptGuard.js';
|
|
49
|
+
import { resolveRemoteServiceConfig } from '../src/remote/remoteServiceConfig.js';
|
|
49
50
|
const VERSION = getCliVersion();
|
|
50
51
|
const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
|
|
51
|
-
const
|
|
52
|
+
const LEGACY_FLAG_ALIASES = new Map([
|
|
53
|
+
['--[no-]notify', '--notify'],
|
|
54
|
+
['--[no-]notify-sound', '--notify-sound'],
|
|
55
|
+
['--[no-]background', '--background'],
|
|
56
|
+
]);
|
|
57
|
+
const normalizedArgv = process.argv.map((arg, index) => {
|
|
58
|
+
if (index < 2)
|
|
59
|
+
return arg;
|
|
60
|
+
return LEGACY_FLAG_ALIASES.get(arg) ?? arg;
|
|
61
|
+
});
|
|
62
|
+
const rawCliArgs = normalizedArgv.slice(2);
|
|
52
63
|
const userCliArgs = rawCliArgs[0] === CLI_ENTRYPOINT ? rawCliArgs.slice(1) : rawCliArgs;
|
|
53
64
|
const isTty = process.stdout.isTTY;
|
|
54
65
|
const program = new Command();
|
|
@@ -120,12 +131,22 @@ program
|
|
|
120
131
|
.addOption(new Option('--mode <mode>', 'Alias for --engine (api | browser).').choices(['api', 'browser']).hideHelp())
|
|
121
132
|
.option('--files-report', 'Show token usage per attached file (also prints automatically when files exceed the token budget).', false)
|
|
122
133
|
.option('-v, --verbose', 'Enable verbose logging for all operations.', false)
|
|
123
|
-
.addOption(new Option('--
|
|
124
|
-
.default(undefined))
|
|
125
|
-
.addOption(new Option('--
|
|
134
|
+
.addOption(new Option('--notify', 'Desktop notification when a session finishes (default on unless CI/SSH).').default(undefined))
|
|
135
|
+
.addOption(new Option('--no-notify', 'Disable desktop notifications.').default(undefined))
|
|
136
|
+
.addOption(new Option('--notify-sound', 'Play a notification sound on completion (default off).').default(undefined))
|
|
137
|
+
.addOption(new Option('--no-notify-sound', 'Disable notification sounds.').default(undefined))
|
|
126
138
|
.addOption(new Option('--timeout <seconds|auto>', 'Overall timeout before aborting the API call (auto = 60m for gpt-5.2-pro, 120s otherwise).')
|
|
127
139
|
.argParser(parseTimeoutOption)
|
|
128
140
|
.default('auto'))
|
|
141
|
+
.addOption(new Option('--background', 'Use Responses API background mode (create + retrieve) for API runs.').default(undefined))
|
|
142
|
+
.addOption(new Option('--no-background', 'Disable Responses API background mode.').default(undefined))
|
|
143
|
+
.addOption(new Option('--http-timeout <ms|s|m|h>', 'HTTP client timeout for API requests (default 20m).')
|
|
144
|
+
.argParser((value) => parseDurationOption(value, 'HTTP timeout'))
|
|
145
|
+
.default(undefined))
|
|
146
|
+
.addOption(new Option('--zombie-timeout <ms|s|m|h>', 'Override stale-session cutoff used by `oracle status` (default 60m).')
|
|
147
|
+
.argParser((value) => parseDurationOption(value, 'Zombie timeout'))
|
|
148
|
+
.default(undefined))
|
|
149
|
+
.option('--zombie-last-activity', 'Base stale-session detection on last log activity instead of start time.', false)
|
|
129
150
|
.addOption(new Option('--preview [mode]', '(alias) Preview the request without calling the model (summary | json | full). Deprecated: use --dry-run instead.')
|
|
130
151
|
.hideHelp()
|
|
131
152
|
.choices(['summary', 'json', 'full'])
|
|
@@ -219,14 +240,73 @@ program
|
|
|
219
240
|
.option('--host <address>', 'Interface to bind (default 0.0.0.0).')
|
|
220
241
|
.option('--port <number>', 'Port to listen on (default random).', parseIntOption)
|
|
221
242
|
.option('--token <value>', 'Access token clients must provide (random if omitted).')
|
|
243
|
+
.option('--manual-login', 'Use a dedicated Chrome profile for manual login (recommended when cookie sync is unavailable).', false)
|
|
244
|
+
.option('--manual-login-profile-dir <path>', 'Chrome profile directory for manual login (default ~/.oracle/browser-profile).')
|
|
222
245
|
.action(async (commandOptions) => {
|
|
223
246
|
const { serveRemote } = await import('../src/remote/server.js');
|
|
224
247
|
await serveRemote({
|
|
225
248
|
host: commandOptions.host,
|
|
226
249
|
port: commandOptions.port,
|
|
227
250
|
token: commandOptions.token,
|
|
251
|
+
manualLoginDefault: commandOptions.manualLogin,
|
|
252
|
+
manualLoginProfileDir: commandOptions.manualLoginProfileDir,
|
|
228
253
|
});
|
|
229
254
|
});
|
|
255
|
+
const bridgeCommand = program.command('bridge').description('Bridge a Windows-hosted ChatGPT session to Linux clients.');
|
|
256
|
+
bridgeCommand
|
|
257
|
+
.command('host')
|
|
258
|
+
.description('Start a secure oracle serve host (optionally with an SSH reverse tunnel).')
|
|
259
|
+
.option('--bind <host:port>', 'Local bind address for the host service (default 127.0.0.1:9473).')
|
|
260
|
+
.option('--token <token|auto>', 'Service access token (default auto).', 'auto')
|
|
261
|
+
.option('--write-connection <path>', 'Write a connection artifact JSON (default ~/.oracle/bridge-connection.json).')
|
|
262
|
+
.option('--ssh <user@host>', 'Maintain an SSH reverse tunnel to the Linux host (ssh -N -R ...).')
|
|
263
|
+
.option('--ssh-remote-port <port>', 'Remote port to bind on the Linux host (default matches --bind port).', parseIntOption)
|
|
264
|
+
.option('--ssh-identity <path>', 'SSH identity file (ssh -i).')
|
|
265
|
+
.option('--ssh-extra-args <args>', 'Extra args passed to ssh (quoted string).')
|
|
266
|
+
.option('--background', 'Run the host in the background and write pid/log files.', false)
|
|
267
|
+
.option('--foreground', 'Run the host in the foreground (default).', false)
|
|
268
|
+
.option('--print', 'Print the client connection string (includes token).', false)
|
|
269
|
+
.option('--print-token', 'Print only the token.', false)
|
|
270
|
+
.action(async (commandOptions) => {
|
|
271
|
+
const { runBridgeHost } = await import('../src/cli/bridge/host.js');
|
|
272
|
+
await runBridgeHost(commandOptions);
|
|
273
|
+
});
|
|
274
|
+
bridgeCommand
|
|
275
|
+
.command('client')
|
|
276
|
+
.description('Configure this machine to use a remote oracle serve host.')
|
|
277
|
+
.requiredOption('--connect <connection>', 'Connection string or path to bridge-connection.json.')
|
|
278
|
+
.option('--config <path>', 'Override the oracle config file location (default ~/.oracle/config.json).')
|
|
279
|
+
.option('--no-write-config', 'Do not write ~/.oracle/config.json (just validate).')
|
|
280
|
+
.option('--no-test', 'Skip remote /health check.')
|
|
281
|
+
.option('--print-env', 'Print env var exports (includes token).', false)
|
|
282
|
+
.action(async (commandOptions) => {
|
|
283
|
+
const { runBridgeClient } = await import('../src/cli/bridge/client.js');
|
|
284
|
+
await runBridgeClient(commandOptions);
|
|
285
|
+
});
|
|
286
|
+
bridgeCommand
|
|
287
|
+
.command('doctor')
|
|
288
|
+
.description('Diagnose bridge connectivity and browser engine prerequisites.')
|
|
289
|
+
.option('--verbose', 'Show extra diagnostics.', false)
|
|
290
|
+
.action(async (commandOptions) => {
|
|
291
|
+
const { runBridgeDoctor } = await import('../src/cli/bridge/doctor.js');
|
|
292
|
+
await runBridgeDoctor(commandOptions);
|
|
293
|
+
});
|
|
294
|
+
bridgeCommand
|
|
295
|
+
.command('codex-config')
|
|
296
|
+
.description('Print a Codex CLI MCP server config snippet for oracle-mcp.')
|
|
297
|
+
.option('--print-token', 'Include ORACLE_REMOTE_TOKEN in the snippet.', false)
|
|
298
|
+
.action(async (commandOptions) => {
|
|
299
|
+
const { runBridgeCodexConfig } = await import('../src/cli/bridge/codexConfig.js');
|
|
300
|
+
await runBridgeCodexConfig(commandOptions);
|
|
301
|
+
});
|
|
302
|
+
bridgeCommand
|
|
303
|
+
.command('claude-config')
|
|
304
|
+
.description('Print a Claude Code MCP config snippet (.mcp.json) for oracle-mcp.')
|
|
305
|
+
.option('--print-token', 'Include ORACLE_REMOTE_TOKEN in the snippet.', false)
|
|
306
|
+
.action(async (commandOptions) => {
|
|
307
|
+
const { runBridgeClaudeConfig } = await import('../src/cli/bridge/claudeConfig.js');
|
|
308
|
+
await runBridgeClaudeConfig(commandOptions);
|
|
309
|
+
});
|
|
230
310
|
program
|
|
231
311
|
.command('tui')
|
|
232
312
|
.description('Launch the interactive terminal UI for humans (no automation).')
|
|
@@ -323,6 +403,9 @@ function buildRunOptions(options, overrides = {}) {
|
|
|
323
403
|
maxOutput: overrides.maxOutput ?? options.maxOutput,
|
|
324
404
|
system: overrides.system ?? options.system,
|
|
325
405
|
timeoutSeconds: overrides.timeoutSeconds ?? options.timeout,
|
|
406
|
+
httpTimeoutMs: overrides.httpTimeoutMs ?? options.httpTimeout,
|
|
407
|
+
zombieTimeoutMs: overrides.zombieTimeoutMs ?? options.zombieTimeout,
|
|
408
|
+
zombieUseLastActivity: overrides.zombieUseLastActivity ?? options.zombieLastActivity,
|
|
326
409
|
silent: overrides.silent ?? options.silent,
|
|
327
410
|
search: overrides.search ?? options.search,
|
|
328
411
|
preview: overrides.preview ?? undefined,
|
|
@@ -373,6 +456,10 @@ function buildRunOptionsFromMetadata(metadata) {
|
|
|
373
456
|
apiKey: undefined,
|
|
374
457
|
baseUrl: normalizeBaseUrl(stored.baseUrl),
|
|
375
458
|
azure: stored.azure,
|
|
459
|
+
timeoutSeconds: stored.timeoutSeconds,
|
|
460
|
+
httpTimeoutMs: stored.httpTimeoutMs,
|
|
461
|
+
zombieTimeoutMs: stored.zombieTimeoutMs,
|
|
462
|
+
zombieUseLastActivity: stored.zombieUseLastActivity,
|
|
376
463
|
sessionId: metadata.id,
|
|
377
464
|
verbose: stored.verbose,
|
|
378
465
|
heartbeatIntervalMs: stored.heartbeatIntervalMs,
|
|
@@ -446,8 +533,14 @@ async function runRootCommand(options) {
|
|
|
446
533
|
}
|
|
447
534
|
};
|
|
448
535
|
applyRetentionOption();
|
|
449
|
-
const
|
|
450
|
-
|
|
536
|
+
const remoteConfig = resolveRemoteServiceConfig({
|
|
537
|
+
cliHost: options.remoteHost,
|
|
538
|
+
cliToken: options.remoteToken,
|
|
539
|
+
userConfig,
|
|
540
|
+
env: process.env,
|
|
541
|
+
});
|
|
542
|
+
const remoteHost = remoteConfig.host;
|
|
543
|
+
const remoteToken = remoteConfig.token;
|
|
451
544
|
if (remoteHost) {
|
|
452
545
|
console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
|
|
453
546
|
}
|
|
@@ -752,7 +845,7 @@ async function runRootCommand(options) {
|
|
|
752
845
|
const baseRunOptions = buildRunOptions(resolvedOptions, {
|
|
753
846
|
preview: false,
|
|
754
847
|
previewMode: undefined,
|
|
755
|
-
background:
|
|
848
|
+
background: resolvedOptions.background ?? userConfig.background,
|
|
756
849
|
baseUrl: resolvedBaseUrl,
|
|
757
850
|
});
|
|
758
851
|
enforceBrowserSearchFlag(baseRunOptions, sessionMode, console.log);
|
|
@@ -956,7 +1049,7 @@ program.action(async function () {
|
|
|
956
1049
|
await runRootCommand(options);
|
|
957
1050
|
});
|
|
958
1051
|
async function main() {
|
|
959
|
-
const parsePromise = program.parseAsync(
|
|
1052
|
+
const parsePromise = program.parseAsync(normalizedArgv);
|
|
960
1053
|
const sigintPromise = once(process, 'SIGINT').then(() => 'sigint');
|
|
961
1054
|
const result = await Promise.race([parsePromise.then(() => 'parsed'), sigintPromise]);
|
|
962
1055
|
if (result === 'sigint') {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer-core';
|
|
2
|
+
const port = parseInt(process.argv[2] || '52990', 10);
|
|
3
|
+
async function main() {
|
|
4
|
+
const browser = await puppeteer.connect({
|
|
5
|
+
browserURL: `http://127.0.0.1:${port}`,
|
|
6
|
+
defaultViewport: null,
|
|
7
|
+
});
|
|
8
|
+
const pages = await browser.pages();
|
|
9
|
+
let targetPage = null;
|
|
10
|
+
for (const page of pages) {
|
|
11
|
+
const url = page.url();
|
|
12
|
+
if (url.includes('chatgpt.com/c/')) {
|
|
13
|
+
targetPage = page;
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (!targetPage) {
|
|
18
|
+
console.error('ChatGPT conversation page not found');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
console.error('Found page:', await targetPage.url());
|
|
22
|
+
// Extract the last assistant message
|
|
23
|
+
const content = (await targetPage.evaluate(() => {
|
|
24
|
+
// Try multiple selectors for ChatGPT's assistant messages
|
|
25
|
+
const selectors = [
|
|
26
|
+
'[data-message-author-role="assistant"] .markdown',
|
|
27
|
+
'[data-message-author-role="assistant"]',
|
|
28
|
+
'.agent-turn .markdown',
|
|
29
|
+
'.agent-turn',
|
|
30
|
+
];
|
|
31
|
+
for (const selector of selectors) {
|
|
32
|
+
const elements = document.querySelectorAll(selector);
|
|
33
|
+
if (elements.length > 0) {
|
|
34
|
+
const lastEl = elements[elements.length - 1];
|
|
35
|
+
return {
|
|
36
|
+
selector,
|
|
37
|
+
count: elements.length,
|
|
38
|
+
text: lastEl.innerText,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Debug: show what's on the page
|
|
43
|
+
const body = document.body.innerHTML;
|
|
44
|
+
return { error: 'No messages found', bodyLength: body.length, sample: body.slice(0, 2000) };
|
|
45
|
+
}));
|
|
46
|
+
if ('error' in content) {
|
|
47
|
+
console.error('Error:', JSON.stringify(content, null, 2));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
console.log(content.text);
|
|
51
|
+
browser.disconnect();
|
|
52
|
+
}
|
|
53
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function normalizeHostPort(hostname, port) {
|
|
4
|
+
const trimmed = hostname.trim();
|
|
5
|
+
const unwrapped = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
|
|
6
|
+
if (unwrapped.includes(':')) {
|
|
7
|
+
return `[${unwrapped}]:${port}`;
|
|
8
|
+
}
|
|
9
|
+
return `${unwrapped}:${port}`;
|
|
10
|
+
}
|
|
11
|
+
export function parseHostPort(raw) {
|
|
12
|
+
const target = raw.trim();
|
|
13
|
+
if (!target) {
|
|
14
|
+
throw new Error('Expected host:port but received an empty value.');
|
|
15
|
+
}
|
|
16
|
+
const ipv6Match = target.match(/^\[(.+)]:(\d+)$/);
|
|
17
|
+
let hostname;
|
|
18
|
+
let portSegment;
|
|
19
|
+
if (ipv6Match) {
|
|
20
|
+
hostname = ipv6Match[1]?.trim();
|
|
21
|
+
portSegment = ipv6Match[2]?.trim();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const lastColon = target.lastIndexOf(':');
|
|
25
|
+
if (lastColon === -1) {
|
|
26
|
+
throw new Error(`Invalid host:port format: ${target}. Expected host:port (IPv6 must use [host]:port notation).`);
|
|
27
|
+
}
|
|
28
|
+
hostname = target.slice(0, lastColon).trim();
|
|
29
|
+
portSegment = target.slice(lastColon + 1).trim();
|
|
30
|
+
if (hostname.includes(':')) {
|
|
31
|
+
throw new Error(`Invalid host:port format: ${target}. Wrap IPv6 addresses in brackets, e.g. "[2001:db8::1]:9473".`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!hostname) {
|
|
35
|
+
throw new Error(`Invalid host:port format: ${target}. Host portion is missing.`);
|
|
36
|
+
}
|
|
37
|
+
const port = Number.parseInt(portSegment ?? '', 10);
|
|
38
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
|
|
39
|
+
throw new Error(`Invalid port: "${portSegment ?? ''}". Expected a number between 1 and 65535.`);
|
|
40
|
+
}
|
|
41
|
+
return { hostname, port };
|
|
42
|
+
}
|
|
43
|
+
export function parseBridgeConnectionString(input) {
|
|
44
|
+
const raw = input.trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
throw new Error('Missing connection string.');
|
|
47
|
+
}
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = raw.includes('://') ? new URL(raw) : new URL(`oracle+tcp://${raw}`);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new Error(`Invalid connection string: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
const hostname = url.hostname?.trim();
|
|
56
|
+
const port = Number.parseInt(url.port ?? '', 10);
|
|
57
|
+
if (!hostname || !Number.isFinite(port) || port <= 0 || port > 65_535) {
|
|
58
|
+
throw new Error(`Invalid connection string host: ${raw}. Expected host:port.`);
|
|
59
|
+
}
|
|
60
|
+
const token = url.searchParams.get('token')?.trim() ?? '';
|
|
61
|
+
if (!token) {
|
|
62
|
+
throw new Error('Connection string is missing token. Expected "?token=...".');
|
|
63
|
+
}
|
|
64
|
+
const remoteHost = normalizeHostPort(hostname, port);
|
|
65
|
+
return { remoteHost, remoteToken: token };
|
|
66
|
+
}
|
|
67
|
+
export function formatBridgeConnectionString(connection, options = {}) {
|
|
68
|
+
const { hostname, port } = parseHostPort(connection.remoteHost);
|
|
69
|
+
const base = `oracle+tcp://${normalizeHostPort(hostname, port)}`;
|
|
70
|
+
if (!options.includeToken) {
|
|
71
|
+
return base;
|
|
72
|
+
}
|
|
73
|
+
const params = new URLSearchParams({ token: connection.remoteToken });
|
|
74
|
+
return `${base}?${params.toString()}`;
|
|
75
|
+
}
|
|
76
|
+
export function looksLikePath(value) {
|
|
77
|
+
return value.includes('/') || value.includes('\\') || value.endsWith('.json');
|
|
78
|
+
}
|
|
79
|
+
export async function readBridgeConnectionArtifact(filePath) {
|
|
80
|
+
const resolved = path.resolve(process.cwd(), filePath);
|
|
81
|
+
const raw = await fs.readFile(resolved, 'utf8');
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Failed to parse connection artifact JSON at ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
90
|
+
throw new Error(`Invalid connection artifact at ${resolved}: expected an object.`);
|
|
91
|
+
}
|
|
92
|
+
const remoteHost = parsed.remoteHost;
|
|
93
|
+
const remoteToken = parsed.remoteToken;
|
|
94
|
+
if (typeof remoteHost !== 'string' || remoteHost.trim().length === 0) {
|
|
95
|
+
throw new Error(`Invalid connection artifact at ${resolved}: remoteHost is missing.`);
|
|
96
|
+
}
|
|
97
|
+
if (typeof remoteToken !== 'string' || remoteToken.trim().length === 0) {
|
|
98
|
+
throw new Error(`Invalid connection artifact at ${resolved}: remoteToken is missing.`);
|
|
99
|
+
}
|
|
100
|
+
// Validate host formatting early so downstream checks don't crash.
|
|
101
|
+
parseHostPort(remoteHost);
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import JSON5 from 'json5';
|
|
4
|
+
export async function readUserConfigFile(configPath) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
7
|
+
const parsed = JSON5.parse(raw);
|
|
8
|
+
return { config: parsed ?? {}, loaded: true };
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const code = error.code;
|
|
12
|
+
if (code === 'ENOENT') {
|
|
13
|
+
return { config: {}, loaded: false };
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function writeUserConfigFile(configPath, config) {
|
|
19
|
+
const dir = path.dirname(configPath);
|
|
20
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
const contents = `${JSON.stringify(config, null, 2)}\n`;
|
|
22
|
+
const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
23
|
+
await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
|
|
24
|
+
await fs.rename(tempPath, configPath);
|
|
25
|
+
if (process.platform !== 'win32') {
|
|
26
|
+
await fs.chmod(configPath, 0o600).catch(() => undefined);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -286,11 +286,15 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
|
|
|
286
286
|
isCompletionVisible(Runtime),
|
|
287
287
|
]);
|
|
288
288
|
const shortAnswer = currentLength > 0 && currentLength < 16;
|
|
289
|
+
const mediumAnswer = currentLength >= 16 && currentLength < 40;
|
|
290
|
+
const longAnswer = currentLength >= 40 && currentLength < 500;
|
|
289
291
|
// Learned: short answers need a longer stability window or they truncate.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
293
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
294
|
+
const completionStableTarget = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 6 : 8;
|
|
295
|
+
const requiredStableCycles = shortAnswer ? 12 : mediumAnswer ? 8 : longAnswer ? 8 : 10;
|
|
292
296
|
const stableMs = Date.now() - lastChangeAt;
|
|
293
|
-
const minStableMs = shortAnswer ? 8000 : 1200;
|
|
297
|
+
const minStableMs = shortAnswer ? 8000 : mediumAnswer ? 1200 : longAnswer ? 2000 : 3000;
|
|
294
298
|
// Require stop button to disappear before treating completion as final.
|
|
295
299
|
if (!stopVisible) {
|
|
296
300
|
const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
|
|
@@ -546,15 +550,19 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
|
|
|
546
550
|
|
|
547
551
|
const waitForSettle = async (snapshot) => {
|
|
548
552
|
// Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
|
|
553
|
+
// Learned: long streaming responses (esp. thinking models) can pause mid-stream;
|
|
554
|
+
// use progressively longer windows to avoid truncation (#71).
|
|
549
555
|
const initialLength = snapshot?.text?.length ?? 0;
|
|
550
556
|
const shortAnswer = initialLength > 0 && initialLength < 16;
|
|
551
|
-
const
|
|
557
|
+
const mediumAnswer = initialLength >= 16 && initialLength < 40;
|
|
558
|
+
const longAnswer = initialLength >= 40 && initialLength < 500;
|
|
559
|
+
const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
|
|
552
560
|
const settleIntervalMs = 400;
|
|
553
561
|
const deadline = Date.now() + settleWindowMs;
|
|
554
562
|
let latest = snapshot;
|
|
555
563
|
let lastLength = snapshot?.text?.length ?? 0;
|
|
556
564
|
let stableCycles = 0;
|
|
557
|
-
const stableTarget = shortAnswer ? 6 : 3;
|
|
565
|
+
const stableTarget = shortAnswer ? 6 : mediumAnswer ? 3 : longAnswer ? 5 : 6;
|
|
558
566
|
while (Date.now() < deadline) {
|
|
559
567
|
await new Promise((resolve) => setTimeout(resolve, settleIntervalMs));
|
|
560
568
|
const refreshedRaw = extractFromTurns();
|
|
@@ -570,7 +570,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
570
570
|
// keep it as a fallback, but strongly prefer visible (even sr-only 1x1) inputs.
|
|
571
571
|
const localSet = new Set(localInputs);
|
|
572
572
|
let idx = 0;
|
|
573
|
-
|
|
573
|
+
let candidates = inputs.map((el) => {
|
|
574
574
|
const accept = el.getAttribute('accept') || '';
|
|
575
575
|
const imageOnly = acceptIsImageOnly(accept);
|
|
576
576
|
const rect = el instanceof HTMLElement ? el.getBoundingClientRect() : { width: 0, height: 0 };
|
|
@@ -583,9 +583,18 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
583
583
|
(!imageOnly ? 30 : isImageAttachment ? 20 : 5);
|
|
584
584
|
el.setAttribute('data-oracle-upload-candidate', 'true');
|
|
585
585
|
el.setAttribute('data-oracle-upload-idx', String(idx));
|
|
586
|
-
return { idx: idx++, score, imageOnly
|
|
586
|
+
return { idx: idx++, score, imageOnly };
|
|
587
587
|
});
|
|
588
588
|
|
|
589
|
+
// When the attachment isn't an image, avoid inputs that only accept images.
|
|
590
|
+
// Some ChatGPT surfaces expose multiple file inputs (e.g. image-only vs generic upload).
|
|
591
|
+
if (!isImageAttachment) {
|
|
592
|
+
const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
|
|
593
|
+
if (nonImage.length > 0) {
|
|
594
|
+
candidates = nonImage;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
589
598
|
// Prefer higher scores first.
|
|
590
599
|
candidates.sort((a, b) => b.score - a.score);
|
|
591
600
|
return {
|
|
@@ -829,8 +838,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
829
838
|
continue;
|
|
830
839
|
}
|
|
831
840
|
const baselineInputSnapshot = await readInputSnapshot(idx);
|
|
832
|
-
const gatherSignals = async () => {
|
|
833
|
-
const signalResult = await waitForAttachmentUiSignal(
|
|
841
|
+
const gatherSignals = async (waitMs = attachmentUiSignalWaitMs) => {
|
|
842
|
+
const signalResult = await waitForAttachmentUiSignal(waitMs);
|
|
834
843
|
const postInputSnapshot = await readInputSnapshot(idx);
|
|
835
844
|
const postInputSignals = inputSignalsFor(baselineInputSnapshot, postInputSnapshot);
|
|
836
845
|
const snapshot = await runtime
|
|
@@ -890,22 +899,6 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
890
899
|
if (!hasExpectedFile) {
|
|
891
900
|
if (mode === 'set') {
|
|
892
901
|
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
|
|
893
|
-
await runtime
|
|
894
|
-
.evaluate({
|
|
895
|
-
expression: `(() => {
|
|
896
|
-
const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
897
|
-
if (!(input instanceof HTMLInputElement)) return false;
|
|
898
|
-
try {
|
|
899
|
-
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
900
|
-
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
901
|
-
return true;
|
|
902
|
-
} catch {
|
|
903
|
-
return false;
|
|
904
|
-
}
|
|
905
|
-
})()`,
|
|
906
|
-
returnByValue: true,
|
|
907
|
-
})
|
|
908
|
-
.catch(() => undefined);
|
|
909
902
|
}
|
|
910
903
|
else {
|
|
911
904
|
const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
|
|
@@ -930,12 +923,43 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
930
923
|
const evaluation = await evaluateSignals(signalState.signalResult, signalState.postInputSignals, immediateInputMatch);
|
|
931
924
|
return { evaluation, signalState, immediateInputMatch };
|
|
932
925
|
};
|
|
926
|
+
const dispatchInputEvents = async () => {
|
|
927
|
+
await runtime
|
|
928
|
+
.evaluate({
|
|
929
|
+
expression: `(() => {
|
|
930
|
+
const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
931
|
+
if (!(input instanceof HTMLInputElement)) return false;
|
|
932
|
+
try {
|
|
933
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
934
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
935
|
+
return true;
|
|
936
|
+
} catch {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
})()`,
|
|
940
|
+
returnByValue: true,
|
|
941
|
+
})
|
|
942
|
+
.catch(() => undefined);
|
|
943
|
+
};
|
|
933
944
|
let result = await runInputAttempt('set');
|
|
934
945
|
if (result.evaluation.status === 'ui') {
|
|
935
946
|
confirmedAttachment = true;
|
|
936
947
|
break;
|
|
937
948
|
}
|
|
938
949
|
if (result.evaluation.status === 'input') {
|
|
950
|
+
await dispatchInputEvents();
|
|
951
|
+
await delay(150);
|
|
952
|
+
const forcedState = await gatherSignals(1_500);
|
|
953
|
+
const forcedEvaluation = await evaluateSignals(forcedState.signalResult, forcedState.postInputSignals, result.immediateInputMatch);
|
|
954
|
+
if (forcedEvaluation.status === 'ui') {
|
|
955
|
+
confirmedAttachment = true;
|
|
956
|
+
break;
|
|
957
|
+
}
|
|
958
|
+
if (forcedEvaluation.status === 'input') {
|
|
959
|
+
logger('Attachment input set; proceeding without UI confirmation.');
|
|
960
|
+
inputConfirmed = true;
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
939
963
|
logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
|
|
940
964
|
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
|
|
941
965
|
await delay(150);
|
|
@@ -124,15 +124,14 @@ export async function connectToChrome(port, logger, host) {
|
|
|
124
124
|
}
|
|
125
125
|
export async function connectToRemoteChrome(host, port, logger, targetUrl) {
|
|
126
126
|
if (targetUrl) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
logger(`Failed to open dedicated remote Chrome tab (${message}); falling back to first target.`);
|
|
127
|
+
const targetConnection = await connectToNewTarget(host, port, targetUrl, logger, {
|
|
128
|
+
opened: () => `Opened dedicated remote Chrome tab targeting ${targetUrl}`,
|
|
129
|
+
openFailed: (message) => `Failed to open dedicated remote Chrome tab (${message}); falling back to first target.`,
|
|
130
|
+
attachFailed: (targetId, message) => `Failed to attach to dedicated remote Chrome tab ${targetId} (${message}); falling back to first target.`,
|
|
131
|
+
closeFailed: (targetId, message) => `Failed to close unused remote Chrome tab ${targetId}: ${message}`,
|
|
132
|
+
});
|
|
133
|
+
if (targetConnection) {
|
|
134
|
+
return { client: targetConnection.client, targetId: targetConnection.targetId };
|
|
136
135
|
}
|
|
137
136
|
}
|
|
138
137
|
const fallbackClient = await CDP({ host, port });
|
|
@@ -154,6 +153,60 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
|
|
|
154
153
|
logger(`Failed to close remote Chrome tab ${targetId}: ${message}`);
|
|
155
154
|
}
|
|
156
155
|
}
|
|
156
|
+
async function connectToNewTarget(host, port, url, logger, messages) {
|
|
157
|
+
try {
|
|
158
|
+
const target = await CDP.New({ host, port, url });
|
|
159
|
+
try {
|
|
160
|
+
const client = await CDP({ host, port, target: target.id });
|
|
161
|
+
if (messages.opened) {
|
|
162
|
+
logger(messages.opened(target.id));
|
|
163
|
+
}
|
|
164
|
+
return { client, targetId: target.id };
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
logger(messages.attachFailed(target.id, message));
|
|
169
|
+
try {
|
|
170
|
+
await CDP.Close({ host, port, id: target.id });
|
|
171
|
+
}
|
|
172
|
+
catch (closeError) {
|
|
173
|
+
const closeMessage = closeError instanceof Error ? closeError.message : String(closeError);
|
|
174
|
+
logger(messages.closeFailed(target.id, closeMessage));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
logger(messages.openFailed(message));
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
export async function connectWithNewTab(port, logger, initialUrl, host) {
|
|
185
|
+
const effectiveHost = host ?? '127.0.0.1';
|
|
186
|
+
const url = initialUrl ?? 'about:blank';
|
|
187
|
+
const targetConnection = await connectToNewTarget(effectiveHost, port, url, logger, {
|
|
188
|
+
opened: (targetId) => `Opened isolated browser tab (target=${targetId})`,
|
|
189
|
+
openFailed: (message) => `Failed to open isolated browser tab (${message}); falling back to default target.`,
|
|
190
|
+
attachFailed: (targetId, message) => `Failed to attach to isolated browser tab ${targetId} (${message}); falling back to default target.`,
|
|
191
|
+
closeFailed: (targetId, message) => `Failed to close unused browser tab ${targetId}: ${message}`,
|
|
192
|
+
});
|
|
193
|
+
if (targetConnection) {
|
|
194
|
+
return targetConnection;
|
|
195
|
+
}
|
|
196
|
+
const client = await connectToChrome(port, logger, effectiveHost);
|
|
197
|
+
return { client };
|
|
198
|
+
}
|
|
199
|
+
export async function closeTab(port, targetId, logger, host) {
|
|
200
|
+
const effectiveHost = host ?? '127.0.0.1';
|
|
201
|
+
try {
|
|
202
|
+
await CDP.Close({ host: effectiveHost, port, id: targetId });
|
|
203
|
+
logger(`Closed isolated browser tab (target=${targetId})`);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
logger(`Failed to close browser tab ${targetId}: ${message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
157
210
|
function buildChromeFlags(headless, debugBindAddress) {
|
|
158
211
|
const flags = [
|
|
159
212
|
'--disable-background-networking',
|