pi-oracle 0.6.17 → 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.
@@ -1,4 +1,4 @@
1
- // Purpose: Define the allowlist/drop policy for importing ChatGPT/OpenAI auth cookies into the isolated oracle browser profile.
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 ChatGPT conversation-state helpers used by the oracle worker.
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 ChatGPT cookies from arbitrary macOS Chromium-family cookie stores when sweet-cookie's built-in browser list is too narrow.
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 ChatGPT config supplies the real set.
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 = entries.some(
719
- (entry) => entry.kind === "textbox" && entry.label === CHATGPT_LABELS.composer && !entry.disabled,
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 === CHATGPT_LABELS.addFiles && !entry.disabled,
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 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);
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 "${CHATGPT_LABELS.composer}"`)) {
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 === CHATGPT_LABELS.send && !candidate.disabled,
1052
+ (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
976
1053
  );
977
- const fileCount = composerFileEntryCount(snapshot, fileLabel);
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 === CHATGPT_LABELS.send && !candidate.disabled,
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 ${CHATGPT_LABELS.send} to become enabled`);
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 === CHATGPT_LABELS.addFiles && candidate.kind === "button",
1297
+ (candidate) => candidate.label === labels.addFiles && candidate.kind === "button",
1183
1298
  );
1184
1299
  if (!addFilesEntry) {
1185
- throw new Error(`Could not find "${CHATGPT_LABELS.addFiles}" button`);
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
- 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
+ }
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: headings.map((heading) => ({ text: renderText(heading.nextElementSibling) })),
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(`ChatGPT response failed: ${responseFailureText}`);
1468
+ throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} response failed: ${responseFailureText}`);
1293
1469
  }
1294
1470
 
1295
1471
  let completionSignature;
1296
- if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
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("Timed out waiting for ChatGPT response completion");
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: "Verifying the imported ChatGPT auth session.",
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: "Configuring the requested ChatGPT model selection.",
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 chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1824
- 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
+ };
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: { chatUrl, conversationId, heartbeatAt: new Date().toISOString() },
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.6.17",
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": {