@indykish/oracle 0.9.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/LICENSE +21 -0
- package/README.md +215 -0
- package/assets-oracle-icon.png +0 -0
- package/dist/bin/oracle-cli.js +1252 -0
- package/dist/bin/oracle-mcp.js +6 -0
- package/dist/scripts/agent-send.js +147 -0
- package/dist/scripts/browser-tools.js +536 -0
- package/dist/scripts/check.js +21 -0
- package/dist/scripts/debug/extract-chatgpt-response.js +53 -0
- package/dist/scripts/docs-list.js +110 -0
- package/dist/scripts/git-policy.js +125 -0
- package/dist/scripts/run-cli.js +14 -0
- package/dist/scripts/runner.js +1378 -0
- package/dist/scripts/test-browser.js +103 -0
- package/dist/scripts/test-remote-chrome.js +68 -0
- package/dist/src/bridge/connection.js +103 -0
- package/dist/src/bridge/userConfigFile.js +28 -0
- package/dist/src/browser/actions/assistantResponse.js +1067 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +138 -0
- package/dist/src/browser/actions/attachments.js +1910 -0
- package/dist/src/browser/actions/domEvents.js +19 -0
- package/dist/src/browser/actions/modelSelection.js +485 -0
- package/dist/src/browser/actions/navigation.js +445 -0
- package/dist/src/browser/actions/promptComposer.js +485 -0
- package/dist/src/browser/actions/remoteFileTransfer.js +37 -0
- package/dist/src/browser/actions/thinkingTime.js +206 -0
- package/dist/src/browser/chromeLifecycle.js +344 -0
- package/dist/src/browser/config.js +103 -0
- package/dist/src/browser/constants.js +71 -0
- package/dist/src/browser/cookies.js +191 -0
- package/dist/src/browser/detect.js +164 -0
- package/dist/src/browser/domDebug.js +36 -0
- package/dist/src/browser/index.js +1741 -0
- package/dist/src/browser/modelStrategy.js +13 -0
- package/dist/src/browser/pageActions.js +5 -0
- package/dist/src/browser/policies.js +43 -0
- package/dist/src/browser/profileState.js +280 -0
- package/dist/src/browser/prompt.js +152 -0
- package/dist/src/browser/promptSummary.js +20 -0
- package/dist/src/browser/reattach.js +186 -0
- package/dist/src/browser/reattachHelpers.js +382 -0
- package/dist/src/browser/sessionRunner.js +119 -0
- package/dist/src/browser/types.js +1 -0
- package/dist/src/browser/utils.js +122 -0
- package/dist/src/browserMode.js +1 -0
- package/dist/src/cli/bridge/claudeConfig.js +54 -0
- package/dist/src/cli/bridge/client.js +73 -0
- package/dist/src/cli/bridge/codexConfig.js +43 -0
- package/dist/src/cli/bridge/doctor.js +107 -0
- package/dist/src/cli/bridge/host.js +259 -0
- package/dist/src/cli/browserConfig.js +278 -0
- package/dist/src/cli/browserDefaults.js +81 -0
- package/dist/src/cli/bundleWarnings.js +9 -0
- package/dist/src/cli/clipboard.js +10 -0
- package/dist/src/cli/detach.js +11 -0
- package/dist/src/cli/dryRun.js +105 -0
- package/dist/src/cli/duplicatePromptGuard.js +14 -0
- package/dist/src/cli/engine.js +41 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/format.js +13 -0
- package/dist/src/cli/help.js +77 -0
- package/dist/src/cli/hiddenAliases.js +22 -0
- package/dist/src/cli/markdownBundle.js +17 -0
- package/dist/src/cli/markdownRenderer.js +97 -0
- package/dist/src/cli/notifier.js +306 -0
- package/dist/src/cli/options.js +281 -0
- package/dist/src/cli/oscUtils.js +2 -0
- package/dist/src/cli/promptRequirement.js +17 -0
- package/dist/src/cli/renderFlags.js +9 -0
- package/dist/src/cli/renderOutput.js +26 -0
- package/dist/src/cli/rootAlias.js +30 -0
- package/dist/src/cli/runOptions.js +78 -0
- package/dist/src/cli/sessionCommand.js +111 -0
- package/dist/src/cli/sessionDisplay.js +567 -0
- package/dist/src/cli/sessionRunner.js +602 -0
- package/dist/src/cli/sessionTable.js +92 -0
- package/dist/src/cli/tagline.js +258 -0
- package/dist/src/cli/tui/index.js +486 -0
- package/dist/src/cli/writeOutputPath.js +21 -0
- package/dist/src/config.js +26 -0
- package/dist/src/gemini-web/client.js +328 -0
- package/dist/src/gemini-web/executor.js +285 -0
- package/dist/src/gemini-web/index.js +1 -0
- package/dist/src/gemini-web/types.js +1 -0
- package/dist/src/heartbeat.js +43 -0
- package/dist/src/mcp/server.js +40 -0
- package/dist/src/mcp/tools/consult.js +290 -0
- package/dist/src/mcp/tools/sessionResources.js +75 -0
- package/dist/src/mcp/tools/sessions.js +105 -0
- package/dist/src/mcp/types.js +22 -0
- package/dist/src/mcp/utils.js +37 -0
- package/dist/src/oracle/background.js +141 -0
- package/dist/src/oracle/claude.js +101 -0
- package/dist/src/oracle/client.js +197 -0
- package/dist/src/oracle/config.js +227 -0
- package/dist/src/oracle/errors.js +132 -0
- package/dist/src/oracle/files.js +378 -0
- package/dist/src/oracle/finishLine.js +32 -0
- package/dist/src/oracle/format.js +30 -0
- package/dist/src/oracle/fsAdapter.js +10 -0
- package/dist/src/oracle/gemini.js +195 -0
- package/dist/src/oracle/logging.js +36 -0
- package/dist/src/oracle/markdown.js +46 -0
- package/dist/src/oracle/modelResolver.js +183 -0
- package/dist/src/oracle/multiModelRunner.js +153 -0
- package/dist/src/oracle/oscProgress.js +24 -0
- package/dist/src/oracle/promptAssembly.js +13 -0
- package/dist/src/oracle/request.js +50 -0
- package/dist/src/oracle/run.js +596 -0
- package/dist/src/oracle/runUtils.js +31 -0
- package/dist/src/oracle/tokenEstimate.js +37 -0
- package/dist/src/oracle/tokenStats.js +39 -0
- package/dist/src/oracle/tokenStringifier.js +24 -0
- package/dist/src/oracle/types.js +1 -0
- package/dist/src/oracle.js +12 -0
- package/dist/src/oracleHome.js +13 -0
- package/dist/src/remote/client.js +129 -0
- package/dist/src/remote/health.js +113 -0
- package/dist/src/remote/remoteServiceConfig.js +31 -0
- package/dist/src/remote/server.js +533 -0
- package/dist/src/remote/types.js +1 -0
- package/dist/src/sessionManager.js +637 -0
- package/dist/src/sessionStore.js +56 -0
- package/dist/src/version.js +39 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/dist/vendor/oracle-notifier/README.md +24 -0
- package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
- package/package.json +115 -0
- package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
- package/vendor/oracle-notifier/README.md +24 -0
- package/vendor/oracle-notifier/build-notifier.sh +93 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from '../constants.js';
|
|
2
|
+
import { delay } from '../utils.js';
|
|
3
|
+
import { logDomFailure } from '../domDebug.js';
|
|
4
|
+
import { buildClickDispatcher } from './domEvents.js';
|
|
5
|
+
import { BrowserAutomationError } from '../../oracle/errors.js';
|
|
6
|
+
const ENTER_KEY_EVENT = {
|
|
7
|
+
key: 'Enter',
|
|
8
|
+
code: 'Enter',
|
|
9
|
+
windowsVirtualKeyCode: 13,
|
|
10
|
+
nativeVirtualKeyCode: 13,
|
|
11
|
+
};
|
|
12
|
+
const ENTER_KEY_TEXT = '\r';
|
|
13
|
+
export async function submitPrompt(deps, prompt, logger) {
|
|
14
|
+
const { runtime, input } = deps;
|
|
15
|
+
await waitForDomReady(runtime, logger, deps.inputTimeoutMs ?? undefined);
|
|
16
|
+
const encodedPrompt = JSON.stringify(prompt);
|
|
17
|
+
const focusResult = await runtime.evaluate({
|
|
18
|
+
expression: `(() => {
|
|
19
|
+
${buildClickDispatcher()}
|
|
20
|
+
const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
|
|
21
|
+
const isVisible = (node) => {
|
|
22
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const rect = node.getBoundingClientRect();
|
|
26
|
+
return rect.width > 0 && rect.height > 0;
|
|
27
|
+
};
|
|
28
|
+
const focusNode = (node) => {
|
|
29
|
+
if (!node) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// Learned: React/ProseMirror require a real click + focus + selection for inserts to stick.
|
|
33
|
+
dispatchClickSequence(node);
|
|
34
|
+
if (typeof node.focus === 'function') {
|
|
35
|
+
node.focus();
|
|
36
|
+
}
|
|
37
|
+
const doc = node.ownerDocument;
|
|
38
|
+
const selection = doc?.getSelection?.();
|
|
39
|
+
if (selection) {
|
|
40
|
+
const range = doc.createRange();
|
|
41
|
+
range.selectNodeContents(node);
|
|
42
|
+
range.collapse(false);
|
|
43
|
+
selection.removeAllRanges();
|
|
44
|
+
selection.addRange(range);
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const candidates = [];
|
|
50
|
+
for (const selector of SELECTORS) {
|
|
51
|
+
const node = document.querySelector(selector);
|
|
52
|
+
if (node) {
|
|
53
|
+
candidates.push(node);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const preferred = candidates.find((node) => isVisible(node)) || candidates[0];
|
|
57
|
+
if (preferred && focusNode(preferred)) {
|
|
58
|
+
return { focused: true };
|
|
59
|
+
}
|
|
60
|
+
return { focused: false };
|
|
61
|
+
})()`,
|
|
62
|
+
returnByValue: true,
|
|
63
|
+
awaitPromise: true,
|
|
64
|
+
});
|
|
65
|
+
if (!focusResult.result?.value?.focused) {
|
|
66
|
+
await logDomFailure(runtime, logger, 'focus-textarea');
|
|
67
|
+
throw new Error('Failed to focus prompt textarea');
|
|
68
|
+
}
|
|
69
|
+
await input.insertText({ text: prompt });
|
|
70
|
+
// Some pages (notably ChatGPT when subscriptions/widgets load) need a brief settle
|
|
71
|
+
// before the send button becomes enabled; give it a short breather to avoid races.
|
|
72
|
+
await delay(500);
|
|
73
|
+
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
74
|
+
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
75
|
+
const verification = await runtime.evaluate({
|
|
76
|
+
expression: `(() => {
|
|
77
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
78
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
79
|
+
const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
80
|
+
const readValue = (node) => {
|
|
81
|
+
if (!node) return '';
|
|
82
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
83
|
+
return node.innerText ?? '';
|
|
84
|
+
};
|
|
85
|
+
const isVisible = (node) => {
|
|
86
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
87
|
+
const rect = node.getBoundingClientRect();
|
|
88
|
+
return rect.width > 0 && rect.height > 0;
|
|
89
|
+
};
|
|
90
|
+
const candidates = inputSelectors
|
|
91
|
+
.map((selector) => document.querySelector(selector))
|
|
92
|
+
.filter((node) => Boolean(node));
|
|
93
|
+
const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
|
|
94
|
+
return {
|
|
95
|
+
editorText: editor?.innerText ?? '',
|
|
96
|
+
fallbackValue: fallback?.value ?? '',
|
|
97
|
+
activeValue: active ? readValue(active) : '',
|
|
98
|
+
};
|
|
99
|
+
})()`,
|
|
100
|
+
returnByValue: true,
|
|
101
|
+
});
|
|
102
|
+
const editorTextRaw = verification.result?.value?.editorText ?? '';
|
|
103
|
+
const fallbackValueRaw = verification.result?.value?.fallbackValue ?? '';
|
|
104
|
+
const activeValueRaw = verification.result?.value?.activeValue ?? '';
|
|
105
|
+
const editorTextTrimmed = editorTextRaw?.trim?.() ?? '';
|
|
106
|
+
const fallbackValueTrimmed = fallbackValueRaw?.trim?.() ?? '';
|
|
107
|
+
const activeValueTrimmed = activeValueRaw?.trim?.() ?? '';
|
|
108
|
+
if (!editorTextTrimmed && !fallbackValueTrimmed && !activeValueTrimmed) {
|
|
109
|
+
// Learned: occasionally Input.insertText doesn't land in the editor; force textContent/value + input events.
|
|
110
|
+
await runtime.evaluate({
|
|
111
|
+
expression: `(() => {
|
|
112
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
113
|
+
if (fallback) {
|
|
114
|
+
fallback.value = ${encodedPrompt};
|
|
115
|
+
fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
|
|
116
|
+
fallback.dispatchEvent(new Event('change', { bubbles: true }));
|
|
117
|
+
}
|
|
118
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
119
|
+
if (editor) {
|
|
120
|
+
editor.textContent = ${encodedPrompt};
|
|
121
|
+
// Nudge ProseMirror to register the textContent write so its state/send-button updates
|
|
122
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
|
|
123
|
+
}
|
|
124
|
+
})()`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const promptLength = prompt.length;
|
|
128
|
+
const postVerification = await runtime.evaluate({
|
|
129
|
+
expression: `(() => {
|
|
130
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
131
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
132
|
+
const inputSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
133
|
+
const readValue = (node) => {
|
|
134
|
+
if (!node) return '';
|
|
135
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
136
|
+
return node.innerText ?? '';
|
|
137
|
+
};
|
|
138
|
+
const isVisible = (node) => {
|
|
139
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
140
|
+
const rect = node.getBoundingClientRect();
|
|
141
|
+
return rect.width > 0 && rect.height > 0;
|
|
142
|
+
};
|
|
143
|
+
const candidates = inputSelectors
|
|
144
|
+
.map((selector) => document.querySelector(selector))
|
|
145
|
+
.filter((node) => Boolean(node));
|
|
146
|
+
const active = candidates.find((node) => isVisible(node)) || candidates[0] || null;
|
|
147
|
+
return {
|
|
148
|
+
editorText: editor?.innerText ?? '',
|
|
149
|
+
fallbackValue: fallback?.value ?? '',
|
|
150
|
+
activeValue: active ? readValue(active) : '',
|
|
151
|
+
};
|
|
152
|
+
})()`,
|
|
153
|
+
returnByValue: true,
|
|
154
|
+
});
|
|
155
|
+
const observedEditor = postVerification.result?.value?.editorText ?? '';
|
|
156
|
+
const observedFallback = postVerification.result?.value?.fallbackValue ?? '';
|
|
157
|
+
const observedActive = postVerification.result?.value?.activeValue ?? '';
|
|
158
|
+
const observedLength = Math.max(observedEditor.length, observedFallback.length, observedActive.length);
|
|
159
|
+
if (promptLength >= 50_000 && observedLength > 0 && observedLength < promptLength - 2_000) {
|
|
160
|
+
// Learned: very large prompts can truncate silently; fail fast so we can fall back to file uploads.
|
|
161
|
+
await logDomFailure(runtime, logger, 'prompt-too-large');
|
|
162
|
+
throw new BrowserAutomationError('Prompt appears truncated in the composer (likely too large).', {
|
|
163
|
+
stage: 'submit-prompt',
|
|
164
|
+
code: 'prompt-too-large',
|
|
165
|
+
promptLength,
|
|
166
|
+
observedLength,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames);
|
|
170
|
+
if (!clicked) {
|
|
171
|
+
await input.dispatchKeyEvent({
|
|
172
|
+
type: 'keyDown',
|
|
173
|
+
...ENTER_KEY_EVENT,
|
|
174
|
+
text: ENTER_KEY_TEXT,
|
|
175
|
+
unmodifiedText: ENTER_KEY_TEXT,
|
|
176
|
+
});
|
|
177
|
+
await input.dispatchKeyEvent({
|
|
178
|
+
type: 'keyUp',
|
|
179
|
+
...ENTER_KEY_EVENT,
|
|
180
|
+
});
|
|
181
|
+
logger('Submitted prompt via Enter key');
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
logger('Clicked send button');
|
|
185
|
+
}
|
|
186
|
+
const commitTimeoutMs = Math.max(60_000, deps.inputTimeoutMs ?? 0);
|
|
187
|
+
// Learned: the send button can succeed but the turn doesn't appear immediately; verify commit via turns/stop button.
|
|
188
|
+
return await verifyPromptCommitted(runtime, prompt, commitTimeoutMs, logger, deps.baselineTurns ?? undefined);
|
|
189
|
+
}
|
|
190
|
+
export async function clearPromptComposer(Runtime, logger) {
|
|
191
|
+
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
192
|
+
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
193
|
+
const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
194
|
+
const result = await Runtime.evaluate({
|
|
195
|
+
expression: `(() => {
|
|
196
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
197
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
198
|
+
const inputSelectors = ${inputSelectorsLiteral};
|
|
199
|
+
let cleared = false;
|
|
200
|
+
if (fallback) {
|
|
201
|
+
fallback.value = '';
|
|
202
|
+
fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
203
|
+
fallback.dispatchEvent(new Event('change', { bubbles: true }));
|
|
204
|
+
cleared = true;
|
|
205
|
+
}
|
|
206
|
+
if (editor) {
|
|
207
|
+
editor.textContent = '';
|
|
208
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
209
|
+
cleared = true;
|
|
210
|
+
}
|
|
211
|
+
const nodes = inputSelectors
|
|
212
|
+
.map((selector) => document.querySelector(selector))
|
|
213
|
+
.filter((node) => Boolean(node));
|
|
214
|
+
for (const node of nodes) {
|
|
215
|
+
if (!node) continue;
|
|
216
|
+
if (node instanceof HTMLTextAreaElement) {
|
|
217
|
+
node.value = '';
|
|
218
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
219
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
220
|
+
cleared = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (node.isContentEditable || node.getAttribute('contenteditable') === 'true') {
|
|
224
|
+
node.textContent = '';
|
|
225
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteByCut' }));
|
|
226
|
+
cleared = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { cleared };
|
|
230
|
+
})()`,
|
|
231
|
+
returnByValue: true,
|
|
232
|
+
});
|
|
233
|
+
if (!result.result?.value?.cleared) {
|
|
234
|
+
await logDomFailure(Runtime, logger, 'clear-composer');
|
|
235
|
+
throw new Error('Failed to clear prompt composer');
|
|
236
|
+
}
|
|
237
|
+
await delay(250);
|
|
238
|
+
}
|
|
239
|
+
async function waitForDomReady(Runtime, logger, timeoutMs = 10_000) {
|
|
240
|
+
const deadline = Date.now() + timeoutMs;
|
|
241
|
+
while (Date.now() < deadline) {
|
|
242
|
+
const { result } = await Runtime.evaluate({
|
|
243
|
+
expression: `(() => {
|
|
244
|
+
const ready = document.readyState === 'complete';
|
|
245
|
+
const composer = document.querySelector('[data-testid*="composer"]') || document.querySelector('form');
|
|
246
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
247
|
+
return { ready, composer: Boolean(composer), fileInput: Boolean(fileInput) };
|
|
248
|
+
})()`,
|
|
249
|
+
returnByValue: true,
|
|
250
|
+
});
|
|
251
|
+
const value = result?.value;
|
|
252
|
+
if (value?.ready && value.composer) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await delay(150);
|
|
256
|
+
}
|
|
257
|
+
logger?.(`Page did not reach ready/composer state within ${timeoutMs}ms; continuing cautiously.`);
|
|
258
|
+
}
|
|
259
|
+
function buildAttachmentReadyExpression(attachmentNames) {
|
|
260
|
+
const namesLiteral = JSON.stringify(attachmentNames.map((name) => name.toLowerCase()));
|
|
261
|
+
return `(() => {
|
|
262
|
+
const names = ${namesLiteral};
|
|
263
|
+
const composer =
|
|
264
|
+
document.querySelector('[data-testid*="composer"]') ||
|
|
265
|
+
document.querySelector('form') ||
|
|
266
|
+
document.body ||
|
|
267
|
+
document;
|
|
268
|
+
const match = (node, name) => (node?.textContent || '').toLowerCase().includes(name);
|
|
269
|
+
|
|
270
|
+
// Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
|
|
271
|
+
const attachmentSelectors = [
|
|
272
|
+
'[data-testid*="chip"]',
|
|
273
|
+
'[data-testid*="attachment"]',
|
|
274
|
+
'[data-testid*="upload"]',
|
|
275
|
+
'[aria-label="Remove file"]',
|
|
276
|
+
'button[aria-label="Remove file"]',
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
const chipsReady = names.every((name) =>
|
|
280
|
+
Array.from(composer.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
|
|
281
|
+
);
|
|
282
|
+
const inputsReady = names.every((name) =>
|
|
283
|
+
Array.from(composer.querySelectorAll('input[type="file"]')).some((el) =>
|
|
284
|
+
Array.from((el instanceof HTMLInputElement ? el.files : []) || []).some((file) =>
|
|
285
|
+
file?.name?.toLowerCase?.().includes(name),
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return chipsReady || inputsReady;
|
|
291
|
+
})()`;
|
|
292
|
+
}
|
|
293
|
+
export function buildAttachmentReadyExpressionForTest(attachmentNames) {
|
|
294
|
+
return buildAttachmentReadyExpression(attachmentNames);
|
|
295
|
+
}
|
|
296
|
+
async function attemptSendButton(Runtime, _logger, attachmentNames) {
|
|
297
|
+
const script = `(() => {
|
|
298
|
+
${buildClickDispatcher()}
|
|
299
|
+
const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
300
|
+
let button = null;
|
|
301
|
+
for (const selector of selectors) {
|
|
302
|
+
button = document.querySelector(selector);
|
|
303
|
+
if (button) break;
|
|
304
|
+
}
|
|
305
|
+
if (!button) return 'missing';
|
|
306
|
+
const ariaDisabled = button.getAttribute('aria-disabled');
|
|
307
|
+
const dataDisabled = button.getAttribute('data-disabled');
|
|
308
|
+
const style = window.getComputedStyle(button);
|
|
309
|
+
const disabled =
|
|
310
|
+
button.hasAttribute('disabled') ||
|
|
311
|
+
ariaDisabled === 'true' ||
|
|
312
|
+
dataDisabled === 'true' ||
|
|
313
|
+
style.pointerEvents === 'none' ||
|
|
314
|
+
style.display === 'none';
|
|
315
|
+
// Learned: some send buttons render but are inert; only click when truly enabled.
|
|
316
|
+
if (disabled) return 'disabled';
|
|
317
|
+
// Use unified pointer/mouse sequence to satisfy React handlers.
|
|
318
|
+
dispatchClickSequence(button);
|
|
319
|
+
return 'clicked';
|
|
320
|
+
})()`;
|
|
321
|
+
const deadline = Date.now() + 8_000;
|
|
322
|
+
while (Date.now() < deadline) {
|
|
323
|
+
const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
|
|
324
|
+
if (needAttachment) {
|
|
325
|
+
const ready = await Runtime.evaluate({
|
|
326
|
+
expression: buildAttachmentReadyExpression(attachmentNames),
|
|
327
|
+
returnByValue: true,
|
|
328
|
+
});
|
|
329
|
+
if (!ready?.result?.value) {
|
|
330
|
+
await delay(150);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
335
|
+
if (result.value === 'clicked') {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (result.value === 'missing') {
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
await delay(100);
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
|
|
346
|
+
const deadline = Date.now() + timeoutMs;
|
|
347
|
+
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
348
|
+
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
349
|
+
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
350
|
+
const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
351
|
+
const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
|
|
352
|
+
const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
|
|
353
|
+
const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
|
|
354
|
+
let baseline = typeof baselineTurns === 'number' && Number.isFinite(baselineTurns) && baselineTurns >= 0
|
|
355
|
+
? Math.floor(baselineTurns)
|
|
356
|
+
: null;
|
|
357
|
+
if (baseline === null) {
|
|
358
|
+
try {
|
|
359
|
+
const { result } = await Runtime.evaluate({
|
|
360
|
+
expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
|
|
361
|
+
returnByValue: true,
|
|
362
|
+
});
|
|
363
|
+
const raw = typeof result?.value === 'number' ? result.value : Number(result?.value);
|
|
364
|
+
if (Number.isFinite(raw)) {
|
|
365
|
+
baseline = Math.max(0, Math.floor(raw));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// ignore; baseline stays unknown
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const baselineLiteral = baseline ?? -1;
|
|
373
|
+
// Learned: ChatGPT can echo/format text; normalize markdown and use prefix matches to detect the sent prompt.
|
|
374
|
+
const script = `(() => {
|
|
375
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
376
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
377
|
+
const inputSelectors = ${inputSelectorsLiteral};
|
|
378
|
+
const normalize = (value) => {
|
|
379
|
+
let text = value?.toLowerCase?.() ?? '';
|
|
380
|
+
// Strip markdown *markers* but keep content (ChatGPT renders fence markers differently).
|
|
381
|
+
text = text.replace(/\`\`\`[^\\n]*\\n([\\s\\S]*?)\`\`\`/g, ' $1 ');
|
|
382
|
+
text = text.replace(/\`\`\`/g, ' ');
|
|
383
|
+
text = text.replace(/\`([^\`]*)\`/g, '$1');
|
|
384
|
+
return text.replace(/\\s+/g, ' ').trim();
|
|
385
|
+
};
|
|
386
|
+
const normalizedPrompt = normalize(${encodedPrompt});
|
|
387
|
+
const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
|
|
388
|
+
const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
|
|
389
|
+
const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
390
|
+
const normalizedTurns = articles.map((node) => normalize(node?.innerText));
|
|
391
|
+
const readValue = (node) => {
|
|
392
|
+
if (!node) return '';
|
|
393
|
+
if (node instanceof HTMLTextAreaElement) return node.value ?? '';
|
|
394
|
+
return node.innerText ?? '';
|
|
395
|
+
};
|
|
396
|
+
const isVisible = (node) => {
|
|
397
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return false;
|
|
398
|
+
const rect = node.getBoundingClientRect();
|
|
399
|
+
return rect.width > 0 && rect.height > 0;
|
|
400
|
+
};
|
|
401
|
+
const inputs = inputSelectors
|
|
402
|
+
.map((selector) => document.querySelector(selector))
|
|
403
|
+
.filter((node) => Boolean(node));
|
|
404
|
+
const visibleInputs = inputs.filter((node) => isVisible(node));
|
|
405
|
+
const activeInputs = visibleInputs.length > 0 ? visibleInputs : inputs;
|
|
406
|
+
const userMatched =
|
|
407
|
+
normalizedPrompt.length > 0 && normalizedTurns.some((text) => text.includes(normalizedPrompt));
|
|
408
|
+
const prefixMatched =
|
|
409
|
+
normalizedPromptPrefix.length > 30 &&
|
|
410
|
+
normalizedTurns.some((text) => text.includes(normalizedPromptPrefix));
|
|
411
|
+
const lastTurn = normalizedTurns[normalizedTurns.length - 1] ?? '';
|
|
412
|
+
const lastMatched =
|
|
413
|
+
normalizedPrompt.length > 0 &&
|
|
414
|
+
(lastTurn.includes(normalizedPrompt) ||
|
|
415
|
+
(normalizedPromptPrefix.length > 30 && lastTurn.includes(normalizedPromptPrefix)));
|
|
416
|
+
const baseline = ${baselineLiteral};
|
|
417
|
+
const hasNewTurn = baseline < 0 ? false : normalizedTurns.length > baseline;
|
|
418
|
+
const stopVisible = Boolean(document.querySelector(${stopSelectorLiteral}));
|
|
419
|
+
const assistantVisible = Boolean(
|
|
420
|
+
document.querySelector(${assistantSelectorLiteral}) ||
|
|
421
|
+
document.querySelector('[data-testid*="assistant"]'),
|
|
422
|
+
);
|
|
423
|
+
// Learned: composer clearing + stop button or assistant presence is a reliable fallback signal.
|
|
424
|
+
const editorValue = editor?.innerText ?? '';
|
|
425
|
+
const fallbackValue = fallback?.value ?? '';
|
|
426
|
+
const activeEmpty =
|
|
427
|
+
activeInputs.length === 0 ? null : activeInputs.every((node) => !String(readValue(node)).trim());
|
|
428
|
+
const composerCleared = activeEmpty ?? !(String(editorValue).trim() || String(fallbackValue).trim());
|
|
429
|
+
const href = typeof location === 'object' && location.href ? location.href : '';
|
|
430
|
+
const inConversation = /\\/c\\//.test(href);
|
|
431
|
+
return {
|
|
432
|
+
baseline,
|
|
433
|
+
userMatched,
|
|
434
|
+
prefixMatched,
|
|
435
|
+
lastMatched,
|
|
436
|
+
hasNewTurn,
|
|
437
|
+
stopVisible,
|
|
438
|
+
assistantVisible,
|
|
439
|
+
composerCleared,
|
|
440
|
+
inConversation,
|
|
441
|
+
href,
|
|
442
|
+
fallbackValue,
|
|
443
|
+
editorValue,
|
|
444
|
+
lastTurn,
|
|
445
|
+
turnsCount: normalizedTurns.length,
|
|
446
|
+
};
|
|
447
|
+
})()`;
|
|
448
|
+
while (Date.now() < deadline) {
|
|
449
|
+
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
450
|
+
const info = result.value;
|
|
451
|
+
const turnsCount = result.value?.turnsCount;
|
|
452
|
+
const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
|
|
453
|
+
const baselineUnknown = typeof info?.baseline === 'number' ? info.baseline < 0 : baselineLiteral < 0;
|
|
454
|
+
if (matchesPrompt && (baselineUnknown || info?.hasNewTurn)) {
|
|
455
|
+
return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
|
|
456
|
+
}
|
|
457
|
+
const fallbackCommit = info?.composerCleared &&
|
|
458
|
+
Boolean(info?.hasNewTurn) &&
|
|
459
|
+
((info?.stopVisible ?? false) || info?.assistantVisible || info?.inConversation);
|
|
460
|
+
if (fallbackCommit) {
|
|
461
|
+
return typeof turnsCount === 'number' && Number.isFinite(turnsCount) ? turnsCount : null;
|
|
462
|
+
}
|
|
463
|
+
await delay(100);
|
|
464
|
+
}
|
|
465
|
+
if (logger) {
|
|
466
|
+
logger(`Prompt commit check failed; latest state: ${await Runtime.evaluate({
|
|
467
|
+
expression: script,
|
|
468
|
+
returnByValue: true,
|
|
469
|
+
}).then((res) => JSON.stringify(res?.result?.value)).catch(() => 'unavailable')}`);
|
|
470
|
+
await logDomFailure(Runtime, logger, 'prompt-commit');
|
|
471
|
+
}
|
|
472
|
+
if (prompt.trim().length >= 50_000) {
|
|
473
|
+
throw new BrowserAutomationError('Prompt did not appear in conversation before timeout (likely too large).', {
|
|
474
|
+
stage: 'submit-prompt',
|
|
475
|
+
code: 'prompt-too-large',
|
|
476
|
+
promptLength: prompt.trim().length,
|
|
477
|
+
timeoutMs,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
|
|
481
|
+
}
|
|
482
|
+
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
483
|
+
export const __test__ = {
|
|
484
|
+
verifyPromptCommitted,
|
|
485
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { FILE_INPUT_SELECTORS } from '../constants.js';
|
|
3
|
+
import { waitForAttachmentVisible } from './attachments.js';
|
|
4
|
+
import { delay } from '../utils.js';
|
|
5
|
+
import { logDomFailure } from '../domDebug.js';
|
|
6
|
+
import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
|
|
7
|
+
/**
|
|
8
|
+
* Upload file to remote Chrome by transferring content via CDP
|
|
9
|
+
* Used when browser is on a different machine than CLI
|
|
10
|
+
*/
|
|
11
|
+
export async function uploadAttachmentViaDataTransfer(deps, attachment, logger) {
|
|
12
|
+
const { runtime, dom } = deps;
|
|
13
|
+
if (!dom) {
|
|
14
|
+
throw new Error('DOM domain unavailable while uploading attachments.');
|
|
15
|
+
}
|
|
16
|
+
logger(`Transferring ${path.basename(attachment.path)} to remote browser...`);
|
|
17
|
+
// Find file input element
|
|
18
|
+
const documentNode = await dom.getDocument();
|
|
19
|
+
let fileInputSelector;
|
|
20
|
+
for (const selector of FILE_INPUT_SELECTORS) {
|
|
21
|
+
const result = await dom.querySelector({ nodeId: documentNode.root.nodeId, selector });
|
|
22
|
+
if (result.nodeId) {
|
|
23
|
+
fileInputSelector = selector;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (!fileInputSelector) {
|
|
28
|
+
await logDomFailure(runtime, logger, 'file-input');
|
|
29
|
+
throw new Error('Unable to locate ChatGPT file attachment input.');
|
|
30
|
+
}
|
|
31
|
+
const transferResult = await transferAttachmentViaDataTransfer(runtime, attachment, fileInputSelector);
|
|
32
|
+
logger(`File transferred: ${transferResult.fileName} (${transferResult.size} bytes)`);
|
|
33
|
+
// Give ChatGPT a moment to process the file
|
|
34
|
+
await delay(500);
|
|
35
|
+
await waitForAttachmentVisible(runtime, transferResult.fileName, 10_000, logger);
|
|
36
|
+
logger('Attachment queued');
|
|
37
|
+
}
|