@steipete/oracle 1.0.7 → 1.1.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 +3 -0
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +9 -3
- package/dist/markdansi/types/index.js +4 -0
- package/dist/oracle/bin/oracle-cli.js +472 -0
- package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
- package/dist/oracle/src/browser/actions/attachments.js +82 -0
- package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
- package/dist/oracle/src/browser/actions/navigation.js +75 -0
- package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
- package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
- package/dist/oracle/src/browser/config.js +33 -0
- package/dist/oracle/src/browser/constants.js +40 -0
- package/dist/oracle/src/browser/cookies.js +210 -0
- package/dist/oracle/src/browser/domDebug.js +36 -0
- package/dist/oracle/src/browser/index.js +331 -0
- package/dist/oracle/src/browser/pageActions.js +5 -0
- package/dist/oracle/src/browser/prompt.js +88 -0
- package/dist/oracle/src/browser/promptSummary.js +20 -0
- package/dist/oracle/src/browser/sessionRunner.js +80 -0
- package/dist/oracle/src/browser/types.js +1 -0
- package/dist/oracle/src/browser/utils.js +62 -0
- package/dist/oracle/src/browserMode.js +1 -0
- package/dist/oracle/src/cli/browserConfig.js +44 -0
- package/dist/oracle/src/cli/dryRun.js +59 -0
- package/dist/oracle/src/cli/engine.js +17 -0
- package/dist/oracle/src/cli/errorUtils.js +9 -0
- package/dist/oracle/src/cli/help.js +70 -0
- package/dist/oracle/src/cli/markdownRenderer.js +15 -0
- package/dist/oracle/src/cli/options.js +103 -0
- package/dist/oracle/src/cli/promptRequirement.js +14 -0
- package/dist/oracle/src/cli/rootAlias.js +30 -0
- package/dist/oracle/src/cli/sessionCommand.js +77 -0
- package/dist/oracle/src/cli/sessionDisplay.js +270 -0
- package/dist/oracle/src/cli/sessionRunner.js +94 -0
- package/dist/oracle/src/heartbeat.js +43 -0
- package/dist/oracle/src/oracle/client.js +48 -0
- package/dist/oracle/src/oracle/config.js +29 -0
- package/dist/oracle/src/oracle/errors.js +101 -0
- package/dist/oracle/src/oracle/files.js +220 -0
- package/dist/oracle/src/oracle/format.js +33 -0
- package/dist/oracle/src/oracle/fsAdapter.js +7 -0
- package/dist/oracle/src/oracle/oscProgress.js +60 -0
- package/dist/oracle/src/oracle/request.js +48 -0
- package/dist/oracle/src/oracle/run.js +444 -0
- package/dist/oracle/src/oracle/tokenStats.js +39 -0
- package/dist/oracle/src/oracle/types.js +1 -0
- package/dist/oracle/src/oracle.js +9 -0
- package/dist/oracle/src/sessionManager.js +205 -0
- package/dist/oracle/src/version.js +39 -0
- package/dist/src/cli/help.js +1 -0
- package/dist/src/cli/markdownRenderer.js +18 -0
- package/dist/src/cli/rootAlias.js +14 -0
- package/dist/src/cli/sessionCommand.js +60 -2
- package/dist/src/cli/sessionDisplay.js +129 -4
- package/dist/src/oracle/oscProgress.js +60 -0
- package/dist/src/oracle/run.js +63 -51
- package/dist/src/sessionManager.js +17 -0
- package/package.json +14 -22
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { createWriteStream } from 'node:fs';
|
|
5
|
+
const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
|
|
6
|
+
const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
|
|
7
|
+
const MAX_STATUS_LIMIT = 1000;
|
|
8
|
+
const DEFAULT_SLUG = 'session';
|
|
9
|
+
const MAX_SLUG_WORDS = 5;
|
|
10
|
+
const MIN_CUSTOM_SLUG_WORDS = 3;
|
|
11
|
+
async function ensureDir(dirPath) {
|
|
12
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
export async function ensureSessionStorage() {
|
|
15
|
+
await ensureDir(SESSIONS_DIR);
|
|
16
|
+
}
|
|
17
|
+
function slugify(text, maxWords = MAX_SLUG_WORDS) {
|
|
18
|
+
const normalized = text?.toLowerCase() ?? '';
|
|
19
|
+
const words = normalized.match(/[a-z0-9]+/g) ?? [];
|
|
20
|
+
const trimmed = words.slice(0, maxWords);
|
|
21
|
+
return trimmed.length > 0 ? trimmed.join('-') : DEFAULT_SLUG;
|
|
22
|
+
}
|
|
23
|
+
function countSlugWords(slug) {
|
|
24
|
+
return slug.split('-').filter(Boolean).length;
|
|
25
|
+
}
|
|
26
|
+
function normalizeCustomSlug(candidate) {
|
|
27
|
+
const slug = slugify(candidate, MAX_SLUG_WORDS);
|
|
28
|
+
const wordCount = countSlugWords(slug);
|
|
29
|
+
if (wordCount < MIN_CUSTOM_SLUG_WORDS || wordCount > MAX_SLUG_WORDS) {
|
|
30
|
+
throw new Error(`Custom slug must include between ${MIN_CUSTOM_SLUG_WORDS} and ${MAX_SLUG_WORDS} words.`);
|
|
31
|
+
}
|
|
32
|
+
return slug;
|
|
33
|
+
}
|
|
34
|
+
export function createSessionId(prompt, customSlug) {
|
|
35
|
+
if (customSlug) {
|
|
36
|
+
return normalizeCustomSlug(customSlug);
|
|
37
|
+
}
|
|
38
|
+
return slugify(prompt);
|
|
39
|
+
}
|
|
40
|
+
function sessionDir(id) {
|
|
41
|
+
return path.join(SESSIONS_DIR, id);
|
|
42
|
+
}
|
|
43
|
+
function metaPath(id) {
|
|
44
|
+
return path.join(sessionDir(id), 'session.json');
|
|
45
|
+
}
|
|
46
|
+
function logPath(id) {
|
|
47
|
+
return path.join(sessionDir(id), 'output.log');
|
|
48
|
+
}
|
|
49
|
+
function requestPath(id) {
|
|
50
|
+
return path.join(sessionDir(id), 'request.json');
|
|
51
|
+
}
|
|
52
|
+
async function fileExists(targetPath) {
|
|
53
|
+
try {
|
|
54
|
+
await fs.access(targetPath);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function ensureUniqueSessionId(baseSlug) {
|
|
62
|
+
let candidate = baseSlug;
|
|
63
|
+
let suffix = 2;
|
|
64
|
+
while (await fileExists(sessionDir(candidate))) {
|
|
65
|
+
candidate = `${baseSlug}-${suffix}`;
|
|
66
|
+
suffix += 1;
|
|
67
|
+
}
|
|
68
|
+
return candidate;
|
|
69
|
+
}
|
|
70
|
+
export async function initializeSession(options, cwd) {
|
|
71
|
+
await ensureSessionStorage();
|
|
72
|
+
const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
|
|
73
|
+
const sessionId = await ensureUniqueSessionId(baseSlug);
|
|
74
|
+
const dir = sessionDir(sessionId);
|
|
75
|
+
await ensureDir(dir);
|
|
76
|
+
const mode = options.mode ?? 'api';
|
|
77
|
+
const browserConfig = options.browserConfig;
|
|
78
|
+
const metadata = {
|
|
79
|
+
id: sessionId,
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
status: 'pending',
|
|
82
|
+
promptPreview: (options.prompt || '').slice(0, 160),
|
|
83
|
+
model: options.model,
|
|
84
|
+
cwd,
|
|
85
|
+
mode,
|
|
86
|
+
browser: browserConfig ? { config: browserConfig } : undefined,
|
|
87
|
+
options: {
|
|
88
|
+
prompt: options.prompt,
|
|
89
|
+
file: options.file ?? [],
|
|
90
|
+
model: options.model,
|
|
91
|
+
maxInput: options.maxInput,
|
|
92
|
+
system: options.system,
|
|
93
|
+
maxOutput: options.maxOutput,
|
|
94
|
+
silent: options.silent,
|
|
95
|
+
filesReport: options.filesReport,
|
|
96
|
+
slug: sessionId,
|
|
97
|
+
mode,
|
|
98
|
+
browserConfig,
|
|
99
|
+
verbose: options.verbose,
|
|
100
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs,
|
|
101
|
+
browserInlineFiles: options.browserInlineFiles,
|
|
102
|
+
background: options.background,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
|
|
106
|
+
await fs.writeFile(requestPath(sessionId), JSON.stringify(metadata.options, null, 2), 'utf8');
|
|
107
|
+
await fs.writeFile(logPath(sessionId), '', 'utf8');
|
|
108
|
+
return metadata;
|
|
109
|
+
}
|
|
110
|
+
export async function readSessionMetadata(sessionId) {
|
|
111
|
+
try {
|
|
112
|
+
const raw = await fs.readFile(metaPath(sessionId), 'utf8');
|
|
113
|
+
return JSON.parse(raw);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function updateSessionMetadata(sessionId, updates) {
|
|
120
|
+
const existing = (await readSessionMetadata(sessionId)) ?? { id: sessionId };
|
|
121
|
+
const next = { ...existing, ...updates };
|
|
122
|
+
await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), 'utf8');
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
export function createSessionLogWriter(sessionId) {
|
|
126
|
+
const stream = createWriteStream(logPath(sessionId), { flags: 'a' });
|
|
127
|
+
const logLine = (line = '') => {
|
|
128
|
+
stream.write(`${line}\n`);
|
|
129
|
+
};
|
|
130
|
+
const writeChunk = (chunk) => {
|
|
131
|
+
stream.write(chunk);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
return { stream, logLine, writeChunk, logPath: logPath(sessionId) };
|
|
135
|
+
}
|
|
136
|
+
export async function listSessionsMetadata() {
|
|
137
|
+
await ensureSessionStorage();
|
|
138
|
+
const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
|
|
139
|
+
const metas = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const meta = await readSessionMetadata(entry);
|
|
142
|
+
if (meta) {
|
|
143
|
+
metas.push(meta);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return metas.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
147
|
+
}
|
|
148
|
+
export function filterSessionsByRange(metas, { hours = 24, includeAll = false, limit = 100 }) {
|
|
149
|
+
const maxLimit = Math.min(limit, MAX_STATUS_LIMIT);
|
|
150
|
+
let filtered = metas;
|
|
151
|
+
if (!includeAll) {
|
|
152
|
+
const cutoff = Date.now() - hours * 60 * 60 * 1000;
|
|
153
|
+
filtered = metas.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff);
|
|
154
|
+
}
|
|
155
|
+
const limited = filtered.slice(0, maxLimit);
|
|
156
|
+
const truncated = filtered.length > maxLimit;
|
|
157
|
+
return { entries: limited, truncated, total: filtered.length };
|
|
158
|
+
}
|
|
159
|
+
export async function readSessionLog(sessionId) {
|
|
160
|
+
try {
|
|
161
|
+
return await fs.readFile(logPath(sessionId), 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
|
|
168
|
+
await ensureSessionStorage();
|
|
169
|
+
const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
|
|
170
|
+
if (!entries.length) {
|
|
171
|
+
return { deleted: 0, remaining: 0 };
|
|
172
|
+
}
|
|
173
|
+
const cutoff = includeAll ? Number.NEGATIVE_INFINITY : Date.now() - hours * 60 * 60 * 1000;
|
|
174
|
+
let deleted = 0;
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const dir = sessionDir(entry);
|
|
177
|
+
let createdMs;
|
|
178
|
+
const meta = await readSessionMetadata(entry);
|
|
179
|
+
if (meta?.createdAt) {
|
|
180
|
+
const parsed = Date.parse(meta.createdAt);
|
|
181
|
+
if (!Number.isNaN(parsed)) {
|
|
182
|
+
createdMs = parsed;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (createdMs == null) {
|
|
186
|
+
try {
|
|
187
|
+
const stats = await fs.stat(dir);
|
|
188
|
+
createdMs = stats.birthtimeMs || stats.mtimeMs;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (includeAll || (createdMs != null && createdMs < cutoff)) {
|
|
195
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
196
|
+
deleted += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const remaining = Math.max(entries.length - deleted, 0);
|
|
200
|
+
return { deleted, remaining };
|
|
201
|
+
}
|
|
202
|
+
export async function wait(ms) {
|
|
203
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
204
|
+
}
|
|
205
|
+
export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
let cachedVersion = null;
|
|
5
|
+
export function getCliVersion() {
|
|
6
|
+
if (cachedVersion) {
|
|
7
|
+
return cachedVersion;
|
|
8
|
+
}
|
|
9
|
+
cachedVersion = readVersionFromPackage();
|
|
10
|
+
return cachedVersion;
|
|
11
|
+
}
|
|
12
|
+
function readVersionFromPackage() {
|
|
13
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
14
|
+
let currentDir = path.dirname(modulePath);
|
|
15
|
+
const filesystemRoot = path.parse(currentDir).root;
|
|
16
|
+
// biome-ignore lint/nursery/noUnnecessaryConditions: deliberate sentinel loop to walk up directories
|
|
17
|
+
while (true) {
|
|
18
|
+
const candidate = path.join(currentDir, 'package.json');
|
|
19
|
+
try {
|
|
20
|
+
const raw = readFileSync(candidate, 'utf8');
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
const version = typeof parsed.version === 'string' && parsed.version.trim().length > 0
|
|
23
|
+
? parsed.version.trim()
|
|
24
|
+
: '0.0.0';
|
|
25
|
+
return version;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
|
29
|
+
if (code && code !== 'ENOENT') {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (currentDir === filesystemRoot) {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
currentDir = path.dirname(currentDir);
|
|
37
|
+
}
|
|
38
|
+
return '0.0.0';
|
|
39
|
+
}
|
package/dist/src/cli/help.js
CHANGED
|
@@ -45,6 +45,7 @@ function renderHelpFooter(program, colors) {
|
|
|
45
45
|
const tips = [
|
|
46
46
|
`${colors.bullet('•')} Attach lots of source (whole directories beat single files) and keep total input under ~196k tokens.`,
|
|
47
47
|
`${colors.bullet('•')} Oracle starts empty—open with a short project briefing (stack, services, build steps), spell out the question and prior attempts, and why it matters; the more explanation and context you provide, the better the response will be.`,
|
|
48
|
+
`${colors.bullet('•')} Oracle is one-shot: it does not remember prior runs, so start fresh each time with full context.`,
|
|
48
49
|
`${colors.bullet('•')} Run ${colors.accent('--files-report')} to inspect token spend before hitting the API.`,
|
|
49
50
|
`${colors.bullet('•')} Non-preview runs spawn detached sessions so they keep streaming even if your terminal closes — reattach anytime via ${colors.accent('pnpm oracle session <slug>')}.`,
|
|
50
51
|
`${colors.bullet('•')} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render as renderMarkdown } from 'markdansi';
|
|
2
|
+
export function renderMarkdownAnsi(markdown) {
|
|
3
|
+
try {
|
|
4
|
+
const color = Boolean(process.stdout.isTTY);
|
|
5
|
+
const width = process.stdout.columns;
|
|
6
|
+
const hyperlinks = color; // enable OSC 8 only when we have color/TTY
|
|
7
|
+
return renderMarkdown(markdown, {
|
|
8
|
+
color,
|
|
9
|
+
width,
|
|
10
|
+
wrap: true,
|
|
11
|
+
hyperlinks,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Last-resort fallback: return the raw markdown so we never crash.
|
|
16
|
+
return markdown;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -14,3 +14,17 @@ export async function handleStatusFlag(options, deps = defaultDeps) {
|
|
|
14
14
|
await deps.showStatus({ hours: 24, includeAll: false, limit: 100, showExamples: true });
|
|
15
15
|
return true;
|
|
16
16
|
}
|
|
17
|
+
const defaultSessionDeps = {
|
|
18
|
+
attachSession,
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Hidden root-level alias to attach to a stored session (`--session <id>`).
|
|
22
|
+
* Returns true when the alias was handled so callers can short-circuit.
|
|
23
|
+
*/
|
|
24
|
+
export async function handleSessionAlias(options, deps = defaultSessionDeps) {
|
|
25
|
+
if (!options.session) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
await deps.attachSession(options.session);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { usesDefaultStatusFilters } from './options.js';
|
|
2
3
|
import { attachSession, showStatus } from './sessionDisplay.js';
|
|
3
|
-
import { deleteSessionsOlderThan } from '../sessionManager.js';
|
|
4
|
+
import { deleteSessionsOlderThan, getSessionPaths } from '../sessionManager.js';
|
|
4
5
|
const defaultDependencies = {
|
|
5
6
|
showStatus,
|
|
6
7
|
attachSession,
|
|
7
8
|
usesDefaultStatusFilters,
|
|
8
9
|
deleteSessionsOlderThan,
|
|
10
|
+
getSessionPaths,
|
|
9
11
|
};
|
|
12
|
+
const SESSION_OPTION_KEYS = new Set(['hours', 'limit', 'all', 'clear', 'clean', 'render', 'renderMarkdown', 'path']);
|
|
10
13
|
export async function handleSessionCommand(sessionId, command, deps = defaultDependencies) {
|
|
11
14
|
const sessionOptions = command.opts();
|
|
15
|
+
if (sessionOptions.verboseRender) {
|
|
16
|
+
process.env.ORACLE_VERBOSE_RENDER = '1';
|
|
17
|
+
}
|
|
18
|
+
const renderSource = command.getOptionValueSource?.('render');
|
|
19
|
+
const renderMarkdownSource = command.getOptionValueSource?.('renderMarkdown');
|
|
20
|
+
const renderExplicit = renderSource === 'cli' || renderMarkdownSource === 'cli';
|
|
21
|
+
const autoRender = !renderExplicit && process.stdout.isTTY;
|
|
22
|
+
const pathRequested = Boolean(sessionOptions.path);
|
|
12
23
|
const clearRequested = Boolean(sessionOptions.clear || sessionOptions.clean);
|
|
13
24
|
if (clearRequested) {
|
|
14
25
|
if (sessionId) {
|
|
@@ -28,6 +39,28 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
|
|
|
28
39
|
process.exitCode = 1;
|
|
29
40
|
return;
|
|
30
41
|
}
|
|
42
|
+
if (pathRequested) {
|
|
43
|
+
if (!sessionId) {
|
|
44
|
+
console.error('The --path flag requires a session ID.');
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const paths = await deps.getSessionPaths(sessionId);
|
|
50
|
+
const richTty = Boolean(process.stdout.isTTY && chalk.level > 0);
|
|
51
|
+
const label = (text) => (richTty ? chalk.cyan(text) : text);
|
|
52
|
+
const value = (text) => (richTty ? chalk.dim(text) : text);
|
|
53
|
+
console.log(`${label('Session dir:')} ${value(paths.dir)}`);
|
|
54
|
+
console.log(`${label('Metadata:')} ${value(paths.metadata)}`);
|
|
55
|
+
console.log(`${label('Request:')} ${value(paths.request)}`);
|
|
56
|
+
console.log(`${label('Log:')} ${value(paths.log)}`);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
31
64
|
if (!sessionId) {
|
|
32
65
|
const showExamples = deps.usesDefaultStatusFilters(command);
|
|
33
66
|
await deps.showStatus({
|
|
@@ -38,7 +71,13 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
|
|
|
38
71
|
});
|
|
39
72
|
return;
|
|
40
73
|
}
|
|
41
|
-
|
|
74
|
+
// Surface any root-level flags that were provided but are ignored when attaching to a session.
|
|
75
|
+
const ignoredFlags = listIgnoredFlags(command);
|
|
76
|
+
if (ignoredFlags.length > 0) {
|
|
77
|
+
console.log(`Ignoring flags on session attach: ${ignoredFlags.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
const renderMarkdown = Boolean(sessionOptions.render || sessionOptions.renderMarkdown || autoRender);
|
|
80
|
+
await deps.attachSession(sessionId, { renderMarkdown });
|
|
42
81
|
}
|
|
43
82
|
export function formatSessionCleanupMessage(result, scope) {
|
|
44
83
|
const deletedLabel = `${result.deleted} ${result.deleted === 1 ? 'session' : 'sessions'}`;
|
|
@@ -46,3 +85,22 @@ export function formatSessionCleanupMessage(result, scope) {
|
|
|
46
85
|
const hint = 'Run "oracle session --clear --all" to delete everything.';
|
|
47
86
|
return `Deleted ${deletedLabel} (${scope}). ${remainingLabel}.\n${hint}`;
|
|
48
87
|
}
|
|
88
|
+
function listIgnoredFlags(command) {
|
|
89
|
+
const opts = command.optsWithGlobals();
|
|
90
|
+
const ignored = [];
|
|
91
|
+
for (const key of Object.keys(opts)) {
|
|
92
|
+
if (SESSION_OPTION_KEYS.has(key)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const source = command.getOptionValueSource?.(key);
|
|
96
|
+
if (source !== 'cli' && source !== 'env') {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const value = opts[key];
|
|
100
|
+
if (value === undefined || value === false || value === null) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
ignored.push(key);
|
|
104
|
+
}
|
|
105
|
+
return ignored;
|
|
106
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import kleur from 'kleur';
|
|
3
3
|
import { filterSessionsByRange, listSessionsMetadata, readSessionLog, readSessionMetadata, SESSIONS_DIR, wait, } from '../sessionManager.js';
|
|
4
|
-
|
|
5
|
-
const
|
|
4
|
+
import { renderMarkdownAnsi } from './markdownRenderer.js';
|
|
5
|
+
const isTty = () => Boolean(process.stdout.isTTY);
|
|
6
|
+
const dim = (text) => (isTty() ? kleur.dim(text) : text);
|
|
7
|
+
const MAX_RENDER_BYTES = 200_000;
|
|
6
8
|
const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
|
|
7
9
|
export async function showStatus({ hours, includeAll, limit, showExamples = false }) {
|
|
8
10
|
const metas = await listSessionsMetadata();
|
|
@@ -50,6 +52,8 @@ export async function attachSession(sessionId, options) {
|
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
const initialStatus = metadata.status;
|
|
55
|
+
const wantsRender = Boolean(options?.renderMarkdown);
|
|
56
|
+
const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
|
|
53
57
|
if (!options?.suppressMetadata) {
|
|
54
58
|
const reattachLine = buildReattachLine(metadata);
|
|
55
59
|
if (reattachLine) {
|
|
@@ -75,15 +79,96 @@ export async function attachSession(sessionId, options) {
|
|
|
75
79
|
if (shouldTrimIntro) {
|
|
76
80
|
const fullLog = await readSessionLog(sessionId);
|
|
77
81
|
const trimmed = trimBeforeFirstAnswer(fullLog);
|
|
78
|
-
|
|
82
|
+
const size = Buffer.byteLength(trimmed, 'utf8');
|
|
83
|
+
const canRender = wantsRender && isTty() && size <= MAX_RENDER_BYTES;
|
|
84
|
+
if (wantsRender && size > MAX_RENDER_BYTES) {
|
|
85
|
+
const msg = `Render skipped (log too large: ${size} bytes > ${MAX_RENDER_BYTES}). Showing raw text.`;
|
|
86
|
+
console.log(dim(msg));
|
|
87
|
+
if (isVerbose) {
|
|
88
|
+
console.log(dim(`Verbose: renderMarkdown=true tty=${isTty()} size=${size}`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (wantsRender && !isTty()) {
|
|
92
|
+
const msg = 'Render requested but stdout is not a TTY; showing raw text.';
|
|
93
|
+
console.log(dim(msg));
|
|
94
|
+
if (isVerbose) {
|
|
95
|
+
console.log(dim(`Verbose: renderMarkdown=true tty=${isTty()} size=${size}`));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (canRender) {
|
|
99
|
+
if (isVerbose) {
|
|
100
|
+
console.log(dim(`Verbose: rendering markdown (size=${size}, tty=${isTty()})`));
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write(renderMarkdownAnsi(trimmed));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
process.stdout.write(trimmed);
|
|
106
|
+
}
|
|
79
107
|
return;
|
|
80
108
|
}
|
|
109
|
+
if (wantsRender) {
|
|
110
|
+
console.log(dim('Render will apply after completion; streaming raw text meanwhile...'));
|
|
111
|
+
if (isVerbose) {
|
|
112
|
+
console.log(dim(`Verbose: streaming phase renderMarkdown=true tty=${isTty()}`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const liveRenderState = wantsRender && isTty()
|
|
116
|
+
? { pending: '', inFence: false, inTable: false, renderedBytes: 0, fallback: false, noticedFallback: false }
|
|
117
|
+
: null;
|
|
81
118
|
let lastLength = 0;
|
|
119
|
+
const renderLiveChunk = (chunk) => {
|
|
120
|
+
if (!liveRenderState || chunk.length === 0) {
|
|
121
|
+
process.stdout.write(chunk);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (liveRenderState.fallback) {
|
|
125
|
+
process.stdout.write(chunk);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
liveRenderState.pending += chunk;
|
|
129
|
+
const { chunks, remainder } = extractRenderableChunks(liveRenderState.pending, liveRenderState);
|
|
130
|
+
liveRenderState.pending = remainder;
|
|
131
|
+
for (const candidate of chunks) {
|
|
132
|
+
const projected = liveRenderState.renderedBytes + Buffer.byteLength(candidate, 'utf8');
|
|
133
|
+
if (projected > MAX_RENDER_BYTES) {
|
|
134
|
+
if (!liveRenderState.noticedFallback) {
|
|
135
|
+
console.log(dim(`Render skipped (log too large: > ${MAX_RENDER_BYTES} bytes). Showing raw text.`));
|
|
136
|
+
liveRenderState.noticedFallback = true;
|
|
137
|
+
}
|
|
138
|
+
liveRenderState.fallback = true;
|
|
139
|
+
process.stdout.write(candidate + liveRenderState.pending);
|
|
140
|
+
liveRenderState.pending = '';
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write(renderMarkdownAnsi(candidate));
|
|
144
|
+
liveRenderState.renderedBytes += Buffer.byteLength(candidate, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const flushRemainder = () => {
|
|
148
|
+
if (!liveRenderState || liveRenderState.fallback) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (liveRenderState.pending.length === 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const text = liveRenderState.pending;
|
|
155
|
+
liveRenderState.pending = '';
|
|
156
|
+
const projected = liveRenderState.renderedBytes + Buffer.byteLength(text, 'utf8');
|
|
157
|
+
if (projected > MAX_RENDER_BYTES) {
|
|
158
|
+
if (!liveRenderState.noticedFallback) {
|
|
159
|
+
console.log(dim(`Render skipped (log too large: > ${MAX_RENDER_BYTES} bytes). Showing raw text.`));
|
|
160
|
+
}
|
|
161
|
+
process.stdout.write(text);
|
|
162
|
+
liveRenderState.fallback = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
process.stdout.write(renderMarkdownAnsi(text));
|
|
166
|
+
};
|
|
82
167
|
const printNew = async () => {
|
|
83
168
|
const text = await readSessionLog(sessionId);
|
|
84
169
|
const nextChunk = text.slice(lastLength);
|
|
85
170
|
if (nextChunk.length > 0) {
|
|
86
|
-
|
|
171
|
+
renderLiveChunk(nextChunk);
|
|
87
172
|
lastLength = text.length;
|
|
88
173
|
}
|
|
89
174
|
};
|
|
@@ -96,6 +181,7 @@ export async function attachSession(sessionId, options) {
|
|
|
96
181
|
}
|
|
97
182
|
if (latest.status === 'completed' || latest.status === 'error') {
|
|
98
183
|
await printNew();
|
|
184
|
+
flushRemainder();
|
|
99
185
|
if (!options?.suppressMetadata) {
|
|
100
186
|
if (latest.status === 'error' && latest.errorMessage) {
|
|
101
187
|
console.log('\nResult:');
|
|
@@ -234,3 +320,42 @@ function printStatusExamples() {
|
|
|
234
320
|
console.log(dim(' Attach to a specific running/completed session to stream its output.'));
|
|
235
321
|
console.log(dim(CLEANUP_TIP));
|
|
236
322
|
}
|
|
323
|
+
function extractRenderableChunks(text, state) {
|
|
324
|
+
const chunks = [];
|
|
325
|
+
let buffer = '';
|
|
326
|
+
const lines = text.split(/(\n)/);
|
|
327
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
328
|
+
const segment = lines[i];
|
|
329
|
+
if (segment === '\n') {
|
|
330
|
+
buffer += segment;
|
|
331
|
+
// Detect code fences
|
|
332
|
+
const prev = lines[i - 1] ?? '';
|
|
333
|
+
const fenceMatch = prev.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
334
|
+
if (!state.inFence && fenceMatch) {
|
|
335
|
+
state.inFence = true;
|
|
336
|
+
state.fenceDelimiter = fenceMatch[2];
|
|
337
|
+
}
|
|
338
|
+
else if (state.inFence && state.fenceDelimiter && prev.startsWith(state.fenceDelimiter)) {
|
|
339
|
+
state.inFence = false;
|
|
340
|
+
state.fenceDelimiter = undefined;
|
|
341
|
+
}
|
|
342
|
+
const trimmed = prev.trim();
|
|
343
|
+
if (!state.inFence) {
|
|
344
|
+
if (!state.inTable && trimmed.startsWith('|') && trimmed.includes('|')) {
|
|
345
|
+
state.inTable = true;
|
|
346
|
+
}
|
|
347
|
+
if (state.inTable && trimmed === '') {
|
|
348
|
+
state.inTable = false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const safeBreak = !state.inFence && !state.inTable && trimmed === '';
|
|
352
|
+
if (safeBreak) {
|
|
353
|
+
chunks.push(buffer);
|
|
354
|
+
buffer = '';
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
buffer += segment;
|
|
359
|
+
}
|
|
360
|
+
return { chunks, remainder: buffer };
|
|
361
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
const OSC = '\u001b]9;4;';
|
|
3
|
+
const ST = '\u001b\\';
|
|
4
|
+
function sanitizeLabel(label) {
|
|
5
|
+
const withoutEscape = label.split('\u001b').join('');
|
|
6
|
+
const withoutBellAndSt = withoutEscape.replaceAll('\u0007', '').replaceAll('\u009c', '');
|
|
7
|
+
return withoutBellAndSt.replaceAll(']', '').trim();
|
|
8
|
+
}
|
|
9
|
+
export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
|
|
10
|
+
if (!isTty) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (env.ORACLE_NO_OSC_PROGRESS === '1') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (env.ORACLE_FORCE_OSC_PROGRESS === '1') {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const termProgram = (env.TERM_PROGRAM ?? '').toLowerCase();
|
|
20
|
+
if (termProgram.includes('ghostty')) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (termProgram.includes('wezterm')) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (env.WT_SESSION) {
|
|
27
|
+
return true; // Windows Terminal exposes this
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
export function startOscProgress(options = {}) {
|
|
32
|
+
const { label = 'Waiting for OpenAI', targetMs = 10 * 60_000, write = (text) => process.stdout.write(text) } = options;
|
|
33
|
+
if (!supportsOscProgress(options.env, options.isTty)) {
|
|
34
|
+
return () => { };
|
|
35
|
+
}
|
|
36
|
+
const cleanLabel = sanitizeLabel(label);
|
|
37
|
+
const target = Math.max(targetMs, 1_000);
|
|
38
|
+
const send = (state, percent) => {
|
|
39
|
+
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
40
|
+
write(`${OSC}${state};${clamped};${cleanLabel}${ST}`);
|
|
41
|
+
};
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
send(1, 0); // activate progress bar
|
|
44
|
+
const timer = setInterval(() => {
|
|
45
|
+
const elapsed = Date.now() - startedAt;
|
|
46
|
+
const percent = Math.min(99, (elapsed / target) * 100);
|
|
47
|
+
send(1, percent);
|
|
48
|
+
}, 900);
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
let stopped = false;
|
|
51
|
+
return () => {
|
|
52
|
+
// biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may try to stop
|
|
53
|
+
if (stopped) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
stopped = true;
|
|
57
|
+
clearInterval(timer);
|
|
58
|
+
send(0, 0); // clear the progress bar
|
|
59
|
+
};
|
|
60
|
+
}
|