@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.
Files changed (58) hide show
  1. package/README.md +29 -4
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/bin/oracle-cli.js +169 -18
  4. package/dist/bin/oracle-mcp.js +6 -0
  5. package/dist/src/browser/actions/modelSelection.js +117 -29
  6. package/dist/src/browser/cookies.js +1 -1
  7. package/dist/src/browser/index.js +2 -1
  8. package/dist/src/browser/prompt.js +6 -5
  9. package/dist/src/browser/sessionRunner.js +4 -2
  10. package/dist/src/cli/dryRun.js +41 -5
  11. package/dist/src/cli/engine.js +7 -0
  12. package/dist/src/cli/help.js +1 -1
  13. package/dist/src/cli/hiddenAliases.js +17 -0
  14. package/dist/src/cli/markdownRenderer.js +79 -0
  15. package/dist/src/cli/notifier.js +223 -0
  16. package/dist/src/cli/promptRequirement.js +3 -0
  17. package/dist/src/cli/runOptions.js +29 -0
  18. package/dist/src/cli/sessionCommand.js +1 -1
  19. package/dist/src/cli/sessionDisplay.js +94 -7
  20. package/dist/src/cli/sessionRunner.js +21 -2
  21. package/dist/src/cli/tui/index.js +436 -0
  22. package/dist/src/config.js +27 -0
  23. package/dist/src/mcp/server.js +36 -0
  24. package/dist/src/mcp/tools/consult.js +158 -0
  25. package/dist/src/mcp/tools/sessionResources.js +64 -0
  26. package/dist/src/mcp/tools/sessions.js +106 -0
  27. package/dist/src/mcp/types.js +17 -0
  28. package/dist/src/mcp/utils.js +24 -0
  29. package/dist/src/oracle/files.js +143 -6
  30. package/dist/src/oracle/run.js +41 -20
  31. package/dist/src/oracle/tokenEstimate.js +34 -0
  32. package/dist/src/sessionManager.js +48 -3
  33. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  34. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  35. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  39. package/dist/vendor/oracle-notifier/README.md +24 -0
  40. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  41. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  42. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  43. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  45. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  46. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  47. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  48. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  49. package/package.json +39 -13
  50. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  51. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  52. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  53. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  54. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  55. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  56. package/vendor/oracle-notifier/README.md +24 -0
  57. package/vendor/oracle-notifier/build-notifier.sh +93 -0
  58. package/dist/.DS_Store +0 -0
@@ -0,0 +1,64 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import fs from 'node:fs/promises';
3
+ import { getSessionPaths, readSessionLog, readSessionMetadata } from '../../sessionManager.js';
4
+ // URIs:
5
+ // - oracle-session://<id>/metadata
6
+ // - oracle-session://<id>/log
7
+ // - oracle-session://<id>/request
8
+ export function registerSessionResources(server) {
9
+ const template = new ResourceTemplate('oracle-session://{id}/{kind}', { list: undefined });
10
+ server.registerResource('oracle-session', template, {
11
+ title: 'oracle session resources',
12
+ description: 'Read stored session metadata, log, or request payload.',
13
+ }, async (uri, variables) => {
14
+ const idRaw = variables?.id;
15
+ const kindRaw = variables?.kind;
16
+ // uri-template variables arrive as string | string[]; collapse to first value.
17
+ const id = Array.isArray(idRaw) ? idRaw[0] : idRaw;
18
+ const kind = Array.isArray(kindRaw) ? kindRaw[0] : kindRaw;
19
+ if (!id || !kind) {
20
+ throw new Error('Missing id or kind');
21
+ }
22
+ const paths = await getSessionPaths(id);
23
+ switch (kind) {
24
+ case 'metadata': {
25
+ const metadata = await readSessionMetadata(id);
26
+ if (!metadata) {
27
+ throw new Error(`Session "${id}" not found.`);
28
+ }
29
+ return {
30
+ contents: [
31
+ {
32
+ uri: uri.href,
33
+ text: JSON.stringify(metadata, null, 2),
34
+ },
35
+ ],
36
+ };
37
+ }
38
+ case 'log': {
39
+ const log = await readSessionLog(id);
40
+ return {
41
+ contents: [
42
+ {
43
+ uri: uri.href,
44
+ text: log,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+ case 'request': {
50
+ const raw = await fs.readFile(paths.request, 'utf8');
51
+ return {
52
+ contents: [
53
+ {
54
+ uri: uri.href,
55
+ text: raw,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+ default:
61
+ throw new Error(`Unsupported resource kind: ${kind}`);
62
+ }
63
+ });
64
+ }
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs/promises';
2
+ import { z } from 'zod';
3
+ import { filterSessionsByRange, getSessionPaths, listSessionsMetadata, readSessionLog, readSessionMetadata, } from '../../sessionManager.js';
4
+ import { sessionsInputSchema } from '../types.js';
5
+ const sessionsInputShape = {
6
+ id: z.string().optional(),
7
+ hours: z.number().optional(),
8
+ limit: z.number().optional(),
9
+ includeAll: z.boolean().optional(),
10
+ detail: z.boolean().optional(),
11
+ };
12
+ const sessionsOutputShape = {
13
+ entries: z
14
+ .array(z.object({
15
+ id: z.string(),
16
+ createdAt: z.string(),
17
+ status: z.string(),
18
+ model: z.string().optional(),
19
+ mode: z.string().optional(),
20
+ }))
21
+ .optional(),
22
+ total: z.number().optional(),
23
+ truncated: z.boolean().optional(),
24
+ session: z
25
+ .object({
26
+ metadata: z.record(z.string(), z.any()),
27
+ log: z.string(),
28
+ request: z.record(z.string(), z.any()).optional(),
29
+ })
30
+ .optional(),
31
+ };
32
+ export function registerSessionsTool(server) {
33
+ server.registerTool('sessions', {
34
+ title: 'List or fetch oracle sessions',
35
+ description: 'List stored sessions (same defaults as `oracle status`) or, with id/slug, return a summary row. Pass detail:true to include metadata, log, and stored request for that session.',
36
+ inputSchema: sessionsInputShape,
37
+ outputSchema: sessionsOutputShape,
38
+ }, async (input) => {
39
+ const textContent = (text) => [{ type: 'text', text }];
40
+ const { id, hours = 24, limit = 100, includeAll = false, detail = false } = sessionsInputSchema.parse(input);
41
+ if (id) {
42
+ if (!detail) {
43
+ const metadata = await readSessionMetadata(id);
44
+ if (!metadata) {
45
+ throw new Error(`Session "${id}" not found.`);
46
+ }
47
+ return {
48
+ content: textContent(`${metadata.createdAt} | ${metadata.status} | ${metadata.model ?? 'n/a'} | ${metadata.id}`),
49
+ structuredContent: {
50
+ entries: [
51
+ {
52
+ id: metadata.id,
53
+ createdAt: metadata.createdAt,
54
+ status: metadata.status,
55
+ model: metadata.model,
56
+ mode: metadata.mode,
57
+ },
58
+ ],
59
+ total: 1,
60
+ truncated: false,
61
+ },
62
+ };
63
+ }
64
+ const metadata = await readSessionMetadata(id);
65
+ if (!metadata) {
66
+ throw new Error(`Session "${id}" not found.`);
67
+ }
68
+ const log = await readSessionLog(id);
69
+ let request;
70
+ try {
71
+ const paths = await getSessionPaths(id);
72
+ const raw = await fs.readFile(paths.request, 'utf8');
73
+ // Old sessions may lack a request payload; treat it as best-effort metadata.
74
+ request = JSON.parse(raw);
75
+ }
76
+ catch {
77
+ request = undefined;
78
+ }
79
+ return {
80
+ content: textContent(log),
81
+ structuredContent: { session: { metadata, log, request } },
82
+ };
83
+ }
84
+ const metas = await listSessionsMetadata();
85
+ const { entries, truncated, total } = filterSessionsByRange(metas, { hours, includeAll, limit });
86
+ return {
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: entries.map((entry) => `${entry.createdAt} | ${entry.status} | ${entry.model ?? 'n/a'} | ${entry.id}`).join('\n'),
91
+ },
92
+ ],
93
+ structuredContent: {
94
+ entries: entries.map((entry) => ({
95
+ id: entry.id,
96
+ createdAt: entry.createdAt,
97
+ status: entry.status,
98
+ model: entry.model,
99
+ mode: entry.mode,
100
+ })),
101
+ total,
102
+ truncated,
103
+ },
104
+ };
105
+ });
106
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ export const consultInputSchema = z.object({
3
+ prompt: z.string().min(1, 'Prompt is required.'),
4
+ files: z.array(z.string()).default([]),
5
+ model: z.string().optional(),
6
+ engine: z.enum(['api', 'browser']).optional(),
7
+ browserModelLabel: z.string().optional(),
8
+ search: z.boolean().optional(),
9
+ slug: z.string().optional(),
10
+ });
11
+ export const sessionsInputSchema = z.object({
12
+ id: z.string().optional(),
13
+ hours: z.number().optional(),
14
+ limit: z.number().optional(),
15
+ includeAll: z.boolean().optional(),
16
+ detail: z.boolean().optional(),
17
+ });
@@ -0,0 +1,24 @@
1
+ import { resolveRunOptionsFromConfig } from '../cli/runOptions.js';
2
+ import { Launcher } from 'chrome-launcher';
3
+ export function mapConsultToRunOptions({ prompt, files, model, engine, search, userConfig, env = process.env, }) {
4
+ // Normalize CLI-style inputs through the shared resolver so config/env defaults apply,
5
+ // then overlay MCP-only overrides such as explicit search toggles.
6
+ const result = resolveRunOptionsFromConfig({ prompt, files, model, engine, userConfig, env });
7
+ if (typeof search === 'boolean') {
8
+ result.runOptions.search = search;
9
+ }
10
+ return result;
11
+ }
12
+ export function ensureBrowserAvailable(engine) {
13
+ if (engine !== 'browser') {
14
+ return null;
15
+ }
16
+ if (process.env.CHROME_PATH) {
17
+ return null;
18
+ }
19
+ const found = Launcher.getFirstInstallation();
20
+ if (!found) {
21
+ return 'Browser engine unavailable: no Chrome installation found and CHROME_PATH is unset.';
22
+ }
23
+ return null;
24
+ }
@@ -4,6 +4,7 @@ import fg from 'fast-glob';
4
4
  import { FileValidationError } from './errors.js';
5
5
  const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB
6
6
  const DEFAULT_FS = fs;
7
+ const DEFAULT_IGNORED_DIRS = ['node_modules', 'dist', 'coverage', '.git', '.turbo', '.next', 'build', 'tmp'];
7
8
  export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEFAULT_FS, maxFileSizeBytes = MAX_FILE_SIZE_BYTES } = {}) {
8
9
  if (!filePaths || filePaths.length === 0) {
9
10
  return [];
@@ -20,7 +21,28 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
20
21
  }
21
22
  candidatePaths = await expandWithCustomFs(partitioned, fsModule);
22
23
  }
23
- if (candidatePaths.length === 0) {
24
+ const allowedLiteralDirs = partitioned.literalDirectories
25
+ .map((dir) => path.resolve(dir))
26
+ .filter((dir) => DEFAULT_IGNORED_DIRS.includes(path.basename(dir)));
27
+ const allowedLiteralFiles = partitioned.literalFiles.map((file) => path.resolve(file));
28
+ const resolvedLiteralDirs = new Set(allowedLiteralDirs);
29
+ const allowedPaths = new Set([...allowedLiteralDirs, ...allowedLiteralFiles]);
30
+ const ignoredWhitelist = await buildIgnoredWhitelist(candidatePaths, cwd, fsModule);
31
+ const ignoredLog = new Set();
32
+ const filteredCandidates = candidatePaths.filter((filePath) => {
33
+ const ignoredDir = findIgnoredAncestor(filePath, cwd, resolvedLiteralDirs, allowedPaths, ignoredWhitelist);
34
+ if (!ignoredDir) {
35
+ return true;
36
+ }
37
+ const displayFile = relativePath(filePath, cwd);
38
+ const key = `${ignoredDir}|${displayFile}`;
39
+ if (!ignoredLog.has(key)) {
40
+ console.log(`Skipping default-ignored path: ${displayFile} (matches ${ignoredDir})`);
41
+ ignoredLog.add(key);
42
+ }
43
+ return false;
44
+ });
45
+ if (filteredCandidates.length === 0) {
24
46
  throw new FileValidationError('No files matched the provided --file patterns.', {
25
47
  patterns: partitioned.globPatterns,
26
48
  excludes: partitioned.excludePatterns,
@@ -28,7 +50,7 @@ export async function readFiles(filePaths, { cwd = process.cwd(), fsModule = DEF
28
50
  }
29
51
  const oversized = [];
30
52
  const accepted = [];
31
- for (const filePath of candidatePaths) {
53
+ for (const filePath of filteredCandidates) {
32
54
  let stats;
33
55
  try {
34
56
  stats = await fsModule.stat(filePath);
@@ -111,15 +133,130 @@ async function expandWithNativeGlob(partitioned, cwd) {
111
133
  if (patterns.length === 0) {
112
134
  return [];
113
135
  }
114
- const matches = await fg(patterns, {
136
+ const dotfileOptIn = patterns.some((pattern) => includesDotfileSegment(pattern));
137
+ const gitignoreSets = await loadGitignoreSets(cwd);
138
+ const matches = (await fg(patterns, {
115
139
  cwd,
116
- absolute: true,
140
+ absolute: false,
117
141
  dot: true,
118
142
  ignore: partitioned.excludePatterns,
119
143
  onlyFiles: true,
120
144
  followSymbolicLinks: false,
121
- });
122
- return Array.from(new Set(matches.map((match) => path.resolve(match))));
145
+ }));
146
+ const resolved = matches.map((match) => path.resolve(cwd, match));
147
+ const filtered = resolved.filter((filePath) => !isGitignored(filePath, gitignoreSets));
148
+ const finalFiles = dotfileOptIn ? filtered : filtered.filter((filePath) => !path.basename(filePath).startsWith('.'));
149
+ return Array.from(new Set(finalFiles));
150
+ }
151
+ async function loadGitignoreSets(cwd) {
152
+ const gitignorePaths = await fg('**/.gitignore', { cwd, dot: true, absolute: true, onlyFiles: true, followSymbolicLinks: false });
153
+ const sets = [];
154
+ for (const filePath of gitignorePaths) {
155
+ try {
156
+ const raw = await fs.readFile(filePath, 'utf8');
157
+ const patterns = raw
158
+ .split('\n')
159
+ .map((line) => line.trim())
160
+ .filter((line) => line.length > 0 && !line.startsWith('#'));
161
+ if (patterns.length > 0) {
162
+ sets.push({ dir: path.dirname(filePath), patterns });
163
+ }
164
+ }
165
+ catch {
166
+ // Ignore unreadable .gitignore files
167
+ }
168
+ }
169
+ // Ensure deterministic parent-before-child ordering
170
+ return sets.sort((a, b) => a.dir.localeCompare(b.dir));
171
+ }
172
+ function isGitignored(filePath, sets) {
173
+ for (const { dir, patterns } of sets) {
174
+ if (!filePath.startsWith(dir)) {
175
+ continue;
176
+ }
177
+ const relative = path.relative(dir, filePath) || path.basename(filePath);
178
+ if (matchesAny(relative, patterns)) {
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ async function buildIgnoredWhitelist(filePaths, cwd, fsModule) {
185
+ const whitelist = new Set();
186
+ for (const filePath of filePaths) {
187
+ const absolute = path.resolve(filePath);
188
+ const rel = path.relative(cwd, absolute);
189
+ const parts = rel.split(path.sep).filter(Boolean);
190
+ for (let i = 0; i < parts.length - 1; i += 1) {
191
+ const part = parts[i];
192
+ if (!DEFAULT_IGNORED_DIRS.includes(part)) {
193
+ continue;
194
+ }
195
+ const dirPath = path.resolve(cwd, ...parts.slice(0, i + 1));
196
+ if (whitelist.has(dirPath)) {
197
+ continue;
198
+ }
199
+ try {
200
+ const stats = await fsModule.stat(path.join(dirPath, '.gitignore'));
201
+ if (stats.isFile()) {
202
+ whitelist.add(dirPath);
203
+ }
204
+ }
205
+ catch {
206
+ // no .gitignore at this level; keep ignored
207
+ }
208
+ }
209
+ }
210
+ return whitelist;
211
+ }
212
+ function findIgnoredAncestor(filePath, cwd, _literalDirs, allowedPaths, ignoredWhitelist) {
213
+ const absolute = path.resolve(filePath);
214
+ if (Array.from(allowedPaths).some((allowed) => absolute === allowed || absolute.startsWith(`${allowed}${path.sep}`))) {
215
+ return null; // explicitly requested path overrides default ignore when the ignored dir itself was passed
216
+ }
217
+ const rel = path.relative(cwd, absolute);
218
+ const parts = rel.split(path.sep);
219
+ for (let idx = 0; idx < parts.length; idx += 1) {
220
+ const part = parts[idx];
221
+ if (!DEFAULT_IGNORED_DIRS.includes(part)) {
222
+ continue;
223
+ }
224
+ const ignoredDir = path.resolve(cwd, parts.slice(0, idx + 1).join(path.sep));
225
+ if (ignoredWhitelist.has(ignoredDir)) {
226
+ continue;
227
+ }
228
+ return part;
229
+ }
230
+ return null;
231
+ }
232
+ function matchesAny(relativePath, patterns) {
233
+ return patterns.some((pattern) => matchesPattern(relativePath, pattern));
234
+ }
235
+ function matchesPattern(relativePath, pattern) {
236
+ if (!pattern) {
237
+ return false;
238
+ }
239
+ const normalized = pattern.replace(/\\+/g, '/');
240
+ // Directory rule
241
+ if (normalized.endsWith('/')) {
242
+ const dir = normalized.slice(0, -1);
243
+ return relativePath === dir || relativePath.startsWith(`${dir}/`);
244
+ }
245
+ // Simple glob support (* and **)
246
+ const regex = globToRegex(normalized);
247
+ return regex.test(relativePath);
248
+ }
249
+ function globToRegex(pattern) {
250
+ const withMarkers = pattern.replace(/\*\*/g, '§§DOUBLESTAR§§').replace(/\*/g, '§§SINGLESTAR§§');
251
+ const escaped = withMarkers.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
252
+ const restored = escaped
253
+ .replace(/§§DOUBLESTAR§§/g, '.*')
254
+ .replace(/§§SINGLESTAR§§/g, '[^/]*');
255
+ return new RegExp(`^${restored}$`);
256
+ }
257
+ function includesDotfileSegment(pattern) {
258
+ const segments = pattern.split('/');
259
+ return segments.some((segment) => segment.startsWith('.') && segment.length > 1);
123
260
  }
124
261
  async function expandWithCustomFs(partitioned, fsModule) {
125
262
  const paths = new Set();
@@ -8,6 +8,7 @@ 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';
@@ -85,19 +86,23 @@ export async function runOracle(options, deps = {}) {
85
86
  logVerbose(`Attached files use ${totalFileTokens.toLocaleString()} tokens`);
86
87
  const systemPrompt = options.system?.trim() || DEFAULT_SYSTEM_PROMPT;
87
88
  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
89
  const fileCount = files.length;
95
90
  const cliVersion = getCliVersion();
96
91
  const richTty = process.stdout.isTTY && chalk.level > 0;
97
92
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
93
+ const requestBody = buildRequestBody({
94
+ modelConfig,
95
+ systemPrompt,
96
+ userPrompt: promptWithFiles,
97
+ searchEnabled,
98
+ maxOutputTokens: options.maxOutput,
99
+ background: useBackground,
100
+ storeResponse: useBackground,
101
+ });
102
+ const estimatedInputTokens = estimateRequestTokens(requestBody, modelConfig);
98
103
  const tokenLabel = richTty ? chalk.green(estimatedInputTokens.toLocaleString()) : estimatedInputTokens.toLocaleString();
99
104
  const fileLabel = richTty ? chalk.magenta(fileCount.toString()) : fileCount.toString();
100
- const headerLine = `Oracle (${cliVersion}) consulting ${headerModelLabel}'s crystal ball with ${tokenLabel} tokens and ${fileLabel} files...`;
105
+ const headerLine = `oracle (${cliVersion}) consulting ${headerModelLabel}'s crystal ball with ${tokenLabel} tokens and ${fileLabel} files...`;
101
106
  const shouldReportFiles = (options.filesReport || fileTokenInfo.totalTokens > inputTokenBudget) && fileTokenInfo.stats.length > 0;
102
107
  if (!isPreview) {
103
108
  log(headerLine);
@@ -112,15 +117,7 @@ export async function runOracle(options, deps = {}) {
112
117
  if (estimatedInputTokens > inputTokenBudget) {
113
118
  throw new PromptValidationError(`Input too large (${estimatedInputTokens.toLocaleString()} tokens). Limit is ${inputTokenBudget.toLocaleString()} tokens.`, { estimatedInputTokens, inputTokenBudget });
114
119
  }
115
- const requestBody = buildRequestBody({
116
- modelConfig,
117
- systemPrompt,
118
- userPrompt: promptWithFiles,
119
- searchEnabled,
120
- maxOutputTokens: options.maxOutput,
121
- background: useBackground,
122
- storeResponse: useBackground,
123
- });
120
+ logVerbose(`Estimated tokens (request body): ${estimatedInputTokens.toLocaleString()}`);
124
121
  if (isPreview && previewMode) {
125
122
  if (previewMode === 'json' || previewMode === 'full') {
126
123
  log('Request JSON');
@@ -231,9 +228,27 @@ export async function runOracle(options, deps = {}) {
231
228
  }
232
229
  logVerbose(`Response status: ${response.status ?? 'completed'}`);
233
230
  if (response.status && response.status !== 'completed') {
234
- const detail = response.error?.message || response.incomplete_details?.reason || response.status;
235
- log(chalk.yellow(`OpenAI ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
236
- throw new OracleResponseError(`Response did not complete: ${detail}`, response);
231
+ // OpenAI can reply `in_progress` even after the stream closes; give it a brief grace poll.
232
+ if (response.id && response.status === 'in_progress') {
233
+ const polishingStart = now();
234
+ const pollIntervalMs = 2_000;
235
+ const maxWaitMs = 60_000;
236
+ log(chalk.dim('Response still in_progress; polling until completion...'));
237
+ // Short polling loop — we don't want to hang forever, just catch late finalization.
238
+ while (now() - polishingStart < maxWaitMs) {
239
+ await wait(pollIntervalMs);
240
+ const refreshed = await openAiClient.responses.retrieve(response.id);
241
+ if (refreshed.status === 'completed') {
242
+ response = refreshed;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ if (response.status !== 'completed') {
248
+ const detail = response.error?.message || response.incomplete_details?.reason || response.status;
249
+ log(chalk.yellow(`OpenAI ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
250
+ throw new OracleResponseError(`Response did not complete: ${detail}`, response);
251
+ }
237
252
  }
238
253
  const answerText = extractTextOutput(response);
239
254
  if (!options.silent) {
@@ -262,6 +277,12 @@ export async function runOracle(options, deps = {}) {
262
277
  .map((value, index) => formatTokenValue(value, usage, index))
263
278
  .join('/');
264
279
  statsParts.push(`tok(i/o/r/t)=${tokensDisplay}`);
280
+ const actualInput = usage.input_tokens;
281
+ if (actualInput !== undefined) {
282
+ const delta = actualInput - estimatedInputTokens;
283
+ const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
284
+ statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
285
+ }
265
286
  if (!searchEnabled) {
266
287
  statsParts.push('search=off');
267
288
  }
@@ -272,7 +293,7 @@ export async function runOracle(options, deps = {}) {
272
293
  return {
273
294
  mode: 'live',
274
295
  response,
275
- usage: { inputTokens, outputTokens, reasoningTokens, totalTokens },
296
+ usage: { inputTokens, outputTokens, reasoningTokens, totalTokens, cost },
276
297
  elapsedMs,
277
298
  };
278
299
  }
@@ -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
+ }
@@ -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,9 @@ 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,
103
107
  },
104
108
  };
105
109
  await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
@@ -110,7 +114,8 @@ export async function initializeSession(options, cwd) {
110
114
  export async function readSessionMetadata(sessionId) {
111
115
  try {
112
116
  const raw = await fs.readFile(metaPath(sessionId), 'utf8');
113
- return JSON.parse(raw);
117
+ const parsed = JSON.parse(raw);
118
+ return await markZombie(parsed, { persist: false }); // transient check; do not touch disk on single read
114
119
  }
115
120
  catch {
116
121
  return null;
@@ -138,8 +143,9 @@ export async function listSessionsMetadata() {
138
143
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
139
144
  const metas = [];
140
145
  for (const entry of entries) {
141
- const meta = await readSessionMetadata(entry);
146
+ let meta = await readSessionMetadata(entry);
142
147
  if (meta) {
148
+ meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
143
149
  metas.push(meta);
144
150
  }
145
151
  }
@@ -164,6 +170,15 @@ export async function readSessionLog(sessionId) {
164
170
  return '';
165
171
  }
166
172
  }
173
+ export async function readSessionRequest(sessionId) {
174
+ try {
175
+ const raw = await fs.readFile(requestPath(sessionId), 'utf8');
176
+ return JSON.parse(raw);
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
167
182
  export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
168
183
  await ensureSessionStorage();
169
184
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
@@ -203,6 +218,7 @@ export async function wait(ms) {
203
218
  return new Promise((resolve) => setTimeout(resolve, ms));
204
219
  }
205
220
  export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
221
+ export { ZOMBIE_MAX_AGE_MS };
206
222
  export async function getSessionPaths(sessionId) {
207
223
  const dir = sessionDir(sessionId);
208
224
  const metadata = metaPath(sessionId);
@@ -220,3 +236,32 @@ export async function getSessionPaths(sessionId) {
220
236
  }
221
237
  return { dir, metadata, log, request };
222
238
  }
239
+ async function markZombie(meta, { persist }) {
240
+ if (!isZombie(meta)) {
241
+ return meta;
242
+ }
243
+ const updated = {
244
+ ...meta,
245
+ status: 'error',
246
+ errorMessage: 'Session marked as zombie (>30m stale)',
247
+ completedAt: new Date().toISOString(),
248
+ };
249
+ if (persist) {
250
+ await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
251
+ }
252
+ return updated;
253
+ }
254
+ function isZombie(meta) {
255
+ if (meta.status !== 'running') {
256
+ return false;
257
+ }
258
+ const reference = meta.startedAt ?? meta.createdAt;
259
+ if (!reference) {
260
+ return false;
261
+ }
262
+ const startedMs = Date.parse(reference);
263
+ if (Number.isNaN(startedMs)) {
264
+ return false;
265
+ }
266
+ return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
267
+ }
@@ -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>