pi-oracle 0.7.7 → 0.7.9
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/CHANGELOG.md +27 -0
- package/README.md +6 -6
- package/docs/ORACLE_DESIGN.md +13 -9
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +18 -17
- package/docs/platform-smoke.md +1 -1
- package/extensions/oracle/index.ts +84 -4
- package/extensions/oracle/lib/auth.ts +4 -4
- package/extensions/oracle/lib/commands.ts +48 -22
- package/extensions/oracle/lib/poller.ts +20 -5
- package/extensions/oracle/lib/runtime.ts +8 -0
- package/extensions/oracle/lib/tools.ts +18 -5
- package/extensions/oracle/shared/browser-profile-helpers.d.mts +15 -0
- package/extensions/oracle/shared/browser-profile-helpers.mjs +37 -13
- package/extensions/oracle/shared/job-observability-helpers.d.mts +3 -1
- package/extensions/oracle/shared/job-observability-helpers.mjs +14 -5
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +9 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +29 -2
- package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +1 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +52 -13
- package/extensions/oracle/worker/run-job.mjs +322 -60
- package/package.json +3 -6
- package/prompts/oracle-followup.md +2 -2
- package/prompts/oracle.md +13 -5
- package/scripts/oracle-real-smoke.mjs +6 -2
|
@@ -30,12 +30,14 @@ import {
|
|
|
30
30
|
effortSelectionVisible,
|
|
31
31
|
snapshotCanSafelySkipModelConfiguration,
|
|
32
32
|
snapshotHasModelConfigurationUi,
|
|
33
|
+
snapshotHasModelOpener,
|
|
33
34
|
snapshotHasUsableComposerControls,
|
|
34
35
|
snapshotStronglyMatchesRequestedModel,
|
|
35
36
|
snapshotWeaklyMatchesRequestedModel,
|
|
36
37
|
autoSwitchToThinkingSelectionVisible,
|
|
38
|
+
stripChatGptResponseChrome,
|
|
37
39
|
} from "./chatgpt-ui-helpers.mjs";
|
|
38
|
-
import { assistantSnapshotSlice, nextStableValueState, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
|
|
40
|
+
import { assistantSnapshotSlice, nextStableValueState, providerSendAccepted, resolveStableConversationUrlCandidate, stripUrlQueryAndHash } from "./chatgpt-flow-helpers.mjs";
|
|
39
41
|
import { assertNotKnownBrowserUserDataPath, scrubSweetCookieSafeStoragePasswordEnv, sweetCookieSafeStoragePasswordScrubbedEnv } from "../shared/browser-profile-helpers.mjs";
|
|
40
42
|
import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
|
|
41
43
|
|
|
@@ -83,11 +85,15 @@ const POST_SEND_SETTLE_MS = 15_000;
|
|
|
83
85
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
84
86
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
85
87
|
) || "agent-browser";
|
|
88
|
+
const CHROME_DEVTOOLS_READY_TIMEOUT_MS = 15_000;
|
|
86
89
|
const CP_BIN = process.env.PI_ORACLE_CP_PATH?.trim() || "cp";
|
|
87
90
|
scrubSweetCookieSafeStoragePasswordEnv();
|
|
88
91
|
|
|
92
|
+
let cpSupportsApfsCloneFlag;
|
|
89
93
|
let currentJob;
|
|
90
94
|
let browserStarted = false;
|
|
95
|
+
let browserProcess;
|
|
96
|
+
let browserProcessError;
|
|
91
97
|
let cleaningUpBrowser = false;
|
|
92
98
|
let cleaningUpRuntime = false;
|
|
93
99
|
let shuttingDown = false;
|
|
@@ -277,6 +283,14 @@ function spawnCommand(command, args, options = {}) {
|
|
|
277
283
|
});
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
async function cpSupportsApfsClone() {
|
|
287
|
+
if (process.platform !== "darwin") return false;
|
|
288
|
+
if (cpSupportsApfsCloneFlag !== undefined) return cpSupportsApfsCloneFlag;
|
|
289
|
+
const probe = await spawnCommand(CP_BIN, ["-c"], { allowFailure: true, timeoutMs: 5_000 });
|
|
290
|
+
cpSupportsApfsCloneFlag = !/invalid option\s+--\s+['"]?c/i.test(`${probe.stderr}\n${probe.stdout}`);
|
|
291
|
+
return cpSupportsApfsCloneFlag;
|
|
292
|
+
}
|
|
293
|
+
|
|
280
294
|
function parseConversationId(chatUrl) {
|
|
281
295
|
if (!chatUrl) return undefined;
|
|
282
296
|
try {
|
|
@@ -321,7 +335,7 @@ async function cloneSeedProfileToRuntime(job) {
|
|
|
321
335
|
await withLock(ORACLE_STATE_DIR, "auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
|
|
322
336
|
await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|
|
323
337
|
await ensurePrivateDir(dirname(job.runtimeProfileDir));
|
|
324
|
-
if (job.config.browser.cloneStrategy === "apfs-clone" &&
|
|
338
|
+
if (job.config.browser.cloneStrategy === "apfs-clone" && await cpSupportsApfsClone()) {
|
|
325
339
|
try {
|
|
326
340
|
await spawnCommand(CP_BIN, ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
|
|
327
341
|
} catch (error) {
|
|
@@ -344,16 +358,24 @@ async function cleanupRuntime(job) {
|
|
|
344
358
|
cleaningUpRuntime = true;
|
|
345
359
|
const warnings = [];
|
|
346
360
|
try {
|
|
361
|
+
let browserClosed = true;
|
|
347
362
|
await closeBrowser(job).catch(async (error) => {
|
|
363
|
+
browserClosed = false;
|
|
348
364
|
const message = `Browser close warning during cleanup: ${error instanceof Error ? error.message : String(error)}`;
|
|
349
365
|
warnings.push(message);
|
|
350
366
|
await log(message).catch(() => undefined);
|
|
351
367
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
if (browserClosed) {
|
|
369
|
+
try {
|
|
370
|
+
assertSafeRuntimeProfilePath(job.runtimeProfileDir, "runtime profile", job.config);
|
|
371
|
+
await rm(job.runtimeProfileDir, { recursive: true, force: true });
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const message = `Runtime profile cleanup warning: ${error instanceof Error ? error.message : String(error)}`;
|
|
374
|
+
warnings.push(message);
|
|
375
|
+
await log(message).catch(() => undefined);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
const message = `Runtime profile cleanup skipped because isolated browser close did not complete: ${job.runtimeProfileDir}`;
|
|
357
379
|
warnings.push(message);
|
|
358
380
|
await log(message).catch(() => undefined);
|
|
359
381
|
}
|
|
@@ -531,6 +553,39 @@ function browserBaseArgs(job, options = {}) {
|
|
|
531
553
|
return args;
|
|
532
554
|
}
|
|
533
555
|
|
|
556
|
+
function waitForChildClose(child, timeoutMs) {
|
|
557
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
let settled = false;
|
|
560
|
+
const timer = setTimeout(() => {
|
|
561
|
+
if (settled) return;
|
|
562
|
+
settled = true;
|
|
563
|
+
resolve(false);
|
|
564
|
+
}, timeoutMs);
|
|
565
|
+
timer.unref?.();
|
|
566
|
+
child.once("close", () => {
|
|
567
|
+
if (settled) return;
|
|
568
|
+
settled = true;
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
resolve(true);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function terminateBrowserProcess() {
|
|
576
|
+
if (!browserProcess) return;
|
|
577
|
+
const child = browserProcess;
|
|
578
|
+
browserProcess = undefined;
|
|
579
|
+
browserProcessError = undefined;
|
|
580
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
581
|
+
killProcessTree(child);
|
|
582
|
+
if (await waitForChildClose(child, 2_000)) return;
|
|
583
|
+
killProcess(child);
|
|
584
|
+
if (!(await waitForChildClose(child, 2_000))) {
|
|
585
|
+
throw new Error(`Timed out terminating isolated Chrome process ${child.pid ?? "(unknown pid)"}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
534
589
|
async function closeBrowser(job) {
|
|
535
590
|
if (cleaningUpBrowser) return;
|
|
536
591
|
cleaningUpBrowser = true;
|
|
@@ -543,15 +598,107 @@ async function closeBrowser(job) {
|
|
|
543
598
|
throw new Error(result.stderr || result.stdout || `agent-browser close exited with code ${result.code}`);
|
|
544
599
|
}
|
|
545
600
|
} finally {
|
|
601
|
+
await terminateBrowserProcess();
|
|
546
602
|
browserStarted = false;
|
|
547
603
|
cleaningUpBrowser = false;
|
|
548
604
|
}
|
|
549
605
|
}
|
|
550
606
|
|
|
607
|
+
function assertSafeBrowserLaunchArg(arg) {
|
|
608
|
+
const value = String(arg).trim().toLowerCase();
|
|
609
|
+
const managedFlags = [
|
|
610
|
+
"--user-data-dir",
|
|
611
|
+
"--remote-debugging-port",
|
|
612
|
+
"--remote-debugging-pipe",
|
|
613
|
+
"--remote-debugging-address",
|
|
614
|
+
"--remote-allow-origins",
|
|
615
|
+
];
|
|
616
|
+
const flag = managedFlags.find((candidate) => value === candidate || value.startsWith(`${candidate}=`) || value.startsWith(`${candidate} `));
|
|
617
|
+
if (flag) {
|
|
618
|
+
throw new Error(`browser.args cannot override oracle-managed Chrome launch isolation flag ${flag}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function safeBrowserLaunchArgs(job) {
|
|
623
|
+
if (!Array.isArray(job.config.browser.args)) return [];
|
|
624
|
+
for (const arg of job.config.browser.args) assertSafeBrowserLaunchArg(arg);
|
|
625
|
+
return job.config.browser.args;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function chromeLaunchArgs(job, url) {
|
|
629
|
+
const args = [
|
|
630
|
+
"--remote-debugging-port=0",
|
|
631
|
+
"--remote-allow-origins=*",
|
|
632
|
+
"--no-first-run",
|
|
633
|
+
"--no-default-browser-check",
|
|
634
|
+
"--disable-background-networking",
|
|
635
|
+
"--disable-backgrounding-occluded-windows",
|
|
636
|
+
"--disable-component-update",
|
|
637
|
+
"--disable-default-apps",
|
|
638
|
+
"--disable-hang-monitor",
|
|
639
|
+
"--disable-popup-blocking",
|
|
640
|
+
"--disable-prompt-on-repost",
|
|
641
|
+
"--disable-sync",
|
|
642
|
+
"--disable-features=Translate",
|
|
643
|
+
"--enable-features=NetworkService,NetworkServiceInProcess",
|
|
644
|
+
"--metrics-recording-only",
|
|
645
|
+
"--password-store=basic",
|
|
646
|
+
"--use-mock-keychain",
|
|
647
|
+
"--enable-unsafe-swiftshader",
|
|
648
|
+
"--window-size=1280,720",
|
|
649
|
+
`--user-data-dir=${job.runtimeProfileDir}`,
|
|
650
|
+
];
|
|
651
|
+
if (job.config.browser.runMode !== "headed") args.push("--headless=new", "--hide-scrollbars");
|
|
652
|
+
if (job.config.browser.userAgent) args.push(`--user-agent=${job.config.browser.userAgent}`);
|
|
653
|
+
args.push(...safeBrowserLaunchArgs(job));
|
|
654
|
+
args.push(url);
|
|
655
|
+
return args;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function waitForDevToolsEndpoint(job) {
|
|
659
|
+
const path = join(job.runtimeProfileDir, "DevToolsActivePort");
|
|
660
|
+
const startedAt = Date.now();
|
|
661
|
+
while (Date.now() - startedAt < CHROME_DEVTOOLS_READY_TIMEOUT_MS) {
|
|
662
|
+
if (browserProcessError) {
|
|
663
|
+
throw new Error(`Chrome failed before DevTools became available: ${browserProcessError instanceof Error ? browserProcessError.message : String(browserProcessError)}`);
|
|
664
|
+
}
|
|
665
|
+
if (browserProcess?.exitCode !== null && browserProcess?.exitCode !== undefined) {
|
|
666
|
+
throw new Error(`Chrome exited before DevTools became available (exit code ${browserProcess.exitCode}).`);
|
|
667
|
+
}
|
|
668
|
+
if (existsSync(path)) {
|
|
669
|
+
const lines = (await readFile(path, "utf8")).trim().split(/\r?\n/);
|
|
670
|
+
const port = lines[0]?.trim();
|
|
671
|
+
const browserPath = lines[1]?.trim();
|
|
672
|
+
if (/^\d+$/.test(port)) {
|
|
673
|
+
return browserPath ? `ws://127.0.0.1:${port}${browserPath}` : `http://127.0.0.1:${port}`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
await sleep(100);
|
|
677
|
+
}
|
|
678
|
+
throw new Error(`Timed out waiting for Chrome DevTools endpoint at ${path}.`);
|
|
679
|
+
}
|
|
680
|
+
|
|
551
681
|
async function launchBrowser(job, url) {
|
|
552
682
|
await closeBrowser(job);
|
|
553
|
-
const
|
|
554
|
-
|
|
683
|
+
const executablePath = job.config.browser.executablePath;
|
|
684
|
+
if (!executablePath) throw new Error("Oracle requires browser.executablePath when launching isolated browser runtimes without owning the global agent-browser daemon.");
|
|
685
|
+
const args = chromeLaunchArgs(job, url);
|
|
686
|
+
await log(`Launching isolated Chrome directly for agent-browser attach: ${JSON.stringify([executablePath, ...args])}`);
|
|
687
|
+
browserProcessError = undefined;
|
|
688
|
+
browserProcess = spawn(executablePath, args, {
|
|
689
|
+
env: sweetCookieSafeStoragePasswordScrubbedEnv(),
|
|
690
|
+
stdio: "ignore",
|
|
691
|
+
detached: false,
|
|
692
|
+
shell: false,
|
|
693
|
+
});
|
|
694
|
+
browserProcess.on("error", (error) => {
|
|
695
|
+
browserProcessError = error;
|
|
696
|
+
log(`Chrome process error: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
|
|
697
|
+
});
|
|
698
|
+
const endpoint = await waitForDevToolsEndpoint(job);
|
|
699
|
+
await log(`Connecting agent-browser session ${job.runtimeSessionName} to isolated Chrome DevTools endpoint`);
|
|
700
|
+
await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "connect", endpoint]);
|
|
701
|
+
await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "open", url]);
|
|
555
702
|
browserStarted = true;
|
|
556
703
|
}
|
|
557
704
|
|
|
@@ -774,8 +921,8 @@ function snapshotHasCompactIntelligenceMenuControls(snapshot) {
|
|
|
774
921
|
return Boolean(findEntry(snapshot, (candidate) => {
|
|
775
922
|
if (candidate.disabled) return false;
|
|
776
923
|
const label = normalizeSnapshotLabel(candidate.label);
|
|
777
|
-
return (candidate.kind === "menu" && /Intelligence.*Instant.*Medium.*High.*Pro/i.test(label))
|
|
778
|
-
|| (candidate.kind === "menuitemradio" && /^(?:Instant
|
|
924
|
+
return (candidate.kind === "menu" && /(?:Intelligence.*Instant.*Medium.*High.*Pro|^(?:Instant|Medium|High|Extra High|Pro Extended)$)/i.test(label))
|
|
925
|
+
|| (candidate.kind === "menuitemradio" && /^(?:Instant(?:\s+5s)?|Medium(?:\s+5\s*[–-]\s*30s)?|High(?:\s+15\s*[–-]\s*60s)?|Extra High|Pro\s+5\+\s*min|Pro Standard|Pro Extended)$/i.test(label));
|
|
779
926
|
}));
|
|
780
927
|
}
|
|
781
928
|
|
|
@@ -783,9 +930,10 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
|
|
|
783
930
|
if (!["button", "radio", "menuitemradio"].includes(candidate.kind || "") || typeof candidate.label !== "string" || candidate.disabled) return false;
|
|
784
931
|
if (candidate.kind === "button") {
|
|
785
932
|
if (/\bexpanded=true\b/.test(String(candidate.line || ""))) return false;
|
|
786
|
-
if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Pro)$/i.test(candidate.label)) return false;
|
|
787
|
-
if (options.ignoreCompactOnlyButtons && /^(?:Medium|High)$/i.test(candidate.label)) return false;
|
|
933
|
+
if (options.ignoreCompactTierButtons && /^(?:Instant|Medium|High|Extra High|Pro|Pro Extended)$/i.test(candidate.label)) return false;
|
|
934
|
+
if (options.ignoreCompactOnlyButtons && /^(?:Medium|High|Extra High)$/i.test(candidate.label)) return false;
|
|
788
935
|
}
|
|
936
|
+
if (selection.modelFamily === "pro" && /^Pro(?:\s+Extended)?$/i.test(candidate.label)) return true;
|
|
789
937
|
return matchesRequestedModelControlLabel(candidate.label, selection);
|
|
790
938
|
}
|
|
791
939
|
|
|
@@ -859,7 +1007,41 @@ async function maybeClickLabeledEntry(job, label, options = {}) {
|
|
|
859
1007
|
}
|
|
860
1008
|
|
|
861
1009
|
async function openEffortDropdown(job) {
|
|
862
|
-
|
|
1010
|
+
let snapshot = await snapshotText(job);
|
|
1011
|
+
if (job.selection?.modelFamily === "pro") {
|
|
1012
|
+
let proEffortEntry = findEntry(
|
|
1013
|
+
snapshot,
|
|
1014
|
+
(candidate) => candidate.kind === "menuitem" && candidate.label === "Pro effort options" && !candidate.disabled,
|
|
1015
|
+
);
|
|
1016
|
+
if (!proEffortEntry) {
|
|
1017
|
+
const opener = findEntry(snapshot, matchesModelConfigurationOpener);
|
|
1018
|
+
if (opener) {
|
|
1019
|
+
await clickRef(job, opener.ref);
|
|
1020
|
+
await agentBrowser(job, "wait", "500");
|
|
1021
|
+
snapshot = await snapshotText(job);
|
|
1022
|
+
proEffortEntry = findEntry(
|
|
1023
|
+
snapshot,
|
|
1024
|
+
(candidate) => candidate.kind === "menuitem" && candidate.label === "Pro effort options" && !candidate.disabled,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (proEffortEntry) {
|
|
1029
|
+
try {
|
|
1030
|
+
await clickRef(job, proEffortEntry.ref);
|
|
1031
|
+
return true;
|
|
1032
|
+
} catch {
|
|
1033
|
+
// Fall through to DOM click. ChatGPT's tiny trailing Pro effort icon can
|
|
1034
|
+
// be covered at the accessibility click point by the parent Pro row.
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
const clicked = await evalPage(job, toJsonScript(`
|
|
1038
|
+
const el = document.querySelector('[aria-label="Pro effort options"], [data-composer-intelligence-pro-effort-action]');
|
|
1039
|
+
if (!el) return false;
|
|
1040
|
+
el.click();
|
|
1041
|
+
return true;
|
|
1042
|
+
`));
|
|
1043
|
+
if (clicked) return true;
|
|
1044
|
+
}
|
|
863
1045
|
const effortLabels = new Set(["Light", "Standard", "Extended", "Heavy"]);
|
|
864
1046
|
const entry = findEntry(
|
|
865
1047
|
snapshot,
|
|
@@ -1157,46 +1339,128 @@ async function waitForSendReady(job) {
|
|
|
1157
1339
|
throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
|
|
1158
1340
|
}
|
|
1159
1341
|
|
|
1160
|
-
async function
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
if (
|
|
1342
|
+
async function activateSendButton(job) {
|
|
1343
|
+
const result = await evalPage(job, toJsonScript(`
|
|
1344
|
+
const labels = ${JSON.stringify(labelsForJob(job))};
|
|
1345
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
1346
|
+
const button = buttons.find((candidate) => {
|
|
1347
|
+
const label = (candidate.getAttribute('aria-label') || candidate.textContent || '').trim();
|
|
1348
|
+
return label === labels.send;
|
|
1349
|
+
});
|
|
1350
|
+
if (!button) return { ok: false, reason: 'send button not found' };
|
|
1351
|
+
if (button.disabled || button.getAttribute('aria-disabled') === 'true') return { ok: false, reason: 'send button disabled' };
|
|
1352
|
+
button.click();
|
|
1353
|
+
return { ok: true };
|
|
1354
|
+
`));
|
|
1355
|
+
return result;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async function sendAcceptanceState(job, baselineAssistantCount) {
|
|
1359
|
+
const [urlResult, snapshot, messages] = await Promise.all([
|
|
1360
|
+
currentUrl(job).then((url) => ({ url, ok: true })).catch(() => ({ url: "", ok: false })),
|
|
1361
|
+
snapshotText(job).catch(() => ""),
|
|
1362
|
+
assistantMessages(job).catch(() => []),
|
|
1363
|
+
]);
|
|
1364
|
+
return {
|
|
1365
|
+
url: urlResult.url,
|
|
1366
|
+
urlKnown: urlResult.ok,
|
|
1367
|
+
assistantCount: Math.max(baselineAssistantCount, messages.length),
|
|
1368
|
+
stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming"),
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function clickSend(job, baselineAssistantCount) {
|
|
1373
|
+
await waitForSendReady(job);
|
|
1374
|
+
const beforeSend = await sendAcceptanceState(job, baselineAssistantCount);
|
|
1375
|
+
const activation = await activateSendButton(job);
|
|
1376
|
+
if (!activation?.ok) throw new Error(`Could not activate ${labelsForJob(job).send}: ${activation?.reason || "DOM activation failed"}`);
|
|
1377
|
+
await log(`Activated ${labelsForJob(job).send}; waiting for provider acceptance evidence`);
|
|
1378
|
+
if (await waitForSendAccepted(job, beforeSend, { timeoutMs: 20_000 })) return;
|
|
1379
|
+
|
|
1380
|
+
await captureDiagnostics(job, "send-not-accepted");
|
|
1381
|
+
throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${labelsForJob(job).send}`);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
async function waitForSendAccepted(job, beforeSend, options = {}) {
|
|
1385
|
+
const timeoutAt = Date.now() + (options.timeoutMs || 15_000);
|
|
1386
|
+
while (Date.now() < timeoutAt) {
|
|
1387
|
+
await heartbeat();
|
|
1388
|
+
const afterSend = await sendAcceptanceState(job, beforeSend.assistantCount || 0);
|
|
1389
|
+
if (providerSendAccepted(beforeSend, afterSend)) return true;
|
|
1390
|
+
await sleep(500);
|
|
1170
1391
|
}
|
|
1171
|
-
|
|
1392
|
+
return false;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
async function dismissProFeedbackModal(job, snapshot) {
|
|
1396
|
+
const entries = parseSnapshotEntries(snapshot);
|
|
1397
|
+
const hasProFeedback = entries.some((entry) => entry.kind === "heading" && entry.label === "Pro feedback" && !entry.disabled);
|
|
1398
|
+
if (!hasProFeedback) return false;
|
|
1399
|
+
const close = entries.find((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
|
|
1400
|
+
if (close) {
|
|
1401
|
+
await clickRef(job, close.ref).catch(() => undefined);
|
|
1402
|
+
await agentBrowser(job, "wait", "500");
|
|
1403
|
+
if (!(await pageText(job).catch(() => "")).includes("Pro feedback")) return true;
|
|
1404
|
+
}
|
|
1405
|
+
await agentBrowser(job, "press", "Escape").catch(() => undefined);
|
|
1406
|
+
await agentBrowser(job, "wait", "500");
|
|
1407
|
+
if (!(await pageText(job).catch(() => "")).includes("Pro feedback")) return true;
|
|
1408
|
+
|
|
1409
|
+
const dismissed = await evalPage(job, toJsonScript(`
|
|
1410
|
+
const dialogText = document.body.innerText || '';
|
|
1411
|
+
if (!/Pro feedback/.test(dialogText)) return false;
|
|
1412
|
+
const button = Array.from(document.querySelectorAll('button'))
|
|
1413
|
+
.find((candidate) => (candidate.getAttribute('aria-label') || candidate.textContent || '').trim() === 'Close');
|
|
1414
|
+
if (!button) return false;
|
|
1415
|
+
button.click();
|
|
1416
|
+
return true;
|
|
1417
|
+
`));
|
|
1418
|
+
if (dismissed) await agentBrowser(job, "wait", "500");
|
|
1419
|
+
return Boolean(dismissed);
|
|
1172
1420
|
}
|
|
1173
1421
|
|
|
1174
1422
|
async function openModelConfiguration(job) {
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1423
|
+
const timeoutAt = Date.now() + 15_000;
|
|
1424
|
+
let lastSnapshot = "";
|
|
1177
1425
|
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
if (
|
|
1182
|
-
await
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1426
|
+
while (Date.now() < timeoutAt) {
|
|
1427
|
+
const initialSnapshot = await snapshotText(job);
|
|
1428
|
+
lastSnapshot = initialSnapshot;
|
|
1429
|
+
if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
|
|
1430
|
+
if (await dismissProFeedbackModal(job, initialSnapshot)) continue;
|
|
1431
|
+
|
|
1432
|
+
for (const predicate of [matchesModelConfigurationOpener]) {
|
|
1433
|
+
const snapshot = await snapshotText(job);
|
|
1434
|
+
lastSnapshot = snapshot;
|
|
1435
|
+
const entry = findEntry(snapshot, predicate);
|
|
1436
|
+
if (!entry) continue;
|
|
1437
|
+
await clickRef(job, entry.ref);
|
|
1438
|
+
await agentBrowser(job, "wait", "800");
|
|
1439
|
+
const after = await snapshotText(job);
|
|
1440
|
+
lastSnapshot = after;
|
|
1441
|
+
if (snapshotHasModelConfigurationUi(after)) return after;
|
|
1442
|
+
if (canUseOpenModelMenuForSelection(after, job.selection)) return after;
|
|
1443
|
+
|
|
1444
|
+
const configureEntry = findEntry(
|
|
1445
|
+
after,
|
|
1446
|
+
(candidate) => candidate.kind === "menuitem" && candidate.label === CHATGPT_LABELS.configure && !candidate.disabled,
|
|
1447
|
+
);
|
|
1187
1448
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1449
|
+
if (configureEntry) {
|
|
1450
|
+
await clickRef(job, configureEntry.ref);
|
|
1451
|
+
await agentBrowser(job, "wait", "1200");
|
|
1452
|
+
const postConfigure = await snapshotText(job);
|
|
1453
|
+
lastSnapshot = postConfigure;
|
|
1454
|
+
if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
|
|
1455
|
+
if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1192
1458
|
|
|
1193
|
-
if (
|
|
1194
|
-
await
|
|
1195
|
-
|
|
1196
|
-
const postConfigure = await snapshotText(job);
|
|
1197
|
-
if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
|
|
1198
|
-
if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
|
|
1459
|
+
if (composerControlsVisible(lastSnapshot, job) && !snapshotHasModelOpener(lastSnapshot)) {
|
|
1460
|
+
await agentBrowser(job, "wait", "1000");
|
|
1461
|
+
continue;
|
|
1199
1462
|
}
|
|
1463
|
+
await agentBrowser(job, "wait", "500");
|
|
1200
1464
|
}
|
|
1201
1465
|
|
|
1202
1466
|
throw new Error("Could not open model configuration UI");
|
|
@@ -1303,7 +1567,11 @@ async function configureModel(job) {
|
|
|
1303
1567
|
throw new Error(`Could not open effort dropdown for requested effort: ${effortLabel}`);
|
|
1304
1568
|
}
|
|
1305
1569
|
await agentBrowser(job, "wait", "300");
|
|
1306
|
-
await
|
|
1570
|
+
if (job.selection.modelFamily === "pro" && await maybeClickLabeledEntry(job, `Pro ${effortLabel}`, { kind: "menuitemradio" })) {
|
|
1571
|
+
// Current ChatGPT exposes Pro effort choices as nested menu radio items.
|
|
1572
|
+
} else {
|
|
1573
|
+
await clickLabeledEntry(job, effortLabel, { kind: "option" });
|
|
1574
|
+
}
|
|
1307
1575
|
await agentBrowser(job, "wait", "400");
|
|
1308
1576
|
const effortSnapshot = await snapshotText(job);
|
|
1309
1577
|
verificationSnapshot = effortSnapshot;
|
|
@@ -2000,15 +2268,8 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
2000
2268
|
}
|
|
2001
2269
|
}
|
|
2002
2270
|
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label) && !capturedArtifactKeys.has(String(label).replace(/\s+/g, "")));
|
|
2006
|
-
if (missedArtifactLabels.length > 0) {
|
|
2007
|
-
await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
|
|
2008
|
-
for (const label of missedArtifactLabels) {
|
|
2009
|
-
artifacts.push({ displayName: label, unconfirmed: true, error: "Response-local artifact signal was present, but no downloadable artifact was captured." });
|
|
2010
|
-
}
|
|
2011
|
-
await flushArtifactsState(artifacts);
|
|
2271
|
+
if (suspiciousLabels.length > 0) {
|
|
2272
|
+
await log(`Ignoring plain-text artifact-like labels without downloadable controls: ${suspiciousLabels.join(", ")}`);
|
|
2012
2273
|
}
|
|
2013
2274
|
|
|
2014
2275
|
return artifacts;
|
|
@@ -2079,8 +2340,8 @@ async function run() {
|
|
|
2079
2340
|
await setComposerText(currentJob, await readFile(currentJob.promptPath, "utf8"));
|
|
2080
2341
|
const baselineAssistantCount = (await assistantMessages(currentJob)).length;
|
|
2081
2342
|
await log(`Assistant response count before send: ${baselineAssistantCount}`);
|
|
2082
|
-
await clickSend(currentJob);
|
|
2083
|
-
await log(`
|
|
2343
|
+
await clickSend(currentJob, baselineAssistantCount);
|
|
2344
|
+
await log(`Send accepted; waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
|
|
2084
2345
|
await sleep(POST_SEND_SETTLE_MS);
|
|
2085
2346
|
|
|
2086
2347
|
const observedChatUrl = isGrokJob(currentJob)
|
|
@@ -2118,14 +2379,15 @@ async function run() {
|
|
|
2118
2379
|
message: "Extracting the completed response body.",
|
|
2119
2380
|
patch: { heartbeatAt: new Date().toISOString() },
|
|
2120
2381
|
}));
|
|
2121
|
-
|
|
2382
|
+
const responseText = isGrokJob(currentJob) ? completion.responseText.trim() : stripChatGptResponseChrome(completion.responseText);
|
|
2383
|
+
await secureWriteText(currentJob.responsePath, `${responseText}\n`);
|
|
2122
2384
|
currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "downloading_artifacts", {
|
|
2123
2385
|
at: new Date().toISOString(),
|
|
2124
2386
|
source: "oracle:worker",
|
|
2125
2387
|
message: "Downloading any response artifacts.",
|
|
2126
2388
|
patch: { heartbeatAt: new Date().toISOString() },
|
|
2127
2389
|
}));
|
|
2128
|
-
const artifacts = await downloadArtifacts(currentJob, completion.responseIndex,
|
|
2390
|
+
const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, responseText);
|
|
2129
2391
|
const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
|
|
2130
2392
|
const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
|
|
2131
2393
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,9 +41,6 @@
|
|
|
41
41
|
"pi": {
|
|
42
42
|
"extensions": [
|
|
43
43
|
"./extensions/oracle/index.ts"
|
|
44
|
-
],
|
|
45
|
-
"prompts": [
|
|
46
|
-
"./prompts"
|
|
47
44
|
]
|
|
48
45
|
},
|
|
49
46
|
"scripts": {
|
|
@@ -83,8 +80,8 @@
|
|
|
83
80
|
"protobufjs": "7.6.1"
|
|
84
81
|
},
|
|
85
82
|
"devDependencies": {
|
|
86
|
-
"@earendil-works/pi-ai": "
|
|
87
|
-
"@earendil-works/pi-coding-agent": "
|
|
83
|
+
"@earendil-works/pi-ai": "0.79.1",
|
|
84
|
+
"@earendil-works/pi-coding-agent": "0.79.1",
|
|
88
85
|
"@types/node": "^22.19.19",
|
|
89
86
|
"esbuild": "^0.28.0",
|
|
90
87
|
"tsx": "^4.22.3",
|
|
@@ -10,7 +10,7 @@ Required workflow:
|
|
|
10
10
|
2. If the request does not include both a prior oracle job id and a follow-up request, stop and report: `Usage: /oracle-followup <job-id> <request>. Find the job id in the earlier oracle response or via /oracle-status.`
|
|
11
11
|
3. Same-thread follow-ups cannot switch providers. If the user asks to move a ChatGPT thread to Grok or a Grok thread to ChatGPT, stop and tell them to start a new `/oracle` job instead.
|
|
12
12
|
4. Call `oracle_preflight` immediately with the parsed `followUpJobId` so readiness checks use the prior job's provider.
|
|
13
|
-
5. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase,
|
|
13
|
+
5. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
|
|
14
14
|
6. Treat the parsed job id as `followUpJobId` for `oracle_submit`.
|
|
15
15
|
7. Understand whether the follow-up request is explicitly narrow or genuinely broad.
|
|
16
16
|
8. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
|
|
@@ -31,7 +31,7 @@ Oracle provider/model (`oracle_submit`):
|
|
|
31
31
|
|
|
32
32
|
Rules:
|
|
33
33
|
- Use `oracle_preflight` before any expensive `/oracle-followup` preparation so missing persisted-session or local auth/config blockers fail fast.
|
|
34
|
-
- If the immediately preceding oracle run for this follow-up failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution,
|
|
34
|
+
- If the immediately preceding oracle run for this follow-up failed because ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle-followup`.
|
|
35
35
|
- This prompt exists so normal users can continue the same provider thread without manually constructing `followUpJobId` tool calls.
|
|
36
36
|
- Always include an archive. Do not submit without context files.
|
|
37
37
|
- By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear follow-up requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
|
package/prompts/oracle.md
CHANGED
|
@@ -5,20 +5,28 @@ You are preparing an /oracle job.
|
|
|
5
5
|
|
|
6
6
|
Do not answer the user's request directly yet.
|
|
7
7
|
|
|
8
|
+
Hard requirements:
|
|
9
|
+
- Do not plan instead of submitting. The point of `/oracle` is dispatch.
|
|
10
|
+
- Do not claim preflight, auth, archive prep, or submission happened unless the matching tool call actually happened in this turn.
|
|
11
|
+
- If the user explicitly says ChatGPT Instant or Instant, use provider `chatgpt` and preset `instant`; never switch that request to Grok.
|
|
12
|
+
- If a required tool call is unavailable or fails, stop and report that exact blocker instead of fabricating progress.
|
|
13
|
+
- After a successful or queued `oracle_submit`, your final answer must be only a terse dispatch summary with the job id and response path. Do not ask questions, offer to watch/poll/read, list next steps, or continue working.
|
|
14
|
+
|
|
8
15
|
Required workflow:
|
|
9
|
-
1. Call `oracle_preflight` immediately. If the user says to use Grok, pass `provider: "grok"` to `oracle_preflight`.
|
|
10
|
-
2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase,
|
|
16
|
+
1. Call `oracle_preflight` immediately. If the user says to use Grok, pass `provider: "grok"` to `oracle_preflight`. If the user says to use ChatGPT, pass `provider: "chatgpt"`. If the user says to use ChatGPT Instant, pass `provider: "chatgpt"` and later call `oracle_submit` with `preset: "instant"`.
|
|
17
|
+
2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, prepare archive inputs, or call `oracle_auth` automatically. Report the blocking issue plus the suggested next step.
|
|
11
18
|
3. Understand the request and decide whether it is explicitly narrow or genuinely broad.
|
|
12
19
|
4. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the provider archive ceiling: 250 MB for ChatGPT, 200 MiB for Grok.
|
|
13
20
|
5. If the user scope is explicit and narrow, start from the directly relevant area but still include nearby files, tests, docs, configs, and adjacent modules when they may improve answer quality. Keep the archive tightly minimal only when the user explicitly asks for that, privacy/sensitivity requires it, or size pressure forces it.
|
|
14
21
|
6. If the request is broad, architectural, release-oriented, or otherwise repo-wide, gather broader context and usually archive `.`.
|
|
15
22
|
7. Choose archive inputs for the oracle job.
|
|
16
23
|
8. Craft a concise but complete oracle prompt for the selected web provider.
|
|
17
|
-
9. Call `oracle_submit` with the prompt and exact archive inputs.
|
|
18
|
-
10. Stop immediately after dispatching the oracle job.
|
|
24
|
+
9. Call `oracle_submit` with the prompt and exact archive inputs. Do not ask for confirmation before this submit step unless `oracle_preflight` or `oracle_submit` returns a blocker that requires user action.
|
|
25
|
+
10. Stop immediately after dispatching the oracle job. “Stop” means no follow-up questions, no offers to poll/watch/read, and no extra next-step list.
|
|
19
26
|
|
|
20
27
|
Oracle provider/model (`oracle_submit`):
|
|
21
28
|
- If the user says to use Grok (for example “Use the oracle to Grok about ...”), pass **`provider: "grok"`**. Grok currently supports only **`mode: "heavy"`**; omit `mode` unless the user explicitly says Heavy.
|
|
29
|
+
- If the user says ChatGPT, pass **`provider: "chatgpt"`**. Never route a ChatGPT request to Grok.
|
|
22
30
|
- Otherwise omit **`provider`** to use the configured default provider, or pass **`provider: "chatgpt"`** only when needed for clarity.
|
|
23
31
|
- To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the canonical preset registry.
|
|
24
32
|
- Matching human-readable preset labels and common hyphen/space variants are also accepted and normalized automatically, but prefer canonical ids when readily available.
|
|
@@ -28,7 +36,7 @@ Oracle provider/model (`oracle_submit`):
|
|
|
28
36
|
|
|
29
37
|
Rules:
|
|
30
38
|
- Use `oracle_preflight` before any expensive `/oracle` preparation so missing persisted-session or local auth/config blockers fail fast.
|
|
31
|
-
- If
|
|
39
|
+
- If ChatGPT/Grok login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, stop and tell the user to run `/oracle-auth` (or `/oracle-auth grok` for Grok). Do not call `oracle_auth` automatically from `/oracle`.
|
|
32
40
|
- Always include an archive. Do not submit without context files.
|
|
33
41
|
- By default, prefer context-rich archives up to the provider ceiling because more relevant context is usually better than less. The ceiling is 250 MB for ChatGPT and 200 MiB for Grok. For broad or unclear requests, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
|
|
34
42
|
- Only limit file selection if the user explicitly requests a tight archive, if privacy/sensitivity requires it, or if the archive would otherwise exceed the size limit after exclusions/pruning.
|
|
@@ -397,7 +397,9 @@ async function run(mode = "packed") {
|
|
|
397
397
|
const agent1 = join(runDir, "agent1");
|
|
398
398
|
const sessions1 = join(runDir, "sessions1");
|
|
399
399
|
const jobs1 = join(runDir, "jobs1");
|
|
400
|
-
|
|
400
|
+
const authSeed1 = join(agent1, "extensions", "oracle-auth-seed-profile");
|
|
401
|
+
mkdirSync(authSeed1, { recursive: true });
|
|
402
|
+
writeFileSync(join(authSeed1, ".oracle-seed-generation"), "real-smoke-fake-worker\n");
|
|
401
403
|
mkdirSync(sessions1, { recursive: true });
|
|
402
404
|
mkdirSync(jobs1, { recursive: true });
|
|
403
405
|
|
|
@@ -444,7 +446,9 @@ async function run(mode = "packed") {
|
|
|
444
446
|
const sessions2 = join(runDir, "sessions2");
|
|
445
447
|
const jobs2 = join(runDir, "jobs2");
|
|
446
448
|
const outside = join(tmpRoot, "outside");
|
|
447
|
-
|
|
449
|
+
const authSeed2 = join(agent2, "extensions", "oracle-auth-seed-profile");
|
|
450
|
+
mkdirSync(authSeed2, { recursive: true });
|
|
451
|
+
writeFileSync(join(authSeed2, ".oracle-seed-generation"), "real-smoke-fake-worker\n");
|
|
448
452
|
mkdirSync(sessions2, { recursive: true });
|
|
449
453
|
mkdirSync(jobs2, { recursive: true });
|
|
450
454
|
mkdirSync(outside, { recursive: true });
|