@steipete/oracle 0.8.1 → 0.8.3
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 +2 -2
- package/dist/bin/oracle-cli.js +2 -0
- package/dist/src/browser/actions/attachments.js +256 -112
- package/dist/src/browser/actions/navigation.js +115 -0
- package/dist/src/browser/actions/thinkingTime.js +2 -0
- package/dist/src/browser/chromeLifecycle.js +2 -0
- package/dist/src/browser/config.js +2 -0
- package/dist/src/browser/cookies.js +29 -2
- package/dist/src/browser/index.js +48 -15
- package/dist/src/browser/pageActions.js +1 -1
- package/dist/src/browser/reattach.js +1 -0
- package/dist/src/cli/browserConfig.js +1 -0
- package/dist/src/cli/browserDefaults.js +3 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Oracle bundles your prompt and files so another AI can answer with real context.
|
|
|
18
18
|
Install globally: `npm install -g @steipete/oracle`
|
|
19
19
|
Homebrew: `brew install steipete/tap/oracle`
|
|
20
20
|
|
|
21
|
-
Requires Node 22+.
|
|
21
|
+
Requires Node 22+. Or use `npx -y @steipete/oracle …` (or pnpx).
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
# Copy the bundle and paste into ChatGPT
|
|
@@ -149,7 +149,7 @@ Advanced flags
|
|
|
149
149
|
|
|
150
150
|
| Area | Flags |
|
|
151
151
|
| --- | --- |
|
|
152
|
-
| Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
|
|
152
|
+
| Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
|
|
153
153
|
| Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
|
|
154
154
|
|
|
155
155
|
Remote browser example
|
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -163,6 +163,7 @@ program
|
|
|
163
163
|
.addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
|
|
164
164
|
.addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
|
|
165
165
|
.addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
|
|
166
|
+
.addOption(new Option('--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.').hideHelp())
|
|
166
167
|
.addOption(new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
|
|
167
168
|
.argParser(parseIntOption))
|
|
168
169
|
.addOption(new Option('--browser-debug-port <port>', '(alias) Use a fixed Chrome DevTools port.').argParser(parseIntOption).hideHelp())
|
|
@@ -926,6 +927,7 @@ function printDebugHelp(cliName) {
|
|
|
926
927
|
['--browser-url <url>', 'Alias for --chatgpt-url.'],
|
|
927
928
|
['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
|
|
928
929
|
['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
|
|
930
|
+
['--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.'],
|
|
929
931
|
['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
|
|
930
932
|
['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
|
|
931
933
|
['--browser-headless', 'Launch Chrome in headless mode.'],
|
|
@@ -4,7 +4,7 @@ import { delay } from '../utils.js';
|
|
|
4
4
|
import { logDomFailure } from '../domDebug.js';
|
|
5
5
|
import { transferAttachmentViaDataTransfer } from './attachmentDataTransfer.js';
|
|
6
6
|
export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
7
|
-
const { runtime, dom } = deps;
|
|
7
|
+
const { runtime, dom, input } = deps;
|
|
8
8
|
if (!dom) {
|
|
9
9
|
throw new Error('DOM domain unavailable while uploading attachments.');
|
|
10
10
|
}
|
|
@@ -170,7 +170,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files
|
|
173
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
174
174
|
const collectFileCount = (candidates) => {
|
|
175
175
|
let count = 0;
|
|
176
176
|
for (const node of candidates) {
|
|
@@ -204,7 +204,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
204
204
|
let hasFileHint = false;
|
|
205
205
|
for (const raw of values) {
|
|
206
206
|
if (!raw) continue;
|
|
207
|
-
|
|
207
|
+
const normalized = normalize(raw);
|
|
208
|
+
if (normalized.includes('file') || normalized.includes('attachment')) {
|
|
208
209
|
hasFileHint = true;
|
|
209
210
|
break;
|
|
210
211
|
}
|
|
@@ -273,43 +274,70 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
273
274
|
};
|
|
274
275
|
};
|
|
275
276
|
// New ChatGPT UI hides the real file input behind a composer "+" menu; click it pre-emptively.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
277
|
+
// Learned: synthetic `.click()` is sometimes ignored (isTrusted checks). Prefer a CDP mouse click when possible.
|
|
278
|
+
const clickPlusTrusted = async () => {
|
|
279
|
+
if (!input || typeof input.dispatchMouseEvent !== 'function')
|
|
280
|
+
return false;
|
|
281
|
+
const locate = await runtime
|
|
282
|
+
.evaluate({
|
|
283
|
+
expression: `(() => {
|
|
284
|
+
const selectors = [
|
|
285
|
+
'#composer-plus-btn',
|
|
286
|
+
'button[data-testid="composer-plus-btn"]',
|
|
287
|
+
'[data-testid*="plus"]',
|
|
288
|
+
'button[aria-label*="add"]',
|
|
289
|
+
'button[aria-label*="attachment"]',
|
|
290
|
+
'button[aria-label*="file"]',
|
|
291
|
+
];
|
|
292
|
+
for (const selector of selectors) {
|
|
293
|
+
const el = document.querySelector(selector);
|
|
294
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
295
|
+
const rect = el.getBoundingClientRect();
|
|
296
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
297
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
298
|
+
const nextRect = el.getBoundingClientRect();
|
|
299
|
+
return { ok: true, x: nextRect.left + nextRect.width / 2, y: nextRect.top + nextRect.height / 2 };
|
|
291
300
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
301
|
+
return { ok: false };
|
|
302
|
+
})()`,
|
|
303
|
+
returnByValue: true,
|
|
304
|
+
})
|
|
305
|
+
.then((res) => res?.result?.value)
|
|
306
|
+
.catch(() => undefined);
|
|
307
|
+
if (!locate?.ok || typeof locate.x !== 'number' || typeof locate.y !== 'number')
|
|
308
|
+
return false;
|
|
309
|
+
const x = locate.x;
|
|
310
|
+
const y = locate.y;
|
|
311
|
+
await input.dispatchMouseEvent({ type: 'mouseMoved', x, y });
|
|
312
|
+
await input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
313
|
+
await input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
314
|
+
return true;
|
|
315
|
+
};
|
|
316
|
+
const clickedTrusted = await clickPlusTrusted().catch(() => false);
|
|
317
|
+
if (!clickedTrusted) {
|
|
318
|
+
await Promise.resolve(runtime.evaluate({
|
|
319
|
+
expression: `(() => {
|
|
320
|
+
const selectors = [
|
|
321
|
+
'#composer-plus-btn',
|
|
322
|
+
'button[data-testid="composer-plus-btn"]',
|
|
323
|
+
'[data-testid*="plus"]',
|
|
324
|
+
'button[aria-label*="add"]',
|
|
325
|
+
'button[aria-label*="attachment"]',
|
|
326
|
+
'button[aria-label*="file"]',
|
|
327
|
+
];
|
|
328
|
+
for (const selector of selectors) {
|
|
329
|
+
const el = document.querySelector(selector);
|
|
330
|
+
if (el instanceof HTMLElement) {
|
|
331
|
+
el.click();
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
307
334
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
335
|
+
return false;
|
|
336
|
+
})()`,
|
|
337
|
+
returnByValue: true,
|
|
338
|
+
})).catch(() => undefined);
|
|
339
|
+
}
|
|
340
|
+
await delay(350);
|
|
313
341
|
const normalizeForMatch = (value) => String(value || '')
|
|
314
342
|
.toLowerCase()
|
|
315
343
|
.replace(/\s+/g, ' ')
|
|
@@ -461,7 +489,7 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
461
489
|
return /\\buploading\\b/.test(text) || /\\bprocessing\\b/.test(text);
|
|
462
490
|
});
|
|
463
491
|
});
|
|
464
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files
|
|
492
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
465
493
|
const collectFileCount = (candidates) => {
|
|
466
494
|
let count = 0;
|
|
467
495
|
for (const node of candidates) {
|
|
@@ -495,7 +523,8 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
495
523
|
let hasFileHint = false;
|
|
496
524
|
for (const raw of values) {
|
|
497
525
|
if (!raw) continue;
|
|
498
|
-
|
|
526
|
+
const lowered = String(raw).toLowerCase();
|
|
527
|
+
if (lowered.includes('file') || lowered.includes('attachment')) {
|
|
499
528
|
hasFileHint = true;
|
|
500
529
|
break;
|
|
501
530
|
}
|
|
@@ -537,23 +566,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
537
566
|
}
|
|
538
567
|
|
|
539
568
|
// Mark candidates with stable indices so we can select them via DOM.querySelector.
|
|
569
|
+
// Learned: ChatGPT sometimes renders a zero-sized file input that does *not* trigger uploads;
|
|
570
|
+
// keep it as a fallback, but strongly prefer visible (even sr-only 1x1) inputs.
|
|
571
|
+
const localSet = new Set(localInputs);
|
|
540
572
|
let idx = 0;
|
|
541
|
-
|
|
573
|
+
const candidates = inputs.map((el) => {
|
|
542
574
|
const accept = el.getAttribute('accept') || '';
|
|
543
575
|
const imageOnly = acceptIsImageOnly(accept);
|
|
576
|
+
const rect = el instanceof HTMLElement ? el.getBoundingClientRect() : { width: 0, height: 0 };
|
|
577
|
+
const visible = rect.width > 0 && rect.height > 0;
|
|
578
|
+
const local = localSet.has(el);
|
|
544
579
|
const score =
|
|
545
580
|
(el.hasAttribute('multiple') ? 100 : 0) +
|
|
546
|
-
(
|
|
581
|
+
(local ? 40 : 0) +
|
|
582
|
+
(visible ? 30 : -200) +
|
|
583
|
+
(!imageOnly ? 30 : isImageAttachment ? 20 : 5);
|
|
547
584
|
el.setAttribute('data-oracle-upload-candidate', 'true');
|
|
548
585
|
el.setAttribute('data-oracle-upload-idx', String(idx));
|
|
549
|
-
return { idx: idx++, score, imageOnly };
|
|
586
|
+
return { idx: idx++, score, imageOnly, visible, local };
|
|
550
587
|
});
|
|
551
|
-
if (!isImageAttachment) {
|
|
552
|
-
const nonImage = candidates.filter((candidate) => !candidate.imageOnly);
|
|
553
|
-
if (nonImage.length > 0) {
|
|
554
|
-
candidates = nonImage;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
588
|
|
|
558
589
|
// Prefer higher scores first.
|
|
559
590
|
candidates.sort((a, b) => b.score - a.score);
|
|
@@ -822,25 +853,25 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
822
853
|
const evaluateSignals = async (signalResult, postInputSignals, immediateInputMatch) => {
|
|
823
854
|
const expectedSatisfied = Boolean(signalResult?.expectedSatisfied) ||
|
|
824
855
|
(signalResult?.signals ? isExpectedSatisfied(signalResult.signals) : false);
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
856
|
+
const inputNameCandidates = resolveInputNameCandidates();
|
|
857
|
+
const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
|
|
858
|
+
(lastInputValue && matchesExpectedName(lastInputValue));
|
|
859
|
+
const inputEvidence = immediateInputMatch ||
|
|
860
|
+
postInputSignals.touched ||
|
|
861
|
+
Boolean(signalResult?.signals?.input) ||
|
|
862
|
+
Boolean(signalResult?.inputDelta) ||
|
|
863
|
+
inputHasFile;
|
|
864
|
+
const uiDirect = Boolean(signalResult?.signals?.ui) || expectedSatisfied;
|
|
865
|
+
const uiDelta = Boolean(signalResult?.chipDelta) || Boolean(signalResult?.uploadDelta) || Boolean(signalResult?.fileCountDelta);
|
|
866
|
+
if (uiDirect || (uiDelta && inputEvidence)) {
|
|
831
867
|
return { status: 'ui' };
|
|
832
868
|
}
|
|
833
869
|
const postSignals = await readAttachmentSignals(expectedName);
|
|
834
870
|
if (postSignals.ui ||
|
|
835
871
|
isExpectedSatisfied(postSignals) ||
|
|
836
|
-
hasChipDelta(postSignals) ||
|
|
837
|
-
hasUploadDelta(postSignals) ||
|
|
838
|
-
hasFileCountDelta(postSignals)) {
|
|
872
|
+
((hasChipDelta(postSignals) || hasUploadDelta(postSignals) || hasFileCountDelta(postSignals)) && inputEvidence)) {
|
|
839
873
|
return { status: 'ui' };
|
|
840
874
|
}
|
|
841
|
-
const inputNameCandidates = resolveInputNameCandidates();
|
|
842
|
-
const inputHasFile = inputNameCandidates.some((name) => matchesExpectedName(name)) ||
|
|
843
|
-
(lastInputValue && matchesExpectedName(lastInputValue));
|
|
844
875
|
const inputSignal = immediateInputMatch ||
|
|
845
876
|
postInputSignals.touched ||
|
|
846
877
|
Boolean(signalResult?.signals?.input) ||
|
|
@@ -859,6 +890,22 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
859
890
|
if (!hasExpectedFile) {
|
|
860
891
|
if (mode === 'set') {
|
|
861
892
|
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [attachment.path] });
|
|
893
|
+
await runtime
|
|
894
|
+
.evaluate({
|
|
895
|
+
expression: `(() => {
|
|
896
|
+
const input = document.querySelector('input[type="file"][data-oracle-upload-idx="${idx}"]');
|
|
897
|
+
if (!(input instanceof HTMLInputElement)) return false;
|
|
898
|
+
try {
|
|
899
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
900
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
901
|
+
return true;
|
|
902
|
+
} catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
})()`,
|
|
906
|
+
returnByValue: true,
|
|
907
|
+
})
|
|
908
|
+
.catch(() => undefined);
|
|
862
909
|
}
|
|
863
910
|
else {
|
|
864
911
|
const selector = `input[type="file"][data-oracle-upload-idx="${idx}"]`;
|
|
@@ -889,9 +936,19 @@ export async function uploadAttachmentFile(deps, attachment, logger, options) {
|
|
|
889
936
|
break;
|
|
890
937
|
}
|
|
891
938
|
if (result.evaluation.status === 'input') {
|
|
892
|
-
logger('Attachment input set;
|
|
893
|
-
|
|
894
|
-
|
|
939
|
+
logger('Attachment input set; retrying with data transfer to trigger ChatGPT upload.');
|
|
940
|
+
await dom.setFileInputFiles({ nodeId: resultNode.nodeId, files: [] }).catch(() => undefined);
|
|
941
|
+
await delay(150);
|
|
942
|
+
result = await runInputAttempt('transfer');
|
|
943
|
+
if (result.evaluation.status === 'ui') {
|
|
944
|
+
confirmedAttachment = true;
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
if (result.evaluation.status === 'input') {
|
|
948
|
+
logger('Attachment input set; proceeding without UI confirmation.');
|
|
949
|
+
inputConfirmed = true;
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
895
952
|
}
|
|
896
953
|
const lateSignals = await readAttachmentSignals(expectedName);
|
|
897
954
|
if (lateSignals.ui ||
|
|
@@ -1007,12 +1064,12 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
|
|
|
1007
1064
|
return parentHasSend ? parent : root;
|
|
1008
1065
|
})();
|
|
1009
1066
|
const removeSelectors = [
|
|
1010
|
-
'[aria-label
|
|
1011
|
-
'[aria-label
|
|
1012
|
-
'
|
|
1013
|
-
'
|
|
1014
|
-
'[data-testid*="remove"]',
|
|
1015
|
-
'[data-testid*="
|
|
1067
|
+
'[aria-label="Remove file"]',
|
|
1068
|
+
'button[aria-label="Remove file"]',
|
|
1069
|
+
'[aria-label*="Remove file"]',
|
|
1070
|
+
'[aria-label*="remove file"]',
|
|
1071
|
+
'[data-testid*="remove-attachment"]',
|
|
1072
|
+
'[data-testid*="attachment-remove"]',
|
|
1016
1073
|
];
|
|
1017
1074
|
const visible = (el) => {
|
|
1018
1075
|
if (!(el instanceof HTMLElement)) return false;
|
|
@@ -1023,10 +1080,15 @@ export async function clearComposerAttachments(Runtime, timeoutMs, logger) {
|
|
|
1023
1080
|
? Array.from(scope.querySelectorAll(removeSelectors.join(','))).filter(visible)
|
|
1024
1081
|
: [];
|
|
1025
1082
|
for (const button of removeButtons.slice(0, 20)) {
|
|
1026
|
-
try {
|
|
1083
|
+
try {
|
|
1084
|
+
if (button instanceof HTMLButtonElement) {
|
|
1085
|
+
// Ensure remove buttons never submit the composer form.
|
|
1086
|
+
button.type = 'button';
|
|
1087
|
+
}
|
|
1088
|
+
button.click();
|
|
1089
|
+
} catch {}
|
|
1027
1090
|
}
|
|
1028
|
-
const
|
|
1029
|
-
const chipCount = scope ? scope.querySelectorAll(chipSelector).length : 0;
|
|
1091
|
+
const chipCount = removeButtons.length;
|
|
1030
1092
|
const inputs = scope ? Array.from(scope.querySelectorAll('input[type="file"]')) : [];
|
|
1031
1093
|
let inputCount = 0;
|
|
1032
1094
|
for (const input of inputs) {
|
|
@@ -1189,7 +1251,7 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
1189
1251
|
if (file?.name) inputNames.push(file.name.toLowerCase());
|
|
1190
1252
|
}
|
|
1191
1253
|
}
|
|
1192
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files
|
|
1254
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
1193
1255
|
const fileCountSelectors = [
|
|
1194
1256
|
'button',
|
|
1195
1257
|
'[role="button"]',
|
|
@@ -1235,7 +1297,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
1235
1297
|
let hasFileHint = false;
|
|
1236
1298
|
for (const raw of candidates) {
|
|
1237
1299
|
if (!raw) continue;
|
|
1238
|
-
|
|
1300
|
+
const lowered = String(raw).toLowerCase();
|
|
1301
|
+
if (lowered.includes('file') || lowered.includes('attachment')) {
|
|
1239
1302
|
hasFileHint = true;
|
|
1240
1303
|
break;
|
|
1241
1304
|
}
|
|
@@ -1340,9 +1403,8 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
1340
1403
|
if (stable && value.state === 'ready') {
|
|
1341
1404
|
return;
|
|
1342
1405
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
}
|
|
1406
|
+
// Don't treat disabled button as complete - wait for it to become 'ready'.
|
|
1407
|
+
// The spinner detection is unreliable, so a disabled button likely means upload is in progress.
|
|
1346
1408
|
if (value.state === 'missing' && (value.filesAttached || fileCountSatisfied)) {
|
|
1347
1409
|
return;
|
|
1348
1410
|
}
|
|
@@ -1363,16 +1425,18 @@ export async function waitForAttachmentCompletion(Runtime, timeoutMs, expectedNa
|
|
|
1363
1425
|
const expectedNoExt = normalizedExpected.replace(/\.[a-z0-9]{1,10}$/i, '');
|
|
1364
1426
|
return !inputNames.some((raw) => raw.includes(normalizedExpected) || (expectedNoExt.length >= 6 && raw.includes(expectedNoExt)));
|
|
1365
1427
|
});
|
|
1366
|
-
|
|
1428
|
+
// Don't include 'disabled' - a disabled button likely means upload is still in progress.
|
|
1429
|
+
const inputStateOk = value.state === 'ready' || value.state === 'missing';
|
|
1367
1430
|
const inputSeenNow = inputMissing.length === 0 || fileCountSatisfied;
|
|
1431
|
+
const inputEvidenceOk = Boolean(value.filesAttached) || Boolean(value.uploading) || fileCountSatisfied;
|
|
1368
1432
|
const stableThresholdMs = value.uploading ? 3000 : 1500;
|
|
1369
|
-
if (inputSeenNow && inputStateOk) {
|
|
1433
|
+
if (inputSeenNow && inputStateOk && inputEvidenceOk) {
|
|
1370
1434
|
if (inputMatchSince === null) {
|
|
1371
1435
|
inputMatchSince = Date.now();
|
|
1372
1436
|
}
|
|
1373
1437
|
sawInputMatch = true;
|
|
1374
1438
|
}
|
|
1375
|
-
if (inputMatchSince !== null && inputStateOk && Date.now() - inputMatchSince > stableThresholdMs) {
|
|
1439
|
+
if (inputMatchSince !== null && inputStateOk && inputEvidenceOk && Date.now() - inputMatchSince > stableThresholdMs) {
|
|
1376
1440
|
return;
|
|
1377
1441
|
}
|
|
1378
1442
|
if (!inputSeenNow && !sawInputMatch) {
|
|
@@ -1416,10 +1480,10 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
|
|
|
1416
1480
|
'[title*="file"]',
|
|
1417
1481
|
'[title*="attachment"]',
|
|
1418
1482
|
];
|
|
1483
|
+
const attachmentUiCount = lastUser.querySelectorAll(attachmentSelectors.join(',')).length;
|
|
1419
1484
|
const hasAttachmentUi =
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files?\\b/;
|
|
1485
|
+
attachmentUiCount > 0 || attrs.some((attr) => attr.includes('file') || attr.includes('attachment'));
|
|
1486
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
1423
1487
|
const fileCountNodes = Array.from(lastUser.querySelectorAll('button,span,div,[aria-label],[title]'));
|
|
1424
1488
|
let fileCount = 0;
|
|
1425
1489
|
for (const node of fileCountNodes) {
|
|
@@ -1435,7 +1499,8 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
|
|
|
1435
1499
|
let hasFileHint = false;
|
|
1436
1500
|
for (const raw of candidates) {
|
|
1437
1501
|
if (!raw) continue;
|
|
1438
|
-
|
|
1502
|
+
const lowered = String(raw).toLowerCase();
|
|
1503
|
+
if (lowered.includes('file') || lowered.includes('attachment')) {
|
|
1439
1504
|
hasFileHint = true;
|
|
1440
1505
|
break;
|
|
1441
1506
|
}
|
|
@@ -1452,7 +1517,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
|
|
|
1452
1517
|
}
|
|
1453
1518
|
}
|
|
1454
1519
|
}
|
|
1455
|
-
return { ok: true, text, attrs, fileCount, hasAttachmentUi };
|
|
1520
|
+
return { ok: true, text, attrs, fileCount, hasAttachmentUi, attachmentUiCount };
|
|
1456
1521
|
})()`;
|
|
1457
1522
|
const deadline = Date.now() + timeoutMs;
|
|
1458
1523
|
let sawAttachmentUi = false;
|
|
@@ -1468,7 +1533,9 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
|
|
|
1468
1533
|
}
|
|
1469
1534
|
const haystack = [value.text ?? '', ...(value.attrs ?? [])].join('\n');
|
|
1470
1535
|
const fileCount = typeof value.fileCount === 'number' ? value.fileCount : 0;
|
|
1536
|
+
const attachmentUiCount = typeof value.attachmentUiCount === 'number' ? value.attachmentUiCount : 0;
|
|
1471
1537
|
const fileCountSatisfied = fileCount >= expectedNormalized.length && expectedNormalized.length > 0;
|
|
1538
|
+
const attachmentUiSatisfied = attachmentUiCount >= expectedNormalized.length && expectedNormalized.length > 0;
|
|
1472
1539
|
const missing = expectedNormalized.filter((expected) => {
|
|
1473
1540
|
const baseName = expected.split('/').pop()?.split('\\').pop() ?? expected;
|
|
1474
1541
|
const normalizedExpected = baseName.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -1479,7 +1546,7 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
|
|
|
1479
1546
|
return false;
|
|
1480
1547
|
return true;
|
|
1481
1548
|
});
|
|
1482
|
-
if (missing.length === 0 || fileCountSatisfied) {
|
|
1549
|
+
if (missing.length === 0 || fileCountSatisfied || attachmentUiSatisfied) {
|
|
1483
1550
|
return true;
|
|
1484
1551
|
}
|
|
1485
1552
|
await delay(250);
|
|
@@ -1500,6 +1567,12 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
1500
1567
|
const expected = ${JSON.stringify(expectedName)};
|
|
1501
1568
|
const normalized = expected.toLowerCase();
|
|
1502
1569
|
const normalizedNoExt = normalized.replace(/\\.[a-z0-9]{1,10}$/i, '');
|
|
1570
|
+
const matchesExpectedFileName = (value) => {
|
|
1571
|
+
const text = String(value || '').toLowerCase();
|
|
1572
|
+
if (!text) return false;
|
|
1573
|
+
if (text.includes(normalized)) return true;
|
|
1574
|
+
return normalizedNoExt.length >= 6 && text.includes(normalizedNoExt);
|
|
1575
|
+
};
|
|
1503
1576
|
const matchNode = (node) => {
|
|
1504
1577
|
if (!node) return false;
|
|
1505
1578
|
if (node.tagName === 'INPUT' && node.type === 'file') return false;
|
|
@@ -1512,39 +1585,96 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
1512
1585
|
return candidates.some((value) => value.includes(normalized) || (normalizedNoExt.length >= 6 && value.includes(normalizedNoExt)));
|
|
1513
1586
|
};
|
|
1514
1587
|
|
|
1515
|
-
const
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1588
|
+
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
1589
|
+
for (const input of inputs) {
|
|
1590
|
+
if (!(input instanceof HTMLInputElement)) continue;
|
|
1591
|
+
const files = Array.from(input.files || []);
|
|
1592
|
+
if (files.some((file) => matchesExpectedFileName(file?.name))) {
|
|
1593
|
+
return { found: true, source: 'file-input' };
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
const promptSelectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
1598
|
+
const sendSelectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
|
|
1599
|
+
const findPromptNode = () => {
|
|
1600
|
+
for (const selector of promptSelectors) {
|
|
1601
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
1602
|
+
for (const node of nodes) {
|
|
1603
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
1604
|
+
const rect = node.getBoundingClientRect();
|
|
1605
|
+
if (rect.width > 0 && rect.height > 0) return node;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
for (const selector of promptSelectors) {
|
|
1609
|
+
const node = document.querySelector(selector);
|
|
1610
|
+
if (node) return node;
|
|
1611
|
+
}
|
|
1612
|
+
return null;
|
|
1613
|
+
};
|
|
1614
|
+
const attachmentSelectors = [
|
|
1615
|
+
'input[type="file"]',
|
|
1616
|
+
'[data-testid*="attachment"]',
|
|
1617
|
+
'[data-testid*="chip"]',
|
|
1618
|
+
'[data-testid*="upload"]',
|
|
1619
|
+
'[data-testid*="file"]',
|
|
1620
|
+
'[aria-label*="Remove"]',
|
|
1621
|
+
'[aria-label*="remove"]',
|
|
1622
|
+
];
|
|
1623
|
+
const locateComposerRoot = () => {
|
|
1624
|
+
const promptNode = findPromptNode();
|
|
1625
|
+
if (promptNode) {
|
|
1626
|
+
const initial =
|
|
1627
|
+
promptNode.closest('[data-testid*="composer"]') ??
|
|
1628
|
+
promptNode.closest('form') ??
|
|
1629
|
+
promptNode.parentElement ??
|
|
1630
|
+
document.body;
|
|
1631
|
+
let current = initial;
|
|
1632
|
+
let fallback = initial;
|
|
1633
|
+
while (current && current !== document.body) {
|
|
1634
|
+
const hasSend = sendSelectors.some((selector) => current.querySelector(selector));
|
|
1635
|
+
if (hasSend) {
|
|
1636
|
+
fallback = current;
|
|
1637
|
+
const hasAttachment = attachmentSelectors.some((selector) => current.querySelector(selector));
|
|
1638
|
+
if (hasAttachment) return current;
|
|
1639
|
+
}
|
|
1640
|
+
current = current.parentElement;
|
|
1641
|
+
}
|
|
1642
|
+
return fallback ?? initial;
|
|
1643
|
+
}
|
|
1644
|
+
return document.querySelector('form') ?? document.body;
|
|
1645
|
+
};
|
|
1646
|
+
const composerRoot = locateComposerRoot() ?? document.body;
|
|
1647
|
+
|
|
1648
|
+
const attachmentMatch = ['[data-testid*="attachment"]','[data-testid*="chip"]','[data-testid*="upload"]','[data-testid*="file"]'].some((selector) =>
|
|
1649
|
+
Array.from(composerRoot.querySelectorAll(selector)).some(matchNode),
|
|
1518
1650
|
);
|
|
1519
1651
|
if (attachmentMatch) {
|
|
1520
1652
|
return { found: true, source: 'attachments' };
|
|
1521
1653
|
}
|
|
1522
1654
|
|
|
1523
|
-
const
|
|
1655
|
+
const removeButtons = Array.from(
|
|
1656
|
+
(composerRoot ?? document).querySelectorAll('[aria-label*="Remove"],[aria-label*="remove"]'),
|
|
1657
|
+
);
|
|
1658
|
+
const visibleRemove = removeButtons.some((btn) => {
|
|
1659
|
+
if (!(btn instanceof HTMLElement)) return false;
|
|
1660
|
+
const rect = btn.getBoundingClientRect();
|
|
1661
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
1662
|
+
const style = window.getComputedStyle(btn);
|
|
1663
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
1664
|
+
});
|
|
1665
|
+
if (visibleRemove) {
|
|
1666
|
+
return { found: true, source: 'remove-button' };
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const cardTexts = Array.from(composerRoot.querySelectorAll('[aria-label*="Remove"]')).map((btn) =>
|
|
1524
1670
|
btn?.parentElement?.parentElement?.innerText?.toLowerCase?.() ?? '',
|
|
1525
1671
|
);
|
|
1526
1672
|
if (cardTexts.some((text) => text.includes(normalized) || (normalizedNoExt.length >= 6 && text.includes(normalizedNoExt)))) {
|
|
1527
1673
|
return { found: true, source: 'attachment-cards' };
|
|
1528
1674
|
}
|
|
1529
1675
|
|
|
1530
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files
|
|
1531
|
-
const fileCountNodes = (()
|
|
1532
|
-
const nodes = [];
|
|
1533
|
-
const seen = new Set();
|
|
1534
|
-
const add = (node) => {
|
|
1535
|
-
if (!node || seen.has(node)) return;
|
|
1536
|
-
seen.add(node);
|
|
1537
|
-
nodes.push(node);
|
|
1538
|
-
};
|
|
1539
|
-
const root =
|
|
1540
|
-
document.querySelector('[data-testid*="composer"]') || document.querySelector('form') || document.body;
|
|
1541
|
-
const localNodes = root ? Array.from(root.querySelectorAll('button,span,div,[aria-label],[title]')) : [];
|
|
1542
|
-
for (const node of localNodes) add(node);
|
|
1543
|
-
for (const node of Array.from(document.querySelectorAll('button,span,div,[aria-label],[title]'))) {
|
|
1544
|
-
add(node);
|
|
1545
|
-
}
|
|
1546
|
-
return nodes;
|
|
1547
|
-
})();
|
|
1676
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
1677
|
+
const fileCountNodes = Array.from(composerRoot.querySelectorAll('button,span,div,[aria-label],[title]'));
|
|
1548
1678
|
let fileCount = 0;
|
|
1549
1679
|
for (const node of fileCountNodes) {
|
|
1550
1680
|
if (!(node instanceof HTMLElement)) continue;
|
|
@@ -1577,7 +1707,8 @@ export async function waitForAttachmentVisible(Runtime, expectedName, timeoutMs,
|
|
|
1577
1707
|
let hasFileHint = false;
|
|
1578
1708
|
for (const raw of candidates) {
|
|
1579
1709
|
if (!raw) continue;
|
|
1580
|
-
|
|
1710
|
+
const lowered = String(raw).toLowerCase();
|
|
1711
|
+
if (lowered.includes('file') || lowered.includes('attachment')) {
|
|
1581
1712
|
hasFileHint = true;
|
|
1582
1713
|
break;
|
|
1583
1714
|
}
|
|
@@ -1635,12 +1766,24 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
|
1635
1766
|
return false;
|
|
1636
1767
|
};
|
|
1637
1768
|
|
|
1769
|
+
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
1770
|
+
for (const input of inputs) {
|
|
1771
|
+
if (!(input instanceof HTMLInputElement)) continue;
|
|
1772
|
+
for (const file of Array.from(input.files || [])) {
|
|
1773
|
+
if (file?.name && matchesExpected(file.name)) {
|
|
1774
|
+
return { found: true, text: 'file-input' };
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1638
1779
|
const selectors = [
|
|
1639
1780
|
'[data-testid*="attachment"]',
|
|
1640
1781
|
'[data-testid*="chip"]',
|
|
1641
1782
|
'[data-testid*="upload"]',
|
|
1642
1783
|
'[aria-label*="Remove"]',
|
|
1643
1784
|
'button[aria-label*="Remove"]',
|
|
1785
|
+
'[aria-label*="remove"]',
|
|
1786
|
+
'button[aria-label*="remove"]',
|
|
1644
1787
|
];
|
|
1645
1788
|
for (const selector of selectors) {
|
|
1646
1789
|
for (const node of Array.from(document.querySelectorAll(selector))) {
|
|
@@ -1659,7 +1802,7 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
|
1659
1802
|
if (cards.some(matchesExpected)) {
|
|
1660
1803
|
return { found: true, text: cards.find(matchesExpected) };
|
|
1661
1804
|
}
|
|
1662
|
-
const countRegex = /(?:^|\\b)(\\d+)\\s+files
|
|
1805
|
+
const countRegex = /(?:^|\\b)(\\d+)\\s+(?:files?|attachments?)\\b/;
|
|
1663
1806
|
const fileCountNodes = (() => {
|
|
1664
1807
|
const nodes = [];
|
|
1665
1808
|
const seen = new Set();
|
|
@@ -1709,7 +1852,8 @@ async function waitForAttachmentAnchored(Runtime, expectedName, timeoutMs) {
|
|
|
1709
1852
|
let hasFileHint = false;
|
|
1710
1853
|
for (const raw of candidates) {
|
|
1711
1854
|
if (!raw) continue;
|
|
1712
|
-
|
|
1855
|
+
const lowered = String(raw).toLowerCase();
|
|
1856
|
+
if (lowered.includes('file') || lowered.includes('attachment')) {
|
|
1713
1857
|
hasFileHint = true;
|
|
1714
1858
|
break;
|
|
1715
1859
|
}
|
|
@@ -1,11 +1,126 @@
|
|
|
1
1
|
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS, } from '../constants.js';
|
|
2
2
|
import { delay } from '../utils.js';
|
|
3
3
|
import { logDomFailure } from '../domDebug.js';
|
|
4
|
+
export function installJavaScriptDialogAutoDismissal(Page, logger) {
|
|
5
|
+
const pageAny = Page;
|
|
6
|
+
if (typeof pageAny.on !== 'function' || typeof pageAny.handleJavaScriptDialog !== 'function') {
|
|
7
|
+
return () => { };
|
|
8
|
+
}
|
|
9
|
+
const handler = async (params) => {
|
|
10
|
+
const type = typeof params?.type === 'string' ? params.type : 'unknown';
|
|
11
|
+
const message = typeof params?.message === 'string' ? params.message : '';
|
|
12
|
+
logger(`[nav] dismissing JS dialog (${type})${message ? `: ${message.slice(0, 140)}` : ''}`);
|
|
13
|
+
try {
|
|
14
|
+
await pageAny.handleJavaScriptDialog?.({ accept: true, promptText: '' });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
18
|
+
logger(`[nav] failed to dismiss JS dialog: ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
pageAny.on('javascriptDialogOpening', handler);
|
|
22
|
+
return () => {
|
|
23
|
+
try {
|
|
24
|
+
pageAny.off?.('javascriptDialogOpening', handler);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
try {
|
|
28
|
+
pageAny.removeListener?.('javascriptDialogOpening', handler);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
4
36
|
export async function navigateToChatGPT(Page, Runtime, url, logger) {
|
|
5
37
|
logger(`Navigating to ${url}`);
|
|
6
38
|
await Page.navigate({ url });
|
|
7
39
|
await waitForDocumentReady(Runtime, 45_000);
|
|
8
40
|
}
|
|
41
|
+
async function dismissBlockingUi(Runtime, logger) {
|
|
42
|
+
const outcome = await Runtime.evaluate({
|
|
43
|
+
expression: `(() => {
|
|
44
|
+
const isVisible = (el) => {
|
|
45
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
46
|
+
const rect = el.getBoundingClientRect();
|
|
47
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
48
|
+
const style = window.getComputedStyle(el);
|
|
49
|
+
if (!style) return false;
|
|
50
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
54
|
+
const labelFor = (el) => normalize(el?.textContent || el?.getAttribute?.('aria-label') || el?.getAttribute?.('title'));
|
|
55
|
+
const buttonCandidates = (root) =>
|
|
56
|
+
Array.from(root.querySelectorAll('button,[role="button"],a')).filter((el) => isVisible(el));
|
|
57
|
+
|
|
58
|
+
const roots = [
|
|
59
|
+
...Array.from(document.querySelectorAll('[role="dialog"],dialog')),
|
|
60
|
+
document.body,
|
|
61
|
+
].filter(Boolean);
|
|
62
|
+
for (const root of roots) {
|
|
63
|
+
const buttons = buttonCandidates(root);
|
|
64
|
+
const close = buttons.find((el) => labelFor(el).includes('close'));
|
|
65
|
+
if (close) {
|
|
66
|
+
(close).click();
|
|
67
|
+
return { dismissed: true, action: 'close' };
|
|
68
|
+
}
|
|
69
|
+
const okLike = buttons.find((el) => {
|
|
70
|
+
const label = labelFor(el);
|
|
71
|
+
return (
|
|
72
|
+
label === 'ok' ||
|
|
73
|
+
label === 'got it' ||
|
|
74
|
+
label === 'dismiss' ||
|
|
75
|
+
label === 'continue' ||
|
|
76
|
+
label === 'back' ||
|
|
77
|
+
label.includes('back to chatgpt') ||
|
|
78
|
+
label.includes('go to chatgpt') ||
|
|
79
|
+
label.includes('return') ||
|
|
80
|
+
label.includes('take me')
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
if (okLike) {
|
|
84
|
+
(okLike).click();
|
|
85
|
+
return { dismissed: true, action: 'confirm' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { dismissed: false };
|
|
89
|
+
})()`,
|
|
90
|
+
returnByValue: true,
|
|
91
|
+
}).catch(() => null);
|
|
92
|
+
const value = outcome?.result?.value;
|
|
93
|
+
if (value?.dismissed) {
|
|
94
|
+
logger(`[nav] dismissed blocking UI (${value.action ?? 'unknown'})`);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
export async function navigateToPromptReadyWithFallback(Page, Runtime, options, deps = {}) {
|
|
100
|
+
const { url, fallbackUrl, timeoutMs, fallbackTimeoutMs, headless, logger, } = options;
|
|
101
|
+
const navigate = deps.navigateToChatGPT ?? navigateToChatGPT;
|
|
102
|
+
const ensureBlocked = deps.ensureNotBlocked ?? ensureNotBlocked;
|
|
103
|
+
const ensureReady = deps.ensurePromptReady ?? ensurePromptReady;
|
|
104
|
+
await navigate(Page, Runtime, url, logger);
|
|
105
|
+
await ensureBlocked(Runtime, headless, logger);
|
|
106
|
+
await dismissBlockingUi(Runtime, logger).catch(() => false);
|
|
107
|
+
try {
|
|
108
|
+
await ensureReady(Runtime, timeoutMs, logger);
|
|
109
|
+
return { usedFallback: false };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (!fallbackUrl || fallbackUrl === url) {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
const fallbackTimeout = fallbackTimeoutMs ?? Math.max(timeoutMs * 2, 120_000);
|
|
116
|
+
logger(`Prompt not ready after ${Math.round(timeoutMs / 1000)}s on ${url}; retrying ${fallbackUrl} with ${Math.round(fallbackTimeout / 1000)}s timeout.`);
|
|
117
|
+
await navigate(Page, Runtime, fallbackUrl, logger);
|
|
118
|
+
await ensureBlocked(Runtime, headless, logger);
|
|
119
|
+
await dismissBlockingUi(Runtime, logger).catch(() => false);
|
|
120
|
+
await ensureReady(Runtime, fallbackTimeout, logger);
|
|
121
|
+
return { usedFallback: true };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
9
124
|
export async function ensureNotBlocked(Runtime, headless, logger) {
|
|
10
125
|
if (await isCloudflareInterstitial(Runtime)) {
|
|
11
126
|
const message = headless
|
|
@@ -110,6 +110,8 @@ function buildThinkingTimeExpression(level) {
|
|
|
110
110
|
for (const selector of CHIP_SELECTORS) {
|
|
111
111
|
const buttons = document.querySelectorAll(selector);
|
|
112
112
|
for (const btn of buttons) {
|
|
113
|
+
// Skip toggle buttons (no haspopup) - only click dropdown triggers to avoid disabling Pro mode
|
|
114
|
+
if (btn.getAttribute?.('aria-haspopup') !== 'menu') continue;
|
|
113
115
|
const aria = normalize(btn.getAttribute?.('aria-label') ?? '');
|
|
114
116
|
const text = normalize(btn.textContent ?? '');
|
|
115
117
|
if (aria.includes('thinking') || text.includes('thinking')) {
|
|
@@ -172,6 +172,8 @@ function buildChromeFlags(headless, debugBindAddress) {
|
|
|
172
172
|
'--disable-features=TranslateUI,AutomationControlled',
|
|
173
173
|
'--mute-audio',
|
|
174
174
|
'--window-size=1280,720',
|
|
175
|
+
'--lang=en-US',
|
|
176
|
+
'--accept-lang=en-US,en',
|
|
175
177
|
];
|
|
176
178
|
if (process.platform !== 'win32' && !isWsl()) {
|
|
177
179
|
flags.push('--password-store=basic', '--use-mock-keychain');
|
|
@@ -14,6 +14,7 @@ export const DEFAULT_BROWSER_CONFIG = {
|
|
|
14
14
|
inputTimeoutMs: 60_000,
|
|
15
15
|
cookieSync: true,
|
|
16
16
|
cookieNames: null,
|
|
17
|
+
cookieSyncWaitMs: 0,
|
|
17
18
|
inlineCookies: null,
|
|
18
19
|
inlineCookiesSource: null,
|
|
19
20
|
headless: false,
|
|
@@ -58,6 +59,7 @@ export function resolveBrowserConfig(config) {
|
|
|
58
59
|
inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
|
|
59
60
|
cookieSync: config?.cookieSync ?? cookieSyncDefault,
|
|
60
61
|
cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
|
|
62
|
+
cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
|
|
61
63
|
inlineCookies: config?.inlineCookies ?? DEFAULT_BROWSER_CONFIG.inlineCookies,
|
|
62
64
|
inlineCookiesSource: config?.inlineCookiesSource ?? DEFAULT_BROWSER_CONFIG.inlineCookiesSource,
|
|
63
65
|
headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { COOKIE_URLS } from './constants.js';
|
|
2
|
+
import { delay } from './utils.js';
|
|
2
3
|
import { getCookies } from '@steipete/sweet-cookie';
|
|
3
4
|
export class ChromeCookieSyncError extends Error {
|
|
4
5
|
}
|
|
5
6
|
export async function syncCookies(Network, url, profile, logger, options = {}) {
|
|
6
|
-
const { allowErrors = false, filterNames, inlineCookies, cookiePath } = options;
|
|
7
|
+
const { allowErrors = false, filterNames, inlineCookies, cookiePath, waitMs = 0 } = options;
|
|
7
8
|
try {
|
|
8
9
|
// Learned: inline cookies are the most deterministic (avoid Keychain + profile ambiguity).
|
|
9
10
|
const cookies = inlineCookies?.length
|
|
10
11
|
? normalizeInlineCookies(inlineCookies, new URL(url).hostname)
|
|
11
|
-
: await
|
|
12
|
+
: await readChromeCookiesWithWait(url, profile, filterNames ?? undefined, cookiePath ?? undefined, waitMs, logger);
|
|
12
13
|
if (!cookies.length) {
|
|
13
14
|
return 0;
|
|
14
15
|
}
|
|
@@ -38,6 +39,32 @@ export async function syncCookies(Network, url, profile, logger, options = {}) {
|
|
|
38
39
|
throw error instanceof ChromeCookieSyncError ? error : new ChromeCookieSyncError(message);
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
async function readChromeCookiesWithWait(url, profile, filterNames, cookiePath, waitMs, logger) {
|
|
43
|
+
if (waitMs <= 0) {
|
|
44
|
+
return readChromeCookies(url, profile, filterNames, cookiePath);
|
|
45
|
+
}
|
|
46
|
+
let cookies = [];
|
|
47
|
+
let firstError;
|
|
48
|
+
try {
|
|
49
|
+
cookies = await readChromeCookies(url, profile, filterNames, cookiePath);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
firstError = error;
|
|
53
|
+
}
|
|
54
|
+
if (cookies.length > 0 && !firstError) {
|
|
55
|
+
return cookies;
|
|
56
|
+
}
|
|
57
|
+
const waitLabel = waitMs >= 1000 ? `${Math.round(waitMs / 1000)}s` : `${waitMs}ms`;
|
|
58
|
+
const message = firstError instanceof Error ? firstError.message : String(firstError ?? '');
|
|
59
|
+
if (firstError) {
|
|
60
|
+
logger(`[cookies] Cookie read failed (${message}); waiting ${waitLabel} then retrying once.`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
logger(`[cookies] No cookies found; waiting ${waitLabel} then retrying once.`);
|
|
64
|
+
}
|
|
65
|
+
await delay(waitMs);
|
|
66
|
+
return readChromeCookies(url, profile, filterNames, cookiePath);
|
|
67
|
+
}
|
|
41
68
|
async function readChromeCookies(url, profile, filterNames, cookiePath) {
|
|
42
69
|
const origins = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
|
|
43
70
|
const chromeProfile = cookiePath ?? profile ?? undefined;
|
|
@@ -5,7 +5,7 @@ import net from 'node:net';
|
|
|
5
5
|
import { resolveBrowserConfig } from './config.js';
|
|
6
6
|
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
|
|
7
7
|
import { syncCookies } from './cookies.js';
|
|
8
|
-
import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
8
|
+
import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
|
|
9
9
|
import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
|
|
10
10
|
import { ensureThinkingTime } from './actions/thinkingTime.js';
|
|
11
11
|
import { estimateTokenCount, withRetries, delay } from './utils.js';
|
|
@@ -129,6 +129,7 @@ export async function runBrowserMode(options) {
|
|
|
129
129
|
let runStatus = 'attempted';
|
|
130
130
|
let connectionClosedUnexpectedly = false;
|
|
131
131
|
let stopThinkingMonitor = null;
|
|
132
|
+
let removeDialogHandler = null;
|
|
132
133
|
let appliedCookies = 0;
|
|
133
134
|
try {
|
|
134
135
|
try {
|
|
@@ -158,6 +159,7 @@ export async function runBrowserMode(options) {
|
|
|
158
159
|
domainEnablers.push(DOM.enable());
|
|
159
160
|
}
|
|
160
161
|
await Promise.all(domainEnablers);
|
|
162
|
+
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
|
|
161
163
|
if (!manualLogin) {
|
|
162
164
|
await Network.clearBrowserCookies();
|
|
163
165
|
}
|
|
@@ -179,6 +181,7 @@ export async function runBrowserMode(options) {
|
|
|
179
181
|
filterNames: config.cookieNames ?? undefined,
|
|
180
182
|
inlineCookies: config.inlineCookies ?? undefined,
|
|
181
183
|
cookiePath: config.chromeCookiePath ?? undefined,
|
|
184
|
+
waitMs: config.cookieSyncWaitMs ?? 0,
|
|
182
185
|
});
|
|
183
186
|
appliedCookies = cookieCount;
|
|
184
187
|
if (config.inlineCookies && cookieCount === 0) {
|
|
@@ -201,7 +204,8 @@ export async function runBrowserMode(options) {
|
|
|
201
204
|
// Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
|
|
202
205
|
// Fail early so the user knows to sign in.
|
|
203
206
|
throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
|
|
204
|
-
'Make sure ChatGPT is signed in in the selected profile,
|
|
207
|
+
'Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, ' +
|
|
208
|
+
'or retry with --browser-cookie-wait 5s if Keychain prompts are slow.', {
|
|
205
209
|
stage: 'execute-browser',
|
|
206
210
|
details: {
|
|
207
211
|
profile: config.chromeProfile ?? 'Default',
|
|
@@ -218,10 +222,17 @@ export async function runBrowserMode(options) {
|
|
|
218
222
|
// Learned: login checks must happen on the base domain before jumping into project URLs.
|
|
219
223
|
await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
|
|
220
224
|
if (config.url !== baseUrl) {
|
|
221
|
-
await raceWithDisconnect(
|
|
222
|
-
|
|
225
|
+
await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
|
|
226
|
+
url: config.url,
|
|
227
|
+
fallbackUrl: baseUrl,
|
|
228
|
+
timeoutMs: config.inputTimeoutMs,
|
|
229
|
+
headless: config.headless,
|
|
230
|
+
logger,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
223
235
|
}
|
|
224
|
-
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
225
236
|
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
226
237
|
const captureRuntimeSnapshot = async () => {
|
|
227
238
|
try {
|
|
@@ -336,6 +347,7 @@ export async function runBrowserMode(options) {
|
|
|
336
347
|
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
337
348
|
const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
|
|
338
349
|
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
350
|
+
let attachmentWaitTimedOut = false;
|
|
339
351
|
let inputOnlyAttachments = false;
|
|
340
352
|
if (submissionAttachments.length > 0) {
|
|
341
353
|
if (!DOM) {
|
|
@@ -345,25 +357,38 @@ export async function runBrowserMode(options) {
|
|
|
345
357
|
for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
|
|
346
358
|
const attachment = submissionAttachments[attachmentIndex];
|
|
347
359
|
logger(`Uploading attachment: ${attachment.displayPath}`);
|
|
348
|
-
const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM }, attachment, logger, { expectedCount: attachmentIndex + 1 });
|
|
360
|
+
const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 });
|
|
349
361
|
if (!uiConfirmed) {
|
|
350
362
|
inputOnlyAttachments = true;
|
|
351
363
|
}
|
|
352
364
|
await delay(500);
|
|
353
365
|
}
|
|
354
|
-
// Scale timeout based on number of files: base
|
|
366
|
+
// Scale timeout based on number of files: base 45s + 20s per additional file.
|
|
355
367
|
const baseTimeout = config.inputTimeoutMs ?? 30_000;
|
|
356
|
-
const perFileTimeout =
|
|
357
|
-
const waitBudget = Math.max(baseTimeout,
|
|
358
|
-
|
|
359
|
-
|
|
368
|
+
const perFileTimeout = 20_000;
|
|
369
|
+
const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
|
|
370
|
+
try {
|
|
371
|
+
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
372
|
+
logger('All attachments uploaded');
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
376
|
+
if (/Attachments did not finish uploading before timeout/i.test(message)) {
|
|
377
|
+
attachmentWaitTimedOut = true;
|
|
378
|
+
logger(`[browser] Attachment upload timed out after ${Math.round(waitBudget / 1000)}s; continuing without confirmation.`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
360
384
|
}
|
|
361
385
|
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
362
386
|
// Learned: return baselineTurns so assistant polling can ignore earlier content.
|
|
387
|
+
const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
|
|
363
388
|
const committedTurns = await submitPrompt({
|
|
364
389
|
runtime: Runtime,
|
|
365
390
|
input: Input,
|
|
366
|
-
attachmentNames,
|
|
391
|
+
attachmentNames: sendAttachmentNames,
|
|
367
392
|
baselineTurns: baselineTurns ?? undefined,
|
|
368
393
|
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
369
394
|
}, prompt, logger);
|
|
@@ -373,14 +398,18 @@ export async function runBrowserMode(options) {
|
|
|
373
398
|
}
|
|
374
399
|
}
|
|
375
400
|
if (attachmentNames.length > 0) {
|
|
376
|
-
if (
|
|
401
|
+
if (attachmentWaitTimedOut) {
|
|
402
|
+
logger('Attachment confirmation timed out; skipping user-turn attachment verification.');
|
|
403
|
+
}
|
|
404
|
+
else if (inputOnlyAttachments) {
|
|
377
405
|
logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
|
|
378
406
|
}
|
|
379
407
|
else {
|
|
380
408
|
const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger);
|
|
381
|
-
if (verified) {
|
|
382
|
-
|
|
409
|
+
if (!verified) {
|
|
410
|
+
throw new Error('Sent user message did not expose attachment UI after upload.');
|
|
383
411
|
}
|
|
412
|
+
logger('Verified attachments present on sent user message');
|
|
384
413
|
}
|
|
385
414
|
}
|
|
386
415
|
// Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
|
|
@@ -618,6 +647,7 @@ export async function runBrowserMode(options) {
|
|
|
618
647
|
catch {
|
|
619
648
|
// ignore
|
|
620
649
|
}
|
|
650
|
+
removeDialogHandler?.();
|
|
621
651
|
removeTerminationHooks?.();
|
|
622
652
|
if (!effectiveKeepBrowser) {
|
|
623
653
|
if (!connectionClosedUnexpectedly) {
|
|
@@ -796,6 +826,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
796
826
|
let answerHtml = '';
|
|
797
827
|
let connectionClosedUnexpectedly = false;
|
|
798
828
|
let stopThinkingMonitor = null;
|
|
829
|
+
let removeDialogHandler = null;
|
|
799
830
|
try {
|
|
800
831
|
const connection = await connectToRemoteChrome(host, port, logger, config.url);
|
|
801
832
|
client = connection.client;
|
|
@@ -811,6 +842,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
811
842
|
domainEnablers.push(DOM.enable());
|
|
812
843
|
}
|
|
813
844
|
await Promise.all(domainEnablers);
|
|
845
|
+
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
|
|
814
846
|
// Skip cookie sync for remote Chrome - it already has cookies
|
|
815
847
|
logger('Skipping cookie sync for remote Chrome (using existing session)');
|
|
816
848
|
await navigateToChatGPT(Page, Runtime, config.url, logger);
|
|
@@ -1082,6 +1114,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1082
1114
|
catch {
|
|
1083
1115
|
// ignore
|
|
1084
1116
|
}
|
|
1117
|
+
removeDialogHandler?.();
|
|
1085
1118
|
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
|
|
1086
1119
|
// Don't kill remote Chrome - it's not ours to manage
|
|
1087
1120
|
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady } from './actions/navigation.js';
|
|
1
|
+
export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, } from './actions/navigation.js';
|
|
2
2
|
export { ensureModelSelection } from './actions/modelSelection.js';
|
|
3
3
|
export { submitPrompt, clearPromptComposer } from './actions/promptComposer.js';
|
|
4
4
|
export { clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, } from './actions/attachments.js';
|
|
@@ -114,6 +114,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
|
|
|
114
114
|
filterNames: resolved.cookieNames ?? undefined,
|
|
115
115
|
inlineCookies: resolved.inlineCookies ?? undefined,
|
|
116
116
|
cookiePath: resolved.chromeCookiePath ?? undefined,
|
|
117
|
+
waitMs: resolved.cookieSyncWaitMs ?? 0,
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
120
|
await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
|
|
@@ -82,6 +82,7 @@ export async function buildBrowserConfig(options) {
|
|
|
82
82
|
inputTimeoutMs: options.browserInputTimeout
|
|
83
83
|
? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
|
|
84
84
|
: undefined,
|
|
85
|
+
cookieSyncWaitMs: options.browserCookieWait ? parseDuration(options.browserCookieWait, 0) : undefined,
|
|
85
86
|
cookieSync: options.browserNoCookieSync ? false : undefined,
|
|
86
87
|
cookieNames,
|
|
87
88
|
inlineCookies: inline?.cookies,
|
|
@@ -33,6 +33,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
|
|
|
33
33
|
if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
|
|
34
34
|
options.browserInputTimeout = String(browser.inputTimeoutMs);
|
|
35
35
|
}
|
|
36
|
+
if (isUnset('browserCookieWait') && typeof browser.cookieSyncWaitMs === 'number') {
|
|
37
|
+
options.browserCookieWait = String(browser.cookieSyncWaitMs);
|
|
38
|
+
}
|
|
36
39
|
if (isUnset('browserHeadless') && browser.headless !== undefined) {
|
|
37
40
|
options.browserHeadless = browser.headless;
|
|
38
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steipete/oracle",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "CLI wrapper around OpenAI Responses API with GPT-5.2 Pro (via gpt-5.1-pro alias), GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/bin/oracle-cli.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
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
24
|
"test:browser": "pnpm run build && tsx scripts/test-browser.ts && ./scripts/browser-smoke.sh",
|
|
25
25
|
"test:live": "ORACLE_LIVE_TEST=1 vitest run tests/live --exclude tests/live/openai-live.test.ts",
|
|
26
|
+
"test:live:fast": "ORACLE_LIVE_TEST=1 ORACLE_LIVE_TEST_FAST=1 vitest run tests/live/browser-fast-live.test.ts",
|
|
26
27
|
"test:pro": "ORACLE_LIVE_TEST=1 vitest run tests/live/openai-live.test.ts",
|
|
27
28
|
"test:coverage": "vitest run --coverage",
|
|
28
29
|
"prepare": "pnpm run build",
|