@steipete/oracle 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -7
- package/assets-oracle-icon.png +0 -0
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +315 -47
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/src/browser/actions/modelSelection.js +117 -29
- package/dist/src/browser/config.js +6 -0
- package/dist/src/browser/cookies.js +50 -12
- package/dist/src/browser/index.js +19 -5
- package/dist/src/browser/prompt.js +6 -5
- package/dist/src/browser/sessionRunner.js +14 -3
- package/dist/src/cli/browserConfig.js +109 -2
- package/dist/src/cli/detach.js +12 -0
- package/dist/src/cli/dryRun.js +60 -8
- package/dist/src/cli/engine.js +7 -0
- package/dist/src/cli/help.js +3 -1
- package/dist/src/cli/hiddenAliases.js +17 -0
- package/dist/src/cli/markdownRenderer.js +79 -0
- package/dist/src/cli/notifier.js +223 -0
- package/dist/src/cli/options.js +22 -0
- package/dist/src/cli/promptRequirement.js +3 -0
- package/dist/src/cli/runOptions.js +43 -0
- package/dist/src/cli/sessionCommand.js +1 -1
- package/dist/src/cli/sessionDisplay.js +94 -7
- package/dist/src/cli/sessionRunner.js +32 -2
- package/dist/src/cli/tui/index.js +457 -0
- package/dist/src/config.js +27 -0
- package/dist/src/mcp/server.js +36 -0
- package/dist/src/mcp/tools/consult.js +158 -0
- package/dist/src/mcp/tools/sessionResources.js +64 -0
- package/dist/src/mcp/tools/sessions.js +106 -0
- package/dist/src/mcp/types.js +17 -0
- package/dist/src/mcp/utils.js +24 -0
- package/dist/src/oracle/client.js +24 -6
- package/dist/src/oracle/config.js +10 -0
- package/dist/src/oracle/files.js +151 -8
- package/dist/src/oracle/format.js +2 -7
- package/dist/src/oracle/fsAdapter.js +4 -1
- package/dist/src/oracle/gemini.js +161 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/oscProgress.js +7 -1
- package/dist/src/oracle/run.js +148 -64
- package/dist/src/oracle/tokenEstimate.js +34 -0
- package/dist/src/oracle.js +1 -0
- package/dist/src/sessionManager.js +50 -3
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +22 -6
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
package/dist/src/oracle/run.js
CHANGED
|
@@ -8,34 +8,31 @@ import { APIConnectionError, APIConnectionTimeoutError } from 'openai';
|
|
|
8
8
|
import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './config.js';
|
|
9
9
|
import { readFiles } from './files.js';
|
|
10
10
|
import { buildPrompt, buildRequestBody } from './request.js';
|
|
11
|
+
import { estimateRequestTokens } from './tokenEstimate.js';
|
|
11
12
|
import { formatElapsed, formatUSD } from './format.js';
|
|
12
13
|
import { getFileTokenStats, printFileTokenStats } from './tokenStats.js';
|
|
13
14
|
import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from './errors.js';
|
|
14
15
|
import { createDefaultClientFactory } from './client.js';
|
|
16
|
+
import { formatBaseUrlForLog, maskApiKey } from './logging.js';
|
|
15
17
|
import { startHeartbeat } from '../heartbeat.js';
|
|
16
18
|
import { startOscProgress } from './oscProgress.js';
|
|
17
19
|
import { getCliVersion } from '../version.js';
|
|
18
20
|
import { createFsAdapter } from './fsAdapter.js';
|
|
19
|
-
|
|
21
|
+
import { resolveGeminiModelId } from './gemini.js';
|
|
22
|
+
const isTty = process.stdout.isTTY && chalk.level > 0;
|
|
20
23
|
const dim = (text) => (isTty ? kleur.dim(text) : text);
|
|
21
24
|
const BACKGROUND_MAX_WAIT_MS = 30 * 60 * 1000;
|
|
22
25
|
const BACKGROUND_POLL_INTERVAL_MS = 5000;
|
|
23
26
|
const BACKGROUND_RETRY_BASE_MS = 3000;
|
|
24
27
|
const BACKGROUND_RETRY_MAX_MS = 15000;
|
|
28
|
+
const DEFAULT_TIMEOUT_NON_PRO_MS = 30_000;
|
|
29
|
+
const DEFAULT_TIMEOUT_PRO_MS = 20 * 60 * 1000;
|
|
25
30
|
const defaultWait = (ms) => new Promise((resolve) => {
|
|
26
31
|
setTimeout(resolve, ms);
|
|
27
32
|
});
|
|
28
33
|
export async function runOracle(options, deps = {}) {
|
|
29
|
-
const { apiKey = options.apiKey
|
|
30
|
-
const
|
|
31
|
-
if (!key)
|
|
32
|
-
return null;
|
|
33
|
-
if (key.length <= 8)
|
|
34
|
-
return `${key[0] ?? ''}***${key[key.length - 1] ?? ''}`;
|
|
35
|
-
const prefix = key.slice(0, 4);
|
|
36
|
-
const suffix = key.slice(-4);
|
|
37
|
-
return `${prefix}****${suffix}`;
|
|
38
|
-
};
|
|
34
|
+
const { apiKey: optionsApiKey = options.apiKey, cwd = process.cwd(), fs: fsModule = createFsAdapter(fs), log = console.log, write = (text) => process.stdout.write(text), now = () => performance.now(), clientFactory = createDefaultClientFactory(), client, wait = defaultWait, } = deps;
|
|
35
|
+
const baseUrl = options.baseUrl?.trim() || process.env.OPENAI_BASE_URL?.trim();
|
|
39
36
|
const logVerbose = (message) => {
|
|
40
37
|
if (options.verbose) {
|
|
41
38
|
log(dim(`[verbose] ${message}`));
|
|
@@ -43,15 +40,22 @@ export async function runOracle(options, deps = {}) {
|
|
|
43
40
|
};
|
|
44
41
|
const previewMode = resolvePreviewMode(options.previewMode ?? options.preview);
|
|
45
42
|
const isPreview = Boolean(previewMode);
|
|
43
|
+
const getApiKeyForModel = (model) => {
|
|
44
|
+
if (model.startsWith('gpt')) {
|
|
45
|
+
return optionsApiKey ?? process.env.OPENAI_API_KEY;
|
|
46
|
+
}
|
|
47
|
+
if (model.startsWith('gemini')) {
|
|
48
|
+
return optionsApiKey ?? process.env.GEMINI_API_KEY;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
};
|
|
52
|
+
const envVar = options.model.startsWith('gpt') ? 'OPENAI_API_KEY' : 'GEMINI_API_KEY';
|
|
53
|
+
const apiKey = getApiKeyForModel(options.model);
|
|
46
54
|
if (!apiKey) {
|
|
47
|
-
throw new PromptValidationError(
|
|
48
|
-
env:
|
|
55
|
+
throw new PromptValidationError(`Missing ${envVar}. Set it via the environment or a .env file.`, {
|
|
56
|
+
env: envVar,
|
|
49
57
|
});
|
|
50
58
|
}
|
|
51
|
-
const maskedKey = maskApiKey(apiKey);
|
|
52
|
-
if (maskedKey) {
|
|
53
|
-
log(dim(`Using OPENAI_API_KEY=${maskedKey}`));
|
|
54
|
-
}
|
|
55
59
|
const modelConfig = MODEL_CONFIGS[options.model];
|
|
56
60
|
if (!modelConfig) {
|
|
57
61
|
throw new PromptValidationError(`Unsupported model "${options.model}". Choose one of: ${Object.keys(MODEL_CONFIGS).join(', ')}`, { model: options.model });
|
|
@@ -61,6 +65,8 @@ export async function runOracle(options, deps = {}) {
|
|
|
61
65
|
const files = await readFiles(options.file ?? [], { cwd, fsModule });
|
|
62
66
|
const searchEnabled = options.search !== false;
|
|
63
67
|
logVerbose(`cwd: ${cwd}`);
|
|
68
|
+
let pendingNoFilesTip = null;
|
|
69
|
+
let pendingShortPromptTip = null;
|
|
64
70
|
if (files.length > 0) {
|
|
65
71
|
const displayPaths = files
|
|
66
72
|
.map((file) => path.relative(cwd, file.path) || file.path)
|
|
@@ -72,9 +78,15 @@ export async function runOracle(options, deps = {}) {
|
|
|
72
78
|
else {
|
|
73
79
|
logVerbose('No files attached.');
|
|
74
80
|
if (!isPreview) {
|
|
75
|
-
|
|
81
|
+
pendingNoFilesTip =
|
|
82
|
+
'Tip: no files attached — Oracle works best with project context. Add files via --file path/to/code or docs.';
|
|
76
83
|
}
|
|
77
84
|
}
|
|
85
|
+
const shortPrompt = (options.prompt?.trim().length ?? 0) < 80;
|
|
86
|
+
if (!isPreview && shortPrompt) {
|
|
87
|
+
pendingShortPromptTip =
|
|
88
|
+
'Tip: brief prompts often yield generic answers — aim for 6–30 sentences and attach key files.';
|
|
89
|
+
}
|
|
78
90
|
const fileTokenInfo = getFileTokenStats(files, {
|
|
79
91
|
cwd,
|
|
80
92
|
tokenizer: modelConfig.tokenizer,
|
|
@@ -85,22 +97,50 @@ export async function runOracle(options, deps = {}) {
|
|
|
85
97
|
logVerbose(`Attached files use ${totalFileTokens.toLocaleString()} tokens`);
|
|
86
98
|
const systemPrompt = options.system?.trim() || DEFAULT_SYSTEM_PROMPT;
|
|
87
99
|
const promptWithFiles = buildPrompt(options.prompt, files, cwd);
|
|
88
|
-
const tokenizerInput = [
|
|
89
|
-
{ role: 'system', content: systemPrompt },
|
|
90
|
-
{ role: 'user', content: promptWithFiles },
|
|
91
|
-
];
|
|
92
|
-
const estimatedInputTokens = modelConfig.tokenizer(tokenizerInput, TOKENIZER_OPTIONS);
|
|
93
|
-
logVerbose(`Estimated tokens (prompt + files): ${estimatedInputTokens.toLocaleString()}`);
|
|
94
100
|
const fileCount = files.length;
|
|
95
101
|
const cliVersion = getCliVersion();
|
|
96
102
|
const richTty = process.stdout.isTTY && chalk.level > 0;
|
|
103
|
+
const timeoutSeconds = options.timeoutSeconds === undefined || options.timeoutSeconds === 'auto'
|
|
104
|
+
? options.model === 'gpt-5-pro'
|
|
105
|
+
? DEFAULT_TIMEOUT_PRO_MS / 1000
|
|
106
|
+
: DEFAULT_TIMEOUT_NON_PRO_MS / 1000
|
|
107
|
+
: options.timeoutSeconds;
|
|
108
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
109
|
+
// Track the concrete model id we dispatch to (especially for Gemini preview aliases)
|
|
110
|
+
const effectiveModelId = options.effectiveModelId ??
|
|
111
|
+
(options.model.startsWith('gemini') ? resolveGeminiModelId(options.model) : modelConfig.model);
|
|
97
112
|
const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
|
|
113
|
+
const requestBody = buildRequestBody({
|
|
114
|
+
modelConfig,
|
|
115
|
+
systemPrompt,
|
|
116
|
+
userPrompt: promptWithFiles,
|
|
117
|
+
searchEnabled,
|
|
118
|
+
maxOutputTokens: options.maxOutput,
|
|
119
|
+
background: useBackground,
|
|
120
|
+
storeResponse: useBackground,
|
|
121
|
+
});
|
|
122
|
+
const estimatedInputTokens = estimateRequestTokens(requestBody, modelConfig);
|
|
98
123
|
const tokenLabel = richTty ? chalk.green(estimatedInputTokens.toLocaleString()) : estimatedInputTokens.toLocaleString();
|
|
99
124
|
const fileLabel = richTty ? chalk.magenta(fileCount.toString()) : fileCount.toString();
|
|
100
|
-
const
|
|
125
|
+
const filesPhrase = fileCount === 0 ? 'no files' : `${fileLabel} files`;
|
|
126
|
+
const headerLine = `🧿 oracle (${cliVersion}) summons ${headerModelLabel} — ${tokenLabel} tokens, ${filesPhrase}`;
|
|
101
127
|
const shouldReportFiles = (options.filesReport || fileTokenInfo.totalTokens > inputTokenBudget) && fileTokenInfo.stats.length > 0;
|
|
102
128
|
if (!isPreview) {
|
|
103
129
|
log(headerLine);
|
|
130
|
+
const maskedKey = maskApiKey(apiKey);
|
|
131
|
+
if (maskedKey) {
|
|
132
|
+
const resolvedSuffix = options.model.startsWith('gemini') && effectiveModelId !== modelConfig.model ? ` (resolved: ${effectiveModelId})` : '';
|
|
133
|
+
log(dim(`Using ${envVar}=${maskedKey} for model ${modelConfig.model}${resolvedSuffix}`));
|
|
134
|
+
}
|
|
135
|
+
if (baseUrl) {
|
|
136
|
+
log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
|
|
137
|
+
}
|
|
138
|
+
if (pendingNoFilesTip) {
|
|
139
|
+
log(dim(pendingNoFilesTip));
|
|
140
|
+
}
|
|
141
|
+
if (pendingShortPromptTip) {
|
|
142
|
+
log(dim(pendingShortPromptTip));
|
|
143
|
+
}
|
|
104
144
|
if (options.model === 'gpt-5-pro') {
|
|
105
145
|
log(dim('Pro is thinking, this can take up to 30 minutes...'));
|
|
106
146
|
}
|
|
@@ -112,15 +152,7 @@ export async function runOracle(options, deps = {}) {
|
|
|
112
152
|
if (estimatedInputTokens > inputTokenBudget) {
|
|
113
153
|
throw new PromptValidationError(`Input too large (${estimatedInputTokens.toLocaleString()} tokens). Limit is ${inputTokenBudget.toLocaleString()} tokens.`, { estimatedInputTokens, inputTokenBudget });
|
|
114
154
|
}
|
|
115
|
-
|
|
116
|
-
modelConfig,
|
|
117
|
-
systemPrompt,
|
|
118
|
-
userPrompt: promptWithFiles,
|
|
119
|
-
searchEnabled,
|
|
120
|
-
maxOutputTokens: options.maxOutput,
|
|
121
|
-
background: useBackground,
|
|
122
|
-
storeResponse: useBackground,
|
|
123
|
-
});
|
|
155
|
+
logVerbose(`Estimated tokens (request body): ${estimatedInputTokens.toLocaleString()}`);
|
|
124
156
|
if (isPreview && previewMode) {
|
|
125
157
|
if (previewMode === 'json' || previewMode === 'full') {
|
|
126
158
|
log('Request JSON');
|
|
@@ -141,11 +173,22 @@ export async function runOracle(options, deps = {}) {
|
|
|
141
173
|
inputTokenBudget,
|
|
142
174
|
};
|
|
143
175
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
176
|
+
const apiEndpoint = modelConfig.model.startsWith('gemini') ? undefined : baseUrl;
|
|
177
|
+
const clientInstance = client ??
|
|
178
|
+
clientFactory(apiKey, {
|
|
179
|
+
baseUrl: apiEndpoint,
|
|
180
|
+
azure: options.azure,
|
|
181
|
+
model: options.model,
|
|
182
|
+
resolvedModelId: effectiveModelId,
|
|
183
|
+
});
|
|
184
|
+
logVerbose('Dispatching request to API...');
|
|
185
|
+
if (options.verbose) {
|
|
186
|
+
log(''); // ensure verbose section is separated from Answer stream
|
|
187
|
+
}
|
|
146
188
|
const stopOscProgress = startOscProgress({
|
|
147
|
-
label: useBackground ? 'Waiting for
|
|
148
|
-
targetMs: useBackground ?
|
|
189
|
+
label: useBackground ? 'Waiting for API (background)' : 'Waiting for API',
|
|
190
|
+
targetMs: useBackground ? timeoutMs : Math.min(timeoutMs, 10 * 60_000),
|
|
191
|
+
indeterminate: true,
|
|
149
192
|
write,
|
|
150
193
|
});
|
|
151
194
|
const runStart = now();
|
|
@@ -153,6 +196,12 @@ export async function runOracle(options, deps = {}) {
|
|
|
153
196
|
let elapsedMs = 0;
|
|
154
197
|
let sawTextDelta = false;
|
|
155
198
|
let answerHeaderPrinted = false;
|
|
199
|
+
const timeoutExceeded = () => now() - runStart >= timeoutMs;
|
|
200
|
+
const throwIfTimedOut = () => {
|
|
201
|
+
if (timeoutExceeded()) {
|
|
202
|
+
throw new OracleTransportError('client-timeout', `Timed out waiting for API response after ${formatElapsed(timeoutMs)}.`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
156
205
|
const ensureAnswerHeader = () => {
|
|
157
206
|
if (!options.silent && !answerHeaderPrinted) {
|
|
158
207
|
log('');
|
|
@@ -163,17 +212,18 @@ export async function runOracle(options, deps = {}) {
|
|
|
163
212
|
try {
|
|
164
213
|
if (useBackground) {
|
|
165
214
|
response = await executeBackgroundResponse({
|
|
166
|
-
client:
|
|
215
|
+
client: clientInstance,
|
|
167
216
|
requestBody,
|
|
168
217
|
log,
|
|
169
218
|
wait,
|
|
170
219
|
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
171
220
|
now,
|
|
221
|
+
maxWaitMs: timeoutMs,
|
|
172
222
|
});
|
|
173
223
|
elapsedMs = now() - runStart;
|
|
174
224
|
}
|
|
175
225
|
else {
|
|
176
|
-
const stream = await
|
|
226
|
+
const stream = await clientInstance.responses.stream(requestBody);
|
|
177
227
|
let heartbeatActive = false;
|
|
178
228
|
let stopHeartbeat = null;
|
|
179
229
|
const stopHeartbeatNow = () => {
|
|
@@ -192,13 +242,16 @@ export async function runOracle(options, deps = {}) {
|
|
|
192
242
|
isActive: () => heartbeatActive,
|
|
193
243
|
makeMessage: (elapsedMs) => {
|
|
194
244
|
const elapsedText = formatElapsed(elapsedMs);
|
|
195
|
-
|
|
245
|
+
const timeoutLabel = Math.round(timeoutMs / 60000);
|
|
246
|
+
return `API connection active — ${elapsedText} elapsed. Timeout in ~${timeoutLabel} min if no response.`;
|
|
196
247
|
},
|
|
197
248
|
});
|
|
198
249
|
}
|
|
199
250
|
try {
|
|
200
251
|
for await (const event of stream) {
|
|
201
|
-
|
|
252
|
+
throwIfTimedOut();
|
|
253
|
+
const isTextDelta = event.type === 'chunk' || event.type === 'response.output_text.delta';
|
|
254
|
+
if (isTextDelta) {
|
|
202
255
|
stopOscProgress();
|
|
203
256
|
stopHeartbeatNow();
|
|
204
257
|
sawTextDelta = true;
|
|
@@ -208,17 +261,17 @@ export async function runOracle(options, deps = {}) {
|
|
|
208
261
|
}
|
|
209
262
|
}
|
|
210
263
|
}
|
|
264
|
+
throwIfTimedOut();
|
|
211
265
|
}
|
|
212
266
|
catch (streamError) {
|
|
213
|
-
|
|
214
|
-
stream.abort();
|
|
215
|
-
}
|
|
267
|
+
// stream.abort() is not available on the interface
|
|
216
268
|
stopHeartbeatNow();
|
|
217
269
|
const transportError = toTransportError(streamError);
|
|
218
270
|
log(chalk.yellow(describeTransportError(transportError)));
|
|
219
271
|
throw transportError;
|
|
220
272
|
}
|
|
221
273
|
response = await stream.finalResponse();
|
|
274
|
+
throwIfTimedOut();
|
|
222
275
|
stopHeartbeatNow();
|
|
223
276
|
elapsedMs = now() - runStart;
|
|
224
277
|
}
|
|
@@ -227,19 +280,42 @@ export async function runOracle(options, deps = {}) {
|
|
|
227
280
|
stopOscProgress();
|
|
228
281
|
}
|
|
229
282
|
if (!response) {
|
|
230
|
-
throw new Error('
|
|
283
|
+
throw new Error('API did not return a response.');
|
|
284
|
+
}
|
|
285
|
+
// biome-ignore lint/nursery/noUnnecessaryConditions: we only add spacing when any streamed text was printed
|
|
286
|
+
if (sawTextDelta && !options.silent) {
|
|
287
|
+
write('\n');
|
|
288
|
+
log('');
|
|
231
289
|
}
|
|
232
290
|
logVerbose(`Response status: ${response.status ?? 'completed'}`);
|
|
233
291
|
if (response.status && response.status !== 'completed') {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
292
|
+
// API can reply `in_progress` even after the stream closes; give it a brief grace poll.
|
|
293
|
+
if (response.id && response.status === 'in_progress') {
|
|
294
|
+
const polishingStart = now();
|
|
295
|
+
const pollIntervalMs = 2_000;
|
|
296
|
+
const maxWaitMs = 60_000;
|
|
297
|
+
log(chalk.dim('Response still in_progress; polling until completion...'));
|
|
298
|
+
// Short polling loop — we don't want to hang forever, just catch late finalization.
|
|
299
|
+
while (now() - polishingStart < maxWaitMs) {
|
|
300
|
+
await wait(pollIntervalMs);
|
|
301
|
+
const refreshed = await clientInstance.responses.retrieve(response.id);
|
|
302
|
+
if (refreshed.status === 'completed') {
|
|
303
|
+
response = refreshed;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (response.status !== 'completed') {
|
|
309
|
+
const detail = response.error?.message || response.incomplete_details?.reason || response.status;
|
|
310
|
+
log(chalk.yellow(`API ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
|
|
311
|
+
throw new OracleResponseError(`Response did not complete: ${detail}`, response);
|
|
312
|
+
}
|
|
237
313
|
}
|
|
238
314
|
const answerText = extractTextOutput(response);
|
|
239
315
|
if (!options.silent) {
|
|
240
316
|
// biome-ignore lint/nursery/noUnnecessaryConditions: flips true when streaming events arrive
|
|
241
317
|
if (sawTextDelta) {
|
|
242
|
-
write('\n
|
|
318
|
+
write('\n');
|
|
243
319
|
}
|
|
244
320
|
else {
|
|
245
321
|
ensureAnswerHeader();
|
|
@@ -261,18 +337,26 @@ export async function runOracle(options, deps = {}) {
|
|
|
261
337
|
const tokensDisplay = [inputTokens, outputTokens, reasoningTokens, totalTokens]
|
|
262
338
|
.map((value, index) => formatTokenValue(value, usage, index))
|
|
263
339
|
.join('/');
|
|
264
|
-
|
|
340
|
+
const tokensLabel = options.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
|
|
341
|
+
statsParts.push(`${tokensLabel}=${tokensDisplay}`);
|
|
342
|
+
const actualInput = usage.input_tokens;
|
|
343
|
+
if (actualInput !== undefined) {
|
|
344
|
+
const delta = actualInput - estimatedInputTokens;
|
|
345
|
+
const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
|
|
346
|
+
statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
|
|
347
|
+
}
|
|
265
348
|
if (!searchEnabled) {
|
|
266
349
|
statsParts.push('search=off');
|
|
267
350
|
}
|
|
268
351
|
if (files.length > 0) {
|
|
269
352
|
statsParts.push(`files=${files.length}`);
|
|
270
353
|
}
|
|
271
|
-
|
|
354
|
+
const sessionPrefix = options.sessionId ? `${options.sessionId} ` : '';
|
|
355
|
+
log(chalk.blue(`Finished ${sessionPrefix}in ${elapsedDisplay} (${statsParts.join(' | ')})`));
|
|
272
356
|
return {
|
|
273
357
|
mode: 'live',
|
|
274
358
|
response,
|
|
275
|
-
usage: { inputTokens, outputTokens, reasoningTokens, totalTokens },
|
|
359
|
+
usage: { inputTokens, outputTokens, reasoningTokens, totalTokens, cost },
|
|
276
360
|
elapsedMs,
|
|
277
361
|
};
|
|
278
362
|
}
|
|
@@ -317,13 +401,13 @@ export function extractTextOutput(response) {
|
|
|
317
401
|
return '';
|
|
318
402
|
}
|
|
319
403
|
async function executeBackgroundResponse(params) {
|
|
320
|
-
const { client, requestBody, log, wait, heartbeatIntervalMs, now } = params;
|
|
404
|
+
const { client, requestBody, log, wait, heartbeatIntervalMs, now, maxWaitMs } = params;
|
|
321
405
|
const initialResponse = await client.responses.create(requestBody);
|
|
322
406
|
if (!initialResponse || !initialResponse.id) {
|
|
323
|
-
throw new OracleResponseError('
|
|
407
|
+
throw new OracleResponseError('API did not return a response ID for the background run.', initialResponse);
|
|
324
408
|
}
|
|
325
409
|
const responseId = initialResponse.id;
|
|
326
|
-
log(dim(`
|
|
410
|
+
log(dim(`API scheduled background response ${responseId} (status=${initialResponse.status ?? 'unknown'}). Monitoring up to ${Math.round(BACKGROUND_MAX_WAIT_MS / 60000)} minutes for completion...`));
|
|
327
411
|
let heartbeatActive = false;
|
|
328
412
|
let stopHeartbeat = null;
|
|
329
413
|
const stopHeartbeatNow = () => {
|
|
@@ -342,7 +426,7 @@ async function executeBackgroundResponse(params) {
|
|
|
342
426
|
isActive: () => heartbeatActive,
|
|
343
427
|
makeMessage: (elapsedMs) => {
|
|
344
428
|
const elapsedText = formatElapsed(elapsedMs);
|
|
345
|
-
return `
|
|
429
|
+
return `API background run still in progress — ${elapsedText} elapsed.`;
|
|
346
430
|
},
|
|
347
431
|
});
|
|
348
432
|
}
|
|
@@ -354,7 +438,7 @@ async function executeBackgroundResponse(params) {
|
|
|
354
438
|
log,
|
|
355
439
|
wait,
|
|
356
440
|
now,
|
|
357
|
-
maxWaitMs
|
|
441
|
+
maxWaitMs,
|
|
358
442
|
});
|
|
359
443
|
}
|
|
360
444
|
finally {
|
|
@@ -373,10 +457,10 @@ async function pollBackgroundResponse(params) {
|
|
|
373
457
|
// biome-ignore lint/nursery/noUnnecessaryConditions: guard only for first iteration
|
|
374
458
|
if (firstCycle) {
|
|
375
459
|
firstCycle = false;
|
|
376
|
-
log(dim(`
|
|
460
|
+
log(dim(`API background response status=${status}. We'll keep retrying automatically.`));
|
|
377
461
|
}
|
|
378
462
|
else if (status !== lastStatus && status !== 'completed') {
|
|
379
|
-
log(dim(`
|
|
463
|
+
log(dim(`API background response status=${status}.`));
|
|
380
464
|
}
|
|
381
465
|
lastStatus = status;
|
|
382
466
|
if (status === 'completed') {
|
|
@@ -387,11 +471,11 @@ async function pollBackgroundResponse(params) {
|
|
|
387
471
|
throw new OracleResponseError(`Response did not complete: ${detail}`, response);
|
|
388
472
|
}
|
|
389
473
|
if (now() - startMark >= maxWaitMs) {
|
|
390
|
-
throw new OracleTransportError('client-timeout', 'Timed out waiting for
|
|
474
|
+
throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
|
|
391
475
|
}
|
|
392
476
|
await wait(BACKGROUND_POLL_INTERVAL_MS);
|
|
393
477
|
if (now() - startMark >= maxWaitMs) {
|
|
394
|
-
throw new OracleTransportError('client-timeout', 'Timed out waiting for
|
|
478
|
+
throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
|
|
395
479
|
}
|
|
396
480
|
const { response: nextResponse, reconnected } = await retrieveBackgroundResponseWithRetry({
|
|
397
481
|
client,
|
|
@@ -404,7 +488,7 @@ async function pollBackgroundResponse(params) {
|
|
|
404
488
|
});
|
|
405
489
|
if (reconnected) {
|
|
406
490
|
const nextStatus = nextResponse.status ?? 'in_progress';
|
|
407
|
-
log(dim(`Reconnected to
|
|
491
|
+
log(dim(`Reconnected to API background response (status=${nextStatus}). API is still working...`));
|
|
408
492
|
}
|
|
409
493
|
response = nextResponse;
|
|
410
494
|
}
|
|
@@ -428,7 +512,7 @@ async function retrieveBackgroundResponseWithRetry(params) {
|
|
|
428
512
|
log(chalk.yellow(`${describeTransportError(transportError)} Retrying in ${formatElapsed(delay)}...`));
|
|
429
513
|
await wait(delay);
|
|
430
514
|
if (now() - startMark >= maxWaitMs) {
|
|
431
|
-
throw new OracleTransportError('client-timeout', 'Timed out waiting for
|
|
515
|
+
throw new OracleTransportError('client-timeout', 'Timed out waiting for API background response to finish.');
|
|
432
516
|
}
|
|
433
517
|
}
|
|
434
518
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { TOKENIZER_OPTIONS } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Estimate input tokens from the full request body instead of just system/user text.
|
|
4
|
+
* This is a conservative approximation: we tokenize the key textual fields and add a fixed buffer
|
|
5
|
+
* to cover structural JSON overhead and server-side wrappers (tools/reasoning/background/store).
|
|
6
|
+
*/
|
|
7
|
+
export function estimateRequestTokens(requestBody, modelConfig, bufferTokens = 200) {
|
|
8
|
+
const parts = [];
|
|
9
|
+
if (requestBody.instructions) {
|
|
10
|
+
parts.push(requestBody.instructions);
|
|
11
|
+
}
|
|
12
|
+
for (const turn of requestBody.input ?? []) {
|
|
13
|
+
for (const content of turn.content ?? []) {
|
|
14
|
+
if (typeof content.text === 'string') {
|
|
15
|
+
parts.push(content.text);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (requestBody.tools && requestBody.tools.length > 0) {
|
|
20
|
+
parts.push(JSON.stringify(requestBody.tools));
|
|
21
|
+
}
|
|
22
|
+
if (requestBody.reasoning) {
|
|
23
|
+
parts.push(JSON.stringify(requestBody.reasoning));
|
|
24
|
+
}
|
|
25
|
+
if (requestBody.background) {
|
|
26
|
+
parts.push('background:true');
|
|
27
|
+
}
|
|
28
|
+
if (requestBody.store) {
|
|
29
|
+
parts.push('store:true');
|
|
30
|
+
}
|
|
31
|
+
const concatenated = parts.join('\n');
|
|
32
|
+
const baseEstimate = modelConfig.tokenizer(concatenated, TOKENIZER_OPTIONS);
|
|
33
|
+
return baseEstimate + bufferTokens;
|
|
34
|
+
}
|
package/dist/src/oracle.js
CHANGED
|
@@ -7,3 +7,4 @@ export { getFileTokenStats, printFileTokenStats } from './oracle/tokenStats.js';
|
|
|
7
7
|
export { OracleResponseError, OracleTransportError, OracleUserError, FileValidationError, BrowserAutomationError, PromptValidationError, describeTransportError, extractResponseMetadata, asOracleUserError, toTransportError, } from './oracle/errors.js';
|
|
8
8
|
export { createDefaultClientFactory } from './oracle/client.js';
|
|
9
9
|
export { runOracle, extractTextOutput } from './oracle/run.js';
|
|
10
|
+
export { resolveGeminiModelId } from './oracle/gemini.js';
|
|
@@ -5,6 +5,7 @@ import { createWriteStream } from 'node:fs';
|
|
|
5
5
|
const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
|
|
6
6
|
const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
|
|
7
7
|
const MAX_STATUS_LIMIT = 1000;
|
|
8
|
+
const ZOMBIE_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
|
|
8
9
|
const DEFAULT_SLUG = 'session';
|
|
9
10
|
const MAX_SLUG_WORDS = 5;
|
|
10
11
|
const MIN_CUSTOM_SLUG_WORDS = 3;
|
|
@@ -67,7 +68,7 @@ async function ensureUniqueSessionId(baseSlug) {
|
|
|
67
68
|
}
|
|
68
69
|
return candidate;
|
|
69
70
|
}
|
|
70
|
-
export async function initializeSession(options, cwd) {
|
|
71
|
+
export async function initializeSession(options, cwd, notifications) {
|
|
71
72
|
await ensureSessionStorage();
|
|
72
73
|
const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
|
|
73
74
|
const sessionId = await ensureUniqueSessionId(baseSlug);
|
|
@@ -84,6 +85,7 @@ export async function initializeSession(options, cwd) {
|
|
|
84
85
|
cwd,
|
|
85
86
|
mode,
|
|
86
87
|
browser: browserConfig ? { config: browserConfig } : undefined,
|
|
88
|
+
notifications,
|
|
87
89
|
options: {
|
|
88
90
|
prompt: options.prompt,
|
|
89
91
|
file: options.file ?? [],
|
|
@@ -99,7 +101,11 @@ export async function initializeSession(options, cwd) {
|
|
|
99
101
|
verbose: options.verbose,
|
|
100
102
|
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
101
103
|
browserInlineFiles: options.browserInlineFiles,
|
|
104
|
+
browserBundleFiles: options.browserBundleFiles,
|
|
102
105
|
background: options.background,
|
|
106
|
+
search: options.search,
|
|
107
|
+
baseUrl: options.baseUrl,
|
|
108
|
+
azure: options.azure,
|
|
103
109
|
},
|
|
104
110
|
};
|
|
105
111
|
await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
|
|
@@ -110,7 +116,8 @@ export async function initializeSession(options, cwd) {
|
|
|
110
116
|
export async function readSessionMetadata(sessionId) {
|
|
111
117
|
try {
|
|
112
118
|
const raw = await fs.readFile(metaPath(sessionId), 'utf8');
|
|
113
|
-
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
return await markZombie(parsed, { persist: false }); // transient check; do not touch disk on single read
|
|
114
121
|
}
|
|
115
122
|
catch {
|
|
116
123
|
return null;
|
|
@@ -138,8 +145,9 @@ export async function listSessionsMetadata() {
|
|
|
138
145
|
const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
|
|
139
146
|
const metas = [];
|
|
140
147
|
for (const entry of entries) {
|
|
141
|
-
|
|
148
|
+
let meta = await readSessionMetadata(entry);
|
|
142
149
|
if (meta) {
|
|
150
|
+
meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
|
|
143
151
|
metas.push(meta);
|
|
144
152
|
}
|
|
145
153
|
}
|
|
@@ -164,6 +172,15 @@ export async function readSessionLog(sessionId) {
|
|
|
164
172
|
return '';
|
|
165
173
|
}
|
|
166
174
|
}
|
|
175
|
+
export async function readSessionRequest(sessionId) {
|
|
176
|
+
try {
|
|
177
|
+
const raw = await fs.readFile(requestPath(sessionId), 'utf8');
|
|
178
|
+
return JSON.parse(raw);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
167
184
|
export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
|
|
168
185
|
await ensureSessionStorage();
|
|
169
186
|
const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
|
|
@@ -203,6 +220,7 @@ export async function wait(ms) {
|
|
|
203
220
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
204
221
|
}
|
|
205
222
|
export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
|
|
223
|
+
export { ZOMBIE_MAX_AGE_MS };
|
|
206
224
|
export async function getSessionPaths(sessionId) {
|
|
207
225
|
const dir = sessionDir(sessionId);
|
|
208
226
|
const metadata = metaPath(sessionId);
|
|
@@ -220,3 +238,32 @@ export async function getSessionPaths(sessionId) {
|
|
|
220
238
|
}
|
|
221
239
|
return { dir, metadata, log, request };
|
|
222
240
|
}
|
|
241
|
+
async function markZombie(meta, { persist }) {
|
|
242
|
+
if (!isZombie(meta)) {
|
|
243
|
+
return meta;
|
|
244
|
+
}
|
|
245
|
+
const updated = {
|
|
246
|
+
...meta,
|
|
247
|
+
status: 'error',
|
|
248
|
+
errorMessage: 'Session marked as zombie (>30m stale)',
|
|
249
|
+
completedAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
if (persist) {
|
|
252
|
+
await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
|
|
253
|
+
}
|
|
254
|
+
return updated;
|
|
255
|
+
}
|
|
256
|
+
function isZombie(meta) {
|
|
257
|
+
if (meta.status !== 'running') {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const reference = meta.startedAt ?? meta.createdAt;
|
|
261
|
+
if (!reference) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
const startedMs = Date.parse(reference);
|
|
265
|
+
if (Number.isNaN(startedMs)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
|
|
269
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>com.steipete.oracle.notifier</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>OracleNotifier</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>Oracle Notifier</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>OracleNotifier</string>
|
|
13
|
+
<key>CFBundleIconFile</key>
|
|
14
|
+
<string>OracleIcon</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>APPL</string>
|
|
17
|
+
<key>LSMinimumSystemVersion</key>
|
|
18
|
+
<string>13.0</string>
|
|
19
|
+
</dict>
|
|
20
|
+
</plist>
|
|
Binary file
|