@steipete/oracle 0.5.3 → 0.5.6

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 CHANGED
@@ -17,6 +17,8 @@ Oracle bundles your prompt and files so another AI can answer with real context.
17
17
 
18
18
  Install globally: `npm install -g @steipete/oracle`
19
19
 
20
+ Use `npx -y @steipete/oracle …` (not `pnpx`)—pnpx's sandboxed cache can’t load the sqlite bindings and will throw missing `node_sqlite3.node` errors.
21
+
20
22
  ```bash
21
23
  # Copy the bundle and paste into ChatGPT
22
24
  npx @steipete/oracle --render --copy -p "Review the TS data layer for schema drift" --file "src/**/*.ts,*/*.test.ts"
@@ -291,8 +291,9 @@ function buildResponseObserverExpression(timeoutMs) {
291
291
  if (!stop) {
292
292
  return;
293
293
  }
294
- const ariaLabel = stop.getAttribute('aria-label') || '';
295
- if (ariaLabel.toLowerCase().includes('stop')) {
294
+ const isStopButton =
295
+ stop.getAttribute('data-testid') === 'stop-button' || stop.getAttribute('aria-label')?.toLowerCase()?.includes('stop');
296
+ if (isStopButton) {
296
297
  return;
297
298
  }
298
299
  dispatchClickSequence(stop);
@@ -7,32 +7,163 @@ export async function uploadAttachmentFile(deps, attachment, logger) {
7
7
  if (!dom) {
8
8
  throw new Error('DOM domain unavailable while uploading attachments.');
9
9
  }
10
- const documentNode = await dom.getDocument();
11
- const selectors = FILE_INPUT_SELECTORS;
12
- let targetNodeId;
13
- for (const selector of selectors) {
14
- const result = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector });
15
- if (result.nodeId) {
16
- targetNodeId = result.nodeId;
17
- break;
10
+ const isAttachmentPresent = async (name) => {
11
+ const check = await runtime.evaluate({
12
+ expression: `(() => {
13
+ const expected = ${JSON.stringify(name.toLowerCase())};
14
+ const selectors = [
15
+ '[data-testid*="attachment"]',
16
+ '[data-testid*="chip"]',
17
+ '[data-testid*="upload"]'
18
+ ];
19
+ const chips = selectors.some((selector) =>
20
+ Array.from(document.querySelectorAll(selector)).some((node) =>
21
+ (node?.textContent || '').toLowerCase().includes(expected),
22
+ ),
23
+ );
24
+ if (chips) return true;
25
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
26
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
27
+ );
28
+ if (cardTexts.some((text) => text.includes(expected))) return true;
29
+
30
+ const filesPill = Array.from(document.querySelectorAll('button,div')).some((node) => {
31
+ const text = (node?.textContent || '').toLowerCase();
32
+ return /\bfiles\b/.test(text) && text.includes('file');
33
+ });
34
+ if (filesPill) return true;
35
+
36
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
37
+ Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(expected)),
38
+ );
39
+ return inputs;
40
+ })()`,
41
+ returnByValue: true,
42
+ });
43
+ return Boolean(check?.result?.value);
44
+ };
45
+ // New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
46
+ await Promise.resolve(runtime.evaluate({
47
+ expression: `(() => {
48
+ const selectors = [
49
+ '#composer-plus-btn',
50
+ 'button[data-testid="composer-plus-btn"]',
51
+ '[data-testid*="plus"]',
52
+ 'button[aria-label*="add"]',
53
+ 'button[aria-label*="attachment"]',
54
+ 'button[aria-label*="file"]',
55
+ ];
56
+ for (const selector of selectors) {
57
+ const el = document.querySelector(selector);
58
+ if (el instanceof HTMLElement) {
59
+ el.click();
60
+ return true;
61
+ }
18
62
  }
63
+ return false;
64
+ })()`,
65
+ returnByValue: true,
66
+ })).catch(() => undefined);
67
+ await delay(250);
68
+ // Helper to click the upload menu item (if present) to reveal the real attachment input.
69
+ await Promise.resolve(runtime.evaluate({
70
+ expression: `(() => {
71
+ const menuItems = Array.from(document.querySelectorAll('[data-testid*="upload"],[data-testid*="attachment"], [role="menuitem"], [data-radix-collection-item]'));
72
+ for (const el of menuItems) {
73
+ const text = (el.textContent || '').toLowerCase();
74
+ const tid = el.getAttribute?.('data-testid')?.toLowerCase?.() || '';
75
+ if (tid.includes('upload') || tid.includes('attachment') || text.includes('upload') || text.includes('file')) {
76
+ if (el instanceof HTMLElement) { el.click(); return true; }
77
+ }
78
+ }
79
+ return false;
80
+ })()`,
81
+ returnByValue: true,
82
+ })).catch(() => undefined);
83
+ const expectedName = path.basename(attachment.path);
84
+ if (await isAttachmentPresent(expectedName)) {
85
+ logger(`Attachment already present: ${path.basename(attachment.path)}`);
86
+ return;
19
87
  }
20
- if (!targetNodeId) {
21
- await logDomFailure(runtime, logger, 'file-input');
88
+ // Find a real input; prefer non-image accept fields and tag it for DOM.setFileInputFiles.
89
+ const markResult = await runtime.evaluate({
90
+ expression: `(() => {
91
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
92
+ const acceptIsImageOnly = (accept) => {
93
+ if (!accept) return false;
94
+ const parts = String(accept)
95
+ .split(',')
96
+ .map((p) => p.trim().toLowerCase())
97
+ .filter(Boolean);
98
+ return parts.length > 0 && parts.every((p) => p.startsWith('image/'));
99
+ };
100
+ const nonImage = inputs.filter((el) => !acceptIsImageOnly(el.getAttribute('accept')));
101
+ const target = (nonImage.length ? nonImage[nonImage.length - 1] : inputs[inputs.length - 1]) ?? null;
102
+ if (target) {
103
+ target.setAttribute('data-oracle-upload-target', 'true');
104
+ return true;
105
+ }
106
+ return false;
107
+ })()`,
108
+ returnByValue: true,
109
+ });
110
+ const marked = Boolean(markResult?.result?.value);
111
+ if (!marked) {
112
+ await logDomFailure(runtime, logger, 'file-input-missing');
22
113
  throw new Error('Unable to locate ChatGPT file attachment input.');
23
114
  }
24
- await dom.setFileInputFiles({ nodeId: targetNodeId, files: [attachment.path] });
25
- const expectedName = path.basename(attachment.path);
26
- const ready = await waitForAttachmentSelection(runtime, expectedName, 10_000);
27
- if (!ready) {
28
- await logDomFailure(runtime, logger, 'file-upload');
29
- throw new Error('Attachment did not register with the ChatGPT composer in time.');
115
+ const documentNode = await dom.getDocument();
116
+ const resultNode = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector: 'input[type="file"][data-oracle-upload-target="true"]' });
117
+ if (!resultNode?.nodeId) {
118
+ await logDomFailure(runtime, logger, 'file-input-missing');
119
+ throw new Error('Unable to locate ChatGPT file attachment input.');
120
+ }
121
+ const resolvedNodeId = resultNode.nodeId;
122
+ const dispatchEvents = FILE_INPUT_SELECTORS
123
+ .map((selector) => `
124
+ (() => {
125
+ const el = document.querySelector(${JSON.stringify(selector)});
126
+ if (el instanceof HTMLInputElement) {
127
+ el.dispatchEvent(new Event('input', { bubbles: true }));
128
+ el.dispatchEvent(new Event('change', { bubbles: true }));
129
+ }
130
+ })();
131
+ `)
132
+ .join('\\n');
133
+ const tryFileInput = async () => {
134
+ await dom.setFileInputFiles({ nodeId: resolvedNodeId, files: [attachment.path] });
135
+ await runtime.evaluate({ expression: `(function(){${dispatchEvents} return true;})()`, returnByValue: true });
136
+ };
137
+ await tryFileInput();
138
+ // Snapshot the attachment state immediately after setting files so we can detect silent failures.
139
+ const snapshotExpr = `(() => {
140
+ const chips = Array.from(document.querySelectorAll('[data-testid*="attachment"],[data-testid*="chip"],[data-testid*="upload"],[aria-label="Remove file"]'))
141
+ .map((node) => (node?.textContent || '').trim())
142
+ .filter(Boolean);
143
+ const inputs = Array.from(document.querySelectorAll('input[type="file"]')).map((el) => ({
144
+ files: Array.from(el.files || []).map((f) => f?.name ?? ''),
145
+ }));
146
+ return { chips, inputs };
147
+ })()`;
148
+ const snapshot = await runtime
149
+ .evaluate({ expression: snapshotExpr, returnByValue: true })
150
+ .then((res) => res?.result?.value)
151
+ .catch(() => undefined);
152
+ if (snapshot) {
153
+ logger?.(`Attachment snapshot after setFileInputFiles: chips=${JSON.stringify(snapshot.chips || [])} inputs=${JSON.stringify(snapshot.inputs || [])}`);
154
+ }
155
+ const inputHasFile = snapshot?.inputs?.some((entry) => (entry.files || []).some((name) => name?.toLowerCase?.().includes(expectedName.toLowerCase()))) ?? false;
156
+ if (await waitForAttachmentAnchored(runtime, expectedName, 20_000)) {
157
+ await waitForAttachmentVisible(runtime, expectedName, 20_000, logger);
158
+ logger(inputHasFile ? 'Attachment queued (file input, confirmed present)' : 'Attachment queued (file input)');
159
+ return;
30
160
  }
31
- await waitForAttachmentVisible(runtime, expectedName, 10_000, logger);
32
- logger('Attachment queued');
161
+ await logDomFailure(runtime, logger, 'file-upload-missing');
162
+ throw new Error('Attachment did not register with the ChatGPT composer in time.');
33
163
  }
34
- export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
164
+ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNames = [], logger) {
35
165
  const deadline = Date.now() + timeoutMs;
166
+ const expectedNormalized = expectedNames.map((name) => name.toLowerCase());
36
167
  const expression = `(() => {
37
168
  const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
38
169
  let button = null;
@@ -58,24 +189,40 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
58
189
  return text.includes('upload') || text.includes('processing') || text.includes('uploading');
59
190
  });
60
191
  });
61
- const fileSelectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
62
- const filesAttached = fileSelectors.some((selector) =>
63
- Array.from(document.querySelectorAll(selector)).some((node) => {
64
- const el = node instanceof HTMLInputElement ? node : null;
65
- return Boolean(el?.files?.length);
66
- }),
192
+ const attachmentSelectors = ['[data-testid*="chip"]', '[data-testid*="attachment"]', '[data-testid*="upload"]'];
193
+ const attachedNames = [];
194
+ for (const selector of attachmentSelectors) {
195
+ for (const node of Array.from(document.querySelectorAll(selector))) {
196
+ const text = node?.textContent?.toLowerCase?.();
197
+ if (text) attachedNames.push(text);
198
+ }
199
+ }
200
+ for (const input of Array.from(document.querySelectorAll('input[type="file"]'))) {
201
+ if (!(input instanceof HTMLInputElement) || !input.files?.length) continue;
202
+ for (const file of Array.from(input.files)) {
203
+ if (file?.name) attachedNames.push(file.name.toLowerCase());
204
+ }
205
+ }
206
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
207
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
67
208
  );
68
- return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached };
209
+ attachedNames.push(...cardTexts.filter(Boolean));
210
+ const filesAttached = attachedNames.length > 0;
211
+ return { state: button ? (disabled ? 'disabled' : 'ready') : 'missing', uploading, filesAttached, attachedNames };
69
212
  })()`;
70
213
  while (Date.now() < deadline) {
71
214
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
72
215
  const value = result?.value;
73
216
  if (value && !value.uploading) {
74
- if (value.state === 'ready') {
75
- return;
76
- }
77
- if (value.state === 'missing' && value.filesAttached) {
78
- return;
217
+ const attached = new Set((value.attachedNames ?? []).map((name) => name.toLowerCase()));
218
+ const missing = expectedNormalized.filter((name) => !attached.has(name));
219
+ if (missing.length === 0) {
220
+ if (value.state === 'ready') {
221
+ return;
222
+ }
223
+ if (value.state === 'missing' && value.filesAttached) {
224
+ return;
225
+ }
79
226
  }
80
227
  }
81
228
  await delay(250);
@@ -85,15 +232,76 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, logger) {
85
232
  throw new Error('Attachments did not finish uploading before timeout.');
86
233
  }
87
234
  export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs, logger) {
88
- const deadline = Date.now() + Math.min(timeoutMs, 2_000);
235
+ // Attachments can take a few seconds to render in the composer (headless/remote Chrome is slower),
236
+ // so respect the caller-provided timeout instead of capping at 2s.
237
+ const deadline = Date.now() + timeoutMs;
89
238
  const expression = `(() => {
90
239
  const expected = ${JSON.stringify(expectedName)};
240
+ const normalized = expected.toLowerCase();
241
+ const matchNode = (node) => {
242
+ if (!node) return false;
243
+ const text = (node.textContent || '').toLowerCase();
244
+ const aria = node.getAttribute?.('aria-label')?.toLowerCase?.() ?? '';
245
+ const title = node.getAttribute?.('title')?.toLowerCase?.() ?? '';
246
+ const testId = node.getAttribute?.('data-testid')?.toLowerCase?.() ?? '';
247
+ const alt = node.getAttribute?.('alt')?.toLowerCase?.() ?? '';
248
+ return [text, aria, title, testId, alt].some((value) => value.includes(normalized));
249
+ };
250
+
91
251
  const turns = Array.from(document.querySelectorAll('article[data-testid^="conversation-turn"]'));
92
252
  const userTurns = turns.filter((node) => node.querySelector('[data-message-author-role="user"]'));
93
253
  const lastUser = userTurns[userTurns.length - 1];
94
- if (!lastUser) return { found: false, userTurns: userTurns.length };
95
- const chips = Array.from(lastUser.querySelectorAll('a, div')).some((el) => (el.textContent || '').includes(expected));
96
- return { found: chips, userTurns: userTurns.length };
254
+ if (lastUser) {
255
+ const turnMatch = Array.from(lastUser.querySelectorAll('*')).some(matchNode);
256
+ if (turnMatch) return { found: true, userTurns: userTurns.length, source: 'turn' };
257
+ }
258
+
259
+ const composerSelectors = [
260
+ '[data-testid*="composer"]',
261
+ 'form textarea',
262
+ 'form [data-testid*="attachment"]',
263
+ '[data-testid*="upload"]',
264
+ '[data-testid*="chip"]',
265
+ 'form',
266
+ 'button',
267
+ 'label'
268
+ ];
269
+ const composerMatch = composerSelectors.some((selector) =>
270
+ Array.from(document.querySelectorAll(selector)).some(matchNode),
271
+ );
272
+ if (composerMatch) {
273
+ return { found: true, userTurns: userTurns.length, source: 'composer' };
274
+ }
275
+
276
+ const attachmentSelectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
277
+ const attachmentMatch = attachmentSelectors.some((selector) =>
278
+ Array.from(document.querySelectorAll(selector)).some(matchNode),
279
+ );
280
+ if (attachmentMatch) {
281
+ return { found: true, userTurns: userTurns.length, source: 'attachments' };
282
+ }
283
+
284
+ const cardTexts = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
285
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
286
+ );
287
+ if (cardTexts.some((text) => text.includes(normalized))) {
288
+ return { found: true, userTurns: userTurns.length, source: 'attachment-cards' };
289
+ }
290
+
291
+ const attrMatch = Array.from(document.querySelectorAll('[aria-label], [title], [data-testid]')).some(matchNode);
292
+ if (attrMatch) {
293
+ return { found: true, userTurns: userTurns.length, source: 'attrs' };
294
+ }
295
+
296
+ const bodyMatch = (document.body?.innerText || '').toLowerCase().includes(normalized);
297
+ if (bodyMatch) {
298
+ return { found: true, userTurns: userTurns.length, source: 'body' };
299
+ }
300
+
301
+ const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
302
+ Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
303
+ );
304
+ return { found: inputHit, userTurns: userTurns.length, source: inputHit ? 'input' : undefined };
97
305
  })()`;
98
306
  while (Date.now() < deadline) {
99
307
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
@@ -107,31 +315,41 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
107
315
  await logDomFailure(Runtime, logger ?? (() => { }), 'attachment-visible');
108
316
  throw new Error('Attachment did not appear in ChatGPT composer.');
109
317
  }
110
- async function waitForAttachmentSelection(Runtime, expectedName, timeoutMs) {
318
+ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
111
319
  const deadline = Date.now() + timeoutMs;
112
320
  const expression = `(() => {
113
- const selectors = ${JSON.stringify(FILE_INPUT_SELECTORS)};
321
+ const normalized = ${JSON.stringify(expectedName.toLowerCase())};
322
+ const selectors = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]'];
114
323
  for (const selector of selectors) {
115
- const inputs = Array.from(document.querySelectorAll(selector));
116
- for (const input of inputs) {
117
- if (!(input instanceof HTMLInputElement) || !input.files) {
118
- continue;
119
- }
120
- const names = Array.from(input.files ?? []).map((file) => file?.name ?? '');
121
- if (names.some((name) => name === ${JSON.stringify(expectedName)})) {
122
- return { matched: true, names };
324
+ for (const node of Array.from(document.querySelectorAll(selector))) {
325
+ const text = (node?.textContent || '').toLowerCase();
326
+ if (text.includes(normalized)) {
327
+ return { found: true, text };
123
328
  }
124
329
  }
125
330
  }
126
- return { matched: false, names: [] };
331
+ const cards = Array.from(document.querySelectorAll('[aria-label="Remove file"]')).map((btn) =>
332
+ btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
333
+ );
334
+ if (cards.some((text) => text.includes(normalized))) {
335
+ return { found: true, text: cards.find((t) => t.includes(normalized)) };
336
+ }
337
+
338
+ // As a last resort, treat file inputs that hold the target name as anchored. Some UIs delay chip rendering.
339
+ const inputHit = Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
340
+ Array.from(el.files || []).some((file) => file?.name?.toLowerCase?.().includes(normalized)),
341
+ );
342
+ if (inputHit) {
343
+ return { found: true, text: 'input-only' };
344
+ }
345
+ return { found: false };
127
346
  })()`;
128
347
  while (Date.now() < deadline) {
129
348
  const { result } = await Runtime.evaluate({ expression, returnByValue: true });
130
- const matched = Boolean(result?.value?.matched);
131
- if (matched) {
349
+ if (result?.value?.found) {
132
350
  return true;
133
351
  }
134
- await delay(150);
352
+ await delay(200);
135
353
  }
136
354
  return false;
137
355
  }
@@ -11,6 +11,7 @@ const ENTER_KEY_EVENT = {
11
11
  const ENTER_KEY_TEXT = '\r';
12
12
  export async function submitPrompt(deps, prompt, logger) {
13
13
  const { runtime, input } = deps;
14
+ await waitForDomReady(runtime, logger);
14
15
  const encodedPrompt = JSON.stringify(prompt);
15
16
  const focusResult = await runtime.evaluate({
16
17
  expression: `(() => {
@@ -89,7 +90,7 @@ export async function submitPrompt(deps, prompt, logger) {
89
90
  })()`,
90
91
  });
91
92
  }
92
- const clicked = await attemptSendButton(runtime);
93
+ const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
93
94
  if (!clicked) {
94
95
  await input.dispatchKeyEvent({
95
96
  type: 'keyDown',
@@ -109,7 +110,27 @@ export async function submitPrompt(deps, prompt, logger) {
109
110
  await verifyPromptCommitted(runtime, prompt, 30_000, logger);
110
111
  await clickAnswerNowIfPresent(runtime, logger);
111
112
  }
112
- async function attemptSendButton(Runtime) {
113
+ async function waitForDomReady(Runtime, logger) {
114
+ const deadline = Date.now() + 10_000;
115
+ while (Date.now() < deadline) {
116
+ const { result } = await Runtime.evaluate({
117
+ expression: `(() => {
118
+ const ready = document.readyState === 'complete';
119
+ const composer = document.querySelector('[data-testid*="composer"]') || document.querySelector('form');
120
+ const fileInput = document.querySelector('input[type="file"]');
121
+ return { ready, composer: Boolean(composer), fileInput: Boolean(fileInput) };
122
+ })()`,
123
+ returnByValue: true,
124
+ });
125
+ const value = result?.value;
126
+ if (value?.ready && value.composer) {
127
+ return;
128
+ }
129
+ await delay(150);
130
+ }
131
+ logger?.('Page did not reach ready/composer state within 10s; continuing cautiously.');
132
+ }
133
+ async function attemptSendButton(Runtime, _logger, attachmentNames) {
113
134
  const script = `(() => {
114
135
  ${buildClickDispatcher()}
115
136
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -133,8 +154,31 @@ async function attemptSendButton(Runtime) {
133
154
  dispatchClickSequence(button);
134
155
  return 'clicked';
135
156
  })()`;
136
- const deadline = Date.now() + 2_000;
157
+ const deadline = Date.now() + 8_000;
137
158
  while (Date.now() < deadline) {
159
+ const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
160
+ if (needAttachment) {
161
+ const ready = await Runtime.evaluate({
162
+ expression: `(() => {
163
+ const names = ${JSON.stringify(attachmentNames.map((n) => n.toLowerCase()))};
164
+ const match = (n, name) => (n?.textContent || '').toLowerCase().includes(name);
165
+ const chipsReady = names.every((name) =>
166
+ Array.from(document.querySelectorAll('[data-testid*="chip"],[data-testid*="attachment"],a,div,span')).some((node) => match(node, name)),
167
+ );
168
+ const inputsReady = names.every((name) =>
169
+ Array.from(document.querySelectorAll('input[type="file"]')).some((el) =>
170
+ Array.from(el.files || []).some((f) => f?.name?.toLowerCase?.().includes(name)),
171
+ ),
172
+ );
173
+ return chipsReady || inputsReady;
174
+ })()`,
175
+ returnByValue: true,
176
+ });
177
+ if (!ready?.result?.value) {
178
+ await delay(150);
179
+ continue;
180
+ }
181
+ }
138
182
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
139
183
  if (result.value === 'clicked') {
140
184
  return true;
@@ -187,24 +231,37 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
187
231
  const fallback = document.querySelector(${fallbackSelectorLiteral});
188
232
  const normalize = (value) => value?.toLowerCase?.().replace(/\\s+/g, ' ').trim() ?? '';
189
233
  const normalizedPrompt = normalize(${encodedPrompt});
234
+ const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
190
235
  const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
191
236
  const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
192
- const userMatched = articles.some((node) => normalize(node?.innerText).includes(normalizedPrompt));
237
+ const normalizedTurns = articles.map((node) => normalize(node?.innerText));
238
+ const userMatched = normalizedTurns.some((text) => text.includes(normalizedPrompt));
239
+ const prefixMatched =
240
+ normalizedPromptPrefix.length > 30 &&
241
+ normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
242
+ const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
193
243
  return {
194
244
  userMatched,
245
+ prefixMatched,
195
246
  fallbackValue: fallback?.value ?? '',
196
247
  editorValue: editor?.innerText ?? '',
248
+ lastTurn,
249
+ turnsCount: normalizedTurns.length,
197
250
  };
198
251
  })()`;
199
252
  while (Date.now() < deadline) {
200
253
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
201
254
  const info = result.value;
202
- if (info?.userMatched) {
255
+ if (info?.userMatched || info?.prefixMatched) {
203
256
  return;
204
257
  }
205
258
  await delay(100);
206
259
  }
207
260
  if (logger) {
261
+ logger(`Prompt commit check failed; latest state: ${await Runtime.evaluate({
262
+ expression: script,
263
+ returnByValue: true,
264
+ }).then((res) => JSON.stringify(res?.result?.value)).catch(() => 'unavailable')}`);
208
265
  await logDomFailure(Runtime, logger, 'prompt-commit');
209
266
  }
210
267
  throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
@@ -32,7 +32,7 @@ export async function launchChrome(config, userDataDir, logger) {
32
32
  logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
33
33
  return Object.assign(launcher, { host: connectHost ?? '127.0.0.1' });
34
34
  }
35
- export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger) {
35
+ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
36
36
  const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
37
37
  let handling;
38
38
  const handleSignal = (signal) => {
@@ -40,14 +40,23 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
40
40
  return;
41
41
  }
42
42
  handling = true;
43
- if (keepBrowser) {
44
- logger(`Received ${signal}; leaving Chrome running for potential reattach`);
43
+ const inFlight = opts?.isInFlight?.() ?? false;
44
+ const leaveRunning = keepBrowser || inFlight;
45
+ if (leaveRunning) {
46
+ logger(`Received ${signal}; leaving Chrome running${inFlight ? ' (assistant response pending)' : ''}`);
45
47
  }
46
48
  else {
47
49
  logger(`Received ${signal}; terminating Chrome process`);
48
50
  }
49
51
  void (async () => {
50
- if (!keepBrowser) {
52
+ if (leaveRunning) {
53
+ // Ensure reattach hints are written before we exit.
54
+ await opts?.emitRuntimeHint?.().catch(() => undefined);
55
+ if (inFlight) {
56
+ logger('Session still in flight; reattach with "oracle session <slug>" to continue.');
57
+ }
58
+ }
59
+ else {
51
60
  try {
52
61
  await chrome.kill();
53
62
  }
@@ -29,6 +29,9 @@ export const FILE_INPUT_SELECTORS = [
29
29
  'input[type="file"][multiple]:not([accept])',
30
30
  'input[type="file"][multiple]',
31
31
  'input[type="file"]:not([accept])',
32
+ 'form input[type="file"][accept]',
33
+ 'input[type="file"][accept]',
34
+ 'input[type="file"]',
32
35
  'input[type="file"][data-testid*="file"]',
33
36
  ];
34
37
  // Legacy single selectors kept for compatibility with older call-sites
@@ -48,11 +51,11 @@ export const UPLOAD_STATUS_SELECTORS = [
48
51
  ];
49
52
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
50
53
  export const SEND_BUTTON_SELECTORS = [
51
- '[data-testid="send-button"]',
52
- 'button[data-testid="composer-send-button"]',
53
- 'button[aria-label="Send message"]',
54
- 'button[aria-label*="Send"]',
54
+ 'button[data-testid="send-button"]',
55
+ 'button[data-testid*="composer-send"]',
56
+ 'form button[type="submit"]',
55
57
  'button[type="submit"][data-testid*="send"]',
58
+ 'button[aria-label*="Send"]',
56
59
  ];
57
60
  export const SEND_BUTTON_SELECTOR = SEND_BUTTON_SELECTORS[0];
58
61
  export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]';
@@ -98,7 +98,10 @@ export async function runBrowserMode(options) {
98
98
  const chromeHost = chrome.host ?? '127.0.0.1';
99
99
  let removeTerminationHooks = null;
100
100
  try {
101
- removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger);
101
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
102
+ isInFlight: () => runStatus !== 'complete',
103
+ emitRuntimeHint,
104
+ });
102
105
  }
103
106
  catch {
104
107
  // ignore failure; cleanup still happens below
@@ -251,6 +254,7 @@ export async function runBrowserMode(options) {
251
254
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
252
255
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
253
256
  }
257
+ const attachmentNames = attachments.map((a) => path.basename(a.path));
254
258
  if (attachments.length > 0) {
255
259
  if (!DOM) {
256
260
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
@@ -260,10 +264,10 @@ export async function runBrowserMode(options) {
260
264
  await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger);
261
265
  }
262
266
  const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
263
- await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, logger));
267
+ await raceWithDisconnect(waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger));
264
268
  logger('All attachments uploaded');
265
269
  }
266
- await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input }, promptText, logger));
270
+ await raceWithDisconnect(submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger));
267
271
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
268
272
  const answer = await raceWithDisconnect(waitForAssistantResponse(Runtime, config.timeoutMs, logger));
269
273
  answerText = answer.text;
@@ -632,6 +636,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
632
636
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
633
637
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
634
638
  }
639
+ const attachmentNames = attachments.map((a) => path.basename(a.path));
635
640
  if (attachments.length > 0) {
636
641
  if (!DOM) {
637
642
  throw new Error('Chrome DOM domain unavailable while uploading attachments.');
@@ -642,10 +647,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
642
647
  await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
643
648
  }
644
649
  const waitBudget = Math.max(config.inputTimeoutMs ?? 30_000, 30_000);
645
- await waitForAttachmentCompletion(Runtime, waitBudget, logger);
650
+ await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
646
651
  logger('All attachments uploaded');
647
652
  }
648
- await submitPrompt({ runtime: Runtime, input: Input }, promptText, logger);
653
+ await submitPrompt({ runtime: Runtime, input: Input, attachmentNames }, promptText, logger);
649
654
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
650
655
  const answer = await waitForAssistantResponse(Runtime, config.timeoutMs, logger);
651
656
  answerText = answer.text;
@@ -101,7 +101,7 @@ export function toTransportError(error, model) {
101
101
  messageText.includes('does not exist') ||
102
102
  messageText.includes('unknown model') ||
103
103
  messageText.includes('model_not_found'))) {
104
- return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Use gpt-5-pro for now until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
104
+ return new OracleTransportError('model-unavailable', 'gpt-5.1-pro is not yet available on this API base. Using gpt-5-pro until OpenAI enables it. // TODO: Remove once gpt-5.1-pro is available', apiError);
105
105
  }
106
106
  if (apiError.status === 404 || apiError.status === 405) {
107
107
  return new OracleTransportError('unsupported-endpoint', 'HTTP 404/405 from the Responses API; this base URL or gateway likely does not expose /v1/responses. Set OPENAI_BASE_URL to api.openai.com/v1, update your Azure API version/deployment, or use the browser engine.', apiError);
@@ -186,14 +186,17 @@ export async function runOracle(options, deps = {}) {
186
186
  : DEFAULT_TIMEOUT_NON_PRO_MS / 1000
187
187
  : options.timeoutSeconds;
188
188
  const timeoutMs = timeoutSeconds * 1000;
189
+ const apiModelFromConfig = modelConfig.apiModel ?? modelConfig.model;
190
+ const modelDowngraded = apiModelFromConfig === 'gpt-5.1-pro';
191
+ const resolvedApiModelId = modelDowngraded ? 'gpt-5-pro' : apiModelFromConfig;
189
192
  // Track the concrete model id we dispatch to (especially for Gemini preview aliases)
190
193
  const effectiveModelId = options.effectiveModelId ??
191
194
  (options.model.startsWith('gemini')
192
195
  ? resolveGeminiModelId(options.model)
193
- : modelConfig.apiModel ?? modelConfig.model);
196
+ : resolvedApiModelId);
194
197
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
195
198
  const requestBody = buildRequestBody({
196
- modelConfig,
199
+ modelConfig: { ...modelConfig, apiModel: resolvedApiModelId },
197
200
  systemPrompt,
198
201
  userPrompt: promptWithFiles,
199
202
  searchEnabled,
@@ -219,6 +222,9 @@ export async function runOracle(options, deps = {}) {
219
222
  if (baseUrl) {
220
223
  log(dim(`Base URL: ${formatBaseUrlForLog(baseUrl)}`));
221
224
  }
225
+ if (modelDowngraded) {
226
+ log(dim('gpt-5.1-pro is not yet available via API; sending request with gpt-5-pro instead.'));
227
+ }
222
228
  if (options.background && !supportsBackground) {
223
229
  log(dim('Background runs are not supported for this model; streaming in foreground instead.'));
224
230
  }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.1 Pro, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "type": "module",
6
6
  "main": "dist/bin/oracle-cli.js",
@@ -8,6 +8,26 @@
8
8
  "oracle": "dist/bin/oracle-cli.js",
9
9
  "oracle-mcp": "dist/bin/oracle-mcp.js"
10
10
  },
11
+ "scripts": {
12
+ "docs:list": "tsx scripts/docs-list.ts",
13
+ "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
14
+ "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const src=path.join('vendor','oracle-notifier'); const dest=path.join('dist','vendor','oracle-notifier'); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){ fs.cpSync(src,dest,{recursive:true,force:true}); }\"",
15
+ "start": "pnpm run build && node ./dist/scripts/run-cli.js",
16
+ "oracle": "pnpm start",
17
+ "check": "pnpm run typecheck",
18
+ "typecheck": "tsc --noEmit",
19
+ "lint": "pnpm run typecheck && biome lint .",
20
+ "test": "vitest run",
21
+ "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
22
+ "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
23
+ "test:mcp:mcporter": "npx -y mcporter list oracle-local --schema --config config/mcporter.json && npx -y mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
24
+ "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
25
+ "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
26
+ "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
27
+ "test:coverage": "vitest run --coverage",
28
+ "prepare": "pnpm run build",
29
+ "mcp": "pnpm run build && node ./dist/bin/oracle-mcp.js"
30
+ },
11
31
  "files": [
12
32
  "dist/**/*",
13
33
  "assets-oracle-icon.png",
@@ -30,11 +50,7 @@
30
50
  "name": "node",
31
51
  "version": ">=20"
32
52
  }
33
- ],
34
- "packageManager": {
35
- "name": "pnpm",
36
- "version": ">=8"
37
- }
53
+ ]
38
54
  },
39
55
  "keywords": [],
40
56
  "author": "",
@@ -87,23 +103,19 @@
87
103
  "optionalDependencies": {
88
104
  "win-dpapi": "npm:@primno/dpapi@2.0.1"
89
105
  },
90
- "scripts": {
91
- "docs:list": "tsx scripts/docs-list.ts",
92
- "build": "tsc -p tsconfig.build.json && pnpm run build:vendor",
93
- "build:vendor": "node -e \"const fs=require('fs'); const path=require('path'); const src=path.join('vendor','oracle-notifier'); const dest=path.join('dist','vendor','oracle-notifier'); fs.mkdirSync(dest,{recursive:true}); if(fs.existsSync(src)){ fs.cpSync(src,dest,{recursive:true,force:true}); }\"",
94
- "start": "pnpm run build && node ./dist/scripts/run-cli.js",
95
- "oracle": "pnpm start",
96
- "check": "pnpm run typecheck",
97
- "typecheck": "tsc --noEmit",
98
- "lint": "pnpm run typecheck && biome lint .",
99
- "test": "vitest run",
100
- "test:mcp": "pnpm run build && pnpm run test:mcp:unit && pnpm run test:mcp:mcporter",
101
- "test:mcp:unit": "vitest run tests/mcp*.test.ts tests/mcp/**/*.test.ts",
102
- "test:mcp:mcporter": "npx -y mcporter list oracle-local --schema --config config/mcporter.json && npx -y mcporter call oracle-local.sessions limit:1 --config config/mcporter.json",
103
- "test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
104
- "test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
105
- "test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
106
- "test:coverage": "vitest run --coverage",
107
- "mcp": "pnpm run build && node ./dist/bin/oracle-mcp.js"
108
- }
109
- }
106
+ "pnpm": {
107
+ "overrides": {
108
+ "zod": "4.1.13",
109
+ "win-dpapi": "npm:@primno/dpapi@2.0.1",
110
+ "zod-to-json-schema": "3.25.0",
111
+ "devtools-protocol": "0.0.1551306"
112
+ },
113
+ "onlyBuiltDependencies": [
114
+ "@cdktf/node-pty-prebuilt-multiarch",
115
+ "keytar",
116
+ "sqlite3",
117
+ "win-dpapi"
118
+ ]
119
+ },
120
+ "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b"
121
+ }
@@ -0,0 +1,24 @@
1
+ # Oracle Notifier helper (macOS, arm64)
2
+
3
+ Builds a tiny signed helper app for macOS notifications with the Oracle icon.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ cd vendor/oracle-notifier
9
+ # Optional: notarize by setting App Store Connect key credentials
10
+ export APP_STORE_CONNECT_API_KEY_P8="$(cat AuthKey_XXXXXX.p8)" # with literal newlines or \n escaped
11
+ export APP_STORE_CONNECT_KEY_ID=XXXXXX
12
+ export APP_STORE_CONNECT_ISSUER_ID=YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
13
+ ./build-notifier.sh
14
+ ```
15
+
16
+ - Requires Xcode command line tools (swiftc) and a macOS Developer ID certificate. Without a valid cert, the build fails (no ad-hoc fallback).
17
+ - If `APP_STORE_CONNECT_*` vars are set, the script notarizes and staples the ticket.
18
+ - Output: `OracleNotifier.app` (arm64 only), bundled with `OracleIcon.icns`.
19
+
20
+ ## Usage
21
+ The CLI prefers this helper on macOS; if it fails or is missing, it falls back to toasted-notifier/terminal-notifier.
22
+
23
+ ## Permissions
24
+ After first run, allow notifications for “Oracle Notifier” in System Settings → Notifications.
File without changes
package/dist/.DS_Store DELETED
Binary file