@steipete/oracle 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +169 -18
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/src/browser/actions/modelSelection.js +117 -29
- package/dist/src/browser/cookies.js +1 -1
- package/dist/src/browser/index.js +2 -1
- package/dist/src/browser/prompt.js +6 -5
- package/dist/src/browser/sessionRunner.js +4 -2
- package/dist/src/cli/dryRun.js +41 -5
- package/dist/src/cli/engine.js +7 -0
- package/dist/src/cli/help.js +1 -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/promptRequirement.js +3 -0
- package/dist/src/cli/runOptions.js +29 -0
- package/dist/src/cli/sessionCommand.js +1 -1
- package/dist/src/cli/sessionDisplay.js +94 -7
- package/dist/src/cli/sessionRunner.js +21 -2
- package/dist/src/cli/tui/index.js +436 -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/files.js +143 -6
- package/dist/src/oracle/run.js +41 -20
- package/dist/src/oracle/tokenEstimate.js +34 -0
- package/dist/src/sessionManager.js +48 -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 +39 -13
- 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/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/dist/.DS_Store +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import { MODEL_CONFIGS } from '../../oracle.js';
|
|
8
|
+
import { renderMarkdownAnsi } from '../markdownRenderer.js';
|
|
9
|
+
import { createSessionLogWriter, getSessionPaths, initializeSession, listSessionsMetadata, readSessionLog, readSessionMetadata, readSessionRequest, ensureSessionStorage, } from '../../sessionManager.js';
|
|
10
|
+
import { performSessionRun } from '../sessionRunner.js';
|
|
11
|
+
import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from '../sessionDisplay.js';
|
|
12
|
+
import { buildBrowserConfig, resolveBrowserModelLabel } from '../browserConfig.js';
|
|
13
|
+
import { resolveNotificationSettings } from '../notifier.js';
|
|
14
|
+
import { loadUserConfig } from '../../config.js';
|
|
15
|
+
const isTty = () => Boolean(process.stdout.isTTY && chalk.level > 0);
|
|
16
|
+
const dim = (text) => (isTty() ? kleur.dim(text) : text);
|
|
17
|
+
const RECENT_WINDOW_HOURS = 24;
|
|
18
|
+
const PAGE_SIZE = 10;
|
|
19
|
+
export async function launchTui({ version }) {
|
|
20
|
+
const userConfig = (await loadUserConfig()).config;
|
|
21
|
+
console.log(chalk.bold(`🧿 oracle v${version}`), dim('— Whispering your tokens to the silicon sage'));
|
|
22
|
+
console.log('');
|
|
23
|
+
let olderOffset = 0;
|
|
24
|
+
for (;;) {
|
|
25
|
+
const { recent, older, hasMoreOlder } = await fetchSessionBuckets(olderOffset);
|
|
26
|
+
const choices = [];
|
|
27
|
+
if (recent.length > 0) {
|
|
28
|
+
choices.push(new inquirer.Separator());
|
|
29
|
+
choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
|
|
30
|
+
choices.push(...recent.map(toSessionChoice));
|
|
31
|
+
}
|
|
32
|
+
else if (older.length > 0 && olderOffset === 0) {
|
|
33
|
+
choices.push(new inquirer.Separator());
|
|
34
|
+
choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
|
|
35
|
+
choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
|
|
36
|
+
}
|
|
37
|
+
if (older.length > 0 && olderOffset > 0) {
|
|
38
|
+
choices.push(new inquirer.Separator());
|
|
39
|
+
choices.push(new inquirer.Separator('Status Model Mode Timestamp Chars Cost Slug'));
|
|
40
|
+
choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
|
|
41
|
+
}
|
|
42
|
+
choices.push(new inquirer.Separator());
|
|
43
|
+
choices.push(new inquirer.Separator('Actions'));
|
|
44
|
+
choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
|
|
45
|
+
if (hasMoreOlder) {
|
|
46
|
+
choices.push({ name: 'Load older', value: '__more__' });
|
|
47
|
+
}
|
|
48
|
+
if (olderOffset > 0) {
|
|
49
|
+
choices.push({ name: 'Back to recent', value: '__reset__' });
|
|
50
|
+
}
|
|
51
|
+
choices.push({ name: 'Exit', value: '__exit__' });
|
|
52
|
+
const { selection } = await inquirer.prompt([
|
|
53
|
+
{
|
|
54
|
+
name: 'selection',
|
|
55
|
+
type: 'list',
|
|
56
|
+
message: 'Select a session or action',
|
|
57
|
+
choices,
|
|
58
|
+
pageSize: 16,
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
if (selection === '__exit__') {
|
|
62
|
+
console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (selection === '__more__') {
|
|
66
|
+
olderOffset += PAGE_SIZE;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (selection === '__reset__') {
|
|
70
|
+
olderOffset = 0;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (selection === '__ask__') {
|
|
74
|
+
await askOracleFlow(version, userConfig);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
await showSessionDetail(selection);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function fetchSessionBuckets(olderOffset) {
|
|
81
|
+
const all = await listSessionsMetadata();
|
|
82
|
+
const cutoff = Date.now() - RECENT_WINDOW_HOURS * 60 * 60 * 1000;
|
|
83
|
+
const recent = all.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff).slice(0, PAGE_SIZE);
|
|
84
|
+
const olderAll = all.filter((meta) => new Date(meta.createdAt).getTime() < cutoff);
|
|
85
|
+
const older = olderAll.slice(olderOffset, olderOffset + PAGE_SIZE);
|
|
86
|
+
const hasMoreOlder = olderAll.length > olderOffset + PAGE_SIZE;
|
|
87
|
+
if (recent.length === 0 && older.length === 0 && olderAll.length > 0) {
|
|
88
|
+
// No recent entries; fall back to top 10 overall.
|
|
89
|
+
return { recent: olderAll.slice(0, PAGE_SIZE), older: [], hasMoreOlder: olderAll.length > PAGE_SIZE };
|
|
90
|
+
}
|
|
91
|
+
return { recent, older, hasMoreOlder };
|
|
92
|
+
}
|
|
93
|
+
function toSessionChoice(meta) {
|
|
94
|
+
return {
|
|
95
|
+
name: formatSessionLabel(meta),
|
|
96
|
+
value: meta.id,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function formatSessionLabel(meta) {
|
|
100
|
+
const status = colorStatus(meta.status ?? 'unknown');
|
|
101
|
+
const created = formatTimestampAligned(meta.createdAt);
|
|
102
|
+
const model = meta.model ?? 'n/a';
|
|
103
|
+
const mode = meta.mode ?? meta.options?.mode ?? 'api';
|
|
104
|
+
const slug = meta.id;
|
|
105
|
+
const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
|
|
106
|
+
const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(5)) : chalk.gray(' -');
|
|
107
|
+
const cost = mode === 'browser' ? null : resolveCost(meta);
|
|
108
|
+
const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(' -');
|
|
109
|
+
return `${status} ${chalk.white(model.padEnd(10))} ${chalk.gray(mode.padEnd(7))} ${chalk.gray(created)} ${charLabel} ${costLabel} ${chalk.cyan(slug)}`;
|
|
110
|
+
}
|
|
111
|
+
function resolveCost(meta) {
|
|
112
|
+
if (meta.usage?.cost != null) {
|
|
113
|
+
return meta.usage.cost;
|
|
114
|
+
}
|
|
115
|
+
if (!meta.model || !meta.usage) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const pricing = MODEL_CONFIGS[meta.model]?.pricing;
|
|
119
|
+
if (!pricing)
|
|
120
|
+
return null;
|
|
121
|
+
const input = meta.usage.inputTokens ?? 0;
|
|
122
|
+
const output = meta.usage.outputTokens ?? 0;
|
|
123
|
+
const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
|
|
124
|
+
return cost > 0 ? cost : null;
|
|
125
|
+
}
|
|
126
|
+
function formatCostTable(cost) {
|
|
127
|
+
return `$${cost.toFixed(3)}`.padStart(7);
|
|
128
|
+
}
|
|
129
|
+
function formatTimestampAligned(iso) {
|
|
130
|
+
const date = new Date(iso);
|
|
131
|
+
const locale = 'en-US';
|
|
132
|
+
const opts = {
|
|
133
|
+
year: 'numeric',
|
|
134
|
+
month: '2-digit',
|
|
135
|
+
day: '2-digit',
|
|
136
|
+
hour: 'numeric',
|
|
137
|
+
minute: '2-digit',
|
|
138
|
+
second: undefined,
|
|
139
|
+
hour12: true,
|
|
140
|
+
};
|
|
141
|
+
const formatted = date.toLocaleString(locale, opts);
|
|
142
|
+
// Insert a leading space when hour is a single digit to align AM/PM column.
|
|
143
|
+
// Example: "11/18/2025, 1:07:05 AM" -> "11/18/2025, 1:07:05 AM"
|
|
144
|
+
return formatted.replace(/(, )(\d:)/, '$1 $2');
|
|
145
|
+
}
|
|
146
|
+
function colorStatus(status) {
|
|
147
|
+
const padded = status.padEnd(9);
|
|
148
|
+
switch (status) {
|
|
149
|
+
case 'completed':
|
|
150
|
+
return chalk.green(padded);
|
|
151
|
+
case 'error':
|
|
152
|
+
return chalk.red(padded);
|
|
153
|
+
case 'running':
|
|
154
|
+
return chalk.yellow(padded);
|
|
155
|
+
default:
|
|
156
|
+
return padded;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function showSessionDetail(sessionId) {
|
|
160
|
+
for (;;) {
|
|
161
|
+
const meta = await readSessionMetadataSafe(sessionId);
|
|
162
|
+
if (!meta) {
|
|
163
|
+
console.log(chalk.red(`No session found with ID ${sessionId}`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.clear();
|
|
167
|
+
printSessionHeader(meta);
|
|
168
|
+
const prompt = await readStoredPrompt(sessionId);
|
|
169
|
+
if (prompt) {
|
|
170
|
+
console.log(chalk.bold('Prompt:'));
|
|
171
|
+
console.log(renderMarkdownAnsi(prompt));
|
|
172
|
+
console.log(dim('---'));
|
|
173
|
+
}
|
|
174
|
+
const logPath = await getSessionLogPath(sessionId);
|
|
175
|
+
if (logPath) {
|
|
176
|
+
console.log(dim(`Log file: ${logPath}`));
|
|
177
|
+
}
|
|
178
|
+
console.log('');
|
|
179
|
+
await renderSessionLog(sessionId);
|
|
180
|
+
const isRunning = meta.status === 'running';
|
|
181
|
+
const actions = [
|
|
182
|
+
...(isRunning ? [{ name: 'Refresh', value: 'refresh' }] : []),
|
|
183
|
+
{ name: 'Back', value: 'back' },
|
|
184
|
+
];
|
|
185
|
+
const { next } = await inquirer.prompt([
|
|
186
|
+
{
|
|
187
|
+
name: 'next',
|
|
188
|
+
type: 'list',
|
|
189
|
+
message: 'Actions',
|
|
190
|
+
choices: actions,
|
|
191
|
+
},
|
|
192
|
+
]);
|
|
193
|
+
if (next === 'back') {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// refresh loops
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function renderSessionLog(sessionId) {
|
|
200
|
+
const raw = await readSessionLog(sessionId);
|
|
201
|
+
const text = trimBeforeFirstAnswer(raw);
|
|
202
|
+
const size = Buffer.byteLength(text, 'utf8');
|
|
203
|
+
if (size > MAX_RENDER_BYTES) {
|
|
204
|
+
console.log(chalk.yellow(`Log is large (${size.toLocaleString()} bytes). Rendering raw text; open the log file for full context.`));
|
|
205
|
+
process.stdout.write(text);
|
|
206
|
+
console.log('');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(renderMarkdownAnsi(text));
|
|
210
|
+
console.log('');
|
|
211
|
+
}
|
|
212
|
+
async function getSessionLogPath(sessionId) {
|
|
213
|
+
try {
|
|
214
|
+
const paths = await getSessionPaths(sessionId);
|
|
215
|
+
return paths.log;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function printSessionHeader(meta) {
|
|
222
|
+
console.log(chalk.bold(`Session ${chalk.cyan(meta.id)}`));
|
|
223
|
+
console.log(`${chalk.white('Status:')} ${meta.status}`);
|
|
224
|
+
console.log(`${chalk.white('Created:')} ${meta.createdAt}`);
|
|
225
|
+
if (meta.model) {
|
|
226
|
+
console.log(`${chalk.white('Model:')} ${meta.model}`);
|
|
227
|
+
}
|
|
228
|
+
const mode = meta.mode ?? meta.options?.mode;
|
|
229
|
+
if (mode) {
|
|
230
|
+
console.log(`${chalk.white('Mode:')} ${mode}`);
|
|
231
|
+
}
|
|
232
|
+
if (meta.errorMessage) {
|
|
233
|
+
console.log(chalk.red(`Error: ${meta.errorMessage}`));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function askOracleFlow(version, userConfig) {
|
|
237
|
+
const modelChoices = Object.keys(MODEL_CONFIGS);
|
|
238
|
+
const hasApiKey = Boolean(process.env.OPENAI_API_KEY);
|
|
239
|
+
const initialMode = hasApiKey ? 'api' : 'browser';
|
|
240
|
+
const preferredMode = userConfig.engine ?? initialMode;
|
|
241
|
+
const answers = await inquirer.prompt([
|
|
242
|
+
{
|
|
243
|
+
name: 'promptInput',
|
|
244
|
+
type: 'input',
|
|
245
|
+
message: 'Paste your prompt text or a path to a file (leave blank to cancel):',
|
|
246
|
+
},
|
|
247
|
+
...(hasApiKey
|
|
248
|
+
? [
|
|
249
|
+
{
|
|
250
|
+
name: 'mode',
|
|
251
|
+
type: 'list',
|
|
252
|
+
message: 'Engine',
|
|
253
|
+
default: preferredMode,
|
|
254
|
+
choices: [
|
|
255
|
+
{ name: 'API', value: 'api' },
|
|
256
|
+
{ name: 'Browser', value: 'browser' },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
: [
|
|
261
|
+
{
|
|
262
|
+
name: 'mode',
|
|
263
|
+
type: 'list',
|
|
264
|
+
message: 'Engine',
|
|
265
|
+
default: preferredMode,
|
|
266
|
+
choices: [{ name: 'Browser', value: 'browser' }],
|
|
267
|
+
},
|
|
268
|
+
]),
|
|
269
|
+
{
|
|
270
|
+
name: 'slug',
|
|
271
|
+
type: 'input',
|
|
272
|
+
message: 'Optional slug (3–5 words, leave blank for auto):',
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'model',
|
|
276
|
+
type: 'list',
|
|
277
|
+
message: 'Model',
|
|
278
|
+
default: 'gpt-5-pro',
|
|
279
|
+
choices: modelChoices,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'files',
|
|
283
|
+
type: 'input',
|
|
284
|
+
message: 'Files or globs to attach (comma-separated, optional):',
|
|
285
|
+
filter: (value) => value
|
|
286
|
+
.split(',')
|
|
287
|
+
.map((entry) => entry.trim())
|
|
288
|
+
.filter(Boolean),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'chromeProfile',
|
|
292
|
+
type: 'input',
|
|
293
|
+
message: 'Chrome profile to reuse cookies from:',
|
|
294
|
+
default: 'Default',
|
|
295
|
+
when: (ans) => ans.mode === 'browser',
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: 'headless',
|
|
299
|
+
type: 'confirm',
|
|
300
|
+
message: 'Run Chrome headless?',
|
|
301
|
+
default: false,
|
|
302
|
+
when: (ans) => ans.mode === 'browser',
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: 'hideWindow',
|
|
306
|
+
type: 'confirm',
|
|
307
|
+
message: 'Hide Chrome window (macOS headful only)?',
|
|
308
|
+
default: false,
|
|
309
|
+
when: (ans) => ans.mode === 'browser',
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'keepBrowser',
|
|
313
|
+
type: 'confirm',
|
|
314
|
+
message: 'Keep browser open after completion?',
|
|
315
|
+
default: false,
|
|
316
|
+
when: (ans) => ans.mode === 'browser',
|
|
317
|
+
},
|
|
318
|
+
]);
|
|
319
|
+
const mode = (answers.mode ?? initialMode);
|
|
320
|
+
const prompt = await resolvePromptInput(answers.promptInput);
|
|
321
|
+
if (!prompt.trim()) {
|
|
322
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const promptWithSuffix = userConfig.promptSuffix ? `${prompt.trim()}\n${userConfig.promptSuffix}` : prompt;
|
|
326
|
+
await ensureSessionStorage();
|
|
327
|
+
const runOptions = {
|
|
328
|
+
prompt: promptWithSuffix,
|
|
329
|
+
model: answers.model,
|
|
330
|
+
file: answers.files,
|
|
331
|
+
slug: answers.slug,
|
|
332
|
+
filesReport: false,
|
|
333
|
+
maxInput: undefined,
|
|
334
|
+
maxOutput: undefined,
|
|
335
|
+
system: undefined,
|
|
336
|
+
silent: false,
|
|
337
|
+
search: undefined,
|
|
338
|
+
preview: false,
|
|
339
|
+
previewMode: undefined,
|
|
340
|
+
apiKey: undefined,
|
|
341
|
+
sessionId: undefined,
|
|
342
|
+
verbose: false,
|
|
343
|
+
heartbeatIntervalMs: undefined,
|
|
344
|
+
browserInlineFiles: false,
|
|
345
|
+
browserBundleFiles: false,
|
|
346
|
+
background: undefined,
|
|
347
|
+
};
|
|
348
|
+
const browserConfig = mode === 'browser'
|
|
349
|
+
? buildBrowserConfig({
|
|
350
|
+
browserChromeProfile: answers.chromeProfile,
|
|
351
|
+
browserHeadless: answers.headless,
|
|
352
|
+
browserHideWindow: answers.hideWindow,
|
|
353
|
+
browserKeepBrowser: answers.keepBrowser,
|
|
354
|
+
browserModelLabel: resolveBrowserModelLabel(undefined, answers.model),
|
|
355
|
+
model: answers.model,
|
|
356
|
+
})
|
|
357
|
+
: undefined;
|
|
358
|
+
const notifications = resolveNotificationSettings({
|
|
359
|
+
cliNotify: undefined,
|
|
360
|
+
cliNotifySound: undefined,
|
|
361
|
+
env: process.env,
|
|
362
|
+
config: userConfig.notify,
|
|
363
|
+
});
|
|
364
|
+
const sessionMeta = await initializeSession({
|
|
365
|
+
...runOptions,
|
|
366
|
+
mode,
|
|
367
|
+
browserConfig,
|
|
368
|
+
}, process.cwd(), notifications);
|
|
369
|
+
const { logLine, writeChunk, stream } = createSessionLogWriter(sessionMeta.id);
|
|
370
|
+
const combinedLog = (message) => {
|
|
371
|
+
if (message) {
|
|
372
|
+
console.log(message);
|
|
373
|
+
logLine(message);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const combinedWrite = (chunk) => {
|
|
377
|
+
writeChunk(chunk);
|
|
378
|
+
return process.stdout.write(chunk);
|
|
379
|
+
};
|
|
380
|
+
console.log(chalk.bold(`Session ${sessionMeta.id} starting...`));
|
|
381
|
+
console.log(dim(`Log path: ${path.join(os.homedir(), '.oracle', 'sessions', sessionMeta.id, 'output.log')}`));
|
|
382
|
+
try {
|
|
383
|
+
await performSessionRun({
|
|
384
|
+
sessionMeta,
|
|
385
|
+
runOptions: { ...runOptions, sessionId: sessionMeta.id },
|
|
386
|
+
mode,
|
|
387
|
+
browserConfig,
|
|
388
|
+
cwd: process.cwd(),
|
|
389
|
+
log: combinedLog,
|
|
390
|
+
write: combinedWrite,
|
|
391
|
+
version,
|
|
392
|
+
notifications,
|
|
393
|
+
});
|
|
394
|
+
console.log(chalk.green(`Session ${sessionMeta.id} completed.`));
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
398
|
+
console.log(chalk.red(`Session ${sessionMeta.id} failed: ${message}`));
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
stream.end();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const readSessionMetadataSafe = (sessionId) => readSessionMetadata(sessionId);
|
|
405
|
+
async function resolvePromptInput(rawInput) {
|
|
406
|
+
const trimmed = rawInput.trim();
|
|
407
|
+
if (!trimmed) {
|
|
408
|
+
return trimmed;
|
|
409
|
+
}
|
|
410
|
+
const asPath = path.resolve(process.cwd(), trimmed);
|
|
411
|
+
try {
|
|
412
|
+
const stats = await fs.stat(asPath);
|
|
413
|
+
if (stats.isFile()) {
|
|
414
|
+
const contents = await fs.readFile(asPath, 'utf8');
|
|
415
|
+
return contents;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// not a file; fall through
|
|
420
|
+
}
|
|
421
|
+
return trimmed;
|
|
422
|
+
}
|
|
423
|
+
async function readStoredPrompt(sessionId) {
|
|
424
|
+
const request = await readSessionRequest(sessionId);
|
|
425
|
+
if (request?.prompt && request.prompt.trim().length > 0) {
|
|
426
|
+
return request.prompt;
|
|
427
|
+
}
|
|
428
|
+
const meta = await readSessionMetadata(sessionId);
|
|
429
|
+
if (meta?.options?.prompt && meta.options.prompt.trim().length > 0) {
|
|
430
|
+
return meta.options.prompt;
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
// Exported for testing
|
|
435
|
+
export { askOracleFlow, showSessionDetail };
|
|
436
|
+
export { resolveCost };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
function resolveConfigPath() {
|
|
6
|
+
const oracleHome = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
|
|
7
|
+
return path.join(oracleHome, 'config.json');
|
|
8
|
+
}
|
|
9
|
+
export async function loadUserConfig() {
|
|
10
|
+
const CONFIG_PATH = resolveConfigPath();
|
|
11
|
+
try {
|
|
12
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
13
|
+
const parsed = JSON5.parse(raw);
|
|
14
|
+
return { config: parsed ?? {}, path: CONFIG_PATH, loaded: true };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const code = error.code;
|
|
18
|
+
if (code === 'ENOENT') {
|
|
19
|
+
return { config: {}, path: CONFIG_PATH, loaded: false };
|
|
20
|
+
}
|
|
21
|
+
console.warn(`Failed to read ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
return { config: {}, path: CONFIG_PATH, loaded: false };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function configPath() {
|
|
26
|
+
return resolveConfigPath();
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { getCliVersion } from '../version.js';
|
|
7
|
+
import { registerConsultTool } from './tools/consult.js';
|
|
8
|
+
import { registerSessionsTool } from './tools/sessions.js';
|
|
9
|
+
import { registerSessionResources } from './tools/sessionResources.js';
|
|
10
|
+
export async function startMcpServer() {
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: 'oracle-mcp',
|
|
13
|
+
version: getCliVersion(),
|
|
14
|
+
}, {
|
|
15
|
+
capabilities: {
|
|
16
|
+
logging: {},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
registerConsultTool(server);
|
|
20
|
+
registerSessionsTool(server);
|
|
21
|
+
registerSessionResources(server);
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
transport.onerror = (error) => {
|
|
24
|
+
console.error('MCP transport error:', error);
|
|
25
|
+
};
|
|
26
|
+
transport.onclose = () => {
|
|
27
|
+
// Keep quiet on normal close; caller owns lifecycle.
|
|
28
|
+
};
|
|
29
|
+
await server.connect(transport);
|
|
30
|
+
}
|
|
31
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('oracle-mcp')) {
|
|
32
|
+
startMcpServer().catch((error) => {
|
|
33
|
+
console.error('Failed to start oracle-mcp:', error);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getCliVersion } from '../../version.js';
|
|
3
|
+
import { LoggingMessageNotificationParamsSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { ensureBrowserAvailable, mapConsultToRunOptions } from '../utils.js';
|
|
5
|
+
import { createSessionLogWriter, initializeSession, readSessionMetadata, readSessionLog, } from '../../sessionManager.js';
|
|
6
|
+
async function readSessionLogTail(sessionId, maxBytes) {
|
|
7
|
+
try {
|
|
8
|
+
const log = await readSessionLog(sessionId);
|
|
9
|
+
if (log.length <= maxBytes) {
|
|
10
|
+
return log;
|
|
11
|
+
}
|
|
12
|
+
return log.slice(-maxBytes);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
import { performSessionRun } from '../../cli/sessionRunner.js';
|
|
19
|
+
import { CHATGPT_URL } from '../../browser/constants.js';
|
|
20
|
+
import { consultInputSchema } from '../types.js';
|
|
21
|
+
import { loadUserConfig } from '../../config.js';
|
|
22
|
+
import { resolveNotificationSettings } from '../../cli/notifier.js';
|
|
23
|
+
import { mapModelToBrowserLabel, resolveBrowserModelLabel } from '../../cli/browserConfig.js';
|
|
24
|
+
// Use raw shapes so the MCP SDK (with its bundled Zod) wraps them and emits valid JSON Schema.
|
|
25
|
+
const consultInputShape = {
|
|
26
|
+
prompt: z.string().min(1, 'Prompt is required.'),
|
|
27
|
+
files: z.array(z.string()).default([]),
|
|
28
|
+
model: z.string().optional(),
|
|
29
|
+
engine: z.enum(['api', 'browser']).optional(),
|
|
30
|
+
browserModelLabel: z.string().optional(),
|
|
31
|
+
search: z.boolean().optional(),
|
|
32
|
+
slug: z.string().optional(),
|
|
33
|
+
};
|
|
34
|
+
const consultOutputShape = {
|
|
35
|
+
sessionId: z.string(),
|
|
36
|
+
status: z.string(),
|
|
37
|
+
output: z.string(),
|
|
38
|
+
};
|
|
39
|
+
export function registerConsultTool(server) {
|
|
40
|
+
server.registerTool('consult', {
|
|
41
|
+
title: 'Run an oracle session',
|
|
42
|
+
description: 'Run a one-shot Oracle session (API or browser). Attach files/dirs for context, optional model/engine overrides, and an optional slug. Background handling follows the CLI defaults; browser runs only start when Chrome is available.',
|
|
43
|
+
// Cast to any to satisfy SDK typings across differing Zod versions.
|
|
44
|
+
inputSchema: consultInputShape,
|
|
45
|
+
outputSchema: consultOutputShape,
|
|
46
|
+
}, async (input) => {
|
|
47
|
+
const textContent = (text) => [{ type: 'text', text }];
|
|
48
|
+
const { prompt, files, model, engine, search, browserModelLabel, slug } = consultInputSchema.parse(input);
|
|
49
|
+
const { config: userConfig } = await loadUserConfig();
|
|
50
|
+
const { runOptions, resolvedEngine } = mapConsultToRunOptions({
|
|
51
|
+
prompt,
|
|
52
|
+
files: files ?? [],
|
|
53
|
+
model,
|
|
54
|
+
engine,
|
|
55
|
+
search,
|
|
56
|
+
userConfig,
|
|
57
|
+
env: process.env,
|
|
58
|
+
});
|
|
59
|
+
const cwd = process.cwd();
|
|
60
|
+
const browserGuard = ensureBrowserAvailable(resolvedEngine);
|
|
61
|
+
if (resolvedEngine === 'browser' &&
|
|
62
|
+
(browserGuard ||
|
|
63
|
+
(process.platform === 'linux' && !process.env.DISPLAY && !process.env.CHROME_PATH))) {
|
|
64
|
+
return {
|
|
65
|
+
isError: true,
|
|
66
|
+
content: textContent(browserGuard ?? 'Browser engine unavailable: set DISPLAY or CHROME_PATH.'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
let browserConfig;
|
|
70
|
+
if (resolvedEngine === 'browser') {
|
|
71
|
+
const preferredLabel = (browserModelLabel ?? model)?.trim();
|
|
72
|
+
const desiredModelLabel = resolveBrowserModelLabel(preferredLabel, runOptions.model);
|
|
73
|
+
// Keep the browser path minimal; only forward a desired model label for the ChatGPT picker.
|
|
74
|
+
browserConfig = {
|
|
75
|
+
url: CHATGPT_URL,
|
|
76
|
+
cookieSync: true,
|
|
77
|
+
headless: false,
|
|
78
|
+
hideWindow: false,
|
|
79
|
+
keepBrowser: false,
|
|
80
|
+
desiredModel: desiredModelLabel || mapModelToBrowserLabel(runOptions.model),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const notifications = resolveNotificationSettings({
|
|
84
|
+
cliNotify: undefined,
|
|
85
|
+
cliNotifySound: undefined,
|
|
86
|
+
env: process.env,
|
|
87
|
+
config: userConfig.notify,
|
|
88
|
+
});
|
|
89
|
+
const sessionMeta = await initializeSession({
|
|
90
|
+
...runOptions,
|
|
91
|
+
mode: resolvedEngine,
|
|
92
|
+
slug,
|
|
93
|
+
browserConfig,
|
|
94
|
+
}, cwd, notifications);
|
|
95
|
+
const logWriter = createSessionLogWriter(sessionMeta.id);
|
|
96
|
+
// Best-effort: emit MCP logging notifications for live chunks but never block the run.
|
|
97
|
+
const sendLog = (text, level = 'info') => server.server
|
|
98
|
+
.sendLoggingMessage(LoggingMessageNotificationParamsSchema.parse({
|
|
99
|
+
level,
|
|
100
|
+
data: { text, bytes: Buffer.byteLength(text, 'utf8') },
|
|
101
|
+
}))
|
|
102
|
+
.catch(() => { });
|
|
103
|
+
// Stream logs to both the session log and MCP logging notifications, but avoid buffering in memory
|
|
104
|
+
const log = (line) => {
|
|
105
|
+
logWriter.logLine(line);
|
|
106
|
+
if (line !== undefined) {
|
|
107
|
+
sendLog(line);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const write = (chunk) => {
|
|
111
|
+
logWriter.writeChunk(chunk);
|
|
112
|
+
sendLog(chunk, 'debug');
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
await performSessionRun({
|
|
117
|
+
sessionMeta,
|
|
118
|
+
runOptions,
|
|
119
|
+
mode: resolvedEngine,
|
|
120
|
+
browserConfig,
|
|
121
|
+
cwd,
|
|
122
|
+
log,
|
|
123
|
+
write,
|
|
124
|
+
version: getCliVersion(),
|
|
125
|
+
notifications,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
log(`Run failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
|
+
return {
|
|
131
|
+
isError: true,
|
|
132
|
+
content: textContent(`Session ${sessionMeta.id} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
logWriter.stream.end();
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const finalMeta = (await readSessionMetadata(sessionMeta.id)) ?? sessionMeta;
|
|
140
|
+
const summary = `Session ${sessionMeta.id} (${finalMeta.status})`;
|
|
141
|
+
const logTail = await readSessionLogTail(sessionMeta.id, 4000);
|
|
142
|
+
return {
|
|
143
|
+
content: textContent([summary, logTail || '(log empty)'].join('\n').trim()),
|
|
144
|
+
structuredContent: {
|
|
145
|
+
sessionId: sessionMeta.id,
|
|
146
|
+
status: finalMeta.status,
|
|
147
|
+
output: logTail ?? '',
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return {
|
|
153
|
+
isError: true,
|
|
154
|
+
content: textContent(`Session completed but metadata fetch failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|