@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,236 @@
1
+ export function isProjectChatgptUrl(url) {
2
+ return /\/project(?:[/?#]|$)/i.test(url ?? "");
3
+ }
4
+ export function isTemporaryChatgptUrl(url) {
5
+ try {
6
+ const parsed = new URL(url ?? "");
7
+ return (parsed.searchParams.get("temporary-chat") ?? "").trim().toLowerCase() === "true";
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export function resolveBrowserArchiveDecision({ mode = "auto", chatgptUrl, conversationUrl, researchMode, followUpCount, }) {
14
+ if (mode === "never") {
15
+ return { mode, shouldArchive: false, reason: "disabled" };
16
+ }
17
+ if (!conversationUrl) {
18
+ return { mode, shouldArchive: false, reason: "missing-conversation-url" };
19
+ }
20
+ if (isTemporaryChatgptUrl(chatgptUrl) || isTemporaryChatgptUrl(conversationUrl)) {
21
+ return { mode, shouldArchive: false, reason: "temporary-chat" };
22
+ }
23
+ if (mode === "always") {
24
+ return { mode, shouldArchive: true, reason: "forced" };
25
+ }
26
+ if (isProjectChatgptUrl(chatgptUrl) || isProjectChatgptUrl(conversationUrl)) {
27
+ return { mode, shouldArchive: false, reason: "project-conversation" };
28
+ }
29
+ if (researchMode === "deep") {
30
+ return { mode, shouldArchive: false, reason: "deep-research" };
31
+ }
32
+ if ((followUpCount ?? 0) > 0) {
33
+ return { mode, shouldArchive: false, reason: "multi-turn" };
34
+ }
35
+ return { mode, shouldArchive: true, reason: "successful-one-shot" };
36
+ }
37
+ export async function archiveChatGptConversation(Runtime, logger, { mode, conversationUrl, }) {
38
+ const evaluated = await Runtime.evaluate({
39
+ expression: buildArchiveConversationExpression(),
40
+ awaitPromise: true,
41
+ returnByValue: true,
42
+ });
43
+ const value = evaluated.result?.value;
44
+ const resolvedUrl = value?.conversationUrl ?? conversationUrl ?? undefined;
45
+ if (value?.status === "archived") {
46
+ logger("[browser] Archived ChatGPT conversation after saving local artifacts.");
47
+ return { mode, attempted: true, archived: true, conversationUrl: resolvedUrl };
48
+ }
49
+ const reason = value?.status === "skipped" ? value.reason : "archive-failed";
50
+ const error = value?.status === "failed" ? value.error : undefined;
51
+ logger(`[browser] ChatGPT archive skipped (${error ?? reason}).`);
52
+ return {
53
+ mode,
54
+ attempted: true,
55
+ archived: false,
56
+ reason,
57
+ conversationUrl: resolvedUrl,
58
+ error,
59
+ };
60
+ }
61
+ export function buildArchiveConversationExpressionForTest() {
62
+ return buildArchiveConversationExpression();
63
+ }
64
+ function buildArchiveConversationExpression() {
65
+ return `(() => {
66
+ const conversationUrl = typeof location === 'object' ? location.href : null;
67
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
68
+ const normalize = (value) =>
69
+ String(value ?? '')
70
+ .replace(/\\s+/g, ' ')
71
+ .trim()
72
+ .toLowerCase();
73
+ const isVisible = (element) => {
74
+ if (!element || !(element instanceof HTMLElement)) return false;
75
+ const rect = element.getBoundingClientRect();
76
+ const style = getComputedStyle(element);
77
+ return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
78
+ };
79
+ const labelFor = (element) =>
80
+ normalize([
81
+ element.getAttribute?.('aria-label'),
82
+ element.getAttribute?.('title'),
83
+ element.textContent,
84
+ ].filter(Boolean).join(' '));
85
+ const click = (element) => {
86
+ const rect = element.getBoundingClientRect();
87
+ const eventInit = {
88
+ bubbles: true,
89
+ cancelable: true,
90
+ view: window,
91
+ clientX: rect.left + rect.width / 2,
92
+ clientY: rect.top + rect.height / 2,
93
+ button: 0,
94
+ };
95
+ if (typeof PointerEvent === 'function') {
96
+ element.dispatchEvent(new PointerEvent('pointerdown', {
97
+ ...eventInit,
98
+ buttons: 1,
99
+ pointerId: 1,
100
+ pointerType: 'mouse',
101
+ isPrimary: true,
102
+ }));
103
+ }
104
+ element.dispatchEvent(new MouseEvent('mousedown', { ...eventInit, buttons: 1 }));
105
+ if (typeof PointerEvent === 'function') {
106
+ element.dispatchEvent(new PointerEvent('pointerup', {
107
+ ...eventInit,
108
+ buttons: 0,
109
+ pointerId: 1,
110
+ pointerType: 'mouse',
111
+ isPrimary: true,
112
+ }));
113
+ }
114
+ element.dispatchEvent(new MouseEvent('mouseup', { ...eventInit, buttons: 0 }));
115
+ element.dispatchEvent(new MouseEvent('click', { ...eventInit, buttons: 0 }));
116
+ };
117
+ const findConversationMenuButton = () => {
118
+ const buttons = Array.from(document.querySelectorAll('button,[role="button"]'))
119
+ .filter((element) => element instanceof HTMLElement && isVisible(element));
120
+ const labelled = buttons
121
+ .map((element) => ({ element, label: labelFor(element), rect: element.getBoundingClientRect() }))
122
+ .filter(({ label }) =>
123
+ label.includes('more') ||
124
+ label.includes('conversation options') ||
125
+ label.includes('open menu') ||
126
+ label.includes('więcej') ||
127
+ label.includes('opcje')
128
+ );
129
+ const headerCandidates = labelled
130
+ .filter(({ rect }) => rect.top < 180 && rect.right > window.innerWidth - 420)
131
+ .sort((a, b) => b.rect.right - a.rect.right);
132
+ return (headerCandidates[0] ?? labelled[0])?.element ?? null;
133
+ };
134
+ const visibleMenuCandidates = () => {
135
+ const menuRoots = Array.from(document.querySelectorAll('[role="menu"]'))
136
+ .filter((element) => element instanceof HTMLElement && isVisible(element));
137
+ const roots = menuRoots.length > 0 ? menuRoots : [document];
138
+ return roots.flatMap((root) =>
139
+ Array.from(root.querySelectorAll('[role="menuitem"],[role="option"],button,div[tabindex],a')),
140
+ ).filter((element) => element instanceof HTMLElement && isVisible(element));
141
+ };
142
+ const findArchiveMenuItem = () => {
143
+ const candidates = visibleMenuCandidates();
144
+ return candidates.find((element) => {
145
+ const label = labelFor(element);
146
+ if (!label) return false;
147
+ if (label.includes('unarchive') || label.includes('restore')) return false;
148
+ return label.includes('archive') || label.includes('archiwizuj');
149
+ }) ?? null;
150
+ };
151
+ const findArchiveConfirmationButton = () => {
152
+ const candidates = Array.from(document.querySelectorAll('[role="dialog"] button,[role="dialog"] [role="button"]'))
153
+ .filter((element) => element instanceof HTMLElement && isVisible(element));
154
+ return candidates.find((element) => {
155
+ const label = labelFor(element);
156
+ if (!label) return false;
157
+ if (label.includes('unarchive') || label.includes('restore')) return false;
158
+ return label === 'archive' || label === 'archiwizuj' || label.includes('archive conversation');
159
+ }) ?? null;
160
+ };
161
+ const hasUnarchiveMenuItem = () => {
162
+ const candidates = visibleMenuCandidates();
163
+ return candidates.some((element) => {
164
+ const label = labelFor(element);
165
+ return (
166
+ label.includes('unarchive') ||
167
+ label.includes('restore') ||
168
+ label.includes('przywróć') ||
169
+ label.includes('przywroc')
170
+ );
171
+ });
172
+ };
173
+ const hasArchiveConfirmation = () => {
174
+ const visibleText = Array.from(document.querySelectorAll('[role="status"],[role="alert"],[data-testid*="toast"],[class*="toast"],[class*="snackbar"]'))
175
+ .filter((element) => element instanceof HTMLElement && isVisible(element))
176
+ .map((element) => labelFor(element))
177
+ .join(' ');
178
+ return (
179
+ visibleText.includes('archived') ||
180
+ visibleText.includes('conversation archived') ||
181
+ visibleText.includes('chat archived') ||
182
+ visibleText.includes('zarchiwizowano') ||
183
+ visibleText.includes('archiwum')
184
+ );
185
+ };
186
+ const waitForArchiveConfirmation = async () => {
187
+ const deadline = Date.now() + 3000;
188
+ while (Date.now() < deadline) {
189
+ if (conversationUrl && location.href !== conversationUrl) return true;
190
+ if (hasArchiveConfirmation()) return true;
191
+ await sleep(150);
192
+ }
193
+ return false;
194
+ };
195
+ const verifyArchivedStateFromMenu = async () => {
196
+ const menuButton = findConversationMenuButton();
197
+ if (!menuButton) return false;
198
+ click(menuButton);
199
+ await sleep(300);
200
+ const archived = hasUnarchiveMenuItem();
201
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
202
+ return archived;
203
+ };
204
+ return (async () => {
205
+ const menuButton = findConversationMenuButton();
206
+ if (!menuButton) {
207
+ return { status: 'skipped', reason: 'conversation-menu-not-found', conversationUrl };
208
+ }
209
+ click(menuButton);
210
+ await sleep(350);
211
+ const archiveItem = findArchiveMenuItem();
212
+ if (!archiveItem) {
213
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
214
+ return { status: 'skipped', reason: 'archive-menu-item-not-found', conversationUrl };
215
+ }
216
+ click(archiveItem);
217
+ await sleep(350);
218
+ const confirmButton = findArchiveConfirmationButton();
219
+ if (confirmButton) {
220
+ click(confirmButton);
221
+ await sleep(500);
222
+ }
223
+ if (await waitForArchiveConfirmation()) {
224
+ return { status: 'archived', conversationUrl };
225
+ }
226
+ if (await verifyArchivedStateFromMenu()) {
227
+ return { status: 'archived', conversationUrl };
228
+ }
229
+ return { status: 'skipped', reason: 'archive-not-confirmed', conversationUrl };
230
+ })().catch((error) => ({
231
+ status: 'failed',
232
+ error: error instanceof Error ? error.message : String(error),
233
+ conversationUrl,
234
+ }));
235
+ })()`;
236
+ }
@@ -109,6 +109,10 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
109
109
  }
110
110
  const refreshed = await refreshAssistantSnapshot(Runtime, parsed, logger, minTurnIndex, expectedConversationId);
111
111
  const candidate = refreshed ?? parsed;
112
+ if (isGeneratedImageAssistantAnswer(candidate)) {
113
+ logger("Captured assistant generated image response");
114
+ return candidate;
115
+ }
112
116
  // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
113
117
  const elapsedMs = Date.now() - start;
114
118
  const remainingMs = Math.max(0, timeoutMs - elapsedMs);
@@ -308,6 +312,9 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
308
312
  isStopButtonVisible(Runtime),
309
313
  isCompletionVisible(Runtime),
310
314
  ]);
315
+ if (isGeneratedImageAssistantAnswer(normalized)) {
316
+ return normalized;
317
+ }
311
318
  const shortAnswer = currentLength > 0 && currentLength < 16;
312
319
  const mediumAnswer = currentLength >= 16 && currentLength < 40;
313
320
  const longAnswer = currentLength >= 40 && currentLength < 500;
@@ -413,6 +420,9 @@ function normalizeAssistantSnapshot(snapshot) {
413
420
  meta: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
414
421
  };
415
422
  }
423
+ function isGeneratedImageAssistantAnswer(answer) {
424
+ return Boolean(answer?.html?.includes("/backend-api/estuary/content?id=file_"));
425
+ }
416
426
  async function waitForCondition(getter, timeoutMs, pollIntervalMs = 400) {
417
427
  const deadline = Date.now() + timeoutMs;
418
428
  while (Date.now() < deadline) {
@@ -627,6 +637,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
627
637
  };
628
638
 
629
639
  const waitForSettle = async (snapshot) => {
640
+ if (String(snapshot?.html ?? '').includes('/backend-api/estuary/content?id=file_')) {
641
+ return snapshot;
642
+ }
630
643
  // Learned: short answers can be 1-2 tokens; enforce longer settle windows to avoid truncation.
631
644
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
632
645
  // use progressively longer windows to avoid truncation (#71).
@@ -754,6 +767,19 @@ function buildAssistantExtractor(functionName) {
754
767
  const html = contentRoot?.innerHTML ?? '';
755
768
  const messageId = messageRoot.getAttribute('data-message-id');
756
769
  const turnId = messageRoot.getAttribute('data-testid');
770
+ const generatedImages = Array.from(messageRoot.querySelectorAll('img')).filter((img) =>
771
+ String(img?.src || '').includes('/backend-api/estuary/content?id=file_')
772
+ );
773
+ const normalizedText = String(text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
774
+ const imageOnlyChrome =
775
+ !normalizedText ||
776
+ normalizedText === 'edit' ||
777
+ normalizedText === 'stopped thinking' ||
778
+ normalizedText === 'stopped thinking edit';
779
+ if (generatedImages.length > 0 && imageOnlyChrome) {
780
+ const label = generatedImages.length === 1 ? 'Generated image.' : \`Generated \${generatedImages.length} images.\`;
781
+ return { text: label, html: messageRoot?.innerHTML ?? html, messageId, turnId, turnIndex: index };
782
+ }
757
783
  if (text.trim()) {
758
784
  return { text, html, messageId, turnId, turnIndex: index };
759
785
  }