pi-oracle 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -705,13 +734,22 @@ function matchesModelConfigurationOpener(candidate) {
705
734
  || /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
706
735
  }
707
736
 
708
- function composerControlsVisible(snapshot) {
737
+ function canUseOpenModelMenuForSelection(snapshot, selection) {
738
+ if (selection.modelFamily !== "instant" || selection.autoSwitchToThinking === true) return false;
739
+ return Boolean(findEntry(
740
+ snapshot,
741
+ (candidate) => candidate.kind === "menuitemradio" && matchesModelFamilyControl(candidate, selection.modelFamily),
742
+ ));
743
+ }
744
+
745
+ function composerControlsVisible(snapshot, job = currentJob) {
746
+ const labels = labelsForJob(job);
709
747
  const entries = parseSnapshotEntries(snapshot);
710
- const hasComposer = entries.some(
711
- (entry) => entry.kind === "textbox" && entry.label === CHATGPT_LABELS.composer && !entry.disabled,
712
- );
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);
713
751
  const hasAddFiles = entries.some(
714
- (entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.addFiles && !entry.disabled,
752
+ (entry) => entry.kind === "button" && entry.label === labels.addFiles && !entry.disabled,
715
753
  );
716
754
  return hasComposer && hasAddFiles;
717
755
  }
@@ -766,13 +804,28 @@ async function openEffortDropdown(job) {
766
804
  }
767
805
 
768
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
+ }
769
820
  const snapshot = await snapshotText(job);
770
- const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === CHATGPT_LABELS.composer);
821
+ const labels = labelsForJob(job);
822
+ const entry = findEntry(snapshot, (candidate) => candidate.kind === "textbox" && candidate.label === labels.composer);
771
823
  if (!entry) throw new Error("Could not find ChatGPT composer textbox");
772
824
  await agentBrowser(job, "fill", entry.ref, text);
773
825
  }
774
826
 
775
827
  function classifyChatPage({ job, url, snapshot, body, probe }) {
828
+ if (isGrokJob(job)) return classifyGrokPage({ url, snapshot, body });
776
829
  const text = `${snapshot}\n${body}`;
777
830
  const challengePatterns = [
778
831
  /just a moment/i,
@@ -783,6 +836,9 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
783
836
  /we detect suspicious activity/i,
784
837
  ];
785
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
+ }
786
842
  return { state: "challenge_blocking", message: "ChatGPT is showing a challenge/verification page" };
787
843
  }
788
844
 
@@ -802,25 +858,35 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
802
858
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
803
859
  const hasUsableComposer = snapshotHasUsableComposerControls(snapshot);
804
860
 
805
- if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer || probe?.domLoginCta))) {
861
+ const probeHasAccountIdentity = probe?.bodyHasId === true || probe?.bodyHasEmail === true;
862
+
863
+ if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer))) {
806
864
  return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
807
865
  }
808
866
 
809
867
  if (onAuthPath || probe?.onAuthPage) {
810
- if (probe?.bodyHasId || probe?.bodyHasEmail) {
868
+ if (probeHasAccountIdentity) {
811
869
  return {
812
870
  state: "auth_transitioning",
813
- message: "ChatGPT is on an auth page even though the backend session is partially authenticated. Rerun /oracle-auth.",
871
+ message: "ChatGPT is on an auth page even though the backend probe returned account-like fields. Rerun /oracle-auth.",
814
872
  };
815
873
  }
816
874
  return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
817
875
  }
818
876
 
877
+ if (onAllowedOrigin && hasUsableComposer && probe?.domLoginCta && !probeHasAccountIdentity) {
878
+ return {
879
+ state: "login_required",
880
+ message: "ChatGPT login is required: the chat shell still shows public Log in/Sign up controls. Run /oracle-auth.",
881
+ };
882
+ }
883
+
819
884
  if (onAllowedOrigin && (probe?.status === 200 || probe?.status === 403) && hasUsableComposer) {
820
- if (probe?.domLoginCta && (probe?.bodyHasId || probe?.bodyHasEmail)) {
885
+ if (probe?.domLoginCta) {
886
+ // The public logged-out composer case returned above, so a remaining visible login CTA here still has account-like probe data.
821
887
  return {
822
888
  state: "auth_transitioning",
823
- message: "ChatGPT backend session is authenticated, but the web shell still shows public login CTA chrome. Rerun /oracle-auth.",
889
+ message: "ChatGPT backend probe returned account-like fields, but the web shell still shows public login controls. Rerun /oracle-auth.",
824
890
  };
825
891
  }
826
892
  return { state: "authenticated_and_ready", message: "ChatGPT is authenticated and ready." };
@@ -833,6 +899,33 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
833
899
  return { state: "unknown", message: "ChatGPT page is not ready yet." };
834
900
  }
835
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
+
836
929
  async function captureDiagnostics(job, reason) {
837
930
  if (!browserStarted) return;
838
931
  try {
@@ -852,7 +945,7 @@ async function captureDiagnostics(job, reason) {
852
945
 
853
946
  async function waitForOracleReady(job) {
854
947
  const startedAt = Date.now();
855
- const timeoutAt = startedAt + 30_000;
948
+ const timeoutAt = startedAt + (isGrokJob(job) ? 30_000 : Math.min(job.config.auth.bootstrapTimeoutMs || 120_000, 120_000));
856
949
  let retriedOutage = false;
857
950
  let retriedAuthTransition = false;
858
951
 
@@ -875,7 +968,7 @@ async function waitForOracleReady(job) {
875
968
  }
876
969
  if (elapsedMs >= 15_000) {
877
970
  await captureDiagnostics(job, "preflight-auth-transition");
878
- throw new Error("ChatGPT backend session is authenticated, but the web shell stayed in a partially logged-in state. Rerun /oracle-auth.");
971
+ throw new Error(classification.message || "ChatGPT auth did not settle into a ready chat shell. Rerun /oracle-auth.");
879
972
  }
880
973
  await sleep(1000);
881
974
  continue;
@@ -919,11 +1012,12 @@ function detectResponseFailureText(text) {
919
1012
  return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
920
1013
  }
921
1014
 
922
- function composerSnapshotSlice(snapshot) {
1015
+ function composerSnapshotSlice(snapshot, job = currentJob) {
923
1016
  const lines = snapshot.split("\n");
1017
+ const labels = labelsForJob(job);
924
1018
  let composerIndex = -1;
925
1019
  for (let index = lines.length - 1; index >= 0; index -= 1) {
926
- if (lines[index].includes(`textbox "${CHATGPT_LABELS.composer}"`)) {
1020
+ if (lines[index].includes(`textbox "${labels.composer}"`) || (isGrokJob(job) && lines[index].includes("contenteditable"))) {
927
1021
  composerIndex = index;
928
1022
  break;
929
1023
  }
@@ -934,8 +1028,8 @@ function composerSnapshotSlice(snapshot) {
934
1028
  return lines.slice(startIndex, endIndex).join("\n");
935
1029
  }
936
1030
 
937
- function composerFileEntryCount(snapshot, fileLabel) {
938
- const composerSlice = composerSnapshotSlice(snapshot);
1031
+ function composerFileEntryCount(snapshot, fileLabel, job = currentJob) {
1032
+ const composerSlice = composerSnapshotSlice(snapshot, job);
939
1033
  return parseSnapshotEntries(composerSlice).filter((candidate) => candidate.label === fileLabel).length;
940
1034
  }
941
1035
 
@@ -952,13 +1046,16 @@ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
952
1046
  throw new Error(`Upload error detected: ${errorText}`);
953
1047
  }
954
1048
 
1049
+ const labels = labelsForJob(job);
955
1050
  const sendEntry = findEntry(
956
1051
  snapshot,
957
- (candidate) => candidate.kind === "button" && candidate.label === CHATGPT_LABELS.send && !candidate.disabled,
1052
+ (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
958
1053
  );
959
- const fileCount = composerFileEntryCount(snapshot, fileLabel);
1054
+ const fileCount = isGrokJob(job) && snapshot.includes(fileLabel)
1055
+ ? baselineCount + 1
1056
+ : composerFileEntryCount(snapshot, fileLabel, job);
960
1057
 
961
- if (sendEntry && fileCount > baselineCount) {
1058
+ if ((sendEntry || isGrokJob(job)) && fileCount > baselineCount) {
962
1059
  stableCount += 1;
963
1060
  if (stableCount >= 2) return sendEntry;
964
1061
  } else {
@@ -982,18 +1079,28 @@ async function waitForSendReady(job) {
982
1079
  throw new Error(`Upload error detected: ${errorText}`);
983
1080
  }
984
1081
 
1082
+ const labels = labelsForJob(job);
985
1083
  const entry = findEntry(
986
1084
  snapshot,
987
- (candidate) => candidate.kind === "button" && candidate.label === CHATGPT_LABELS.send && !candidate.disabled,
1085
+ (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
988
1086
  );
989
1087
  if (entry) return entry;
990
1088
  await sleep(1000);
991
1089
  }
992
- throw new Error(`Timed out waiting for ${CHATGPT_LABELS.send} to become enabled`);
1090
+ throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
993
1091
  }
994
1092
 
995
1093
  async function clickSend(job) {
996
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
+ }
997
1104
  await clickRef(job, entry.ref);
998
1105
  }
999
1106
 
@@ -1009,6 +1116,7 @@ async function openModelConfiguration(job) {
1009
1116
  await agentBrowser(job, "wait", "800");
1010
1117
  const after = await snapshotText(job);
1011
1118
  if (snapshotHasModelConfigurationUi(after)) return after;
1119
+ if (canUseOpenModelMenuForSelection(after, job.selection)) return after;
1012
1120
 
1013
1121
  const configureEntry = findEntry(
1014
1122
  after,
@@ -1020,6 +1128,7 @@ async function openModelConfiguration(job) {
1020
1128
  await agentBrowser(job, "wait", "1200");
1021
1129
  const postConfigure = await snapshotText(job);
1022
1130
  if (snapshotHasModelConfigurationUi(postConfigure)) return postConfigure;
1131
+ if (canUseOpenModelMenuForSelection(postConfigure, job.selection)) return postConfigure;
1023
1132
  }
1024
1133
  }
1025
1134
 
@@ -1075,6 +1184,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1075
1184
  }
1076
1185
 
1077
1186
  async function configureModel(job) {
1187
+ if (isGrokJob(job)) return configureGrokModel(job);
1078
1188
  const initialSnapshot = await snapshotText(job);
1079
1189
  if (snapshotCanSafelySkipModelConfiguration(initialSnapshot, job.selection)) {
1080
1190
  await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
@@ -1149,6 +1259,30 @@ async function configureModel(job) {
1149
1259
  await waitForModelConfigurationToSettle(job, { stronglyVerified });
1150
1260
  }
1151
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
+
1152
1286
  async function uploadArchive(job) {
1153
1287
  if (!existsSync(job.archivePath)) {
1154
1288
  throw new Error(`Archive missing: ${job.archivePath}`);
@@ -1156,26 +1290,50 @@ async function uploadArchive(job) {
1156
1290
 
1157
1291
  const fileLabel = basename(job.archivePath);
1158
1292
  const addFilesSnapshot = await snapshotText(job);
1159
- const baselineComposerFileCount = composerFileEntryCount(addFilesSnapshot, fileLabel);
1293
+ const baselineComposerFileCount = composerFileEntryCount(addFilesSnapshot, fileLabel, job);
1294
+ const labels = labelsForJob(job);
1160
1295
  const addFilesEntry = findEntry(
1161
1296
  addFilesSnapshot,
1162
- (candidate) => candidate.label === CHATGPT_LABELS.addFiles && candidate.kind === "button",
1297
+ (candidate) => candidate.label === labels.addFiles && candidate.kind === "button",
1163
1298
  );
1164
1299
  if (!addFilesEntry) {
1165
- throw new Error(`Could not find "${CHATGPT_LABELS.addFiles}" button`);
1300
+ throw new Error(`Could not find "${labels.addFiles}" button`);
1166
1301
  }
1167
1302
 
1168
1303
  await clickRef(job, addFilesEntry.ref);
1169
1304
  await agentBrowser(job, "wait", "500");
1170
1305
  await agentBrowser(job, "upload", "input[type=file]", job.archivePath);
1171
1306
  await log(`Selected archive for upload: ${job.archivePath}`);
1172
- await waitForUploadConfirmed(job, fileLabel, baselineComposerFileCount);
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
+ }
1173
1329
  await log(`Upload confirmed for: ${fileLabel}`);
1330
+ if (isGrokJob(job)) await agentBrowser(job, "press", "Escape").catch(() => undefined);
1174
1331
  await rm(job.archivePath, { force: true });
1175
1332
  await mutateJob((current) => ({ ...current, archiveDeletedAfterUpload: true }));
1176
1333
  }
1177
1334
 
1178
1335
  async function assistantMessages(job) {
1336
+ if (isGrokJob(job)) return grokAssistantMessages(job);
1179
1337
  const result = await evalPage(
1180
1338
  job,
1181
1339
  toJsonScript(`
@@ -1206,8 +1364,11 @@ async function assistantMessages(job) {
1206
1364
  .trim();
1207
1365
  return text;
1208
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) }));
1209
1370
  return {
1210
- messages: headings.map((heading) => ({ text: renderText(heading.nextElementSibling) })),
1371
+ messages: headingMessages.some((message) => message.text) ? headingMessages : nodeMessages,
1211
1372
  };
1212
1373
  `),
1213
1374
  );
@@ -1216,6 +1377,41 @@ async function assistantMessages(job) {
1216
1377
  return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1217
1378
  }
1218
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
+
1219
1415
  async function waitForStableChatUrl(job, previousChatUrl) {
1220
1416
  const timeoutAt = Date.now() + 60_000;
1221
1417
  /** @type {import("./chatgpt-flow-helpers.d.mts").OracleStableValueState | undefined} */
@@ -1244,9 +1440,9 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1244
1440
  while (Date.now() < timeoutAt) {
1245
1441
  await heartbeat();
1246
1442
  const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
1247
- const hasStopStreaming = snapshot.includes("Stop streaming");
1443
+ const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
1248
1444
  const hasRetryButton = snapshot.includes('button "Retry"');
1249
- 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;
1250
1446
  const responseFailureText = detectResponseFailureText(`${snapshot}\n${body}`);
1251
1447
  const messages = await assistantMessages(job);
1252
1448
  const targetMessage = messages[baselineAssistantCount];
@@ -1269,17 +1465,17 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1269
1465
  continue;
1270
1466
  }
1271
1467
  }
1272
- throw new Error(`ChatGPT response failed: ${responseFailureText}`);
1468
+ throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} response failed: ${responseFailureText}`);
1273
1469
  }
1274
1470
 
1275
1471
  let completionSignature;
1276
- if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1472
+ if (!hasStopStreaming && targetText && (hasTargetCopyResponse || isGrokJob(job))) {
1277
1473
  completionSignature = deriveAssistantCompletionSignature({
1278
1474
  hasStopStreaming,
1279
- hasTargetCopyResponse,
1475
+ hasTargetCopyResponse: hasTargetCopyResponse || isGrokJob(job),
1280
1476
  responseText: targetText,
1281
1477
  });
1282
- } else if (!hasStopStreaming && !targetText) {
1478
+ } else if (!hasStopStreaming && hasTargetCopyResponse && !targetText) {
1283
1479
  const artifactSignals = await collectArtifactCandidates(job, baselineAssistantCount, targetText).catch(() => ({ candidates: [], suspiciousLabels: [] }));
1284
1480
  completionSignature = deriveAssistantCompletionSignature({
1285
1481
  hasStopStreaming,
@@ -1305,7 +1501,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1305
1501
  await sleep(job.config.worker.pollMs);
1306
1502
  }
1307
1503
 
1308
- throw new Error("Timed out waiting for ChatGPT response completion");
1504
+ throw new Error(`Timed out waiting for ${isGrokJob(job) ? "Grok" : "ChatGPT"} response completion`);
1309
1505
  }
1310
1506
 
1311
1507
  async function sha256(path) {
@@ -1637,6 +1833,11 @@ async function flushArtifactsState(artifacts) {
1637
1833
  }
1638
1834
 
1639
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
+ }
1640
1841
  if (!job.config.artifacts.capture) {
1641
1842
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1642
1843
  await mutateJob((current) => ({ ...current, artifactPaths: [] }));
@@ -1775,14 +1976,14 @@ async function run() {
1775
1976
  currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "verifying_auth", {
1776
1977
  at: new Date().toISOString(),
1777
1978
  source: "oracle:worker",
1778
- message: "Verifying the imported ChatGPT auth session.",
1979
+ message: `Verifying the imported ${isGrokJob(currentJob) ? "Grok" : "ChatGPT"} browser session.`,
1779
1980
  patch: { heartbeatAt: new Date().toISOString() },
1780
1981
  }));
1781
1982
  await waitForOracleReady(currentJob);
1782
1983
  currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "configuring_model", {
1783
1984
  at: new Date().toISOString(),
1784
1985
  source: "oracle:worker",
1785
- message: "Configuring the requested ChatGPT model selection.",
1986
+ message: `Configuring the requested ${isGrokJob(currentJob) ? "Grok" : "ChatGPT"} model selection.`,
1786
1987
  patch: { heartbeatAt: new Date().toISOString() },
1787
1988
  }));
1788
1989
  await configureModel(currentJob);
@@ -1800,16 +2001,35 @@ async function run() {
1800
2001
  await log(`Waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
1801
2002
  await sleep(POST_SEND_SETTLE_MS);
1802
2003
 
1803
- const chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1804
- const conversationId = parseConversationId(chatUrl) || currentJob.conversationId;
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
+ };
1805
2012
  currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "awaiting_response", {
1806
2013
  at: new Date().toISOString(),
1807
2014
  source: "oracle:worker",
1808
2015
  message: "Waiting for the assistant response to finish streaming.",
1809
- patch: { chatUrl, conversationId, heartbeatAt: new Date().toISOString() },
2016
+ patch: awaitingResponsePatch,
1810
2017
  }));
1811
2018
 
1812
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
+ }
1813
2033
  currentJob = await mutateJob((job) => transitionOracleJobPhase(job, "extracting_response", {
1814
2034
  at: new Date().toISOString(),
1815
2035
  source: "oracle:worker",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.6.16",
4
- "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
3
+ "version": "0.7.0",
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": {