@steipete/oracle 0.10.0 → 0.11.1

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 (52) hide show
  1. package/README.md +56 -11
  2. package/dist/bin/oracle-cli.js +104 -16
  3. package/dist/src/browser/actions/archiveConversation.js +236 -0
  4. package/dist/src/browser/actions/assistantResponse.js +26 -0
  5. package/dist/src/browser/actions/deepResearch.js +662 -0
  6. package/dist/src/browser/actions/modelSelection.js +86 -16
  7. package/dist/src/browser/actions/navigation.js +22 -0
  8. package/dist/src/browser/actions/projectSources.js +491 -0
  9. package/dist/src/browser/actions/promptComposer.js +52 -27
  10. package/dist/src/browser/actions/thinkingStatus.js +391 -0
  11. package/dist/src/browser/artifacts.js +150 -0
  12. package/dist/src/browser/attachRunning.js +31 -0
  13. package/dist/src/browser/chatgptImages.js +315 -0
  14. package/dist/src/browser/chromeLifecycle.js +214 -3
  15. package/dist/src/browser/config.js +27 -9
  16. package/dist/src/browser/constants.js +8 -0
  17. package/dist/src/browser/controlPlan.js +81 -0
  18. package/dist/src/browser/detect.js +206 -33
  19. package/dist/src/browser/domDebug.js +49 -0
  20. package/dist/src/browser/index.js +1234 -479
  21. package/dist/src/browser/liveTabs.js +434 -0
  22. package/dist/src/browser/profileState.js +83 -3
  23. package/dist/src/browser/projectSourcesRunner.js +366 -0
  24. package/dist/src/browser/reattach.js +117 -45
  25. package/dist/src/browser/reattachHelpers.js +1 -1
  26. package/dist/src/browser/sessionRunner.js +53 -1
  27. package/dist/src/browser/tabLeaseRegistry.js +182 -0
  28. package/dist/src/cli/bridge/claudeConfig.js +12 -8
  29. package/dist/src/cli/bridge/codexConfig.js +2 -2
  30. package/dist/src/cli/browserConfig.js +41 -8
  31. package/dist/src/cli/browserDefaults.js +31 -7
  32. package/dist/src/cli/browserTabs.js +228 -0
  33. package/dist/src/cli/dryRun.js +33 -1
  34. package/dist/src/cli/duplicatePromptGuard.js +10 -2
  35. package/dist/src/cli/help.js +1 -1
  36. package/dist/src/cli/options.js +4 -0
  37. package/dist/src/cli/projectSources.js +116 -0
  38. package/dist/src/cli/sessionCommand.js +51 -0
  39. package/dist/src/cli/sessionDisplay.js +121 -9
  40. package/dist/src/cli/sessionRunner.js +51 -7
  41. package/dist/src/mcp/consultPresets.js +19 -0
  42. package/dist/src/mcp/server.js +2 -0
  43. package/dist/src/mcp/tools/consult.js +201 -26
  44. package/dist/src/mcp/tools/projectSources.js +123 -0
  45. package/dist/src/mcp/types.js +11 -2
  46. package/dist/src/mcp/utils.js +6 -1
  47. package/dist/src/oracle/run.js +4 -1
  48. package/dist/src/projectSources/plan.js +27 -0
  49. package/dist/src/projectSources/types.js +1 -0
  50. package/dist/src/projectSources/url.js +23 -0
  51. package/dist/src/sessionManager.js +1 -0
  52. package/package.json +7 -6
@@ -0,0 +1,228 @@
1
+ import fs from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import chalk from "chalk";
4
+ import { sessionStore } from "../sessionStore.js";
5
+ import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, extractConversationIdFromUrl, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
6
+ import { resolveOutputPath } from "./writeOutputPath.js";
7
+ const LIVE_POLL_MS = 2000;
8
+ const DEFAULT_STALL_THRESHOLD_MS = 60_000;
9
+ function sessionBrowserEndpoint(meta) {
10
+ const runtime = meta?.browser?.runtime ?? {};
11
+ const remote = meta?.browser?.config?.remoteChrome ?? {};
12
+ const host = runtime.chromeHost ?? remote.host;
13
+ const port = runtime.chromePort ?? remote.port;
14
+ if (!host || !port) {
15
+ return null;
16
+ }
17
+ return { host, port };
18
+ }
19
+ function collectUniqueEndpoints(metas) {
20
+ const entries = new Map();
21
+ entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}`, {
22
+ host: DEFAULT_REMOTE_CHROME_HOST,
23
+ port: DEFAULT_REMOTE_CHROME_PORT,
24
+ });
25
+ for (const meta of metas) {
26
+ const endpoint = sessionBrowserEndpoint(meta);
27
+ if (!endpoint) {
28
+ continue;
29
+ }
30
+ entries.set(`${endpoint.host}:${endpoint.port}`, endpoint);
31
+ }
32
+ return Array.from(entries.values());
33
+ }
34
+ function buildSessionIndex(metas) {
35
+ return metas
36
+ .filter((meta) => meta?.mode === "browser")
37
+ .sort((left, right) => String(right.createdAt ?? "").localeCompare(String(left.createdAt ?? "")));
38
+ }
39
+ function resolveLinkedSession(tab, metas) {
40
+ return buildSessionIndex(metas).find((meta) => sessionMatchesTab(meta, tab)) ?? null;
41
+ }
42
+ function snippet(text, max = 120) {
43
+ const normalized = String(text ?? "")
44
+ .replace(/\s+/g, " ")
45
+ .trim();
46
+ if (normalized.length <= max) {
47
+ return normalized;
48
+ }
49
+ return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
50
+ }
51
+ function resolveSessionTabRef(meta) {
52
+ const runtime = meta?.browser?.runtime ?? {};
53
+ const harvest = meta?.browser?.harvest ?? {};
54
+ return (harvest.url ??
55
+ runtime.tabUrl ??
56
+ harvest.conversationId ??
57
+ runtime.conversationId ??
58
+ harvest.targetId ??
59
+ runtime.chromeTargetId ??
60
+ "current");
61
+ }
62
+ export function resolveSessionTabRefForTest(meta) {
63
+ return resolveSessionTabRef(meta);
64
+ }
65
+ async function persistHarvest(sessionId, meta, harvested) {
66
+ const hash = createHash("sha1")
67
+ .update(harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "")
68
+ .digest("hex");
69
+ const browser = {
70
+ ...(meta.browser ?? {}),
71
+ harvest: {
72
+ targetId: harvested.targetId,
73
+ url: harvested.url,
74
+ conversationId: harvested.conversationId ?? extractConversationIdFromUrl(harvested.url),
75
+ harvestedAt: new Date().toISOString(),
76
+ assistantHash: hash,
77
+ state: harvested.state,
78
+ stopExists: harvested.stopExists,
79
+ sendExists: harvested.sendExists,
80
+ assistantCount: harvested.assistantCount,
81
+ currentModelLabel: harvested.currentModelLabel,
82
+ lastAssistantSnippet: harvested.lastAssistantSnippet,
83
+ },
84
+ };
85
+ await sessionStore.updateSession(sessionId, { browser });
86
+ }
87
+ function printHarvestSummary(sessionId, harvested) {
88
+ console.log(chalk.bold(`Session: ${sessionId}`));
89
+ console.log(`Target: ${harvested.targetId}`);
90
+ console.log(`State: ${formatBrowserTabState(harvested)}`);
91
+ console.log(`Model: ${harvested.currentModelLabel || "(unknown)"}`);
92
+ console.log(`URL: ${harvested.url}`);
93
+ console.log(`Assistant turns: ${harvested.assistantCount}`);
94
+ console.log(`Signals: stop=${harvested.stopExists ? "yes" : "no"} send=${harvested.sendExists ? "yes" : "no"}`);
95
+ if (harvested.lastUserSnippet) {
96
+ console.log(`Last user: ${harvested.lastUserSnippet}`);
97
+ }
98
+ console.log(chalk.dim("---"));
99
+ }
100
+ async function maybeWriteHarvestOutput(pathInput, cwd, content) {
101
+ const resolved = resolveOutputPath(pathInput, cwd);
102
+ if (!resolved) {
103
+ return;
104
+ }
105
+ const payload = content ?? "";
106
+ if (resolved === "-" || resolved === "/dev/stdout") {
107
+ process.stdout.write(`${payload}${payload.endsWith("\n") ? "" : "\n"}`);
108
+ return;
109
+ }
110
+ await fs.writeFile(resolved, payload, "utf8");
111
+ console.log(chalk.dim(`Wrote harvested assistant output to ${resolved}`));
112
+ }
113
+ export async function showBrowserTabsStatus() {
114
+ const metas = await sessionStore.listSessions().catch(() => []);
115
+ const endpoints = collectUniqueEndpoints(metas);
116
+ let printedAny = false;
117
+ for (const endpoint of endpoints) {
118
+ let tabs;
119
+ try {
120
+ tabs = await collectChatGptTabs(endpoint);
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (tabs.length === 0) {
126
+ continue;
127
+ }
128
+ printedAny = true;
129
+ console.log(chalk.bold(`Browser Tabs ${endpoint.host}:${endpoint.port}`));
130
+ for (const tab of tabs) {
131
+ const linkedSession = resolveLinkedSession({ ...tab, host: endpoint.host, port: endpoint.port }, metas);
132
+ console.log(`- ${tab.targetId} ${formatBrowserTabState(tab)} model=${tab.currentModelLabel || "(unknown)"} turns=${tab.assistantCount} stop=${tab.stopExists ? "yes" : "no"} send=${tab.sendExists ? "yes" : "no"}`);
133
+ console.log(` title=${tab.title || "(untitled)"}`);
134
+ console.log(` url=${tab.url}`);
135
+ if (linkedSession) {
136
+ console.log(` session=${linkedSession.id}`);
137
+ }
138
+ if (tab.lastAssistantSnippet) {
139
+ console.log(` last=${snippet(tab.lastAssistantSnippet)}`);
140
+ }
141
+ }
142
+ }
143
+ if (!printedAny) {
144
+ console.log("No live ChatGPT tabs found on known Chrome DevTools endpoints.");
145
+ }
146
+ }
147
+ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
148
+ const meta = await sessionStore.readSession(sessionId);
149
+ if (!meta) {
150
+ throw new Error(`No session found with ID ${sessionId}.`);
151
+ }
152
+ const endpoint = sessionBrowserEndpoint(meta) ?? {
153
+ host: DEFAULT_REMOTE_CHROME_HOST,
154
+ port: DEFAULT_REMOTE_CHROME_PORT,
155
+ };
156
+ const harvested = await harvestChatGptTab({
157
+ host: endpoint.host,
158
+ port: endpoint.port,
159
+ ref: options.browserTabRef ?? resolveSessionTabRef(meta),
160
+ stallWindowMs: options.stallWindowMs,
161
+ });
162
+ await persistHarvest(sessionId, meta, harvested);
163
+ printHarvestSummary(sessionId, harvested);
164
+ const output = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
165
+ if (options.writeOutputPath) {
166
+ await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
167
+ }
168
+ if (!options.quietOutput && output) {
169
+ process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
170
+ }
171
+ return harvested;
172
+ }
173
+ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
174
+ const meta = await sessionStore.readSession(sessionId);
175
+ if (!meta) {
176
+ throw new Error(`No session found with ID ${sessionId}.`);
177
+ }
178
+ const endpoint = sessionBrowserEndpoint(meta) ?? {
179
+ host: DEFAULT_REMOTE_CHROME_HOST,
180
+ port: DEFAULT_REMOTE_CHROME_PORT,
181
+ };
182
+ const browserTabRef = options.browserTabRef ?? resolveSessionTabRef(meta);
183
+ const stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
184
+ let lastHash = null;
185
+ let unchangedSince = Date.now();
186
+ while (true) {
187
+ const harvested = await harvestChatGptTab({
188
+ host: endpoint.host,
189
+ port: endpoint.port,
190
+ ref: browserTabRef,
191
+ });
192
+ const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
193
+ const hash = createHash("sha1").update(fullText).digest("hex");
194
+ if (hash !== lastHash) {
195
+ lastHash = hash;
196
+ unchangedSince = Date.now();
197
+ const statusLine = `[${new Date().toISOString()}] state=${harvested.state} stop=${harvested.stopExists ? "yes" : "no"} ` +
198
+ `send=${harvested.sendExists ? "yes" : "no"} model=${harvested.currentModelLabel || "(unknown)"} ` +
199
+ `snippet=${snippet(harvested.lastAssistantSnippet || fullText, 160)}`;
200
+ console.log(statusLine);
201
+ await persistHarvest(sessionId, meta, harvested);
202
+ }
203
+ const derivedState = harvested.stopExists
204
+ ? Date.now() - unchangedSince >= stallThresholdMs
205
+ ? "stalled"
206
+ : "running"
207
+ : harvested.authenticated
208
+ ? "completed"
209
+ : "detached";
210
+ if (derivedState === "completed" || derivedState === "stalled" || derivedState === "detached") {
211
+ const finalHarvest = {
212
+ ...harvested,
213
+ state: derivedState,
214
+ };
215
+ await persistHarvest(sessionId, meta, finalHarvest);
216
+ printHarvestSummary(sessionId, finalHarvest);
217
+ const output = finalHarvest.lastAssistantMarkdown ?? finalHarvest.lastAssistantText ?? "";
218
+ if (options.writeOutputPath) {
219
+ await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
220
+ }
221
+ if (output) {
222
+ process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
223
+ }
224
+ return finalHarvest;
225
+ }
226
+ await new Promise((resolve) => setTimeout(resolve, LIVE_POLL_MS));
227
+ }
228
+ }
@@ -4,6 +4,7 @@ import { isKnownModel } from "../oracle/modelResolver.js";
4
4
  import { assembleBrowserPrompt } from "../browser/prompt.js";
5
5
  import { buildTokenEstimateSuffix, formatAttachmentLabel } from "../browser/promptSummary.js";
6
6
  import { buildCookiePlan } from "../browser/policies.js";
7
+ import { describeBrowserControlPlan, formatBrowserControlPlan } from "../browser/controlPlan.js";
7
8
  export async function runDryRunSummary({ engine, runOptions, cwd, version, log, browserConfig, }, deps = {}) {
8
9
  if (engine === "browser") {
9
10
  await runBrowserDryRun({ runOptions, cwd, version, log, browserConfig }, deps);
@@ -40,20 +41,34 @@ async function runApiDryRun({ runOptions, cwd, version, log, }, deps) {
40
41
  printFileTokenStats(stats, { inputTokenBudget: inputBudget, log });
41
42
  }
42
43
  async function runBrowserDryRun({ runOptions, cwd, version, log, browserConfig, }, deps) {
44
+ validateBrowserFollowUps(runOptions, browserConfig);
43
45
  const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt;
44
46
  const artifacts = await assemblePromptImpl(runOptions, { cwd });
45
47
  const suffix = buildTokenEstimateSuffix(artifacts);
46
48
  const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
47
49
  log(chalk.cyan(headerLine));
50
+ logBrowserControlPlan(browserConfig, log, "dry-run");
51
+ logBrowserFollowUpSummary(runOptions.browserFollowUps, log, "dry-run");
48
52
  logBrowserCookieStrategy(browserConfig, log, "dry-run");
53
+ logBrowserArchivePolicy(browserConfig, log, "dry-run");
49
54
  logBrowserFileSummary(artifacts, log, "dry-run");
50
55
  }
56
+ function logBrowserControlPlan(browserConfig, log, label) {
57
+ const plan = describeBrowserControlPlan(browserConfig);
58
+ for (const line of formatBrowserControlPlan(plan, label)) {
59
+ log(chalk.dim(line));
60
+ }
61
+ }
51
62
  function logBrowserCookieStrategy(browserConfig, log, label) {
52
63
  if (!browserConfig)
53
64
  return;
54
65
  const plan = buildCookiePlan(browserConfig);
55
66
  log(chalk.bold(`[${label}] ${plan.description}`));
56
67
  }
68
+ function logBrowserArchivePolicy(browserConfig, log, label) {
69
+ const mode = browserConfig?.archiveConversations ?? "auto";
70
+ log(chalk.dim(`[${label}] ChatGPT archive policy: ${mode}.`));
71
+ }
57
72
  function logBrowserFileSummary(artifacts, log, label) {
58
73
  if (artifacts.attachments.length > 0) {
59
74
  const prefix = artifacts.bundled
@@ -75,12 +90,15 @@ function logBrowserFileSummary(artifacts, log, label) {
75
90
  }
76
91
  log(chalk.dim(`[${label}] No files attached.`));
77
92
  }
78
- export async function runBrowserPreview({ runOptions, cwd, version, previewMode, log, }, deps = {}) {
93
+ export async function runBrowserPreview({ runOptions, cwd, version, previewMode, log, browserConfig, }, deps = {}) {
94
+ validateBrowserFollowUps(runOptions, browserConfig);
79
95
  const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt;
80
96
  const artifacts = await assemblePromptImpl(runOptions, { cwd });
81
97
  const suffix = buildTokenEstimateSuffix(artifacts);
82
98
  const headerLine = `[preview] Oracle (${version}) browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`;
83
99
  log(chalk.cyan(headerLine));
100
+ logBrowserControlPlan(browserConfig, log, "preview");
101
+ logBrowserFollowUpSummary(runOptions.browserFollowUps, log, "preview");
84
102
  logBrowserFileSummary(artifacts, log, "preview");
85
103
  if (previewMode === "json" || previewMode === "full") {
86
104
  const attachmentSummary = artifacts.attachments.map((attachment) => ({
@@ -96,6 +114,7 @@ export async function runBrowserPreview({ runOptions, cwd, version, previewMode,
96
114
  inlineFileCount: artifacts.inlineFileCount,
97
115
  bundled: artifacts.bundled,
98
116
  tokenEstimate: artifacts.estimatedInputTokens,
117
+ browserFollowUps: runOptions.browserFollowUps ?? [],
99
118
  };
100
119
  log("");
101
120
  log(chalk.bold("Preview JSON"));
@@ -107,3 +126,16 @@ export async function runBrowserPreview({ runOptions, cwd, version, previewMode,
107
126
  log(artifacts.composerText || chalk.dim("(empty prompt)"));
108
127
  }
109
128
  }
129
+ function logBrowserFollowUpSummary(followUps, log, label) {
130
+ const count = followUps?.filter((entry) => entry.trim().length > 0).length ?? 0;
131
+ if (count > 0) {
132
+ log(chalk.bold(`[${label}] Browser follow-ups: ${count} additional prompt(s).`));
133
+ log(chalk.dim(`[${label}] Multi-turn is explicit only: Oracle will send these prompts in order, but it never invents follow-ups automatically.`));
134
+ }
135
+ }
136
+ function validateBrowserFollowUps(runOptions, browserConfig) {
137
+ const followUpCount = runOptions.browserFollowUps?.filter((entry) => entry.trim().length > 0).length ?? 0;
138
+ if (followUpCount > 0 && browserConfig?.researchMode === "deep") {
139
+ throw new Error("Browser follow-ups are not supported with Deep Research mode. Put the full research plan into the initial prompt or run a normal browser consult for multi-turn review.");
140
+ }
141
+ }
@@ -1,12 +1,20 @@
1
1
  import chalk from "chalk";
2
- export async function shouldBlockDuplicatePrompt({ prompt, force, sessionStore, log = console.log, }) {
2
+ function normalizeRunSignature(prompt, browserFollowUps) {
3
+ const followUps = (browserFollowUps ?? [])
4
+ .map((entry) => entry.trim())
5
+ .filter(Boolean)
6
+ .join("\n\n--- browser follow-up ---\n\n");
7
+ return [prompt.trim(), followUps].filter(Boolean).join("\n\n--- browser follow-ups ---\n\n");
8
+ }
9
+ export async function shouldBlockDuplicatePrompt({ prompt, browserFollowUps, force, sessionStore, log = console.log, }) {
3
10
  if (force)
4
11
  return false;
5
12
  const normalized = prompt?.trim();
6
13
  if (!normalized)
7
14
  return false;
15
+ const signature = normalizeRunSignature(normalized, browserFollowUps);
8
16
  const running = (await sessionStore.listSessions()).filter((entry) => entry.status === "running");
9
- const duplicate = running.find((entry) => (entry.options?.prompt?.trim?.() ?? "") === normalized);
17
+ const duplicate = running.find((entry) => normalizeRunSignature(entry.options?.prompt?.trim?.() ?? "", entry.options?.browserFollowUps) === signature);
10
18
  if (!duplicate)
11
19
  return false;
12
20
  log(chalk.yellow(`A session with the same prompt is already running (${duplicate.id}). Reattach with "oracle session ${duplicate.id}" or rerun with --force to start another run.`));
@@ -49,7 +49,7 @@ function renderHelpFooter(program, colors) {
49
49
  `${colors.bullet("•")} Spell out the project + platform + version requirements (repo name, target OS/toolchain versions, API dependencies) so Oracle doesn’t guess defaults.`,
50
50
  `${colors.bullet("•")} When comparing multiple repos/files, spell out each repo + path + role (e.g., “Project A SettingsView → apps/project-a/Sources/SettingsView.swift; Project B SettingsView → ../project-b/mac/...”) so the model knows exactly which file is which.`,
51
51
  `${colors.bullet("•")} Best results: 6–30 sentences plus key source files; very short prompts often yield generic answers.`,
52
- `${colors.bullet("•")} Oracle is one-shot by default. For OpenAI/Azure API runs, you can chain follow-ups by passing ${colors.accent("--followup <sessionId|responseId>")} (continues via Responses API previous_response_id).`,
52
+ `${colors.bullet("•")} Oracle is one-shot by default. API runs can continue with ${colors.accent("--followup <sessionId|responseId>")}; browser runs can ask sequential ChatGPT turns with repeated ${colors.accent("--browser-follow-up")}.`,
53
53
  `${colors.bullet("•")} Run ${colors.accent("--files-report")} to inspect token spend before hitting the API.`,
54
54
  `${colors.bullet("•")} Non-preview runs spawn detached sessions (especially gpt-5.5-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent("oracle session <slug>")} to resume/inspect the existing run.`,
55
55
  `${colors.bullet("•")} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
@@ -58,6 +58,10 @@ export function collectModelList(value, previous = []) {
58
58
  .filter((entry) => entry.length > 0);
59
59
  return previous.concat(entries);
60
60
  }
61
+ export function collectTextValues(value, previous = []) {
62
+ const trimmed = value.trim();
63
+ return trimmed ? previous.concat(trimmed) : previous;
64
+ }
61
65
  export function parseFloatOption(value) {
62
66
  const parsed = Number.parseFloat(value);
63
67
  if (Number.isNaN(parsed)) {
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { buildBrowserConfig } from "./browserConfig.js";
5
+ import { readFiles } from "../oracle/files.js";
6
+ import { loadUserConfig } from "../config.js";
7
+ import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
8
+ import { runBrowserProjectSources } from "../browser/projectSourcesRunner.js";
9
+ import { normalizeProjectSourcesUrl } from "../projectSources/url.js";
10
+ export async function runProjectSourcesCliCommand(operation, options) {
11
+ const { config: userConfig } = await loadUserConfig();
12
+ const configuredUrl = userConfig.browser?.chatgptUrl ?? userConfig.browser?.url;
13
+ const projectUrl = normalizeProjectSourcesUrl(options.chatgptUrl ?? configuredUrl ?? "");
14
+ const maxFileSizeBytes = options.maxFileSizeBytes ?? resolveConfiguredMaxFileSizeBytes(userConfig, process.env);
15
+ const files = operation === "add"
16
+ ? await resolveProjectSourceFiles(options.file ?? [], {
17
+ cwd: process.cwd(),
18
+ maxFileSizeBytes,
19
+ })
20
+ : [];
21
+ if (operation === "add" && files.length === 0) {
22
+ throw new Error("project-sources add requires at least one --file.");
23
+ }
24
+ const browserConfig = await buildProjectSourcesBrowserConfig({
25
+ options,
26
+ projectUrl,
27
+ configuredBrowser: userConfig.browser ?? {},
28
+ });
29
+ const result = await runBrowserProjectSources({
30
+ operation,
31
+ chatgptUrl: projectUrl,
32
+ files,
33
+ dryRun: options.dryRun,
34
+ config: browserConfig,
35
+ log: (message) => {
36
+ if (options.verbose || !message.startsWith("[debug]")) {
37
+ console.log(chalk.dim(message));
38
+ }
39
+ },
40
+ });
41
+ printProjectSourcesResult(result, Boolean(options.json));
42
+ }
43
+ export async function resolveProjectSourceFiles(fileInputs, options) {
44
+ const files = await readFiles(fileInputs, {
45
+ cwd: options.cwd,
46
+ maxFileSizeBytes: options.maxFileSizeBytes,
47
+ readContents: false,
48
+ });
49
+ const attachments = [];
50
+ for (const file of files) {
51
+ const stats = await fs.stat(file.path);
52
+ attachments.push({
53
+ path: file.path,
54
+ displayPath: path.relative(options.cwd, file.path) || path.basename(file.path),
55
+ sizeBytes: stats.size,
56
+ });
57
+ }
58
+ return attachments;
59
+ }
60
+ export async function buildProjectSourcesBrowserConfig({ options, projectUrl, configuredBrowser, }) {
61
+ const flagConfig = removeUndefined(await buildBrowserConfig({
62
+ ...options,
63
+ model: "gpt-5.5-pro",
64
+ chatgptUrl: projectUrl,
65
+ }));
66
+ const envProfileDir = process.env.ORACLE_BROWSER_PROFILE_DIR?.trim();
67
+ const manualLogin = flagConfig.manualLogin ?? configuredBrowser.manualLogin ?? (envProfileDir ? true : undefined);
68
+ const manualLoginProfileDir = manualLogin === true
69
+ ? (flagConfig.manualLoginProfileDir ??
70
+ configuredBrowser.manualLoginProfileDir ??
71
+ envProfileDir ??
72
+ null)
73
+ : null;
74
+ return {
75
+ ...configuredBrowser,
76
+ ...flagConfig,
77
+ url: projectUrl,
78
+ chatgptUrl: projectUrl,
79
+ cookieSync: manualLogin ? false : (flagConfig.cookieSync ?? configuredBrowser.cookieSync),
80
+ manualLogin,
81
+ manualLoginProfileDir,
82
+ desiredModel: null,
83
+ modelStrategy: "ignore",
84
+ researchMode: "off",
85
+ };
86
+ }
87
+ function removeUndefined(value) {
88
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
89
+ }
90
+ function printProjectSourcesResult(result, json) {
91
+ if (json) {
92
+ console.log(JSON.stringify(result, null, 2));
93
+ return;
94
+ }
95
+ if (result.status === "dry-run") {
96
+ console.log(chalk.bold(`Project Sources ${result.operation} dry run`));
97
+ console.log(`Project: ${result.projectUrl}`);
98
+ const plan = result.plannedUploads ?? [];
99
+ if (plan.length > 0) {
100
+ console.log(`Planned uploads: ${plan.length}`);
101
+ for (const upload of plan) {
102
+ console.log(` batch ${upload.batch}: ${upload.displayPath}`);
103
+ }
104
+ }
105
+ return;
106
+ }
107
+ console.log(chalk.bold(`Project Sources ${result.operation} completed`));
108
+ console.log(`Project: ${result.projectUrl}`);
109
+ const before = result.sourcesBefore?.length ?? 0;
110
+ const after = result.sourcesAfter?.length ?? 0;
111
+ console.log(`Before: ${before}`);
112
+ console.log(`After: ${after}`);
113
+ if (result.added && result.added.length > 0) {
114
+ console.log(`Added: ${result.added.map((source) => source.name).join(", ")}`);
115
+ }
116
+ }
@@ -1,10 +1,13 @@
1
1
  import chalk from "chalk";
2
2
  import { usesDefaultStatusFilters } from "./options.js";
3
3
  import { attachSession, showStatus, } from "./sessionDisplay.js";
4
+ import { harvestSessionBrowserOutput, liveTailSessionBrowserOutput, } from "./browserTabs.js";
4
5
  import { sessionStore } from "../sessionStore.js";
5
6
  const defaultDependencies = {
6
7
  showStatus,
7
8
  attachSession,
9
+ harvestSessionBrowserOutput,
10
+ liveTailSessionBrowserOutput,
8
11
  usesDefaultStatusFilters,
9
12
  deleteSessionsOlderThan: (options) => sessionStore.deleteOlderThan(options),
10
13
  getSessionPaths: (sessionId) => sessionStore.getPaths(sessionId),
@@ -19,9 +22,26 @@ const SESSION_OPTION_KEYS = new Set([
19
22
  "renderMarkdown",
20
23
  "path",
21
24
  "model",
25
+ "harvest",
26
+ "live",
27
+ "writeOutput",
28
+ "browserTab",
22
29
  ]);
23
30
  export async function handleSessionCommand(sessionId, command, deps = defaultDependencies) {
24
31
  const sessionOptions = command.opts();
32
+ const allOptions = command.optsWithGlobals?.() ?? sessionOptions;
33
+ const writeOutputPath = sessionOptions.writeOutput ??
34
+ sessionOptions.writeOutputPath ??
35
+ allOptions.writeOutput ??
36
+ allOptions.writeOutputPath ??
37
+ command.getOptionValue?.("writeOutput") ??
38
+ command.getOptionValue?.("writeOutputPath");
39
+ const browserTabRef = sessionOptions.browserTab ??
40
+ sessionOptions.browserTabRef ??
41
+ allOptions.browserTab ??
42
+ allOptions.browserTabRef ??
43
+ command.getOptionValue?.("browserTab") ??
44
+ command.getOptionValue?.("browserTabRef");
25
45
  if (sessionOptions.verboseRender) {
26
46
  process.env.ORACLE_VERBOSE_RENDER = "1";
27
47
  }
@@ -71,6 +91,37 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
71
91
  }
72
92
  return;
73
93
  }
94
+ const harvestRequested = Boolean(sessionOptions.harvest);
95
+ const liveRequested = Boolean(sessionOptions.live);
96
+ if (harvestRequested && liveRequested) {
97
+ console.error("Cannot combine --harvest and --live. Choose one.");
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ if (writeOutputPath && !harvestRequested && !liveRequested) {
102
+ console.error("The --write-output flag requires --harvest or --live.");
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ if (harvestRequested || liveRequested) {
107
+ if (!sessionId) {
108
+ console.error(`The ${harvestRequested ? "--harvest" : "--live"} flag requires a session ID.`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ if (harvestRequested) {
113
+ await deps.harvestSessionBrowserOutput(sessionId, {
114
+ writeOutputPath,
115
+ browserTabRef,
116
+ });
117
+ return;
118
+ }
119
+ await deps.liveTailSessionBrowserOutput(sessionId, {
120
+ writeOutputPath,
121
+ browserTabRef,
122
+ });
123
+ return;
124
+ }
74
125
  if (!sessionId) {
75
126
  const showExamples = deps.usesDefaultStatusFilters(command);
76
127
  await deps.showStatus({