pi-oracle 0.6.17 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +52 -39
- package/docs/ORACLE_DESIGN.md +29 -26
- package/extensions/oracle/lib/auth.ts +4 -3
- package/extensions/oracle/lib/commands.ts +15 -4
- package/extensions/oracle/lib/config.ts +50 -2
- package/extensions/oracle/lib/jobs.ts +1 -1
- package/extensions/oracle/lib/runtime.ts +10 -0
- package/extensions/oracle/lib/tools.ts +123 -36
- package/extensions/oracle/shared/job-observability-helpers.d.mts +2 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +6 -2
- package/extensions/oracle/worker/auth-bootstrap.mjs +63 -15
- package/extensions/oracle/worker/auth-cookie-policy.mjs +18 -4
- package/extensions/oracle/worker/auth-flow-helpers.mjs +3 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +2 -2
- package/extensions/oracle/worker/chromium-cookie-source.mjs +2 -2
- package/extensions/oracle/worker/run-job.mjs +234 -34
- package/package.json +6 -5
- package/prompts/oracle-followup.md +21 -19
- package/prompts/oracle.md +11 -9
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Purpose: Define the allowlist/drop policy for importing
|
|
1
|
+
// Purpose: Define the allowlist/drop policy for importing provider auth cookies into the isolated oracle browser profile.
|
|
2
2
|
// Responsibilities: Recognize required auth cookies, drop noisy/irrelevant cookies, and normalize cookie import decisions.
|
|
3
3
|
// Scope: Pure cookie-policy logic only; reading cookies from Chrome and writing them into the isolated profile happen elsewhere.
|
|
4
4
|
// Usage: Imported by auth-bootstrap and sanity tests to keep cookie import behavior deterministic and reviewable.
|
|
@@ -14,6 +14,17 @@ const AUTH_COOKIE_NAME_PATTERNS = [
|
|
|
14
14
|
/^auth-session-minimized(?:-client-checksum)?$/,
|
|
15
15
|
/^(?:login_session|auth_provider|hydra_redirect|iss_context|rg_context)$/,
|
|
16
16
|
/^cf_clearance$/,
|
|
17
|
+
/^__(?:cf_bm|cflb)$/,
|
|
18
|
+
/^_cfuvid$/,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const GROK_AUTH_COOKIE_NAME_PATTERNS = [
|
|
22
|
+
/^sso(?:-rw)?$/,
|
|
23
|
+
/^auth_token$/,
|
|
24
|
+
/^ct0$/,
|
|
25
|
+
/^cf_clearance$/,
|
|
26
|
+
/^__(?:cf_bm|cflb)$/,
|
|
27
|
+
/^_cfuvid$/,
|
|
17
28
|
];
|
|
18
29
|
|
|
19
30
|
const DROPPED_COOKIE_NAME_PATTERNS = [
|
|
@@ -21,9 +32,6 @@ const DROPPED_COOKIE_NAME_PATTERNS = [
|
|
|
21
32
|
/^_uet/,
|
|
22
33
|
/^_rdt_uuid$/,
|
|
23
34
|
/^(?:marketing|analytics)_consent$/,
|
|
24
|
-
/^__cf_bm$/,
|
|
25
|
-
/^__cflb$/,
|
|
26
|
-
/^_cfuvid$/,
|
|
27
35
|
/^_dd_s$/,
|
|
28
36
|
/^g_state$/,
|
|
29
37
|
/^country$/,
|
|
@@ -41,6 +49,9 @@ const BASE_ALLOWED_COOKIE_HOSTS = new Set([
|
|
|
41
49
|
'sentinel.openai.com',
|
|
42
50
|
'atlas.openai.com',
|
|
43
51
|
'ws.chatgpt.com',
|
|
52
|
+
'grok.com',
|
|
53
|
+
'x.ai',
|
|
54
|
+
'x.com',
|
|
44
55
|
]);
|
|
45
56
|
|
|
46
57
|
function normalizeSameSite(value) {
|
|
@@ -101,6 +112,9 @@ export function normalizeImportedCookie(cookie, fallbackHost) {
|
|
|
101
112
|
export function classifyImportedCookie(cookie, chatUrl) {
|
|
102
113
|
if (matchesAny(DROPPED_COOKIE_NAME_PATTERNS, cookie.name)) return 'noise';
|
|
103
114
|
if (!isAllowedCookieDomain(cookie.domain, chatUrl)) return 'foreign-domain';
|
|
115
|
+
if (['grok.com', 'x.ai', 'x.com'].includes(cookie.domain)) {
|
|
116
|
+
return matchesAny(GROK_AUTH_COOKIE_NAME_PATTERNS, cookie.name) ? 'keep' : 'non-auth';
|
|
117
|
+
}
|
|
104
118
|
if (!matchesAny(AUTH_COOKIE_NAME_PATTERNS, cookie.name)) return 'non-auth';
|
|
105
119
|
return 'keep';
|
|
106
120
|
}
|
|
@@ -82,6 +82,9 @@ export function classifyChatAuthPage(args) {
|
|
|
82
82
|
/we detect suspicious activity/i,
|
|
83
83
|
];
|
|
84
84
|
if (challengePatterns.some((pattern) => pattern.test(text))) {
|
|
85
|
+
if (/verification successful|waiting for chatgpt\.com to respond/i.test(text)) {
|
|
86
|
+
return { state: "unknown", message: "ChatGPT verification is still settling." };
|
|
87
|
+
}
|
|
85
88
|
return {
|
|
86
89
|
state: "challenge_blocking",
|
|
87
90
|
message:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Purpose: Provide pure
|
|
1
|
+
// Purpose: Provide pure provider conversation-state helpers used by the oracle worker.
|
|
2
2
|
// Responsibilities: Slice assistant snapshot regions, normalize URLs, and track stable conversation URL observations.
|
|
3
3
|
// Scope: Pure worker flow logic only; browser I/O and polling loops stay in run-job.mjs.
|
|
4
4
|
// Usage: Imported by run-job.mjs and sanity tests to validate conversation-state heuristics without driving a browser.
|
|
@@ -53,7 +53,7 @@ export function stripUrlQueryAndHash(url) {
|
|
|
53
53
|
*/
|
|
54
54
|
export function isConversationPathUrl(url) {
|
|
55
55
|
try {
|
|
56
|
-
return /\/c\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
|
|
56
|
+
return /\/(?:c|chat)\/[A-Za-z0-9-]+$/i.test(new URL(url).pathname);
|
|
57
57
|
} catch {
|
|
58
58
|
return false;
|
|
59
59
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Purpose: Read
|
|
1
|
+
// Purpose: Read provider cookies from arbitrary macOS Chromium-family cookie stores when sweet-cookie's built-in browser list is too narrow.
|
|
2
2
|
// Responsibilities: Snapshot a Chromium Cookies SQLite DB, decrypt AES-CBC cookie values with a configured Keychain item, and return sweet-cookie-shaped cookie objects.
|
|
3
3
|
// Scope: macOS Chromium cookie extraction only; auth policy filtering and browser seeding stay in auth-bootstrap.mjs.
|
|
4
4
|
// Usage: auth-bootstrap.mjs uses this when auth.chromiumKeychain is configured alongside auth.chromeCookiePath.
|
|
@@ -117,7 +117,7 @@ function buildHostWhereClause(origins) {
|
|
|
117
117
|
try {
|
|
118
118
|
for (const domain of parentCookieDomains(new URL(origin).hostname)) domains.add(domain);
|
|
119
119
|
} catch {
|
|
120
|
-
// Ignore malformed origins; validated
|
|
120
|
+
// Ignore malformed origins; validated provider config supplies the real set.
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
if (domains.size === 0) return "0";
|
|
@@ -54,6 +54,13 @@ const CHATGPT_LABELS = {
|
|
|
54
54
|
autoSwitchToThinking: "Auto-switch to Thinking",
|
|
55
55
|
configure: "Configure...",
|
|
56
56
|
};
|
|
57
|
+
const GROK_LABELS = {
|
|
58
|
+
composer: "Ask Grok anything",
|
|
59
|
+
addFiles: "Attach",
|
|
60
|
+
send: "Submit",
|
|
61
|
+
modelSelect: "Model select",
|
|
62
|
+
stop: "Stop model response",
|
|
63
|
+
};
|
|
57
64
|
const WORKER_SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
58
65
|
const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
59
66
|
const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
|
|
@@ -81,6 +88,18 @@ let cleaningUpRuntime = false;
|
|
|
81
88
|
let shuttingDown = false;
|
|
82
89
|
let lastHeartbeatMs = 0;
|
|
83
90
|
|
|
91
|
+
function providerForJob(job) {
|
|
92
|
+
return job?.selection?.provider === "grok" ? "grok" : "chatgpt";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isGrokJob(job) {
|
|
96
|
+
return providerForJob(job) === "grok";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function labelsForJob(job) {
|
|
100
|
+
return isGrokJob(job) ? GROK_LABELS : CHATGPT_LABELS;
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
async function ensurePrivateDir(path) {
|
|
85
104
|
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
86
105
|
await chmod(path, 0o700).catch(() => undefined);
|
|
@@ -239,13 +258,22 @@ function parseConversationId(chatUrl) {
|
|
|
239
258
|
if (!chatUrl) return undefined;
|
|
240
259
|
try {
|
|
241
260
|
const parsed = new URL(chatUrl);
|
|
242
|
-
const match = parsed.pathname.match(/\/c\/([^/?#]+)/i);
|
|
261
|
+
const match = parsed.pathname.match(/\/(?:c|chat)\/([^/?#]+)/i);
|
|
243
262
|
return match?.[1];
|
|
244
263
|
} catch {
|
|
245
264
|
return undefined;
|
|
246
265
|
}
|
|
247
266
|
}
|
|
248
267
|
|
|
268
|
+
async function removeChromiumProcessSingletonArtifacts(profileDir) {
|
|
269
|
+
await Promise.all([
|
|
270
|
+
rm(join(profileDir, "SingletonLock"), { force: true }),
|
|
271
|
+
rm(join(profileDir, "SingletonSocket"), { force: true }),
|
|
272
|
+
rm(join(profileDir, "SingletonCookie"), { force: true }),
|
|
273
|
+
rm(join(profileDir, "DevToolsActivePort"), { force: true }),
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
|
|
249
277
|
async function cloneSeedProfileToRuntime(job) {
|
|
250
278
|
const seedDir = job.config.browser.authSeedProfileDir;
|
|
251
279
|
if (!existsSync(seedDir)) {
|
|
@@ -270,6 +298,7 @@ async function cloneSeedProfileToRuntime(job) {
|
|
|
270
298
|
} else {
|
|
271
299
|
await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
|
|
272
300
|
}
|
|
301
|
+
await removeChromiumProcessSingletonArtifacts(job.runtimeProfileDir);
|
|
273
302
|
}, 10 * 60 * 1000);
|
|
274
303
|
|
|
275
304
|
return seedGeneration;
|
|
@@ -713,13 +742,14 @@ function canUseOpenModelMenuForSelection(snapshot, selection) {
|
|
|
713
742
|
));
|
|
714
743
|
}
|
|
715
744
|
|
|
716
|
-
function composerControlsVisible(snapshot) {
|
|
745
|
+
function composerControlsVisible(snapshot, job = currentJob) {
|
|
746
|
+
const labels = labelsForJob(job);
|
|
717
747
|
const entries = parseSnapshotEntries(snapshot);
|
|
718
|
-
const hasComposer =
|
|
719
|
-
(entry) => entry.kind === "textbox" && entry.label ===
|
|
720
|
-
|
|
748
|
+
const hasComposer = isGrokJob(job)
|
|
749
|
+
? entries.some((entry) => !entry.disabled && ((entry.kind === "textbox" && entry.label === labels.composer) || /editable/.test(String(entry.line || ""))))
|
|
750
|
+
: entries.some((entry) => entry.kind === "textbox" && entry.label === labels.composer && !entry.disabled);
|
|
721
751
|
const hasAddFiles = entries.some(
|
|
722
|
-
(entry) => entry.kind === "button" && entry.label ===
|
|
752
|
+
(entry) => entry.kind === "button" && entry.label === labels.addFiles && !entry.disabled,
|
|
723
753
|
);
|
|
724
754
|
return hasComposer && hasAddFiles;
|
|
725
755
|
}
|
|
@@ -774,13 +804,28 @@ async function openEffortDropdown(job) {
|
|
|
774
804
|
}
|
|
775
805
|
|
|
776
806
|
async function setComposerText(job, text) {
|
|
807
|
+
if (isGrokJob(job)) {
|
|
808
|
+
const result = await evalPage(job, toJsonScript(`
|
|
809
|
+
const el = document.querySelector('[contenteditable="true"], [contenteditable=true]');
|
|
810
|
+
if (!el) return { ok: false };
|
|
811
|
+
el.focus();
|
|
812
|
+
document.execCommand('selectAll', false, null);
|
|
813
|
+
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
814
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(text)} }));
|
|
815
|
+
return { ok: true };
|
|
816
|
+
`));
|
|
817
|
+
if (!result?.ok) throw new Error("Could not find Grok composer textbox");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
777
820
|
const snapshot = await snapshotText(job);
|
|
778
|
-
const
|
|
821
|
+
const labels = labelsForJob(job);
|
|
822
|
+
const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === labels.composer);
|
|
779
823
|
if (!entry) throw new Error("Could not find ChatGPT composer textbox");
|
|
780
824
|
await agentBrowser(job, "fill", entry.ref, text);
|
|
781
825
|
}
|
|
782
826
|
|
|
783
827
|
function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
828
|
+
if (isGrokJob(job)) return classifyGrokPage({ url, snapshot, body });
|
|
784
829
|
const text = `${snapshot}\n${body}`;
|
|
785
830
|
const challengePatterns = [
|
|
786
831
|
/just a moment/i,
|
|
@@ -791,6 +836,9 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
791
836
|
/we detect suspicious activity/i,
|
|
792
837
|
];
|
|
793
838
|
if (challengePatterns.some((pattern) => pattern.test(text))) {
|
|
839
|
+
if (/verification successful|waiting for chatgpt\.com to respond/i.test(text)) {
|
|
840
|
+
return { state: "unknown", message: "ChatGPT verification is still settling." };
|
|
841
|
+
}
|
|
794
842
|
return { state: "challenge_blocking", message: "ChatGPT is showing a challenge/verification page" };
|
|
795
843
|
}
|
|
796
844
|
|
|
@@ -851,6 +899,33 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
851
899
|
return { state: "unknown", message: "ChatGPT page is not ready yet." };
|
|
852
900
|
}
|
|
853
901
|
|
|
902
|
+
function hasGrokLoginCta(text) {
|
|
903
|
+
const lines = String(text || "").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
904
|
+
return lines.some((line) => {
|
|
905
|
+
const accessibleControl = line.match(/^-\s*(?:button|link|menuitem)\s+"([^"]+)"/i)?.[1]?.trim();
|
|
906
|
+
const label = accessibleControl || line;
|
|
907
|
+
return /^(?:sign in|log in|continue with x|continue with google|create account)$/i.test(label);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function classifyGrokPage({ url, snapshot, body }) {
|
|
912
|
+
const text = `${snapshot}\n${body}`;
|
|
913
|
+
if (/captcha|cloudflare|verify you are human|unusual activity|suspicious activity/i.test(text)) {
|
|
914
|
+
return { state: "challenge_blocking", message: "Grok is showing a challenge/verification page" };
|
|
915
|
+
}
|
|
916
|
+
if (/something went wrong|network error|try again later|rate limit/i.test(text)) {
|
|
917
|
+
return { state: "transient_outage_error", message: "Grok is showing a transient outage/error page" };
|
|
918
|
+
}
|
|
919
|
+
const onGrokOrigin = typeof url === "string" && url.startsWith("https://grok.com");
|
|
920
|
+
if (onGrokOrigin && hasGrokLoginCta(text)) {
|
|
921
|
+
return { state: "login_required", message: "Grok login is required. Sign in to Grok in the configured browser profile and rerun /oracle-auth grok." };
|
|
922
|
+
}
|
|
923
|
+
const hasComposer = snapshot.includes(`button "${GROK_LABELS.addFiles}"`) && (snapshot.includes(`textbox "${GROK_LABELS.composer}"`) || snapshot.includes("contenteditable"));
|
|
924
|
+
if (onGrokOrigin && hasComposer) return { state: "authenticated_and_ready", message: "Grok is ready." };
|
|
925
|
+
if (url && !onGrokOrigin) return { state: "login_required", message: "Grok redirected away from grok.com. Sign in to Grok in the configured browser profile and rerun /oracle-auth grok if needed." };
|
|
926
|
+
return { state: "unknown", message: "Grok page is not ready yet." };
|
|
927
|
+
}
|
|
928
|
+
|
|
854
929
|
async function captureDiagnostics(job, reason) {
|
|
855
930
|
if (!browserStarted) return;
|
|
856
931
|
try {
|
|
@@ -870,7 +945,7 @@ async function captureDiagnostics(job, reason) {
|
|
|
870
945
|
|
|
871
946
|
async function waitForOracleReady(job) {
|
|
872
947
|
const startedAt = Date.now();
|
|
873
|
-
const timeoutAt = startedAt + 30_000;
|
|
948
|
+
const timeoutAt = startedAt + (isGrokJob(job) ? 30_000 : Math.min(job.config.auth.bootstrapTimeoutMs || 120_000, 120_000));
|
|
874
949
|
let retriedOutage = false;
|
|
875
950
|
let retriedAuthTransition = false;
|
|
876
951
|
|
|
@@ -937,11 +1012,12 @@ function detectResponseFailureText(text) {
|
|
|
937
1012
|
return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
|
|
938
1013
|
}
|
|
939
1014
|
|
|
940
|
-
function composerSnapshotSlice(snapshot) {
|
|
1015
|
+
function composerSnapshotSlice(snapshot, job = currentJob) {
|
|
941
1016
|
const lines = snapshot.split("\n");
|
|
1017
|
+
const labels = labelsForJob(job);
|
|
942
1018
|
let composerIndex = -1;
|
|
943
1019
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
944
|
-
if (lines[index].includes(`textbox "${
|
|
1020
|
+
if (lines[index].includes(`textbox "${labels.composer}"`) || (isGrokJob(job) && lines[index].includes("contenteditable"))) {
|
|
945
1021
|
composerIndex = index;
|
|
946
1022
|
break;
|
|
947
1023
|
}
|
|
@@ -952,8 +1028,8 @@ function composerSnapshotSlice(snapshot) {
|
|
|
952
1028
|
return lines.slice(startIndex, endIndex).join("\n");
|
|
953
1029
|
}
|
|
954
1030
|
|
|
955
|
-
function composerFileEntryCount(snapshot, fileLabel) {
|
|
956
|
-
const composerSlice = composerSnapshotSlice(snapshot);
|
|
1031
|
+
function composerFileEntryCount(snapshot, fileLabel, job = currentJob) {
|
|
1032
|
+
const composerSlice = composerSnapshotSlice(snapshot, job);
|
|
957
1033
|
return parseSnapshotEntries(composerSlice).filter((candidate) => candidate.label === fileLabel).length;
|
|
958
1034
|
}
|
|
959
1035
|
|
|
@@ -970,13 +1046,16 @@ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
|
|
|
970
1046
|
throw new Error(`Upload error detected: ${errorText}`);
|
|
971
1047
|
}
|
|
972
1048
|
|
|
1049
|
+
const labels = labelsForJob(job);
|
|
973
1050
|
const sendEntry = findEntry(
|
|
974
1051
|
snapshot,
|
|
975
|
-
(candidate) => candidate.kind === "button" && candidate.label ===
|
|
1052
|
+
(candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
|
|
976
1053
|
);
|
|
977
|
-
const fileCount =
|
|
1054
|
+
const fileCount = isGrokJob(job) && snapshot.includes(fileLabel)
|
|
1055
|
+
? baselineCount + 1
|
|
1056
|
+
: composerFileEntryCount(snapshot, fileLabel, job);
|
|
978
1057
|
|
|
979
|
-
if (sendEntry && fileCount > baselineCount) {
|
|
1058
|
+
if ((sendEntry || isGrokJob(job)) && fileCount > baselineCount) {
|
|
980
1059
|
stableCount += 1;
|
|
981
1060
|
if (stableCount >= 2) return sendEntry;
|
|
982
1061
|
} else {
|
|
@@ -1000,18 +1079,28 @@ async function waitForSendReady(job) {
|
|
|
1000
1079
|
throw new Error(`Upload error detected: ${errorText}`);
|
|
1001
1080
|
}
|
|
1002
1081
|
|
|
1082
|
+
const labels = labelsForJob(job);
|
|
1003
1083
|
const entry = findEntry(
|
|
1004
1084
|
snapshot,
|
|
1005
|
-
(candidate) => candidate.kind === "button" && candidate.label ===
|
|
1085
|
+
(candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
|
|
1006
1086
|
);
|
|
1007
1087
|
if (entry) return entry;
|
|
1008
1088
|
await sleep(1000);
|
|
1009
1089
|
}
|
|
1010
|
-
throw new Error(`Timed out waiting for ${
|
|
1090
|
+
throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
|
|
1011
1091
|
}
|
|
1012
1092
|
|
|
1013
1093
|
async function clickSend(job) {
|
|
1014
1094
|
const entry = await waitForSendReady(job);
|
|
1095
|
+
if (isGrokJob(job)) {
|
|
1096
|
+
const result = await evalPage(job, toJsonScript(`
|
|
1097
|
+
const button = document.querySelector('button[aria-label="Submit"]');
|
|
1098
|
+
if (!button || button.disabled) return { ok: false };
|
|
1099
|
+
button.click();
|
|
1100
|
+
return { ok: true };
|
|
1101
|
+
`));
|
|
1102
|
+
if (result?.ok) return;
|
|
1103
|
+
}
|
|
1015
1104
|
await clickRef(job, entry.ref);
|
|
1016
1105
|
}
|
|
1017
1106
|
|
|
@@ -1095,6 +1184,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
|
|
|
1095
1184
|
}
|
|
1096
1185
|
|
|
1097
1186
|
async function configureModel(job) {
|
|
1187
|
+
if (isGrokJob(job)) return configureGrokModel(job);
|
|
1098
1188
|
const initialSnapshot = await snapshotText(job);
|
|
1099
1189
|
if (snapshotCanSafelySkipModelConfiguration(initialSnapshot, job.selection)) {
|
|
1100
1190
|
await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
|
|
@@ -1169,6 +1259,30 @@ async function configureModel(job) {
|
|
|
1169
1259
|
await waitForModelConfigurationToSettle(job, { stronglyVerified });
|
|
1170
1260
|
}
|
|
1171
1261
|
|
|
1262
|
+
async function configureGrokModel(job) {
|
|
1263
|
+
const snapshot = await snapshotText(job);
|
|
1264
|
+
if (/\bHeavy\b/.test(snapshot) && !snapshot.includes(`button "${GROK_LABELS.modelSelect}"`)) {
|
|
1265
|
+
await log("Grok model already appears configured for Heavy; skipping reconfiguration");
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
const modelButton = findEntry(snapshot, (candidate) => candidate.kind === "button" && candidate.label === GROK_LABELS.modelSelect && !candidate.disabled);
|
|
1269
|
+
if (!modelButton) throw new Error("Could not find Grok model selector");
|
|
1270
|
+
await clickRef(job, modelButton.ref);
|
|
1271
|
+
await agentBrowser(job, "wait", "500");
|
|
1272
|
+
const menuSnapshot = await snapshotText(job);
|
|
1273
|
+
const heavy = findEntry(menuSnapshot, (candidate) => ["menuitem", "menuitemradio", "option", "button"].includes(candidate.kind || "") && /^Heavy\b/i.test(String(candidate.label || "")) && !candidate.disabled);
|
|
1274
|
+
if (!heavy) throw new Error("Could not find Grok Heavy model option");
|
|
1275
|
+
await clickRef(job, heavy.ref);
|
|
1276
|
+
await agentBrowser(job, "wait", "800");
|
|
1277
|
+
const after = await snapshotText(job);
|
|
1278
|
+
if (!/\bHeavy\b/i.test(after)) {
|
|
1279
|
+
if (after.includes('link "Sign in"') || after.includes('button "Sign in"')) {
|
|
1280
|
+
throw new Error("Grok Heavy requires a signed-in Grok session. Set defaults.provider='grok', run /oracle-auth, and retry.");
|
|
1281
|
+
}
|
|
1282
|
+
throw new Error("Could not verify Grok Heavy selection after model configuration");
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1172
1286
|
async function uploadArchive(job) {
|
|
1173
1287
|
if (!existsSync(job.archivePath)) {
|
|
1174
1288
|
throw new Error(`Archive missing: ${job.archivePath}`);
|
|
@@ -1176,26 +1290,50 @@ async function uploadArchive(job) {
|
|
|
1176
1290
|
|
|
1177
1291
|
const fileLabel = basename(job.archivePath);
|
|
1178
1292
|
const addFilesSnapshot = await snapshotText(job);
|
|
1179
|
-
const baselineComposerFileCount = composerFileEntryCount(addFilesSnapshot, fileLabel);
|
|
1293
|
+
const baselineComposerFileCount = composerFileEntryCount(addFilesSnapshot, fileLabel, job);
|
|
1294
|
+
const labels = labelsForJob(job);
|
|
1180
1295
|
const addFilesEntry = findEntry(
|
|
1181
1296
|
addFilesSnapshot,
|
|
1182
|
-
(candidate) => candidate.label ===
|
|
1297
|
+
(candidate) => candidate.label === labels.addFiles && candidate.kind === "button",
|
|
1183
1298
|
);
|
|
1184
1299
|
if (!addFilesEntry) {
|
|
1185
|
-
throw new Error(`Could not find "${
|
|
1300
|
+
throw new Error(`Could not find "${labels.addFiles}" button`);
|
|
1186
1301
|
}
|
|
1187
1302
|
|
|
1188
1303
|
await clickRef(job, addFilesEntry.ref);
|
|
1189
1304
|
await agentBrowser(job, "wait", "500");
|
|
1190
1305
|
await agentBrowser(job, "upload", "input[type=file]", job.archivePath);
|
|
1191
1306
|
await log(`Selected archive for upload: ${job.archivePath}`);
|
|
1192
|
-
|
|
1307
|
+
if (isGrokJob(job)) {
|
|
1308
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
1309
|
+
let stablePolls = 0;
|
|
1310
|
+
while (Date.now() < deadline) {
|
|
1311
|
+
await heartbeat();
|
|
1312
|
+
const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
|
|
1313
|
+
const errorText = detectUploadErrorText(`${snapshot}\n${body}`);
|
|
1314
|
+
if (errorText) {
|
|
1315
|
+
throw new Error(`Upload error detected: ${errorText}`);
|
|
1316
|
+
}
|
|
1317
|
+
if (`${snapshot}\n${body}`.includes(fileLabel)) {
|
|
1318
|
+
stablePolls += 1;
|
|
1319
|
+
if (stablePolls >= 2) break;
|
|
1320
|
+
} else {
|
|
1321
|
+
stablePolls = 0;
|
|
1322
|
+
}
|
|
1323
|
+
await sleep(1000);
|
|
1324
|
+
}
|
|
1325
|
+
if (stablePolls < 2) throw new Error(`Timed out waiting for Grok upload confirmation for ${fileLabel}`);
|
|
1326
|
+
} else {
|
|
1327
|
+
await waitForUploadConfirmed(job, fileLabel, baselineComposerFileCount);
|
|
1328
|
+
}
|
|
1193
1329
|
await log(`Upload confirmed for: ${fileLabel}`);
|
|
1330
|
+
if (isGrokJob(job)) await agentBrowser(job, "press", "Escape").catch(() => undefined);
|
|
1194
1331
|
await rm(job.archivePath, { force: true });
|
|
1195
1332
|
await mutateJob((current) => ({ ...current, archiveDeletedAfterUpload: true }));
|
|
1196
1333
|
}
|
|
1197
1334
|
|
|
1198
1335
|
async function assistantMessages(job) {
|
|
1336
|
+
if (isGrokJob(job)) return grokAssistantMessages(job);
|
|
1199
1337
|
const result = await evalPage(
|
|
1200
1338
|
job,
|
|
1201
1339
|
toJsonScript(`
|
|
@@ -1226,8 +1364,11 @@ async function assistantMessages(job) {
|
|
|
1226
1364
|
.trim();
|
|
1227
1365
|
return text;
|
|
1228
1366
|
};
|
|
1367
|
+
const headingMessages = headings.map((heading) => ({ text: renderText(heading.nextElementSibling) }));
|
|
1368
|
+
const messageNodes = Array.from(document.querySelectorAll('[data-testid="assistant-message"], [data-message-author-role="assistant"]'));
|
|
1369
|
+
const nodeMessages = messageNodes.map((node) => ({ text: renderText(node) }));
|
|
1229
1370
|
return {
|
|
1230
|
-
messages:
|
|
1371
|
+
messages: headingMessages.some((message) => message.text) ? headingMessages : nodeMessages,
|
|
1231
1372
|
};
|
|
1232
1373
|
`),
|
|
1233
1374
|
);
|
|
@@ -1236,6 +1377,41 @@ async function assistantMessages(job) {
|
|
|
1236
1377
|
return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
|
|
1237
1378
|
}
|
|
1238
1379
|
|
|
1380
|
+
async function grokAssistantMessages(job) {
|
|
1381
|
+
const result = await evalPage(
|
|
1382
|
+
job,
|
|
1383
|
+
toJsonScript(`
|
|
1384
|
+
const normalize = (value) => String(value || '').split('\\n\\n\\n').join('\\n\\n').trim();
|
|
1385
|
+
const renderText = (node) => {
|
|
1386
|
+
if (!node) return '';
|
|
1387
|
+
const clone = node.cloneNode(true);
|
|
1388
|
+
clone.querySelectorAll('button,[aria-label="Copy"],[aria-label="Like"],[aria-label="Dislike"],[aria-label="Regenerate"],[aria-label="More actions"],.thinking-container').forEach((el) => el.remove());
|
|
1389
|
+
const text = normalize(clone.innerText || clone.textContent || '');
|
|
1390
|
+
const lines = text.split('\\n');
|
|
1391
|
+
if (/^Thought for /i.test(lines[0] || '')) return lines.slice(1).join('\\n').trim();
|
|
1392
|
+
return text;
|
|
1393
|
+
};
|
|
1394
|
+
const bubbles = Array.from(document.querySelectorAll('.message-bubble'));
|
|
1395
|
+
const sourceNodes = bubbles.length > 0
|
|
1396
|
+
? bubbles
|
|
1397
|
+
: Array.from(document.querySelectorAll('div')).filter((node) => {
|
|
1398
|
+
const classText = String(node.className || '');
|
|
1399
|
+
return classText.includes('group') && classText.includes('flex') && classText.includes('flex-col') && classText.includes('justify-center');
|
|
1400
|
+
});
|
|
1401
|
+
const messages = sourceNodes
|
|
1402
|
+
.map((node) => node.closest('[data-message-author-role], [data-testid*="message"], .group') || node)
|
|
1403
|
+
.filter((node, index, all) => all.indexOf(node) === index)
|
|
1404
|
+
.filter((node) => node.getAttribute('data-testid') !== 'user-message' && node.getAttribute('data-message-author-role') !== 'user')
|
|
1405
|
+
.filter((node) => !node.querySelector('button[aria-label="Edit"]'))
|
|
1406
|
+
.map((node) => ({ text: renderText(node.querySelector('.message-bubble') || node) }))
|
|
1407
|
+
.filter((message) => message.text && !message.text.toLowerCase().startsWith('executed code'));
|
|
1408
|
+
return { messages };
|
|
1409
|
+
`),
|
|
1410
|
+
);
|
|
1411
|
+
if (!Array.isArray(result?.messages)) return [];
|
|
1412
|
+
return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1239
1415
|
async function waitForStableChatUrl(job, previousChatUrl) {
|
|
1240
1416
|
const timeoutAt = Date.now() + 60_000;
|
|
1241
1417
|
/** @type {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState | undefined} */
|
|
@@ -1264,9 +1440,9 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
|
|
|
1264
1440
|
while (Date.now() < timeoutAt) {
|
|
1265
1441
|
await heartbeat();
|
|
1266
1442
|
const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
|
|
1267
|
-
const hasStopStreaming = snapshot.includes("Stop streaming");
|
|
1443
|
+
const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
|
|
1268
1444
|
const hasRetryButton = snapshot.includes('button "Retry"');
|
|
1269
|
-
const copyResponseCount = (snapshot.match(/Copy response/g) || []).length;
|
|
1445
|
+
const copyResponseCount = isGrokJob(job) ? (snapshot.match(/button "Copy"/g) || []).length : (snapshot.match(/Copy response/g) || []).length;
|
|
1270
1446
|
const responseFailureText = detectResponseFailureText(`${snapshot}\n${body}`);
|
|
1271
1447
|
const messages = await assistantMessages(job);
|
|
1272
1448
|
const targetMessage = messages[baselineAssistantCount];
|
|
@@ -1289,17 +1465,17 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
|
|
|
1289
1465
|
continue;
|
|
1290
1466
|
}
|
|
1291
1467
|
}
|
|
1292
|
-
throw new Error(
|
|
1468
|
+
throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} response failed: ${responseFailureText}`);
|
|
1293
1469
|
}
|
|
1294
1470
|
|
|
1295
1471
|
let completionSignature;
|
|
1296
|
-
if (!hasStopStreaming &&
|
|
1472
|
+
if (!hasStopStreaming && targetText && (hasTargetCopyResponse || isGrokJob(job))) {
|
|
1297
1473
|
completionSignature = deriveAssistantCompletionSignature({
|
|
1298
1474
|
hasStopStreaming,
|
|
1299
|
-
hasTargetCopyResponse,
|
|
1475
|
+
hasTargetCopyResponse: hasTargetCopyResponse || isGrokJob(job),
|
|
1300
1476
|
responseText: targetText,
|
|
1301
1477
|
});
|
|
1302
|
-
} else if (!hasStopStreaming && !targetText) {
|
|
1478
|
+
} else if (!hasStopStreaming && hasTargetCopyResponse && !targetText) {
|
|
1303
1479
|
const artifactSignals = await collectArtifactCandidates(job, baselineAssistantCount, targetText).catch(() => ({ candidates: [], suspiciousLabels: [] }));
|
|
1304
1480
|
completionSignature = deriveAssistantCompletionSignature({
|
|
1305
1481
|
hasStopStreaming,
|
|
@@ -1325,7 +1501,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
|
|
|
1325
1501
|
await sleep(job.config.worker.pollMs);
|
|
1326
1502
|
}
|
|
1327
1503
|
|
|
1328
|
-
throw new Error(
|
|
1504
|
+
throw new Error(`Timed out waiting for ${isGrokJob(job) ? "Grok" : "ChatGPT"} response completion`);
|
|
1329
1505
|
}
|
|
1330
1506
|
|
|
1331
1507
|
async function sha256(path) {
|
|
@@ -1657,6 +1833,11 @@ async function flushArtifactsState(artifacts) {
|
|
|
1657
1833
|
}
|
|
1658
1834
|
|
|
1659
1835
|
async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
1836
|
+
if (isGrokJob(job)) {
|
|
1837
|
+
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
1838
|
+
await mutateJob((current) => ({ ...current, artifactPaths: [] }));
|
|
1839
|
+
return [];
|
|
1840
|
+
}
|
|
1660
1841
|
if (!job.config.artifacts.capture) {
|
|
1661
1842
|
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
1662
1843
|
await mutateJob((current) => ({ ...current, artifactPaths: [] }));
|
|
@@ -1795,14 +1976,14 @@ async function run() {
|
|
|
1795
1976
|
currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "verifying_auth", {
|
|
1796
1977
|
at: new Date().toISOString(),
|
|
1797
1978
|
source: "oracle:worker",
|
|
1798
|
-
message:
|
|
1979
|
+
message: `Verifying the imported ${isGrokJob(currentJob) ? "Grok" : "ChatGPT"} browser session.`,
|
|
1799
1980
|
patch: { heartbeatAt: new Date().toISOString() },
|
|
1800
1981
|
}));
|
|
1801
1982
|
await waitForOracleReady(currentJob);
|
|
1802
1983
|
currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "configuring_model", {
|
|
1803
1984
|
at: new Date().toISOString(),
|
|
1804
1985
|
source: "oracle:worker",
|
|
1805
|
-
message:
|
|
1986
|
+
message: `Configuring the requested ${isGrokJob(currentJob) ? "Grok" : "ChatGPT"} model selection.`,
|
|
1806
1987
|
patch: { heartbeatAt: new Date().toISOString() },
|
|
1807
1988
|
}));
|
|
1808
1989
|
await configureModel(currentJob);
|
|
@@ -1820,16 +2001,35 @@ async function run() {
|
|
|
1820
2001
|
await log(`Waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
|
|
1821
2002
|
await sleep(POST_SEND_SETTLE_MS);
|
|
1822
2003
|
|
|
1823
|
-
const
|
|
1824
|
-
|
|
2004
|
+
const observedChatUrl = isGrokJob(currentJob)
|
|
2005
|
+
? stripQuery(await currentUrl(currentJob))
|
|
2006
|
+
: await waitForStableChatUrl(currentJob, currentJob.chatUrl);
|
|
2007
|
+
const observedConversationId = parseConversationId(observedChatUrl) || currentJob.conversationId;
|
|
2008
|
+
const awaitingResponsePatch = {
|
|
2009
|
+
heartbeatAt: new Date().toISOString(),
|
|
2010
|
+
...(observedConversationId ? { chatUrl: observedChatUrl, conversationId: observedConversationId } : {}),
|
|
2011
|
+
};
|
|
1825
2012
|
currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "awaiting_response", {
|
|
1826
2013
|
at: new Date().toISOString(),
|
|
1827
2014
|
source: "oracle:worker",
|
|
1828
2015
|
message: "Waiting for the assistant response to finish streaming.",
|
|
1829
|
-
patch:
|
|
2016
|
+
patch: awaitingResponsePatch,
|
|
1830
2017
|
}));
|
|
1831
2018
|
|
|
1832
2019
|
const completion = await waitForChatCompletion(currentJob, baselineAssistantCount);
|
|
2020
|
+
if (isGrokJob(currentJob) && !currentJob.conversationId) {
|
|
2021
|
+
const stableGrokChatUrl = await waitForStableChatUrl(currentJob, undefined);
|
|
2022
|
+
const stableGrokConversationId = parseConversationId(stableGrokChatUrl);
|
|
2023
|
+
if (!stableGrokConversationId) {
|
|
2024
|
+
throw new Error(`Grok response completed but the conversation URL did not stabilize; current URL: ${stableGrokChatUrl || "(unknown)"}`);
|
|
2025
|
+
}
|
|
2026
|
+
currentJob = await mutateJob((job) => ({
|
|
2027
|
+
...job,
|
|
2028
|
+
chatUrl: stableGrokChatUrl,
|
|
2029
|
+
conversationId: stableGrokConversationId,
|
|
2030
|
+
heartbeatAt: new Date().toISOString(),
|
|
2031
|
+
}));
|
|
2032
|
+
}
|
|
1833
2033
|
currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "extracting_response", {
|
|
1834
2034
|
at: new Date().toISOString(),
|
|
1835
2035
|
source: "oracle:worker",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
7
7
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"pi-extension",
|
|
13
13
|
"extension",
|
|
14
14
|
"chatgpt",
|
|
15
|
+
"grok",
|
|
15
16
|
"oracle"
|
|
16
17
|
],
|
|
17
18
|
"repository": {
|
|
@@ -60,10 +61,10 @@
|
|
|
60
61
|
},
|
|
61
62
|
"overrides": {
|
|
62
63
|
"basic-ftp": "6.0.1",
|
|
63
|
-
"protobufjs": "7.5.
|
|
64
|
+
"protobufjs": "7.5.6"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.75.3",
|
|
67
68
|
"@types/node": "^25.6.1",
|
|
68
69
|
"esbuild": "^0.28.0",
|
|
69
70
|
"tsx": "^4.21.0",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"typescript": "^6.0.3"
|
|
72
73
|
},
|
|
73
74
|
"engines": {
|
|
74
|
-
"node": ">=22"
|
|
75
|
+
"node": ">=22.19.0"
|
|
75
76
|
},
|
|
76
77
|
"os": [
|
|
77
78
|
"darwin"
|