@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,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
|
+
}
|
package/dist/src/oracle/files.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:
|
|
140
|
+
absolute: false,
|
|
117
141
|
dot: true,
|
|
118
142
|
ignore: partitioned.excludePatterns,
|
|
119
143
|
onlyFiles: true,
|
|
120
144
|
followSymbolicLinks: false,
|
|
121
|
-
});
|
|
122
|
-
|
|
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();
|
package/dist/src/oracle/run.js
CHANGED
|
@@ -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 = `
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
Binary file
|