pi-oracle 0.6.10 → 0.6.12

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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.12 - 2026-05-01
6
+
7
+ ### Changed
8
+ - updated the local pi development baseline to `@mariozechner/pi-coding-agent` `0.71.1` and refreshed the TypeBox development dependency
9
+ - regenerated the npm lockfile against the current stable dependency graph
10
+
11
+ ### Compatibility
12
+ - reviewed the pi `0.71.1` changelog and confirmed the oracle extension remains compatible with current extension lifecycle, package install/update, and TypeBox 1.x guidance
13
+
14
+
15
+ ## 0.6.11 - 2026-04-30
16
+
17
+ ### Fixed
18
+ - updated ChatGPT auth/readiness detection and model preset selection for the current ChatGPT UI, including bare effort chips like `Light` and current `Model` controls
19
+ - restored generated artifact capture for ChatGPT's current `Download the file` behavior buttons by associating response-local filenames with generic download controls and following the JSON `download_url` indirection to the final file bytes
20
+ - reduced false-positive artifact reporting from response text labels that look like filenames but do not have a confirmed downloadable control
21
+
22
+ ### Validation
23
+ - verified live `instant`, `thinking_light`, `instant_auto_switch`, `pro_standard`, follow-up, and isolated local-extension oracle runs against the changed ChatGPT UI
24
+ - verified byte-correct generated artifact downloads in both live and isolated local-extension runs
25
+
5
26
  ## 0.6.10 - 2026-04-23
6
27
 
7
28
  ### Changed
@@ -22,6 +22,7 @@ export interface StructuralArtifactCandidateInput {
22
22
  focusableInteractiveCount?: number;
23
23
  focusableArtifactLabelCount?: number;
24
24
  focusableOtherTextLength?: number;
25
+ fromResponseTextLabel?: boolean;
25
26
  }
26
27
 
27
28
  export interface StructuralArtifactCandidate {
@@ -8,6 +8,7 @@ const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
8
8
  export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
9
9
  const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
10
10
  const GENERIC_DOWNLOAD_CONTROL_PATTERN = /(?:^|\b)(?:download|save)(?:\b|$)/i;
11
+ const CODE_MEMBER_CALL_LABEL_PATTERN = /^[A-Za-z_$][\w$]*\.(?:catch|close|filter|join|json|map|match|open|read|slice|split|text|then|trim|write)$/;
11
12
 
12
13
  export function parseSnapshotEntries(snapshot) {
13
14
  return String(snapshot || "")
@@ -47,7 +48,7 @@ export function extractArtifactLabels(value) {
47
48
  const labels = [];
48
49
  for (const match of String(value || "").matchAll(FILE_LABEL_PATTERN)) {
49
50
  const normalized = sanitizeArtifactLabel(match[1] || match[0] || "");
50
- if (!normalized || seen.has(normalized)) continue;
51
+ if (!normalized || CODE_MEMBER_CALL_LABEL_PATTERN.test(normalized) || seen.has(normalized)) continue;
51
52
  seen.add(normalized);
52
53
  labels.push(normalized);
53
54
  }
@@ -118,6 +119,10 @@ export function isStructuralArtifactCandidate(candidate) {
118
119
  return true;
119
120
  }
120
121
 
122
+ if (candidate?.fromResponseTextLabel === true && hasGenericDownloadControl(candidate?.controlLabel)) {
123
+ return true;
124
+ }
125
+
121
126
  return false;
122
127
  }
123
128
 
@@ -4,6 +4,8 @@
4
4
  // Usage: Imported by auth-bootstrap.mjs and sanity tests to exercise auth classification behavior without driving a browser.
5
5
  // Invariants/Assumptions: Inputs are already captured snapshots/probe results from the live browser session; outputs are deterministic and side-effect free.
6
6
 
7
+ import { snapshotHasUsableComposerControls } from "./chatgpt-ui-helpers.mjs";
8
+
7
9
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthLoginProbe} OracleAuthLoginProbe */
8
10
  /** @typedef {import("./auth-flow-helpers.d.mts").OracleAuthPageClassification} OracleAuthPageClassification */
9
11
 
@@ -67,9 +69,9 @@ export function classifyChatAuthPage(args) {
67
69
  const onAllowedOrigin = args.allowedOrigins.some((origin) => args.url.startsWith(origin));
68
70
  const hasComposer = args.snapshot.includes(`textbox "${composerLabel}"`);
69
71
  const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
70
- const hasModelControl =
71
- args.snapshot.includes('button "Model selector"') ||
72
- /button "(?:Instant|(?:(?:Light|Standard|Extended|Heavy) )?Thinking|(?:(?:Light|Standard|Extended|Heavy) )?Pro)(?:, click to remove)?"/i.test(args.snapshot);
72
+ const hasUsableComposer = composerLabel === DEFAULT_COMPOSER_LABEL && addFilesLabel === DEFAULT_ADD_FILES_LABEL
73
+ ? snapshotHasUsableComposerControls(args.snapshot)
74
+ : hasComposer && hasAddFiles;
73
75
 
74
76
  const challengePatterns = [
75
77
  /just a moment/i,
@@ -107,7 +109,7 @@ export function classifyChatAuthPage(args) {
107
109
  return { state: "transient_outage_error", message: `ChatGPT is showing a transient outage/error page. Logs: ${args.logPath}` };
108
110
  }
109
111
 
110
- if (args.probe?.status === 401 || args.probe?.status === 403) {
112
+ if (args.probe?.status === 401 || (args.probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer || args.probe?.domLoginCta))) {
111
113
  return {
112
114
  state: "login_required",
113
115
  message:
@@ -133,7 +135,7 @@ export function classifyChatAuthPage(args) {
133
135
  };
134
136
  }
135
137
 
136
- if (onAllowedOrigin && args.probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
138
+ if (onAllowedOrigin && (args.probe?.status === 200 || args.probe?.status === 403) && hasUsableComposer) {
137
139
  if (!args.probe?.domLoginCta) {
138
140
  return {
139
141
  state: "authenticated_and_ready",
@@ -150,7 +152,7 @@ export function classifyChatAuthPage(args) {
150
152
  };
151
153
  }
152
154
 
153
- if (onAllowedOrigin && args.probe?.ok && hasComposer && hasAddFiles && hasModelControl) {
155
+ if (onAllowedOrigin && args.probe?.ok && hasUsableComposer) {
154
156
  return {
155
157
  state: "authenticated_and_ready",
156
158
  message: `Imported ChatGPT auth from ${args.cookieSourceLabel} into the isolated oracle profile. Logs: ${args.logPath}`,
@@ -15,6 +15,8 @@ export declare function requestedEffortLabel(selection: OracleUiSelection): stri
15
15
  export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
16
16
  export declare function thinkingChipVisible(snapshot: string): boolean;
17
17
  export declare function snapshotHasModelConfigurationUi(snapshot: string): boolean;
18
+ export declare function snapshotHasUsableComposerControls(snapshot: string): boolean;
19
+ export declare function snapshotHasModelOpener(snapshot: string): boolean;
18
20
  export declare function autoSwitchToThinkingSelectionVisible(snapshot: string): boolean | undefined;
19
21
  export declare function snapshotCanSafelySkipModelConfiguration(snapshot: string, selection: OracleUiSelection): boolean;
20
22
  export declare function snapshotStronglyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
@@ -28,6 +28,8 @@ const MODEL_FAMILY_PREFIX = {
28
28
  const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
29
29
  const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
30
30
  const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
31
+ const EFFORT_LABELS = new Set(["Light", "Standard", "Extended", "Heavy"]);
32
+ const BARE_EFFORT_PATTERN = /^(light|standard|extended|heavy)(?:, click to remove)?$/i;
31
33
  const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
32
34
  const PRO_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?pro(?:, click to remove)?$/i;
33
35
  const MODEL_FAMILY_CONTROL_KINDS = new Set(["button", "radio", "menuitemradio"]);
@@ -115,6 +117,14 @@ function parseComposerChipSelection(label) {
115
117
  const normalized = normalizeChipLabel(label).toLowerCase();
116
118
  if (!normalized) return undefined;
117
119
 
120
+ const bareEffortMatch = normalized.match(BARE_EFFORT_PATTERN);
121
+ if (bareEffortMatch) {
122
+ return {
123
+ modelFamily: /** @type {OracleUiModelFamily} */ ("thinking"),
124
+ effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ (bareEffortMatch[1].toLowerCase()),
125
+ };
126
+ }
127
+
118
128
  const thinkingMatch = normalized.match(THINKING_CHIP_PATTERN);
119
129
  if (thinkingMatch) {
120
130
  return {
@@ -158,6 +168,11 @@ function detectSelectedModelFamily(entries) {
158
168
  }
159
169
  }
160
170
 
171
+ const hasLatestModelCombobox = entries.some(
172
+ (entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === "model" && /^latest\b/i.test(normalizeText(entry.value)),
173
+ );
174
+ if (hasLatestModelCombobox) return undefined;
175
+
161
176
  const hasProEffortCombobox = entries.some(
162
177
  (entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === PRO_THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase(),
163
178
  );
@@ -227,11 +242,42 @@ export function snapshotHasModelConfigurationUi(snapshot) {
227
242
  );
228
243
  const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === "Close" && !entry.disabled);
229
244
  const hasEffortCombobox = entries.some(
230
- (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
245
+ (entry) => entry.kind === "combobox" && EFFORT_LABELS.has(entry.value || "") && !entry.disabled,
231
246
  );
232
247
  return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
233
248
  }
234
249
 
250
+ /**
251
+ * @param {string} snapshot
252
+ * @returns {boolean}
253
+ */
254
+ export function snapshotHasUsableComposerControls(snapshot) {
255
+ /** @type {SnapshotEntry[]} */
256
+ const entries = parseSnapshotEntries(snapshot);
257
+ const hasComposer = entries.some((entry) => entry.kind === "textbox" && entry.label === "Chat with ChatGPT" && !entry.disabled);
258
+ const hasAddFiles = entries.some((entry) => entry.kind === "button" && entry.label === "Add files and more" && !entry.disabled);
259
+ return hasComposer && hasAddFiles;
260
+ }
261
+
262
+ /**
263
+ * @param {string} snapshot
264
+ * @returns {boolean}
265
+ */
266
+ export function snapshotHasModelOpener(snapshot) {
267
+ /** @type {SnapshotEntry[]} */
268
+ const entries = parseSnapshotEntries(snapshot);
269
+ return entries.some((entry) => {
270
+ if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
271
+ const label = normalizeChipLabel(entry.label);
272
+ return label === "Model"
273
+ || label === "Model selector"
274
+ || EFFORT_LABELS.has(label)
275
+ || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
276
+ || THINKING_CHIP_PATTERN.test(label)
277
+ || PRO_CHIP_PATTERN.test(label);
278
+ });
279
+ }
280
+
235
281
  /**
236
282
  * @param {string} snapshot
237
283
  * @returns {boolean | undefined}
@@ -28,6 +28,7 @@ import {
28
28
  effortSelectionVisible,
29
29
  snapshotCanSafelySkipModelConfiguration,
30
30
  snapshotHasModelConfigurationUi,
31
+ snapshotHasUsableComposerControls,
31
32
  snapshotStronglyMatchesRequestedModel,
32
33
  snapshotWeaklyMatchesRequestedModel,
33
34
  autoSwitchToThinkingSelectionVisible,
@@ -257,8 +258,18 @@ async function cloneSeedProfileToRuntime(job) {
257
258
  await withLock(ORACLE_STATE_DIR, "auth", "global", { jobId: job.id, processPid: process.pid, action: "cloneSeedProfile" }, async () => {
258
259
  await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
259
260
  await ensurePrivateDir(dirname(job.runtimeProfileDir));
260
- const cloneArgs = job.config.browser.cloneStrategy === "apfs-clone" ? ["-cR", seedDir, job.runtimeProfileDir] : ["-R", seedDir, job.runtimeProfileDir];
261
- await spawnCommand("cp", cloneArgs, { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
261
+ if (job.config.browser.cloneStrategy === "apfs-clone") {
262
+ try {
263
+ await spawnCommand("/bin/cp", ["-cR", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
264
+ } catch (error) {
265
+ const message = error instanceof Error ? error.message : String(error);
266
+ await log(`APFS clone copy failed; falling back to recursive copy: ${message}`);
267
+ await rm(job.runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
268
+ await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
269
+ }
270
+ } else {
271
+ await spawnCommand("/bin/cp", ["-R", seedDir, job.runtimeProfileDir], { timeoutMs: PROFILE_CLONE_TIMEOUT_MS });
272
+ }
262
273
  }, 10 * 60 * 1000);
263
274
 
264
275
  return seedGeneration;
@@ -686,7 +697,9 @@ function matchesModelFamilyControl(candidate, family) {
686
697
  function matchesModelConfigurationOpener(candidate) {
687
698
  if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
688
699
  const label = String(candidate.label || "");
689
- return candidate.label === "Model selector"
700
+ return candidate.label === "Model"
701
+ || candidate.label === "Model selector"
702
+ || /^(?:Light|Standard|Extended|Heavy)(?:, click to remove)?$/i.test(label)
690
703
  || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
691
704
  || /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
692
705
  || /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
@@ -787,11 +800,9 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
787
800
  const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
788
801
  const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
789
802
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
790
- const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
791
- const hasAddFiles = snapshot.includes(`button "${CHATGPT_LABELS.addFiles}"`);
792
- const hasModelControl = snapshot.includes('button "Model selector"') || /button "(?:Instant|(?:(?:Light|Standard|Extended|Heavy) )?Thinking|(?:(?:Light|Standard|Extended|Heavy) )?Pro)(?:, click to remove)?"/i.test(snapshot);
803
+ const hasUsableComposer = snapshotHasUsableComposerControls(snapshot);
793
804
 
794
- if (probe?.status === 401 || probe?.status === 403) {
805
+ if (probe?.status === 401 || (probe?.status === 403 && (!onAllowedOrigin || !hasUsableComposer || probe?.domLoginCta))) {
795
806
  return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
796
807
  }
797
808
 
@@ -805,7 +816,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
805
816
  return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
806
817
  }
807
818
 
808
- if (onAllowedOrigin && probe?.status === 200 && hasComposer && hasAddFiles && hasModelControl) {
819
+ if (onAllowedOrigin && (probe?.status === 200 || probe?.status === 403) && hasUsableComposer) {
809
820
  if (probe?.domLoginCta && (probe?.bodyHasId || probe?.bodyHasEmail)) {
810
821
  return {
811
822
  state: "auth_transitioning",
@@ -1314,10 +1325,125 @@ function preferredArtifactName(label, index) {
1314
1325
  return `artifact-${String(index + 1).padStart(2, "0")}`;
1315
1326
  }
1316
1327
 
1328
+ async function downloadArtifactViaBrowserEval(job, selector, destinationPath) {
1329
+ const result = await evalPage(job, toAsyncJsonScript(`
1330
+ const selector = ${JSON.stringify(selector)};
1331
+ const maxBytes = 25 * 1024 * 1024;
1332
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1333
+ const element = document.querySelector(selector);
1334
+ if (!element) return { ok: false, error: 'artifact selector not found' };
1335
+
1336
+ const urls = [];
1337
+ const captures = [];
1338
+ const originalOpen = window.open;
1339
+ const originalFetch = window.fetch?.bind(window);
1340
+ const originalAnchorClick = HTMLAnchorElement.prototype.click;
1341
+
1342
+ const arrayBufferToBase64 = (buffer) => {
1343
+ const bytes = new Uint8Array(buffer);
1344
+ let binary = '';
1345
+ const chunkSize = 0x8000;
1346
+ for (let index = 0; index < bytes.length; index += chunkSize) {
1347
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
1348
+ }
1349
+ return btoa(binary);
1350
+ };
1351
+
1352
+ const shouldCapture = (url, headers) => {
1353
+ const contentDisposition = headers?.get?.('content-disposition') || '';
1354
+ const contentType = headers?.get?.('content-type') || '';
1355
+ const signal = [url, contentDisposition, contentType].join(' ').toLowerCase();
1356
+ return /download|files|oaiusercontent|attachment/i.test(signal) || signal.includes('estuary/content');
1357
+ };
1358
+
1359
+ const captureResponse = async (response, source) => {
1360
+ if (!response || captures.length > 0 || !shouldCapture(response.url || '', response.headers)) return;
1361
+ const contentLength = Number(response.headers.get('content-length') || 0);
1362
+ if (contentLength > maxBytes) {
1363
+ captures.push({ ok: false, error: 'artifact response too large for browser-eval fallback', url: response.url || '', source });
1364
+ return;
1365
+ }
1366
+ const clone = response.clone();
1367
+ const buffer = await clone.arrayBuffer();
1368
+ if (buffer.byteLength > maxBytes) {
1369
+ captures.push({ ok: false, error: 'artifact response too large for browser-eval fallback', url: response.url || '', source });
1370
+ return;
1371
+ }
1372
+ const contentType = response.headers.get('content-type') || '';
1373
+ if (contentType.toLowerCase().includes('application/json') && originalFetch) {
1374
+ try {
1375
+ const text = new TextDecoder().decode(buffer);
1376
+ const payload = JSON.parse(text);
1377
+ const downloadUrl = typeof payload?.download_url === 'string' ? payload.download_url : undefined;
1378
+ if (downloadUrl) {
1379
+ const fileResponse = await originalFetch(downloadUrl, { credentials: 'include' });
1380
+ await captureResponse(fileResponse, 'download_url');
1381
+ if (captures.length > 0) return;
1382
+ }
1383
+ } catch (_error) {
1384
+ // Fall through and preserve the JSON payload as last-resort evidence.
1385
+ }
1386
+ }
1387
+ captures.push({
1388
+ ok: true,
1389
+ url: response.url || '',
1390
+ source,
1391
+ contentType,
1392
+ contentDisposition: response.headers.get('content-disposition') || '',
1393
+ bytesBase64: arrayBufferToBase64(buffer),
1394
+ });
1395
+ };
1396
+
1397
+ try {
1398
+ window.open = (url, ...args) => {
1399
+ if (url) urls.push(String(url));
1400
+ return originalOpen.call(window, url, ...args);
1401
+ };
1402
+ HTMLAnchorElement.prototype.click = function patchedAnchorClick() {
1403
+ if (this.href) urls.push(this.href);
1404
+ return originalAnchorClick.call(this);
1405
+ };
1406
+ if (originalFetch) {
1407
+ window.fetch = async (...args) => {
1408
+ const response = await originalFetch(...args);
1409
+ const requestUrl = String(args[0]?.url || args[0] || response?.url || '');
1410
+ if (shouldCapture(requestUrl, response?.headers) || shouldCapture(response?.url || '', response?.headers)) {
1411
+ await captureResponse(response, 'fetch');
1412
+ }
1413
+ return response;
1414
+ };
1415
+ }
1416
+
1417
+ element.click();
1418
+ await sleep(3000);
1419
+ for (const url of urls) {
1420
+ if (captures.length > 0 || !url || !originalFetch) continue;
1421
+ try {
1422
+ const response = await originalFetch(url, { credentials: 'include' });
1423
+ await captureResponse(response, 'url');
1424
+ } catch (_error) {
1425
+ // Keep trying any other captured URLs.
1426
+ }
1427
+ }
1428
+ return captures[0] || { ok: false, error: 'click did not expose a downloadable artifact response', urls };
1429
+ } finally {
1430
+ window.open = originalOpen;
1431
+ HTMLAnchorElement.prototype.click = originalAnchorClick;
1432
+ if (originalFetch) window.fetch = originalFetch;
1433
+ }
1434
+ `));
1435
+
1436
+ if (!result?.ok || typeof result.bytesBase64 !== "string") {
1437
+ throw new Error(result?.error || "browser-eval artifact fallback did not capture a file");
1438
+ }
1439
+
1440
+ await writeFile(destinationPath, Buffer.from(result.bytesBase64, "base64"), { mode: 0o600 });
1441
+ return result;
1442
+ }
1443
+
1317
1444
  async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1318
1445
  const snapshot = await snapshotText(job);
1319
- const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex);
1320
- if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
1446
+ const targetSlice = assistantSnapshotSlice(snapshot, CHATGPT_LABELS.composer, responseIndex) || snapshot;
1321
1447
 
1322
1448
  const structural = await evalPage(
1323
1449
  job,
@@ -1355,7 +1481,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1355
1481
  const isDownloadControl = (value) => downloadControlPattern.test(normalize(value));
1356
1482
  const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
1357
1483
  .filter((el) => normalize(el.textContent) === 'ChatGPT said:');
1358
- const host = headings[${responseIndex}]?.nextElementSibling;
1484
+ const host = headings[${responseIndex}]?.nextElementSibling || document.querySelector('main') || document.body;
1359
1485
  if (!host) return { candidates: [] };
1360
1486
 
1361
1487
  const interactiveElements = (node) => node ? Array.from(node.querySelectorAll('button, a')) : [];
@@ -1380,6 +1506,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1380
1506
  return undefined;
1381
1507
  };
1382
1508
 
1509
+ const responseTextArtifactLabels = ${JSON.stringify(extractArtifactLabels(responseText))};
1383
1510
  const candidates = interactiveElements(host)
1384
1511
  .map((button, index) => {
1385
1512
  const controlLabel = normalize(button.textContent || button.getAttribute('aria-label') || button.getAttribute('title'));
@@ -1390,7 +1517,13 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1390
1517
  const paragraphArtifactLabels = artifactLabelsForNode(paragraph);
1391
1518
  const listItemArtifactLabels = artifactLabelsForNode(listItem);
1392
1519
  const focusableArtifactLabels = artifactLabelsForNode(focusable);
1393
- const label = uniqueLabel(ownArtifactLabels, listItemArtifactLabels, paragraphArtifactLabels, focusableArtifactLabels);
1520
+ const label = uniqueLabel(
1521
+ ownArtifactLabels,
1522
+ listItemArtifactLabels,
1523
+ paragraphArtifactLabels,
1524
+ focusableArtifactLabels,
1525
+ isDownloadControl(controlLabel) && responseTextArtifactLabels.length > 0 ? [responseTextArtifactLabels.at(-1)] : [],
1526
+ );
1394
1527
  if (!label && !isFileLabel(controlLabel) && !isDownloadControl(controlLabel)) return null;
1395
1528
  if (!label) return null;
1396
1529
  const marker = artifactPrefix + index;
@@ -1409,6 +1542,7 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1409
1542
  focusableInteractiveCount: interactiveElements(focusable).length,
1410
1543
  focusableArtifactLabelCount: Array.from(new Set(focusableArtifactLabels)).length,
1411
1544
  focusableOtherTextLength: otherTextLength(focusable?.textContent, [...focusableArtifactLabels, ...interactiveLabels(focusable)]),
1545
+ fromResponseTextLabel: responseTextArtifactLabels.includes(label),
1412
1546
  };
1413
1547
  })
1414
1548
  .filter(Boolean);
@@ -1510,12 +1644,6 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1510
1644
  }
1511
1645
 
1512
1646
  let { targetSlice, candidates, suspiciousLabels } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
1513
- if (!targetSlice) {
1514
- await log(`No assistant response found in snapshot for response index ${responseIndex}`);
1515
- await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1516
- await mutateJob((current) => ({ ...current, artifactPaths: [] }));
1517
- return [];
1518
- }
1519
1647
 
1520
1648
  await log(`Artifact candidates: ${candidates.map((candidate) => candidate.label).join(", ") || "(none)"}`);
1521
1649
  if (suspiciousLabels.length > 0) {
@@ -1542,11 +1670,17 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1542
1670
  await rm(destinationPath, { force: true }).catch(() => undefined);
1543
1671
  try {
1544
1672
  await log(`Artifact "${originalCandidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using selector ${activeCandidate.selector}`);
1545
- await withHeartbeatWhile(() =>
1546
- agentBrowser(job, "download", activeCandidate.selector, destinationPath, {
1547
- timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
1548
- }),
1549
- );
1673
+ try {
1674
+ const fallback = await downloadArtifactViaBrowserEval(job, activeCandidate.selector, destinationPath);
1675
+ await log(`Artifact "${originalCandidate.label}" captured via browser-eval fallback (${fallback.source || "unknown"}${fallback.contentType ? `, ${fallback.contentType}` : ""})`);
1676
+ } catch (fallbackError) {
1677
+ await log(`Artifact "${originalCandidate.label}" browser-eval fallback did not capture file: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
1678
+ await withHeartbeatWhile(() =>
1679
+ agentBrowser(job, "download", activeCandidate.selector, destinationPath, {
1680
+ timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
1681
+ }),
1682
+ );
1683
+ }
1550
1684
  await heartbeat(undefined, { force: true });
1551
1685
  await chmod(destinationPath, 0o600).catch(() => undefined);
1552
1686
  const [size, checksum, detectedType] = await Promise.all([
@@ -1584,7 +1718,8 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1584
1718
  }
1585
1719
 
1586
1720
  const capturedArtifactLabels = new Set(artifacts.map((artifact) => artifact.displayName).filter(Boolean));
1587
- const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label));
1721
+ const capturedArtifactKeys = new Set([...capturedArtifactLabels].map((label) => String(label).replace(/\s+/g, "")));
1722
+ const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label) && !capturedArtifactKeys.has(String(label).replace(/\s+/g, "")));
1588
1723
  if (missedArtifactLabels.length > 0) {
1589
1724
  await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
1590
1725
  for (const label of missedArtifactLabels) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -62,11 +62,11 @@
62
62
  "protobufjs": "7.5.5"
63
63
  },
64
64
  "devDependencies": {
65
- "@mariozechner/pi-coding-agent": "^0.70.0",
65
+ "@mariozechner/pi-coding-agent": "^0.71.1",
66
66
  "@types/node": "^25.6.0",
67
67
  "esbuild": "^0.28.0",
68
68
  "tsx": "^4.21.0",
69
- "typebox": "^1.1.33",
69
+ "typebox": "^1.1.37",
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "engines": {