pi-oracle 0.2.0 → 0.2.2

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
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 - 2026-04-07
4
+
5
+ ### Fixed
6
+ - missed ChatGPT file artifacts now map generic download controls onto nearby filenames and download from live DOM selectors instead of relying only on filename-labeled snapshot refs
7
+ - oracle jobs no longer report a false-clean completion when response-local artifact signals are present but capture fails or remains inconclusive
8
+ - artifact label extraction now collapses paths and mixed response text down to real filenames so suspicious artifact fallback logic does not emit bogus labels
9
+
10
+ ### Added
11
+ - regression coverage for artifact label extraction edge cases and ambiguous download-control artifact detection
12
+
13
+ ## 0.2.1 - 2026-04-07
14
+
15
+ ### Fixed
16
+ - wake-up guidance now tells receivers to use `oracle_read(jobId)` as the canonical way to consume completion results
17
+ - manual inspection before the first wake-up no longer suppresses the initial reminder attempt
18
+ - wake-up settlement now records provenance so suppressed/settled delivery can be explained in postmortems
19
+ - queued archive cleanup retries now retry queued archive deletion and keep warnings until cleanup succeeds
20
+ - queued archive byte-pressure accounting now includes retained pre-submit archives instead of only currently queued jobs
21
+
3
22
  ## 0.2.0 - 2026-04-06
4
23
 
5
24
  ### Added
package/README.md CHANGED
@@ -136,7 +136,9 @@ Detailed design and maintainer docs:
136
136
  Completion notification semantics:
137
137
  - oracle responses and artifacts are always persisted durably in oracle job state under the configured oracle jobs dir (`${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default)
138
138
  - completion delivery into pi sessions is best-effort wake-up based; the extension no longer appends synthetic assistant completion messages into session history
139
- - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened
139
+ - wake-up content tells the receiving assistant to call `oracle_read(jobId)` as the canonical completion-consumption path, with saved file paths included as secondary context
140
+ - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened, and now persists settlement provenance for postmortems
141
+ - manual inspection before the first wake-up attempt is recorded as observation only and does not suppress the first reminder send
140
142
  - if a wake-up does not land, the job remains available via its saved response/artifacts and status commands
141
143
 
142
144
  ## Privacy / local data
@@ -336,6 +336,14 @@ Important fields include:
336
336
  - `wakeupAttemptCount`
337
337
  - `wakeupLastRequestedAt`
338
338
  - `wakeupSettledAt`
339
+ - `wakeupSettledSource`
340
+ - `wakeupSettledSessionFile`
341
+ - `wakeupSettledSessionKey`
342
+ - `wakeupSettledBeforeFirstAttempt`
343
+ - `wakeupObservedAt`
344
+ - `wakeupObservedSource`
345
+ - `wakeupObservedSessionFile`
346
+ - `wakeupObservedSessionKey`
339
347
  - `notifyClaimedAt`
340
348
  - `notifyClaimedBy`
341
349
  - `artifactFailureCount`
@@ -461,7 +469,9 @@ The extension still uses the same general `pi`-native background completion patt
461
469
  - poller scans jobs on an interval
462
470
  - completed job durability lives in oracle job state plus saved response/artifact files, not in synthetic session-history assistant messages
463
471
  - when a matching job reaches `complete`, `failed`, or `cancelled`, the poller issues bounded best-effort wake-up reminders to whichever matching session is currently live
464
- - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened
472
+ - those wake-ups direct the receiver to `oracle_read(jobId)` as the canonical completion-consumption path, while still surfacing saved response/artifact paths as secondary context
473
+ - manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened and persists provenance about which path/session settled the wake-up
474
+ - manual inspection before the first wake-up attempt is recorded separately as observation metadata and does not suppress the first reminder send
465
475
  - if no wake-up lands, the job remains available via `/oracle-status`, `oracle_read`, and the saved `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` response/artifact files
466
476
  - because completion delivery is best-effort, pruning uses explicit terminal-job age policy instead of pretending a durable session notification happened
467
477
  - recently sent wake-ups keep response/artifact files retained briefly so follow-up turns do not point at deleted paths if cleanup or pruning races with delivery
@@ -121,7 +121,11 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
121
121
  return;
122
122
  }
123
123
  if (isTerminalOracleJob(job)) {
124
- await markWakeupSettled(job.id);
124
+ await markWakeupSettled(job.id, {
125
+ source: "oracle_status",
126
+ sessionFile: ctx.sessionManager.getSessionFile?.(),
127
+ cwd: ctx.cwd,
128
+ });
125
129
  }
126
130
  ctx.ui.notify(summarizeJob(job.id), "info");
127
131
  },
@@ -25,6 +25,8 @@ export type OracleJobPhase =
25
25
  | "failed"
26
26
  | "cancelled";
27
27
 
28
+ export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status";
29
+
28
30
  export const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "submitted", "waiting"];
29
31
  export const OPEN_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["queued", ...ACTIVE_ORACLE_JOB_STATUSES];
30
32
  export const TERMINAL_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["complete", "failed", "cancelled"];
@@ -56,6 +58,10 @@ export function shouldAdvanceQueueAfterCancellation(job: Pick<OracleJob, "status
56
58
  return job.status === "cancelled" && !job.cleanupPending && !job.cleanupWarnings?.length;
57
59
  }
58
60
 
61
+ export function hasRetainedPreSubmitArchive(job: Pick<OracleJob, "submittedAt" | "archiveDeletedAfterUpload" | "archivePath">): boolean {
62
+ return !job.submittedAt && !job.archiveDeletedAfterUpload && typeof job.archivePath === "string" && job.archivePath.length > 0;
63
+ }
64
+
59
65
  export function hasDurableWorkerHandoff(
60
66
  job: Pick<OracleJob, "status" | "phase" | "workerPid" | "workerStartedAt" | "heartbeatAt">,
61
67
  ): boolean {
@@ -147,6 +153,14 @@ export interface OracleJob {
147
153
  wakeupAttemptCount?: number;
148
154
  wakeupLastRequestedAt?: string;
149
155
  wakeupSettledAt?: string;
156
+ wakeupSettledSource?: OracleWakeupSettlementSource;
157
+ wakeupSettledSessionFile?: string;
158
+ wakeupSettledSessionKey?: string;
159
+ wakeupSettledBeforeFirstAttempt?: boolean;
160
+ wakeupObservedAt?: string;
161
+ wakeupObservedSource?: OracleWakeupSettlementSource;
162
+ wakeupObservedSessionFile?: string;
163
+ wakeupObservedSessionKey?: string;
150
164
  notifyClaimedAt?: string;
151
165
  notifyClaimedBy?: string;
152
166
  artifactFailureCount?: number;
@@ -443,17 +457,32 @@ function getTerminalCleanupStaleReason(job: Pick<OracleJob, "status" | "cleanupP
443
457
  }
444
458
 
445
459
  export async function cleanupJobResources(
446
- job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId">,
460
+ job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId" | "archivePath" | "archiveDeletedAfterUpload">,
447
461
  ): Promise<OracleCleanupReport> {
462
+ const report: OracleCleanupReport = { attempted: [], warnings: [] };
463
+
464
+ if (hasRetainedPreSubmitArchive(job)) {
465
+ report.attempted.push("queuedArchive");
466
+ await rm(job.archivePath, { force: true }).catch((error: Error) => {
467
+ report.warnings.push(`Failed to remove queued archive ${job.archivePath}: ${error.message}`);
468
+ });
469
+ }
470
+
448
471
  if (!job.submittedAt) {
449
- return { attempted: [], warnings: [] };
472
+ return report;
450
473
  }
451
- return cleanupRuntimeArtifacts({
474
+
475
+ const runtimeReport = await cleanupRuntimeArtifacts({
452
476
  runtimeId: job.runtimeId,
453
477
  runtimeProfileDir: job.runtimeProfileDir,
454
478
  runtimeSessionName: job.runtimeSessionName,
455
479
  conversationId: job.conversationId,
456
480
  });
481
+
482
+ return {
483
+ attempted: [...report.attempted, ...runtimeReport.attempted],
484
+ warnings: [...report.warnings, ...runtimeReport.warnings],
485
+ };
457
486
  }
458
487
 
459
488
  function getCleanupRetentionMs(job: OracleJob): { complete: number; failed: number } {
@@ -774,12 +803,57 @@ export async function noteWakeupRequested(jobId: string, at = new Date().toISOSt
774
803
  }
775
804
  }
776
805
 
777
- export async function markWakeupSettled(jobId: string, at = new Date().toISOString()): Promise<OracleJob | undefined> {
806
+ function getWakeupSessionKey(sessionFile: string | undefined, cwd: string | undefined): string | undefined {
807
+ if (!sessionFile || !cwd) return undefined;
808
+ const projectId = getProjectId(cwd);
809
+ return `${projectId}::${getSessionId(sessionFile, projectId)}`;
810
+ }
811
+
812
+ export async function markWakeupSettled(
813
+ jobId: string,
814
+ options: {
815
+ source: OracleWakeupSettlementSource;
816
+ sessionFile?: string;
817
+ cwd?: string;
818
+ at?: string;
819
+ allowBeforeFirstAttempt?: boolean;
820
+ },
821
+ ): Promise<OracleJob | undefined> {
822
+ const at = options.at ?? new Date().toISOString();
823
+ const sessionKey = getWakeupSessionKey(options.sessionFile, options.cwd);
824
+
778
825
  try {
779
- return await updateJob(jobId, (job) => ({
780
- ...job,
781
- wakeupSettledAt: job.wakeupSettledAt ?? at,
782
- }));
826
+ return await updateJob(jobId, (job) => {
827
+ const beforeFirstAttempt = !job.wakeupLastRequestedAt && (job.wakeupAttemptCount ?? 0) === 0;
828
+ if (job.wakeupSettledAt) {
829
+ return {
830
+ ...job,
831
+ wakeupSettledSource: job.wakeupSettledSource ?? options.source,
832
+ wakeupSettledSessionFile: job.wakeupSettledSessionFile ?? options.sessionFile,
833
+ wakeupSettledSessionKey: job.wakeupSettledSessionKey ?? sessionKey,
834
+ wakeupSettledBeforeFirstAttempt: job.wakeupSettledBeforeFirstAttempt ?? beforeFirstAttempt,
835
+ };
836
+ }
837
+
838
+ if (beforeFirstAttempt && !options.allowBeforeFirstAttempt) {
839
+ return {
840
+ ...job,
841
+ wakeupObservedAt: job.wakeupObservedAt ?? at,
842
+ wakeupObservedSource: job.wakeupObservedSource ?? options.source,
843
+ wakeupObservedSessionFile: job.wakeupObservedSessionFile ?? options.sessionFile,
844
+ wakeupObservedSessionKey: job.wakeupObservedSessionKey ?? sessionKey,
845
+ };
846
+ }
847
+
848
+ return {
849
+ ...job,
850
+ wakeupSettledAt: at,
851
+ wakeupSettledSource: options.source,
852
+ wakeupSettledSessionFile: options.sessionFile,
853
+ wakeupSettledSessionKey: sessionKey,
854
+ wakeupSettledBeforeFirstAttempt: beforeFirstAttempt,
855
+ };
856
+ });
783
857
  } catch {
784
858
  return readJob(jobId);
785
859
  }
@@ -805,17 +879,14 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
805
879
  }, now),
806
880
  }));
807
881
 
808
- const cleanupWarnings: string[] = [];
809
- await rm(cancelled.archivePath, { force: true }).catch((error: Error) => {
810
- cleanupWarnings.push(`Failed to remove queued archive ${cancelled.archivePath}: ${error.message}`);
811
- });
812
- if (cleanupWarnings.length === 0) return cancelled;
882
+ const cleanupReport = await cleanupJobResources(cancelled);
883
+ if (cleanupReport.warnings.length === 0) return cancelled;
813
884
 
814
885
  return updateJob(id, (job) => ({
815
886
  ...job,
816
- cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupWarnings],
887
+ cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
817
888
  lastCleanupAt: now,
818
- error: [job.error, ...cleanupWarnings].filter(Boolean).join("\n"),
889
+ error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
819
890
  }));
820
891
  }
821
892
 
@@ -180,9 +180,10 @@ function buildNotificationContent(job: OraclePollerJob): string {
180
180
  const artifactsPath = `${getJobDir(job.id)}/artifacts`;
181
181
  return [
182
182
  `Oracle job ${job.id} is ${job.status}.`,
183
- `Read response: ${responsePath}`,
183
+ `Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
184
+ `Response file: ${responsePath}`,
184
185
  `Artifacts: ${artifactsPath}`,
185
- job.error ? `Error: ${job.error}` : "Continue from the oracle output.",
186
+ job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
186
187
  ].join("\n");
187
188
  }
188
189
 
@@ -231,7 +231,7 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
231
231
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
232
232
 
233
233
  export interface OracleCleanupReport {
234
- attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease">;
234
+ attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease" | "queuedArchive">;
235
235
  warnings: string[];
236
236
  }
237
237
 
@@ -13,6 +13,7 @@ import {
13
13
  getJobDir,
14
14
  getSessionFile,
15
15
  hasDurableWorkerHandoff,
16
+ hasRetainedPreSubmitArchive,
16
17
  isOpenOracleJob,
17
18
  isTerminalOracleJob,
18
19
  listOracleJobDirs,
@@ -436,27 +437,60 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
436
437
  return createArchiveForTesting(cwd, files, archivePath);
437
438
  }
438
439
 
439
- async function getQueuedArchivePressure(): Promise<{ queuedJobs: number; queuedArchiveBytes: number }> {
440
- const queuedJobs = listOracleJobDirs()
440
+ export interface QueuedArchivePressure {
441
+ queuedJobs: number;
442
+ queuedArchiveBytes: number;
443
+ }
444
+
445
+ export async function getQueuedArchivePressure(): Promise<QueuedArchivePressure> {
446
+ const jobs = listOracleJobDirs()
441
447
  .map((dir) => readJob(dir))
442
- .filter((job): job is NonNullable<typeof job> => Boolean(job && job.status === "queued"));
448
+ .filter((job): job is NonNullable<typeof job> => Boolean(job));
443
449
 
444
450
  const queuedArchiveBytes = (await Promise.all(
445
- queuedJobs.map(async (job) => {
446
- try {
447
- return (await stat(job.archivePath)).size;
448
- } catch {
449
- return 0;
450
- }
451
- }),
451
+ jobs
452
+ .filter((job) => hasRetainedPreSubmitArchive(job))
453
+ .map(async (job) => {
454
+ try {
455
+ return (await stat(job.archivePath)).size;
456
+ } catch {
457
+ return 0;
458
+ }
459
+ }),
452
460
  )).reduce((sum, bytes) => sum + bytes, 0);
453
461
 
454
462
  return {
455
- queuedJobs: queuedJobs.length,
463
+ queuedJobs: jobs.filter((job) => job.status === "queued").length,
456
464
  queuedArchiveBytes,
457
465
  };
458
466
  }
459
467
 
468
+ export function getQueueAdmissionFailure(args: {
469
+ queuePressure: QueuedArchivePressure;
470
+ archiveBytes: number;
471
+ activeJobs: number;
472
+ maxActiveJobs: number;
473
+ maxQueuedJobs: number;
474
+ maxQueuedArchiveBytes: number;
475
+ }): string | undefined {
476
+ if (args.queuePressure.queuedJobs >= args.maxQueuedJobs) {
477
+ return (
478
+ `Oracle is busy (${args.activeJobs}/${args.maxActiveJobs} active, ${args.queuePressure.queuedJobs}/${args.maxQueuedJobs} queued). ` +
479
+ "Retry later instead of enqueuing more archive state."
480
+ );
481
+ }
482
+
483
+ const queuedArchiveBytes = args.queuePressure.queuedArchiveBytes + args.archiveBytes;
484
+ if (queuedArchiveBytes > args.maxQueuedArchiveBytes) {
485
+ return (
486
+ `Oracle queued archive storage is full (${queuedArchiveBytes} bytes > ${args.maxQueuedArchiveBytes} bytes across queued jobs and retained pre-submit archives). ` +
487
+ "Retry later or narrow the archive inputs."
488
+ );
489
+ }
490
+
491
+ return undefined;
492
+ }
493
+
460
494
  function validateSubmissionOptions(
461
495
  params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
462
496
  modelFamily: OracleModelFamily,
@@ -638,18 +672,17 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
638
672
  if (!runtimeAttempt.acquired) {
639
673
  const queuePressure = await getQueuedArchivePressure();
640
674
  const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
641
- if (queuePressure.queuedJobs >= maxQueuedJobs) {
642
- throw new Error(
643
- `Oracle is busy (${runtimeAttempt.liveLeases.length}/${config.browser.maxConcurrentJobs} active, ${queuePressure.queuedJobs}/${maxQueuedJobs} queued). ` +
644
- "Retry later instead of enqueuing more archive state.",
645
- );
646
- }
647
675
  const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
648
- if (queuePressure.queuedArchiveBytes + currentArchive.archiveBytes > maxQueuedArchiveBytes) {
649
- throw new Error(
650
- `Oracle queued archive storage is full (${queuePressure.queuedArchiveBytes + currentArchive.archiveBytes} bytes > ${maxQueuedArchiveBytes} bytes across queued jobs). ` +
651
- "Retry later or narrow the archive inputs.",
652
- );
676
+ const queueAdmissionFailure = getQueueAdmissionFailure({
677
+ queuePressure,
678
+ archiveBytes: currentArchive.archiveBytes,
679
+ activeJobs: runtimeAttempt.liveLeases.length,
680
+ maxActiveJobs: config.browser.maxConcurrentJobs,
681
+ maxQueuedJobs,
682
+ maxQueuedArchiveBytes,
683
+ });
684
+ if (queueAdmissionFailure) {
685
+ throw new Error(queueAdmissionFailure);
653
686
  }
654
687
 
655
688
  queued = true;
@@ -854,7 +887,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
854
887
  if (!job || job.projectId !== getProjectId(ctx.cwd)) {
855
888
  throw new Error(`Oracle job not found in this project: ${params.jobId}`);
856
889
  }
857
- const latest = isTerminalOracleJob(job) ? await markWakeupSettled(job.id) : job;
890
+ const latest = isTerminalOracleJob(job)
891
+ ? await markWakeupSettled(job.id, {
892
+ source: "oracle_read",
893
+ sessionFile: getSessionFile(ctx),
894
+ cwd: ctx.cwd,
895
+ })
896
+ : job;
858
897
  const current = latest ?? readJob(job.id) ?? job;
859
898
 
860
899
  let responsePreview = "";
@@ -10,20 +10,36 @@ export interface SnapshotEntry {
10
10
 
11
11
  export interface StructuralArtifactCandidateInput {
12
12
  label?: string;
13
+ selector?: string;
14
+ controlLabel?: string;
13
15
  paragraphText?: string;
14
16
  listItemText?: string;
15
- paragraphFileButtonCount?: number;
17
+ paragraphInteractiveCount?: number;
18
+ paragraphArtifactLabelCount?: number;
16
19
  paragraphOtherTextLength?: number;
17
- listItemFileButtonCount?: number;
18
- focusableFileButtonCount?: number;
20
+ listItemInteractiveCount?: number;
21
+ listItemArtifactLabelCount?: number;
22
+ focusableInteractiveCount?: number;
23
+ focusableArtifactLabelCount?: number;
19
24
  focusableOtherTextLength?: number;
20
25
  }
21
26
 
22
27
  export interface StructuralArtifactCandidate {
23
28
  label: string;
29
+ selector?: string;
30
+ controlLabel?: string;
31
+ }
32
+
33
+ export interface StructuralArtifactCandidatePartition {
34
+ confirmed: StructuralArtifactCandidate[];
35
+ suspicious: StructuralArtifactCandidate[];
24
36
  }
25
37
 
26
38
  export function parseSnapshotEntries(snapshot: string): SnapshotEntry[];
39
+ export function extractArtifactLabels(value: string): string[];
27
40
  export function filterStructuralArtifactCandidates(
28
41
  candidates: StructuralArtifactCandidateInput[],
29
42
  ): StructuralArtifactCandidate[];
43
+ export function partitionStructuralArtifactCandidates(
44
+ candidates: StructuralArtifactCandidateInput[],
45
+ ): StructuralArtifactCandidatePartition;
@@ -1,7 +1,8 @@
1
- export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])`;
2
- const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE);
1
+ export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^A-Za-z0-9._~/-])((?:(?:[A-Za-z]:)?[\\/]|[.~][\\/])?(?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+\.[A-Za-z0-9]{1,12})(?=$|[^A-Za-z0-9._~/-])`;
2
+ const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
3
3
  export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
4
4
  const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
5
+ const GENERIC_DOWNLOAD_CONTROL_PATTERN = /(?:^|\b)(?:download|save)(?:\b|$)/i;
5
6
 
6
7
  export function parseSnapshotEntries(snapshot) {
7
8
  return String(snapshot || "")
@@ -29,11 +30,61 @@ function normalizeText(value) {
29
30
  return String(value || "").replace(/\s+/g, " ").trim();
30
31
  }
31
32
 
33
+ function sanitizeArtifactLabel(value) {
34
+ const normalized = normalizeText(value).replace(/^[^A-Za-z0-9._~/-]+|[^A-Za-z0-9._~/-]+$/g, "");
35
+ if (!normalized) return "";
36
+ const basename = normalized.split(/[\\/]/).filter(Boolean).at(-1) || "";
37
+ return basename.replace(/^[^A-Za-z0-9._-]+|[^A-Za-z0-9._-]+$/g, "");
38
+ }
39
+
40
+ export function extractArtifactLabels(value) {
41
+ const seen = new Set();
42
+ const labels = [];
43
+ for (const match of String(value || "").matchAll(FILE_LABEL_PATTERN)) {
44
+ const normalized = sanitizeArtifactLabel(match[1] || match[0] || "");
45
+ if (!normalized || seen.has(normalized)) continue;
46
+ seen.add(normalized);
47
+ labels.push(normalized);
48
+ }
49
+ return labels;
50
+ }
51
+
32
52
  export function isLikelyArtifactLabel(label) {
33
53
  const normalized = normalizeText(label);
34
54
  if (!normalized) return false;
35
55
  if (GENERIC_ARTIFACT_LABEL_SET.has(normalized.toUpperCase())) return true;
36
- return FILE_LABEL_PATTERN.test(normalized);
56
+ return extractArtifactLabels(normalized).length > 0;
57
+ }
58
+
59
+ function hasGenericDownloadControl(controlLabel) {
60
+ return GENERIC_DOWNLOAD_CONTROL_PATTERN.test(normalizeText(controlLabel));
61
+ }
62
+
63
+ function normalizeCandidate(candidate) {
64
+ const label = normalizeText(candidate?.label);
65
+ return label ? { ...candidate, label } : undefined;
66
+ }
67
+
68
+ function hasArtifactSignal(candidate) {
69
+ const label = normalizeText(candidate?.label);
70
+ if (!isLikelyArtifactLabel(label)) return false;
71
+
72
+ const paragraphInteractiveCount = Number(candidate?.paragraphInteractiveCount || 0);
73
+ const listItemInteractiveCount = Number(candidate?.listItemInteractiveCount || 0);
74
+ const focusableInteractiveCount = Number(candidate?.focusableInteractiveCount || 0);
75
+ const paragraphArtifactLabelCount = Number(candidate?.paragraphArtifactLabelCount || 0);
76
+ const listItemArtifactLabelCount = Number(candidate?.listItemArtifactLabelCount || 0);
77
+ const focusableArtifactLabelCount = Number(candidate?.focusableArtifactLabelCount || 0);
78
+
79
+ return (
80
+ hasGenericDownloadControl(candidate?.controlLabel) ||
81
+ paragraphInteractiveCount > 0 ||
82
+ listItemInteractiveCount > 0 ||
83
+ focusableInteractiveCount > 0 ||
84
+ paragraphArtifactLabelCount > 0 ||
85
+ listItemArtifactLabelCount > 0 ||
86
+ focusableArtifactLabelCount > 0
87
+ );
37
88
  }
38
89
 
39
90
  export function isStructuralArtifactCandidate(candidate) {
@@ -41,36 +92,56 @@ export function isStructuralArtifactCandidate(candidate) {
41
92
  if (!isLikelyArtifactLabel(label)) return false;
42
93
 
43
94
  const listItemText = normalizeText(candidate?.listItemText);
44
- const listItemFileButtonCount = Number(candidate?.listItemFileButtonCount || 0);
45
- const paragraphFileButtonCount = Number(candidate?.paragraphFileButtonCount || 0);
95
+ const listItemInteractiveCount = Number(candidate?.listItemInteractiveCount || 0);
96
+ const listItemArtifactLabelCount = Number(candidate?.listItemArtifactLabelCount || 0);
97
+ const paragraphInteractiveCount = Number(candidate?.paragraphInteractiveCount || 0);
98
+ const paragraphArtifactLabelCount = Number(candidate?.paragraphArtifactLabelCount || 0);
46
99
  const paragraphOtherTextLength = Number(candidate?.paragraphOtherTextLength ?? Number.POSITIVE_INFINITY);
47
- const focusableFileButtonCount = Number(candidate?.focusableFileButtonCount || 0);
100
+ const focusableInteractiveCount = Number(candidate?.focusableInteractiveCount || 0);
101
+ const focusableArtifactLabelCount = Number(candidate?.focusableArtifactLabelCount || 0);
48
102
  const focusableOtherTextLength = Number(candidate?.focusableOtherTextLength ?? Number.POSITIVE_INFINITY);
49
103
 
50
- if (listItemText === label && listItemFileButtonCount === 1) {
104
+ if (listItemText === label && listItemInteractiveCount === 1 && listItemArtifactLabelCount === 1) {
51
105
  return true;
52
106
  }
53
107
 
54
- if (paragraphFileButtonCount === 1 && paragraphOtherTextLength <= 32) {
108
+ if (paragraphArtifactLabelCount === 1 && paragraphInteractiveCount === 1 && paragraphOtherTextLength <= 32) {
55
109
  return true;
56
110
  }
57
111
 
58
- if (focusableFileButtonCount >= 1 && focusableOtherTextLength <= 64) {
112
+ if (focusableArtifactLabelCount >= 1 && focusableInteractiveCount >= 1 && focusableOtherTextLength <= 64) {
59
113
  return true;
60
114
  }
61
115
 
62
116
  return false;
63
117
  }
64
118
 
65
- export function filterStructuralArtifactCandidates(candidates) {
66
- const seen = new Set();
67
- const filtered = [];
119
+ export function partitionStructuralArtifactCandidates(candidates) {
120
+ const confirmedSeen = new Set();
121
+ const suspiciousSeen = new Set();
122
+ const confirmed = [];
123
+ const suspicious = [];
124
+
68
125
  for (const candidate of candidates || []) {
69
- const label = normalizeText(candidate?.label);
70
- if (!label || seen.has(label)) continue;
71
- if (!isStructuralArtifactCandidate(candidate)) continue;
72
- seen.add(label);
73
- filtered.push({ label });
126
+ const normalized = normalizeCandidate(candidate);
127
+ if (!normalized) continue;
128
+ if (!hasArtifactSignal(normalized)) continue;
129
+
130
+ if (isStructuralArtifactCandidate(normalized)) {
131
+ if (confirmedSeen.has(normalized.label)) continue;
132
+ confirmedSeen.add(normalized.label);
133
+ confirmed.push(normalized);
134
+ continue;
135
+ }
136
+
137
+ if (suspiciousSeen.has(normalized.label)) continue;
138
+ suspiciousSeen.add(normalized.label);
139
+ suspicious.push(normalized);
74
140
  }
75
- return filtered;
141
+
142
+ return { confirmed, suspicious: suspicious.filter((candidate) => !confirmedSeen.has(candidate.label)) };
143
+ }
144
+
145
+ export function filterStructuralArtifactCandidates(candidates) {
146
+ return partitionStructuralArtifactCandidates(candidates).confirmed;
76
147
  }
@@ -4,7 +4,7 @@ import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawn, execFileSync } from "node:child_process";
7
- import { FILE_LABEL_PATTERN_SOURCE, filterStructuralArtifactCandidates, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries } from "./artifact-heuristics.mjs";
7
+ import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
8
8
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
9
9
 
10
10
  const jobId = process.argv[2];
@@ -1497,28 +1497,52 @@ function preferredArtifactName(label, index) {
1497
1497
  async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1498
1498
  const snapshot = await snapshotText(job);
1499
1499
  const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1500
- if (!targetSlice) return { snapshot, targetSlice, candidates: [] };
1500
+ if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
1501
1501
 
1502
1502
  const structural = await evalPage(
1503
1503
  job,
1504
1504
  toJsonScript(`
1505
1505
  const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
1506
1506
  const genericArtifactLabels = new Set(${JSON.stringify(GENERIC_ARTIFACT_LABELS)});
1507
- const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)});
1507
+ const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)}, 'g');
1508
+ const downloadControlPattern = /(?:^|\\b)(?:download|save)(?:\\b|$)/i;
1509
+ const artifactMarkerAttr = 'data-pi-oracle-artifact-candidate';
1510
+ const artifactPrefix = 'pi-oracle-artifact-${jobId}-${responseIndex}-';
1511
+ const sanitize = (value) => normalize(value).replace(/^[^A-Za-z0-9._~/-]+|[^A-Za-z0-9._~/-]+$/g, '');
1512
+ const sanitizeArtifactLabel = (value) => {
1513
+ const normalized = sanitize(value);
1514
+ if (!normalized) return '';
1515
+ const basename = normalized.split(/[\\/]/).filter(Boolean).at(-1) || '';
1516
+ return basename.replace(/^[^A-Za-z0-9._-]+|[^A-Za-z0-9._-]+$/g, '');
1517
+ };
1518
+ const extractArtifactLabels = (value) => {
1519
+ const seen = new Set();
1520
+ const labels = [];
1521
+ for (const match of String(value || '').matchAll(fileLabelPattern)) {
1522
+ const label = sanitizeArtifactLabel(match[1] || match[0] || '');
1523
+ if (!label || seen.has(label)) continue;
1524
+ seen.add(label);
1525
+ labels.push(label);
1526
+ }
1527
+ return labels;
1528
+ };
1508
1529
  const isFileLabel = (value) => {
1509
1530
  const normalized = normalize(value);
1510
1531
  if (!normalized) return false;
1511
1532
  if (genericArtifactLabels.has(normalized.toUpperCase())) return true;
1512
- return fileLabelPattern.test(normalized);
1533
+ return extractArtifactLabels(normalized).length > 0;
1513
1534
  };
1535
+ const isDownloadControl = (value) => downloadControlPattern.test(normalize(value));
1514
1536
  const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
1515
1537
  .filter((el) => normalize(el.textContent) === 'ChatGPT said:');
1516
1538
  const host = headings[${responseIndex}]?.nextElementSibling;
1517
1539
  if (!host) return { candidates: [] };
1518
1540
 
1519
- const fileButtons = (node) => node
1520
- ? Array.from(node.querySelectorAll('button, a')).map((candidate) => normalize(candidate.textContent)).filter(isFileLabel)
1521
- : [];
1541
+ const interactiveElements = (node) => node ? Array.from(node.querySelectorAll('button, a')) : [];
1542
+ const interactiveLabels = (node) => interactiveElements(node)
1543
+ .map((candidate) => normalize(candidate.textContent || candidate.getAttribute('aria-label') || candidate.getAttribute('title')))
1544
+ .filter(Boolean);
1545
+ const artifactLabelsForNode = (node) => extractArtifactLabels(node?.textContent || '');
1522
1546
  const otherTextLength = (text, labels) => {
1523
1547
  let remaining = normalize(text);
1524
1548
  for (const label of labels || []) {
@@ -1528,25 +1552,43 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1528
1552
  return remaining.length;
1529
1553
  };
1530
1554
  const focusableFor = (node) => node?.closest('[tabindex]');
1555
+ const uniqueLabel = (...groups) => {
1556
+ for (const group of groups) {
1557
+ const labels = Array.from(new Set((group || []).map(sanitizeArtifactLabel).filter(Boolean)));
1558
+ if (labels.length === 1) return labels[0];
1559
+ }
1560
+ return undefined;
1561
+ };
1531
1562
 
1532
- const candidates = Array.from(host.querySelectorAll('button, a'))
1533
- .map((button) => {
1534
- const label = normalize(button.textContent);
1535
- if (!isFileLabel(label)) return null;
1563
+ const candidates = interactiveElements(host)
1564
+ .map((button, index) => {
1565
+ const controlLabel = normalize(button.textContent || button.getAttribute('aria-label') || button.getAttribute('title'));
1536
1566
  const paragraph = button.closest('p');
1537
1567
  const listItem = button.closest('li');
1538
1568
  const focusable = focusableFor(button);
1539
- const paragraphFileLabels = fileButtons(paragraph);
1540
- const focusableFileLabels = fileButtons(focusable);
1569
+ const ownArtifactLabels = extractArtifactLabels(controlLabel);
1570
+ const paragraphArtifactLabels = artifactLabelsForNode(paragraph);
1571
+ const listItemArtifactLabels = artifactLabelsForNode(listItem);
1572
+ const focusableArtifactLabels = artifactLabelsForNode(focusable);
1573
+ const label = uniqueLabel(ownArtifactLabels, listItemArtifactLabels, paragraphArtifactLabels, focusableArtifactLabels);
1574
+ if (!label && !isFileLabel(controlLabel) && !isDownloadControl(controlLabel)) return null;
1575
+ if (!label) return null;
1576
+ const marker = artifactPrefix + index;
1577
+ button.setAttribute(artifactMarkerAttr, marker);
1541
1578
  return {
1542
1579
  label,
1580
+ selector: '[' + artifactMarkerAttr + '="' + marker + '"]',
1581
+ controlLabel,
1543
1582
  paragraphText: normalize(paragraph?.textContent),
1544
1583
  listItemText: normalize(listItem?.textContent),
1545
- paragraphFileButtonCount: paragraphFileLabels.length,
1546
- paragraphOtherTextLength: otherTextLength(paragraph?.textContent, paragraphFileLabels),
1547
- listItemFileButtonCount: fileButtons(listItem).length,
1548
- focusableFileButtonCount: focusableFileLabels.length,
1549
- focusableOtherTextLength: otherTextLength(focusable?.textContent, focusableFileLabels),
1584
+ paragraphInteractiveCount: interactiveElements(paragraph).length,
1585
+ paragraphArtifactLabelCount: Array.from(new Set(paragraphArtifactLabels)).length,
1586
+ paragraphOtherTextLength: otherTextLength(paragraph?.textContent, [...paragraphArtifactLabels, ...interactiveLabels(paragraph)]),
1587
+ listItemInteractiveCount: interactiveElements(listItem).length,
1588
+ listItemArtifactLabelCount: Array.from(new Set(listItemArtifactLabels)).length,
1589
+ focusableInteractiveCount: interactiveElements(focusable).length,
1590
+ focusableArtifactLabelCount: Array.from(new Set(focusableArtifactLabels)).length,
1591
+ focusableOtherTextLength: otherTextLength(focusable?.textContent, [...focusableArtifactLabels, ...interactiveLabels(focusable)]),
1550
1592
  };
1551
1593
  })
1552
1594
  .filter(Boolean);
@@ -1555,10 +1597,26 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
1555
1597
  `),
1556
1598
  );
1557
1599
 
1600
+ const partitioned = partitionStructuralArtifactCandidates(structural?.candidates || []);
1601
+ const snapshotEntries = parseSnapshotEntries(targetSlice);
1602
+ const hasGenericArtifactControl = snapshotEntries.some(
1603
+ (entry) =>
1604
+ (entry.kind === "button" || entry.kind === "link") &&
1605
+ !entry.disabled &&
1606
+ /(?:^|\b)(?:download|save)(?:\b|$)/i.test(`${entry.label || ""} ${entry.value || ""}`),
1607
+ );
1608
+ const suspiciousFromText = hasGenericArtifactControl
1609
+ ? extractArtifactLabels(responseText)
1610
+ .filter((label) => !partitioned.confirmed.some((candidate) => candidate.label === label) && !partitioned.suspicious.some((candidate) => candidate.label === label))
1611
+ .map((label) => ({ label }))
1612
+ : [];
1613
+
1558
1614
  return {
1559
1615
  snapshot,
1560
1616
  targetSlice,
1561
- candidates: filterStructuralArtifactCandidates(structural?.candidates || []),
1617
+ candidates: partitioned.confirmed,
1618
+ suspiciousLabels: [...partitioned.suspicious.map((candidate) => candidate.label), ...suspiciousFromText.map((candidate) => candidate.label)]
1619
+ .filter((label, index, labels) => labels.indexOf(label) === index),
1562
1620
  };
1563
1621
  }
1564
1622
 
@@ -1566,11 +1624,14 @@ async function waitForStableArtifactCandidates(job, responseIndex, responseText
1566
1624
  const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
1567
1625
  let lastSignature;
1568
1626
  let stablePolls = 0;
1569
- let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
1627
+ let latest = { snapshot: "", targetSlice: undefined, candidates: [], suspiciousLabels: [] };
1570
1628
 
1571
1629
  while (Date.now() < deadline) {
1572
1630
  latest = await collectArtifactCandidates(job, responseIndex, responseText);
1573
- const signature = latest.candidates.map((candidate) => candidate.label).join("\n");
1631
+ const signature = JSON.stringify({
1632
+ candidates: latest.candidates.map((candidate) => candidate.label),
1633
+ suspiciousLabels: latest.suspiciousLabels,
1634
+ });
1574
1635
  if (signature === lastSignature) stablePolls += 1;
1575
1636
  else {
1576
1637
  lastSignature = signature;
@@ -1628,7 +1689,7 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1628
1689
  return [];
1629
1690
  }
1630
1691
 
1631
- const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
1692
+ let { targetSlice, candidates, suspiciousLabels } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
1632
1693
  if (!targetSlice) {
1633
1694
  await log(`No assistant response found in snapshot for response index ${responseIndex}`);
1634
1695
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
@@ -1637,33 +1698,32 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1637
1698
  }
1638
1699
 
1639
1700
  await log(`Artifact candidates: ${candidates.map((candidate) => candidate.label).join(", ") || "(none)"}`);
1701
+ if (suspiciousLabels.length > 0) {
1702
+ await log(`Suspicious artifact signals: ${suspiciousLabels.join(", ")}`);
1703
+ }
1640
1704
 
1641
1705
  const artifactsDir = `${jobDir}/artifacts`;
1642
1706
  await ensurePrivateDir(artifactsDir);
1643
1707
  const artifacts = [];
1644
1708
  await flushArtifactsState(artifacts);
1645
1709
 
1646
- for (const [index, candidate] of candidates.entries()) {
1710
+ for (const [index, originalCandidate] of candidates.entries()) {
1647
1711
  let downloaded = false;
1712
+ let activeCandidate = originalCandidate;
1648
1713
  for (let attempt = 1; attempt <= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS && !downloaded; attempt += 1) {
1649
- const freshSnapshot = await snapshotText(job);
1650
- const freshSlice = assistantSnapshotSlice(freshSnapshot, responseIndex);
1651
- if (!freshSlice) break;
1652
- const freshEntries = parseSnapshotEntries(freshSlice);
1653
- const entry = freshEntries.find(
1654
- (artifactEntry) => artifactEntry.label === candidate.label && (artifactEntry.kind === "button" || artifactEntry.kind === "link") && !artifactEntry.disabled,
1655
- );
1656
- if (!entry) {
1657
- await log(`Artifact "${candidate.label}" not found in fresh snapshot, skipping`);
1714
+ if (!activeCandidate?.selector) {
1715
+ await log(`Artifact "${originalCandidate.label}" has no live selector, marking unconfirmed`);
1716
+ artifacts.push({ displayName: originalCandidate.label, unconfirmed: true, error: "Artifact candidate lost its live selector before download." });
1717
+ await flushArtifactsState(artifacts);
1658
1718
  break;
1659
1719
  }
1660
1720
 
1661
- const destinationPath = join(artifactsDir, preferredArtifactName(candidate.label, index));
1721
+ const destinationPath = join(artifactsDir, preferredArtifactName(originalCandidate.label, index));
1662
1722
  await rm(destinationPath, { force: true }).catch(() => undefined);
1663
1723
  try {
1664
- await log(`Artifact "${candidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using ref ${entry.ref}`);
1724
+ await log(`Artifact "${originalCandidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using selector ${activeCandidate.selector}`);
1665
1725
  await withHeartbeatWhile(() =>
1666
- agentBrowser(job, "download", entry.ref, destinationPath, {
1726
+ agentBrowser(job, "download", activeCandidate.selector, destinationPath, {
1667
1727
  timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
1668
1728
  }),
1669
1729
  );
@@ -1675,7 +1735,7 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1675
1735
  detectType(destinationPath),
1676
1736
  ]);
1677
1737
  artifacts.push({
1678
- displayName: candidate.label,
1738
+ displayName: originalCandidate.label,
1679
1739
  fileName: basename(destinationPath),
1680
1740
  copiedPath: destinationPath,
1681
1741
  size,
@@ -1686,11 +1746,15 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1686
1746
  } catch (error) {
1687
1747
  const message = error instanceof Error ? error.message : String(error);
1688
1748
  await rm(destinationPath, { force: true }).catch(() => undefined);
1689
- await log(`Artifact "${candidate.label}" download failed on attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS}: ${message}`);
1749
+ await log(`Artifact "${originalCandidate.label}" download failed on attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS}: ${message}`);
1690
1750
  if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
1691
- artifacts.push({ displayName: candidate.label, unconfirmed: true, error: message });
1751
+ artifacts.push({ displayName: originalCandidate.label, unconfirmed: true, error: message });
1692
1752
  } else {
1693
- await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${candidate.label}`);
1753
+ const refreshed = await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${originalCandidate.label}`);
1754
+ targetSlice = refreshed.targetSlice;
1755
+ candidates = refreshed.candidates;
1756
+ suspiciousLabels = refreshed.suspiciousLabels;
1757
+ activeCandidate = candidates.find((candidate) => candidate.label === originalCandidate.label);
1694
1758
  await sleep(1_000);
1695
1759
  }
1696
1760
  } finally {
@@ -1699,6 +1763,16 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
1699
1763
  }
1700
1764
  }
1701
1765
 
1766
+ const capturedArtifactLabels = new Set(artifacts.map((artifact) => artifact.displayName).filter(Boolean));
1767
+ const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label));
1768
+ if (missedArtifactLabels.length > 0) {
1769
+ await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
1770
+ for (const label of missedArtifactLabels) {
1771
+ artifacts.push({ displayName: label, unconfirmed: true, error: "Response-local artifact signal was present, but no downloadable artifact was captured." });
1772
+ }
1773
+ await flushArtifactsState(artifacts);
1774
+ }
1775
+
1702
1776
  return artifacts;
1703
1777
  }
1704
1778
 
@@ -1759,9 +1833,10 @@ async function run() {
1759
1833
  currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
1760
1834
  const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
1761
1835
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1836
+ const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
1762
1837
 
1763
1838
  await heartbeat(
1764
- phasePatch(artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete", {
1839
+ phasePatch(finalPhase, {
1765
1840
  status: "complete",
1766
1841
  completedAt: new Date().toISOString(),
1767
1842
  responsePath: currentJob.responsePath,
@@ -1773,7 +1848,7 @@ async function run() {
1773
1848
  );
1774
1849
  const persistedJob = await readJob().catch(() => undefined);
1775
1850
  await log(`Persisted final status after completion write: ${persistedJob?.status || "unknown"}`);
1776
- await log(`Job ${currentJob.id} complete`);
1851
+ await log(`Job ${currentJob.id} complete (${finalPhase}, artifact failures=${artifactFailureCount})`);
1777
1852
  } catch (error) {
1778
1853
  if (!shuttingDown) {
1779
1854
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",