@steipete/oracle 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/assets-oracle-icon.png +0 -0
  4. package/dist/bin/oracle-cli.js +954 -0
  5. package/dist/bin/oracle-mcp.js +6 -0
  6. package/dist/bin/oracle.js +683 -0
  7. package/dist/markdansi/types/index.js +4 -0
  8. package/dist/oracle/bin/oracle-cli.js +472 -0
  9. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  10. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  11. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  12. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  13. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  14. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  15. package/dist/oracle/src/browser/config.js +33 -0
  16. package/dist/oracle/src/browser/constants.js +40 -0
  17. package/dist/oracle/src/browser/cookies.js +210 -0
  18. package/dist/oracle/src/browser/domDebug.js +36 -0
  19. package/dist/oracle/src/browser/index.js +331 -0
  20. package/dist/oracle/src/browser/pageActions.js +5 -0
  21. package/dist/oracle/src/browser/prompt.js +88 -0
  22. package/dist/oracle/src/browser/promptSummary.js +20 -0
  23. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  24. package/dist/oracle/src/browser/types.js +1 -0
  25. package/dist/oracle/src/browser/utils.js +62 -0
  26. package/dist/oracle/src/browserMode.js +1 -0
  27. package/dist/oracle/src/cli/browserConfig.js +44 -0
  28. package/dist/oracle/src/cli/dryRun.js +59 -0
  29. package/dist/oracle/src/cli/engine.js +17 -0
  30. package/dist/oracle/src/cli/errorUtils.js +9 -0
  31. package/dist/oracle/src/cli/help.js +70 -0
  32. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  33. package/dist/oracle/src/cli/options.js +103 -0
  34. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  35. package/dist/oracle/src/cli/rootAlias.js +30 -0
  36. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  37. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  38. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  39. package/dist/oracle/src/heartbeat.js +43 -0
  40. package/dist/oracle/src/oracle/client.js +48 -0
  41. package/dist/oracle/src/oracle/config.js +29 -0
  42. package/dist/oracle/src/oracle/errors.js +101 -0
  43. package/dist/oracle/src/oracle/files.js +220 -0
  44. package/dist/oracle/src/oracle/format.js +33 -0
  45. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  46. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  47. package/dist/oracle/src/oracle/request.js +48 -0
  48. package/dist/oracle/src/oracle/run.js +444 -0
  49. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  50. package/dist/oracle/src/oracle/types.js +1 -0
  51. package/dist/oracle/src/oracle.js +9 -0
  52. package/dist/oracle/src/sessionManager.js +205 -0
  53. package/dist/oracle/src/version.js +39 -0
  54. package/dist/scripts/browser-tools.js +536 -0
  55. package/dist/scripts/check.js +21 -0
  56. package/dist/scripts/chrome/browser-tools.js +295 -0
  57. package/dist/scripts/run-cli.js +14 -0
  58. package/dist/src/browser/actions/assistantResponse.js +555 -0
  59. package/dist/src/browser/actions/attachments.js +82 -0
  60. package/dist/src/browser/actions/modelSelection.js +300 -0
  61. package/dist/src/browser/actions/navigation.js +175 -0
  62. package/dist/src/browser/actions/promptComposer.js +167 -0
  63. package/dist/src/browser/actions/remoteFileTransfer.js +154 -0
  64. package/dist/src/browser/chromeCookies.js +274 -0
  65. package/dist/src/browser/chromeLifecycle.js +107 -0
  66. package/dist/src/browser/config.js +49 -0
  67. package/dist/src/browser/constants.js +42 -0
  68. package/dist/src/browser/cookies.js +130 -0
  69. package/dist/src/browser/domDebug.js +36 -0
  70. package/dist/src/browser/index.js +541 -0
  71. package/dist/src/browser/keytarShim.js +56 -0
  72. package/dist/src/browser/pageActions.js +5 -0
  73. package/dist/src/browser/policies.js +43 -0
  74. package/dist/src/browser/prompt.js +82 -0
  75. package/dist/src/browser/promptSummary.js +20 -0
  76. package/dist/src/browser/sessionRunner.js +96 -0
  77. package/dist/src/browser/types.js +1 -0
  78. package/dist/src/browser/utils.js +112 -0
  79. package/dist/src/browser/windowsCookies.js +218 -0
  80. package/dist/src/browserMode.js +1 -0
  81. package/dist/src/cli/browserConfig.js +193 -0
  82. package/dist/src/cli/bundleWarnings.js +9 -0
  83. package/dist/src/cli/clipboard.js +10 -0
  84. package/dist/src/cli/detach.js +11 -0
  85. package/dist/src/cli/dryRun.js +103 -0
  86. package/dist/src/cli/duplicatePromptGuard.js +14 -0
  87. package/dist/src/cli/engine.js +25 -0
  88. package/dist/src/cli/errorUtils.js +9 -0
  89. package/dist/src/cli/format.js +13 -0
  90. package/dist/src/cli/help.js +77 -0
  91. package/dist/src/cli/hiddenAliases.js +22 -0
  92. package/dist/src/cli/markdownBundle.js +17 -0
  93. package/dist/src/cli/markdownRenderer.js +97 -0
  94. package/dist/src/cli/notifier.js +300 -0
  95. package/dist/src/cli/options.js +193 -0
  96. package/dist/src/cli/oscUtils.js +20 -0
  97. package/dist/src/cli/promptRequirement.js +17 -0
  98. package/dist/src/cli/renderFlags.js +9 -0
  99. package/dist/src/cli/renderOutput.js +26 -0
  100. package/dist/src/cli/rootAlias.js +30 -0
  101. package/dist/src/cli/runOptions.js +62 -0
  102. package/dist/src/cli/sessionCommand.js +111 -0
  103. package/dist/src/cli/sessionDisplay.js +540 -0
  104. package/dist/src/cli/sessionRunner.js +419 -0
  105. package/dist/src/cli/tagline.js +258 -0
  106. package/dist/src/cli/tui/index.js +520 -0
  107. package/dist/src/cli/writeOutputPath.js +21 -0
  108. package/dist/src/config.js +27 -0
  109. package/dist/src/heartbeat.js +43 -0
  110. package/dist/src/mcp/server.js +36 -0
  111. package/dist/src/mcp/tools/consult.js +221 -0
  112. package/dist/src/mcp/tools/sessionResources.js +75 -0
  113. package/dist/src/mcp/tools/sessions.js +96 -0
  114. package/dist/src/mcp/types.js +18 -0
  115. package/dist/src/mcp/utils.js +27 -0
  116. package/dist/src/oracle/background.js +134 -0
  117. package/dist/src/oracle/claude.js +95 -0
  118. package/dist/src/oracle/client.js +87 -0
  119. package/dist/src/oracle/config.js +92 -0
  120. package/dist/src/oracle/errors.js +104 -0
  121. package/dist/src/oracle/files.js +371 -0
  122. package/dist/src/oracle/format.js +30 -0
  123. package/dist/src/oracle/fsAdapter.js +10 -0
  124. package/dist/src/oracle/gemini.js +185 -0
  125. package/dist/src/oracle/logging.js +36 -0
  126. package/dist/src/oracle/markdown.js +46 -0
  127. package/dist/src/oracle/multiModelRunner.js +164 -0
  128. package/dist/src/oracle/oscProgress.js +66 -0
  129. package/dist/src/oracle/promptAssembly.js +13 -0
  130. package/dist/src/oracle/request.js +49 -0
  131. package/dist/src/oracle/run.js +492 -0
  132. package/dist/src/oracle/runUtils.js +27 -0
  133. package/dist/src/oracle/tokenEstimate.js +37 -0
  134. package/dist/src/oracle/tokenStats.js +39 -0
  135. package/dist/src/oracle/tokenStringifier.js +24 -0
  136. package/dist/src/oracle/types.js +1 -0
  137. package/dist/src/oracle.js +12 -0
  138. package/dist/src/remote/client.js +128 -0
  139. package/dist/src/remote/server.js +294 -0
  140. package/dist/src/remote/types.js +1 -0
  141. package/dist/src/sessionManager.js +462 -0
  142. package/dist/src/sessionStore.js +56 -0
  143. package/dist/src/version.js +39 -0
  144. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  145. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  146. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  147. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  148. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  149. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  150. package/dist/vendor/oracle-notifier/README.md +24 -0
  151. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  152. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  153. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  154. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  155. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  156. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  157. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  158. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  159. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  160. package/package.json +102 -0
  161. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  162. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  163. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  164. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  165. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  166. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  167. package/vendor/oracle-notifier/README.md +24 -0
  168. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -0,0 +1,520 @@
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 { DEFAULT_MODEL, MODEL_CONFIGS } from '../../oracle.js';
8
+ import { renderMarkdownAnsi } from '../markdownRenderer.js';
9
+ import { sessionStore, pruneOldSessions } from '../../sessionStore.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 disabledChoice = (label) => ({
18
+ name: label,
19
+ value: '__disabled__',
20
+ disabled: true,
21
+ });
22
+ const RECENT_WINDOW_HOURS = 24;
23
+ const PAGE_SIZE = 10;
24
+ const STATUS_PAD = 9;
25
+ const MODEL_PAD = 13;
26
+ const MODE_PAD = 7;
27
+ const TIMESTAMP_PAD = 19;
28
+ const CHARS_PAD = 5;
29
+ const COST_PAD = 7;
30
+ export async function launchTui({ version }) {
31
+ const userConfig = (await loadUserConfig()).config;
32
+ const rich = isTty();
33
+ if (rich) {
34
+ console.log(chalk.bold('🧿 oracle'), `${version}`, dim('— Whispering your tokens to the silicon sage'));
35
+ }
36
+ else {
37
+ console.log(`🧿 oracle ${version} — Whispering your tokens to the silicon sage`);
38
+ }
39
+ console.log('');
40
+ let showingOlder = false;
41
+ for (;;) {
42
+ const { recent, older, olderTotal } = await fetchSessionBuckets();
43
+ const choices = [];
44
+ const headerLabel = dim(`${'Status'.padEnd(STATUS_PAD)} ${'Model'.padEnd(MODEL_PAD)} ${'Mode'.padEnd(MODE_PAD)} ${'Timestamp'.padEnd(TIMESTAMP_PAD)} ${'Chars'.padStart(CHARS_PAD)} ${'Cost'.padStart(COST_PAD)} Slug`);
45
+ // Start with a selectable row so focus never lands on a separator
46
+ choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
47
+ choices.push(disabledChoice(''));
48
+ if (!showingOlder) {
49
+ if (recent.length > 0) {
50
+ choices.push(disabledChoice(headerLabel));
51
+ choices.push(...recent.map(toSessionChoice));
52
+ }
53
+ else if (older.length > 0) {
54
+ // No recent entries; show first page of older.
55
+ choices.push(disabledChoice(headerLabel));
56
+ choices.push(...older.slice(0, PAGE_SIZE).map(toSessionChoice));
57
+ }
58
+ }
59
+ else if (older.length > 0) {
60
+ choices.push(disabledChoice(headerLabel));
61
+ choices.push(...older.map(toSessionChoice));
62
+ }
63
+ choices.push(disabledChoice(''));
64
+ choices.push(disabledChoice('Actions'));
65
+ choices.push({ name: chalk.bold.green('ask oracle'), value: '__ask__' });
66
+ if (!showingOlder && olderTotal > 0) {
67
+ choices.push({ name: 'Older page', value: '__older__' });
68
+ }
69
+ else {
70
+ choices.push({ name: 'Newer (recent)', value: '__reset__' });
71
+ }
72
+ choices.push({ name: 'Exit', value: '__exit__' });
73
+ const selection = await new Promise((resolve) => {
74
+ const prompt = inquirer.prompt([
75
+ {
76
+ name: 'selection',
77
+ type: 'list',
78
+ message: 'Select a session or action',
79
+ choices,
80
+ pageSize: 16,
81
+ loop: false,
82
+ },
83
+ ]);
84
+ prompt
85
+ .then(({ selection: answer }) => resolve(answer))
86
+ .catch((error) => {
87
+ console.error(chalk.red('Paging failed; returning to recent list.'), error instanceof Error ? error.message : error);
88
+ resolve('__reset__');
89
+ });
90
+ });
91
+ if (selection === '__exit__') {
92
+ console.log(chalk.green('🧿 Closing the book. See you next prompt.'));
93
+ return;
94
+ }
95
+ if (selection === '__ask__') {
96
+ await askOracleFlow(version, userConfig);
97
+ continue;
98
+ }
99
+ if (selection === '__older__') {
100
+ showingOlder = true;
101
+ continue;
102
+ }
103
+ if (selection === '__reset__') {
104
+ showingOlder = false;
105
+ continue;
106
+ }
107
+ await showSessionDetail(selection);
108
+ }
109
+ }
110
+ async function fetchSessionBuckets() {
111
+ const all = await sessionStore.listSessions();
112
+ const cutoff = Date.now() - RECENT_WINDOW_HOURS * 60 * 60 * 1000;
113
+ const recent = all.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff).slice(0, PAGE_SIZE);
114
+ const olderAll = all.filter((meta) => new Date(meta.createdAt).getTime() < cutoff);
115
+ const older = olderAll.slice(0, PAGE_SIZE);
116
+ const hasMoreOlder = olderAll.length > PAGE_SIZE;
117
+ if (recent.length === 0 && older.length === 0 && olderAll.length > 0) {
118
+ // No recent entries; fall back to top 10 overall.
119
+ return { recent: olderAll.slice(0, PAGE_SIZE), older: [], hasMoreOlder: olderAll.length > PAGE_SIZE, olderTotal: olderAll.length };
120
+ }
121
+ return { recent, older, hasMoreOlder, olderTotal: olderAll.length };
122
+ }
123
+ function toSessionChoice(meta) {
124
+ return {
125
+ name: formatSessionLabel(meta),
126
+ value: meta.id,
127
+ };
128
+ }
129
+ function formatSessionLabel(meta) {
130
+ const status = colorStatus(meta.status ?? 'unknown');
131
+ const created = formatTimestampAligned(meta.createdAt);
132
+ const model = meta.model ?? 'n/a';
133
+ const mode = meta.mode ?? meta.options?.mode ?? 'api';
134
+ const slug = meta.id;
135
+ const chars = meta.options?.prompt?.length ?? meta.promptPreview?.length ?? 0;
136
+ const charLabel = chars > 0 ? chalk.gray(String(chars).padStart(CHARS_PAD)) : chalk.gray(`${''.padStart(CHARS_PAD - 1)}-`);
137
+ const cost = mode === 'browser' ? null : resolveCost(meta);
138
+ const costLabel = cost != null ? chalk.gray(formatCostTable(cost)) : chalk.gray(`${''.padStart(COST_PAD - 1)}-`);
139
+ return `${status} ${chalk.white(model.padEnd(MODEL_PAD))} ${chalk.gray(mode.padEnd(MODE_PAD))} ${chalk.gray(created.padEnd(TIMESTAMP_PAD))} ${charLabel} ${costLabel} ${chalk.cyan(slug)}`;
140
+ }
141
+ function resolveCost(meta) {
142
+ if (meta.usage?.cost != null) {
143
+ return meta.usage.cost;
144
+ }
145
+ if (!meta.model || !meta.usage) {
146
+ return null;
147
+ }
148
+ const pricing = MODEL_CONFIGS[meta.model]?.pricing;
149
+ if (!pricing)
150
+ return null;
151
+ const input = meta.usage.inputTokens ?? 0;
152
+ const output = meta.usage.outputTokens ?? 0;
153
+ const cost = input * pricing.inputPerToken + output * pricing.outputPerToken;
154
+ return cost > 0 ? cost : null;
155
+ }
156
+ function formatCostTable(cost) {
157
+ return `$${cost.toFixed(3)}`.padStart(COST_PAD);
158
+ }
159
+ function formatTimestampAligned(iso) {
160
+ const date = new Date(iso);
161
+ const locale = 'en-US';
162
+ const opts = {
163
+ year: 'numeric',
164
+ month: '2-digit',
165
+ day: '2-digit',
166
+ hour: 'numeric',
167
+ minute: '2-digit',
168
+ second: undefined,
169
+ hour12: true,
170
+ };
171
+ let formatted = date.toLocaleString(locale, opts);
172
+ // Drop the comma and use double-space between date and time for alignment.
173
+ formatted = formatted.replace(', ', ' ');
174
+ // Insert a leading space when hour is a single digit to align AM/PM column.
175
+ // Example: "11/18/2025 1:07 AM" -> "11/18/2025 1:07 AM"
176
+ return formatted.replace(/(\s)(\d:)/, '$1 $2');
177
+ }
178
+ function colorStatus(status) {
179
+ const padded = status.padEnd(9);
180
+ switch (status) {
181
+ case 'completed':
182
+ return chalk.green(padded);
183
+ case 'error':
184
+ return chalk.red(padded);
185
+ case 'running':
186
+ return chalk.yellow(padded);
187
+ default:
188
+ return padded;
189
+ }
190
+ }
191
+ async function showSessionDetail(sessionId) {
192
+ for (;;) {
193
+ const meta = await readSessionMetadataSafe(sessionId);
194
+ if (!meta) {
195
+ console.log(chalk.red(`No session found with ID ${sessionId}`));
196
+ return;
197
+ }
198
+ console.clear();
199
+ printSessionHeader(meta);
200
+ if (meta.models && meta.models.length > 0) {
201
+ printModelSummaries(meta.models);
202
+ }
203
+ const prompt = await readStoredPrompt(sessionId);
204
+ if (prompt) {
205
+ console.log(chalk.bold('Prompt:'));
206
+ console.log(renderMarkdownAnsi(prompt));
207
+ console.log(dim('---'));
208
+ }
209
+ const logPath = await getSessionLogPath(sessionId);
210
+ if (logPath) {
211
+ console.log(dim(`Log file: ${logPath}`));
212
+ }
213
+ console.log('');
214
+ await renderSessionLog(sessionId);
215
+ const isRunning = meta.status === 'running';
216
+ const modelActions = meta.models?.map((run) => ({
217
+ name: `View ${run.model} log (${run.status})`,
218
+ value: `log:${run.model}`,
219
+ })) ?? [];
220
+ const actions = [
221
+ { name: 'View combined log', value: 'log:__all__' },
222
+ ...modelActions,
223
+ ...(isRunning ? [{ name: 'Refresh', value: 'refresh' }] : []),
224
+ { name: 'Back', value: 'back' },
225
+ ];
226
+ const { next } = await inquirer.prompt([
227
+ {
228
+ name: 'next',
229
+ type: 'list',
230
+ message: 'Actions',
231
+ choices: actions,
232
+ },
233
+ ]);
234
+ if (next === 'back') {
235
+ return;
236
+ }
237
+ if (next === 'refresh') {
238
+ continue;
239
+ }
240
+ if (next.startsWith('log:')) {
241
+ const [, target] = next.split(':');
242
+ await renderSessionLog(sessionId, target === '__all__' ? undefined : target);
243
+ }
244
+ }
245
+ }
246
+ async function renderSessionLog(sessionId, model) {
247
+ const raw = model ? await sessionStore.readModelLog(sessionId, model) : await sessionStore.readLog(sessionId);
248
+ const headerLabel = model ? `Log (${model})` : 'Log';
249
+ console.log(chalk.bold(headerLabel));
250
+ const text = trimBeforeFirstAnswer(raw);
251
+ const size = Buffer.byteLength(text, 'utf8');
252
+ if (size > MAX_RENDER_BYTES) {
253
+ console.log(chalk.yellow(`Log is large (${size.toLocaleString()} bytes). Rendering raw text; open the log file for full context.`));
254
+ process.stdout.write(text);
255
+ console.log('');
256
+ return;
257
+ }
258
+ if (!text.trim()) {
259
+ console.log(dim('(log is empty)'));
260
+ console.log('');
261
+ return;
262
+ }
263
+ process.stdout.write(renderMarkdownAnsi(text));
264
+ console.log('');
265
+ }
266
+ async function getSessionLogPath(sessionId) {
267
+ try {
268
+ const paths = await sessionStore.getPaths(sessionId);
269
+ return paths.log;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ function printSessionHeader(meta) {
276
+ console.log(chalk.bold(`Session ${chalk.cyan(meta.id)}`));
277
+ console.log(`${chalk.white('Status:')} ${meta.status}`);
278
+ console.log(`${chalk.white('Created:')} ${meta.createdAt}`);
279
+ if (meta.model) {
280
+ console.log(`${chalk.white('Model:')} ${meta.model}`);
281
+ }
282
+ const mode = meta.mode ?? meta.options?.mode;
283
+ if (mode) {
284
+ console.log(`${chalk.white('Mode:')} ${mode}`);
285
+ }
286
+ if (meta.errorMessage) {
287
+ console.log(chalk.red(`Error: ${meta.errorMessage}`));
288
+ }
289
+ }
290
+ function printModelSummaries(models) {
291
+ if (models.length === 0) {
292
+ return;
293
+ }
294
+ console.log(chalk.bold('Models:'));
295
+ for (const run of models) {
296
+ const usage = run.usage
297
+ ? ` tok=${run.usage.outputTokens?.toLocaleString() ?? 0}/${run.usage.totalTokens?.toLocaleString() ?? 0}`
298
+ : '';
299
+ console.log(` - ${chalk.cyan(run.model)} — ${run.status}${usage}`);
300
+ }
301
+ console.log('');
302
+ }
303
+ async function askOracleFlow(version, userConfig) {
304
+ const modelChoices = Object.keys(MODEL_CONFIGS);
305
+ const hasApiKey = Boolean(process.env.OPENAI_API_KEY);
306
+ const initialMode = hasApiKey ? 'api' : 'browser';
307
+ const preferredMode = userConfig.engine ?? initialMode;
308
+ const answers = await inquirer.prompt([
309
+ {
310
+ name: 'promptInput',
311
+ type: 'input',
312
+ message: 'Paste your prompt text or a path to a file (leave blank to cancel):',
313
+ },
314
+ ...(hasApiKey
315
+ ? [
316
+ {
317
+ name: 'mode',
318
+ type: 'list',
319
+ message: 'Engine',
320
+ default: preferredMode,
321
+ choices: [
322
+ { name: 'API', value: 'api' },
323
+ { name: 'Browser', value: 'browser' },
324
+ ],
325
+ },
326
+ ]
327
+ : [
328
+ {
329
+ name: 'mode',
330
+ type: 'list',
331
+ message: 'Engine',
332
+ default: preferredMode,
333
+ choices: [{ name: 'Browser', value: 'browser' }],
334
+ },
335
+ ]),
336
+ {
337
+ name: 'slug',
338
+ type: 'input',
339
+ message: 'Optional slug (3–5 words, leave blank for auto):',
340
+ },
341
+ {
342
+ name: 'model',
343
+ type: 'list',
344
+ message: 'Model',
345
+ default: DEFAULT_MODEL,
346
+ choices: modelChoices,
347
+ },
348
+ {
349
+ name: 'models',
350
+ type: 'checkbox',
351
+ message: 'Additional API models to fan out to (optional)',
352
+ choices: modelChoices,
353
+ when: (ans) => ans.mode === 'api',
354
+ filter: (values) => Array.isArray(values)
355
+ ? values
356
+ .map((entry) => entry.trim())
357
+ .filter((entry) => modelChoices.includes(entry))
358
+ : [],
359
+ },
360
+ {
361
+ name: 'files',
362
+ type: 'input',
363
+ message: 'Files or globs to attach (comma-separated, optional):',
364
+ filter: (value) => value
365
+ .split(',')
366
+ .map((entry) => entry.trim())
367
+ .filter(Boolean),
368
+ },
369
+ {
370
+ name: 'chromeProfile',
371
+ type: 'input',
372
+ message: 'Chrome profile to reuse cookies from:',
373
+ default: 'Default',
374
+ when: (ans) => ans.mode === 'browser',
375
+ },
376
+ {
377
+ name: 'chromeCookiePath',
378
+ type: 'input',
379
+ message: 'Cookie DB path (Chromium/Edge, optional):',
380
+ when: (ans) => ans.mode === 'browser',
381
+ },
382
+ {
383
+ name: 'hideWindow',
384
+ type: 'confirm',
385
+ message: 'Hide Chrome window (macOS headful only)?',
386
+ default: false,
387
+ when: (ans) => ans.mode === 'browser',
388
+ },
389
+ {
390
+ name: 'keepBrowser',
391
+ type: 'confirm',
392
+ message: 'Keep browser open after completion?',
393
+ default: false,
394
+ when: (ans) => ans.mode === 'browser',
395
+ },
396
+ ]);
397
+ const mode = (answers.mode ?? initialMode);
398
+ const prompt = await resolvePromptInput(answers.promptInput);
399
+ if (!prompt.trim()) {
400
+ console.log(chalk.yellow('Cancelled.'));
401
+ return;
402
+ }
403
+ const promptWithSuffix = userConfig.promptSuffix ? `${prompt.trim()}\n${userConfig.promptSuffix}` : prompt;
404
+ await sessionStore.ensureStorage();
405
+ await pruneOldSessions(userConfig.sessionRetentionHours, (message) => console.log(chalk.dim(message)));
406
+ const normalizedMultiModels = Array.isArray(answers.models) && answers.models.length > 0
407
+ ? Array.from(new Set([answers.model, ...answers.models].filter((entry) => modelChoices.includes(entry))))
408
+ : [answers.model];
409
+ const runOptions = {
410
+ prompt: promptWithSuffix,
411
+ model: answers.model,
412
+ file: answers.files,
413
+ models: normalizedMultiModels.length > 1 ? normalizedMultiModels : undefined,
414
+ slug: answers.slug,
415
+ filesReport: false,
416
+ maxInput: undefined,
417
+ maxOutput: undefined,
418
+ system: undefined,
419
+ silent: false,
420
+ search: undefined,
421
+ preview: false,
422
+ previewMode: undefined,
423
+ apiKey: undefined,
424
+ sessionId: undefined,
425
+ verbose: false,
426
+ heartbeatIntervalMs: undefined,
427
+ browserInlineFiles: false,
428
+ browserBundleFiles: false,
429
+ background: undefined,
430
+ };
431
+ const browserConfig = mode === 'browser'
432
+ ? await buildBrowserConfig({
433
+ browserChromeProfile: answers.chromeProfile,
434
+ browserCookiePath: answers.chromeCookiePath,
435
+ browserHideWindow: answers.hideWindow,
436
+ browserKeepBrowser: answers.keepBrowser,
437
+ browserModelLabel: resolveBrowserModelLabel(undefined, answers.model),
438
+ model: answers.model,
439
+ })
440
+ : undefined;
441
+ const notifications = resolveNotificationSettings({
442
+ cliNotify: undefined,
443
+ cliNotifySound: undefined,
444
+ env: process.env,
445
+ config: userConfig.notify,
446
+ });
447
+ const sessionMeta = await sessionStore.createSession({
448
+ ...runOptions,
449
+ mode,
450
+ browserConfig,
451
+ }, process.cwd(), notifications);
452
+ const { logLine, writeChunk, stream } = sessionStore.createLogWriter(sessionMeta.id);
453
+ const combinedLog = (message) => {
454
+ if (message) {
455
+ console.log(message);
456
+ logLine(message);
457
+ }
458
+ };
459
+ // Write streamed chunks to the session log; stdout handling is owned by runOracle.
460
+ const combinedWrite = (chunk) => {
461
+ writeChunk(chunk);
462
+ return true;
463
+ };
464
+ console.log(chalk.bold(`Session ${sessionMeta.id} starting...`));
465
+ console.log(dim(`Log path: ${path.join(os.homedir(), '.oracle', 'sessions', sessionMeta.id, 'output.log')}`));
466
+ try {
467
+ await performSessionRun({
468
+ sessionMeta,
469
+ runOptions: { ...runOptions, sessionId: sessionMeta.id },
470
+ mode,
471
+ browserConfig,
472
+ cwd: process.cwd(),
473
+ log: combinedLog,
474
+ write: combinedWrite,
475
+ version,
476
+ notifications,
477
+ });
478
+ console.log(chalk.green(`Session ${sessionMeta.id} completed.`));
479
+ }
480
+ catch (error) {
481
+ const message = error instanceof Error ? error.message : String(error);
482
+ console.log(chalk.red(`Session ${sessionMeta.id} failed: ${message}`));
483
+ }
484
+ finally {
485
+ stream.end();
486
+ }
487
+ }
488
+ const readSessionMetadataSafe = (sessionId) => sessionStore.readSession(sessionId);
489
+ async function resolvePromptInput(rawInput) {
490
+ const trimmed = rawInput.trim();
491
+ if (!trimmed) {
492
+ return trimmed;
493
+ }
494
+ const asPath = path.resolve(process.cwd(), trimmed);
495
+ try {
496
+ const stats = await fs.stat(asPath);
497
+ if (stats.isFile()) {
498
+ const contents = await fs.readFile(asPath, 'utf8');
499
+ return contents;
500
+ }
501
+ }
502
+ catch {
503
+ // not a file; fall through
504
+ }
505
+ return trimmed;
506
+ }
507
+ async function readStoredPrompt(sessionId) {
508
+ const request = await sessionStore.readRequest(sessionId);
509
+ if (request?.prompt && request.prompt.trim().length > 0) {
510
+ return request.prompt;
511
+ }
512
+ const meta = await sessionStore.readSession(sessionId);
513
+ if (meta?.options?.prompt && meta.options.prompt.trim().length > 0) {
514
+ return meta.options.prompt;
515
+ }
516
+ return null;
517
+ }
518
+ // Exported for testing
519
+ export { askOracleFlow, showSessionDetail };
520
+ export { resolveCost };
@@ -0,0 +1,21 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { sessionStore } from '../sessionStore.js';
4
+ export function resolveOutputPath(input, cwd) {
5
+ if (!input || input.trim().length === 0) {
6
+ return undefined;
7
+ }
8
+ const expanded = input.startsWith('~/') ? path.join(os.homedir(), input.slice(2)) : input;
9
+ if (expanded === '-' || expanded === '/dev/stdout') {
10
+ return expanded;
11
+ }
12
+ const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
13
+ const sessionsDir = sessionStore.sessionsDir();
14
+ const normalizedSessionsDir = path.resolve(sessionsDir);
15
+ const normalizedTarget = path.resolve(absolute);
16
+ if (normalizedTarget === normalizedSessionsDir ||
17
+ normalizedTarget.startsWith(`${normalizedSessionsDir}${path.sep}`)) {
18
+ throw new Error(`Refusing to write output inside session storage (${normalizedSessionsDir}). Choose another path.`);
19
+ }
20
+ return absolute;
21
+ }
@@ -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,43 @@
1
+ export function startHeartbeat(config) {
2
+ const { intervalMs, log, isActive, makeMessage } = config;
3
+ if (!intervalMs || intervalMs <= 0) {
4
+ return () => { };
5
+ }
6
+ let stopped = false;
7
+ let pending = false;
8
+ const start = Date.now();
9
+ const timer = setInterval(async () => {
10
+ // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
11
+ if (stopped || pending) {
12
+ return;
13
+ }
14
+ if (!isActive()) {
15
+ stop();
16
+ return;
17
+ }
18
+ pending = true;
19
+ try {
20
+ const elapsed = Date.now() - start;
21
+ const message = await makeMessage(elapsed);
22
+ if (message && !stopped) {
23
+ log(message);
24
+ }
25
+ }
26
+ catch {
27
+ // ignore heartbeat errors
28
+ }
29
+ finally {
30
+ pending = false;
31
+ }
32
+ }, intervalMs);
33
+ timer.unref?.();
34
+ const stop = () => {
35
+ // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
36
+ if (stopped) {
37
+ return;
38
+ }
39
+ stopped = true;
40
+ clearInterval(timer);
41
+ };
42
+ return stop;
43
+ }
@@ -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
+ }