pi-oracle 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-04-08
4
+
5
+ ### Changed
6
+ - breaking: `oracle_submit` and oracle config defaults now use preset-only model selection; legacy `modelFamily` / `effort` / `autoSwitchToThinking` submit inputs and default config fields were removed in favor of canonical preset ids
7
+ - oracle jobs now persist a resolved `selection` snapshot and the worker configures ChatGPT from that persisted selection instead of re-deriving model settings from legacy job fields
8
+ - oracle model preset definitions now come from a single canonical registry in `extensions/oracle/lib/config.ts`
9
+
10
+ ### Fixed
11
+ - removed duplicate hand-maintained preset-id examples from agent-facing prompt and design docs so callers are directed to the tool schema / canonical registry instead of stale inline lists
12
+ - oracle sanity coverage now validates the preset-only contract from the registered tool schema and canonical registry instead of brittle prose-only assertions
13
+ - worker model configuration now consistently uses the explicit `configureModel(job)` parameter instead of hidden coupling through the module-global current job
14
+
15
+ ## 0.2.2 - 2026-04-07
16
+
17
+ ### Fixed
18
+ - 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
19
+ - oracle jobs no longer report a false-clean completion when response-local artifact signals are present but capture fails or remains inconclusive
20
+ - artifact label extraction now collapses paths and mixed response text down to real filenames so suspicious artifact fallback logic does not emit bogus labels
21
+
22
+ ### Added
23
+ - regression coverage for artifact label extraction edge cases and ambiguous download-control artifact detection
24
+
3
25
  ## 0.2.1 - 2026-04-07
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -111,6 +111,7 @@ Config files:
111
111
  - project: `.pi/extensions/oracle.json`
112
112
 
113
113
  Common settings:
114
+ - `defaults.preset`
114
115
  - `browser.args`
115
116
  - `browser.executablePath`
116
117
  - `browser.authSeedProfileDir`
@@ -133,7 +133,9 @@ The authenticated seed profile remains the source of truth for future oracle run
133
133
 
134
134
  ### `oracle_submit`
135
135
 
136
- 1. validate config and model options
136
+ Agent-facing submissions use **`preset`**; the canonical registry is `ORACLE_SUBMIT_PRESETS` in `extensions/oracle/lib/config.ts`. **`preset` is the only model-selection parameter** on `oracle_submit`. There are no `modelFamily`, `effort`, or `autoSwitchToThinking` fields.
137
+
138
+ 1. resolve the preset (submit-time or config default) into an execution snapshot
137
139
  2. resolve optional `followUpJobId` into a prior `chatUrl` and `conversationId`
138
140
  3. build the archive first into a temporary path
139
141
  4. allocate a unique runtime:
@@ -216,9 +218,7 @@ Browser/auth settings are global-only because they control local privileged brow
216
218
  ```json
217
219
  {
218
220
  "defaults": {
219
- "modelFamily": "pro",
220
- "effort": "extended",
221
- "autoSwitchToThinking": false
221
+ "preset": "<preset id from ORACLE_SUBMIT_PRESETS>"
222
222
  },
223
223
  "browser": {
224
224
  "sessionPrefix": "oracle",
@@ -317,9 +317,7 @@ Important fields include:
317
317
  - `sessionId`
318
318
  - `originSessionFile`
319
319
  - `requestSource`
320
- - `chatModelFamily`
321
- - `effort`
322
- - `autoSwitchToThinking`
320
+ - `selection`: resolved execution snapshot with `{ preset, modelFamily, effort?, autoSwitchToThinking }`
323
321
  - `followUpToJobId`
324
322
  - `chatUrl`
325
323
  - `conversationId`
@@ -10,13 +10,61 @@ export type OracleModelFamily = (typeof MODEL_FAMILIES)[number];
10
10
  export const EFFORTS = ["light", "standard", "extended", "heavy"] as const;
11
11
  export type OracleEffort = (typeof EFFORTS)[number];
12
12
 
13
+ /**
14
+ * Canonical preset registry for `oracle_submit` preset selection.
15
+ * This is the single authored source of truth — all derived lists come from `Object.keys(...)`.
16
+ */
17
+ export const ORACLE_SUBMIT_PRESETS = {
18
+ pro_standard: { label: "Pro - Standard", modelFamily: "pro" as const, effort: "standard" as const, autoSwitchToThinking: false },
19
+ pro_extended: { label: "Pro - Extended", modelFamily: "pro" as const, effort: "extended" as const, autoSwitchToThinking: false },
20
+ thinking_light: { label: "Thinking - Light", modelFamily: "thinking" as const, effort: "light" as const, autoSwitchToThinking: false },
21
+ thinking_standard: { label: "Thinking - Standard", modelFamily: "thinking" as const, effort: "standard" as const, autoSwitchToThinking: false },
22
+ thinking_extended: { label: "Thinking - Extended", modelFamily: "thinking" as const, effort: "extended" as const, autoSwitchToThinking: false },
23
+ thinking_heavy: { label: "Thinking - Heavy", modelFamily: "thinking" as const, effort: "heavy" as const, autoSwitchToThinking: false },
24
+ instant: { label: "Instant", modelFamily: "instant" as const, autoSwitchToThinking: false },
25
+ instant_auto_switch: { label: "Instant - Auto-switch to Thinking Enabled", modelFamily: "instant" as const, autoSwitchToThinking: true },
26
+ } as const;
27
+
28
+ export type OracleSubmitPresetId = keyof typeof ORACLE_SUBMIT_PRESETS;
29
+
30
+ export type OracleSubmitPreset = typeof ORACLE_SUBMIT_PRESETS[OracleSubmitPresetId];
31
+
32
+ export function getOracleSubmitPresetById(id: OracleSubmitPresetId): OracleSubmitPreset {
33
+ const found = ORACLE_SUBMIT_PRESETS[id];
34
+ if (!found) {
35
+ throw new Error(`Unknown oracle_submit preset: ${id}`);
36
+ }
37
+ return found;
38
+ }
39
+
40
+ /** Resolved execution snapshot generated from a preset at submit time. */
41
+ export type OracleResolvedSelection = {
42
+ preset: OracleSubmitPresetId;
43
+ modelFamily: OracleModelFamily;
44
+ effort?: OracleEffort;
45
+ autoSwitchToThinking: boolean;
46
+ };
47
+
48
+ /**
49
+ * Resolve a preset id into the execution snapshot that gets persisted on the job.
50
+ * @throws if the preset id is unknown.
51
+ */
52
+ export function resolveOracleSubmitPreset(presetId: OracleSubmitPresetId): OracleResolvedSelection {
53
+ const def = getOracleSubmitPresetById(presetId);
54
+ return {
55
+ preset: presetId,
56
+ modelFamily: def.modelFamily,
57
+ effort: def.modelFamily === "instant" ? undefined : def.effort,
58
+ autoSwitchToThinking: def.modelFamily === "instant" ? def.autoSwitchToThinking : false,
59
+ };
60
+ }
61
+
13
62
  export const BROWSER_RUN_MODES = ["headless", "headed"] as const;
14
63
  export type OracleBrowserRunMode = (typeof BROWSER_RUN_MODES)[number];
15
64
 
16
65
  export const CLONE_STRATEGIES = ["apfs-clone", "copy"] as const;
17
66
  export type OracleCloneStrategy = (typeof CLONE_STRATEGIES)[number];
18
67
 
19
- const PRO_EFFORTS = ["standard", "extended"] as const satisfies readonly OracleEffort[];
20
68
  const ALLOWED_CHATGPT_ORIGINS = new Set(["https://chatgpt.com", "https://chat.openai.com"]);
21
69
  const PROJECT_OVERRIDE_KEYS = new Set(["defaults", "worker", "poller", "artifacts", "cleanup"]);
22
70
  const DEFAULT_MAC_CHROME_EXECUTABLE = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
@@ -24,9 +72,7 @@ const DEFAULT_MAC_CHROME_USER_DATA_DIR = join(homedir(), "Library", "Application
24
72
 
25
73
  export interface OracleConfig {
26
74
  defaults: {
27
- modelFamily: OracleModelFamily;
28
- effort: OracleEffort;
29
- autoSwitchToThinking: boolean;
75
+ preset: OracleSubmitPresetId;
30
76
  };
31
77
  browser: {
32
78
  sessionPrefix: string;
@@ -98,9 +144,7 @@ const detectedChromeProfileName = detectDefaultChromeProfileName();
98
144
 
99
145
  export const DEFAULT_CONFIG: OracleConfig = {
100
146
  defaults: {
101
- modelFamily: "pro",
102
- effort: "extended",
103
- autoSwitchToThinking: false,
147
+ preset: "pro_extended",
104
148
  },
105
149
  browser: {
106
150
  sessionPrefix: "oracle",
@@ -292,19 +336,13 @@ function normalizeLegacyBrowserConfig(root: Record<string, unknown>): Record<str
292
336
  return root;
293
337
  }
294
338
 
339
+ const PRESET_IDS = Object.keys(ORACLE_SUBMIT_PRESETS) as unknown as readonly OracleSubmitPresetId[];
340
+
295
341
  function validateOracleConfig(value: unknown): OracleConfig {
296
342
  const root = normalizeLegacyBrowserConfig(expectObject(value, "root"));
297
343
 
298
344
  const defaults = expectObject(root.defaults, "defaults");
299
- const modelFamily = expectEnum(defaults.modelFamily, "defaults.modelFamily", MODEL_FAMILIES);
300
- const effort = expectEnum(defaults.effort, "defaults.effort", EFFORTS);
301
- const autoSwitchToThinking = expectBoolean(defaults.autoSwitchToThinking, "defaults.autoSwitchToThinking");
302
- if (modelFamily === "pro" && effort !== "standard" && effort !== "extended") {
303
- throw new Error(`Invalid oracle config: defaults.effort must be one of ${PRO_EFFORTS.join(", ")} for pro`);
304
- }
305
- if (modelFamily !== "instant" && autoSwitchToThinking) {
306
- throw new Error("Invalid oracle config: defaults.autoSwitchToThinking is only valid for instant");
307
- }
345
+ const preset = expectEnum(defaults.preset, "defaults.preset", PRESET_IDS);
308
346
 
309
347
  const browser = expectObject(root.browser, "browser");
310
348
  const auth = expectObject(root.auth, "auth");
@@ -321,9 +359,7 @@ function validateOracleConfig(value: unknown): OracleConfig {
321
359
 
322
360
  return {
323
361
  defaults: {
324
- modelFamily,
325
- effort,
326
- autoSwitchToThinking,
362
+ preset,
327
363
  },
328
364
  browser: {
329
365
  sessionPrefix: expectString(browser.sessionPrefix, "browser.sessionPrefix"),
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
- import type { OracleConfig, OracleEffort, OracleModelFamily } from "./config.js";
7
+ import type { OracleConfig, OracleResolvedSelection } from "./config.js";
8
8
  import { withJobLock, withLock } from "./locks.js";
9
9
  import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId, requirePersistedSessionFile, type OracleCleanupReport } from "./runtime.js";
10
10
 
@@ -133,9 +133,7 @@ export interface OracleJob {
133
133
  sessionId: string;
134
134
  originSessionFile?: string;
135
135
  requestSource: "command" | "tool";
136
- chatModelFamily: OracleModelFamily;
137
- effort?: OracleEffort;
138
- autoSwitchToThinking?: boolean;
136
+ selection: OracleResolvedSelection;
139
137
  followUpToJobId?: string;
140
138
  chatUrl?: string;
141
139
  conversationId?: string;
@@ -185,9 +183,7 @@ export interface OracleJob {
185
183
  export interface OracleSubmitInput {
186
184
  prompt: string;
187
185
  files: string[];
188
- modelFamily: OracleModelFamily;
189
- effort?: OracleEffort;
190
- autoSwitchToThinking?: boolean;
186
+ selection: OracleResolvedSelection;
191
187
  followUpToJobId?: string;
192
188
  chatUrl?: string;
193
189
  requestSource: "command" | "tool";
@@ -971,10 +967,6 @@ export async function createJob(
971
967
 
972
968
  const createdAt = options?.createdAt ?? new Date().toISOString();
973
969
  const initialState = options?.initialState ?? "submitted";
974
- const normalizedEffort = input.modelFamily === "instant" ? undefined : (input.effort ?? config.defaults.effort);
975
- const normalizedAutoSwitchToThinking = input.modelFamily === "instant"
976
- ? (input.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking)
977
- : false;
978
970
  const job: OracleJob = {
979
971
  id,
980
972
  status: initialState,
@@ -988,9 +980,7 @@ export async function createJob(
988
980
  sessionId,
989
981
  originSessionFile: sessionFile,
990
982
  requestSource: input.requestSource,
991
- chatModelFamily: input.modelFamily,
992
- effort: normalizedEffort,
993
- autoSwitchToThinking: normalizedAutoSwitchToThinking,
983
+ selection: input.selection,
994
984
  followUpToJobId: input.followUpToJobId,
995
985
  chatUrl: input.followUpToJobId ? input.chatUrl : undefined,
996
986
  conversationId,
@@ -5,7 +5,12 @@ import { basename, join, posix } from "node:path";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "@sinclair/typebox";
7
7
  import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
8
- import { loadOracleConfig, EFFORTS, MODEL_FAMILIES, type OracleEffort, type OracleModelFamily } from "./config.js";
8
+ import {
9
+ loadOracleConfig,
10
+ ORACLE_SUBMIT_PRESETS,
11
+ resolveOracleSubmitPreset,
12
+ type OracleSubmitPresetId,
13
+ } from "./config.js";
9
14
  import {
10
15
  appendCleanupWarnings,
11
16
  cancelOracleJob,
@@ -53,10 +58,11 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
53
58
  description: "Exact project-relative files/directories to include in the oracle archive.",
54
59
  minItems: 1,
55
60
  }),
56
- modelFamily: Type.Optional(stringEnum(MODEL_FAMILIES, "ChatGPT model family: instant, thinking, or pro.")),
57
- effort: Type.Optional(stringEnum(EFFORTS, "Reasoning effort. Use only values supported by the chosen model family.")),
58
- autoSwitchToThinking: Type.Optional(
59
- Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
61
+ preset: Type.Optional(
62
+ stringEnum(
63
+ [...Object.keys(ORACLE_SUBMIT_PRESETS)] as const,
64
+ "ChatGPT model preset. Omit to use the configured default preset.",
65
+ ),
60
66
  ),
61
67
  followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
62
68
  });
@@ -69,12 +75,6 @@ const ORACLE_CANCEL_PARAMS = Type.Object({
69
75
  jobId: Type.String({ description: "Oracle job id." }),
70
76
  });
71
77
 
72
- const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
73
- instant: [],
74
- thinking: ["light", "standard", "extended", "heavy"],
75
- pro: ["standard", "extended"],
76
- };
77
-
78
78
  const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
79
79
  const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
80
80
  const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
@@ -491,29 +491,6 @@ export function getQueueAdmissionFailure(args: {
491
491
  return undefined;
492
492
  }
493
493
 
494
- function validateSubmissionOptions(
495
- params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
496
- modelFamily: OracleModelFamily,
497
- effort: OracleEffort | undefined,
498
- autoSwitchToThinking: boolean,
499
- ): void {
500
- if (modelFamily === "instant" && params.effort !== undefined) {
501
- throw new Error("Instant model family does not support effort selection");
502
- }
503
-
504
- if (effort && !VALID_EFFORTS[modelFamily].includes(effort)) {
505
- throw new Error(`Invalid effort for ${modelFamily}: ${effort}`);
506
- }
507
-
508
- if (modelFamily !== "instant" && params.autoSwitchToThinking === true) {
509
- throw new Error("autoSwitchToThinking is only valid for the instant model family");
510
- }
511
-
512
- if (modelFamily !== "instant" && autoSwitchToThinking) {
513
- throw new Error(`autoSwitchToThinking cannot be enabled for ${modelFamily}`);
514
- }
515
- }
516
-
517
494
  function resolveFollowUp(previousJobId: string | undefined, cwd: string): {
518
495
  followUpToJobId?: string;
519
496
  chatUrl?: string;
@@ -601,7 +578,8 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
601
578
  name: "oracle_submit",
602
579
  label: "Oracle Submit",
603
580
  description:
604
- "Dispatch a background ChatGPT web oracle job after gathering context. Always pass a prompt and exact project-relative archive inputs.",
581
+ "Dispatch a background ChatGPT web oracle job after gathering context. Always pass a prompt and exact project-relative archive inputs. " +
582
+ "Optional ChatGPT model: set parameter `preset`, or omit it for configured defaults (see `preset` field for allowed ids).",
605
583
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
606
584
  promptGuidelines: [
607
585
  "Gather context before calling oracle_submit.",
@@ -613,7 +591,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
613
591
  "If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
614
592
  "If oracle_submit returns a queued job instead of an immediately dispatched one, treat that as success and stop exactly the same way.",
615
593
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
616
- "Only use autoSwitchToThinking with modelFamily=instant.",
594
+ "Use `preset` as the only model-selection parameter on oracle_submit. Allowed values come from the tool schema enum. Omit preset to use the configured default.",
617
595
  ],
618
596
  parameters: ORACLE_SUBMIT_PARAMS,
619
597
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -621,16 +599,9 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
621
599
  const originSessionFile = requirePersistedSessionFile(getSessionFile(ctx), "submit oracle jobs");
622
600
  const projectId = getProjectId(ctx.cwd);
623
601
  const sessionId = getSessionId(originSessionFile, projectId);
624
- const submittedModelFamily = params.modelFamily as OracleModelFamily | undefined;
625
- const submittedEffort = params.effort as OracleEffort | undefined;
626
- const modelFamily: OracleModelFamily = submittedModelFamily ?? config.defaults.modelFamily;
627
- const requestedEffort: OracleEffort = submittedEffort ?? config.defaults.effort;
628
- const effort: OracleEffort | undefined = modelFamily === "instant" ? undefined : requestedEffort;
629
- const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
630
- const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
602
+ const presetId = (params.preset as OracleSubmitPresetId | undefined) ?? config.defaults.preset;
603
+ const selection = resolveOracleSubmitPreset(presetId);
631
604
  const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
632
-
633
- validateSubmissionOptions({ effort: submittedEffort, autoSwitchToThinking: params.autoSwitchToThinking }, modelFamily, effort, autoSwitchToThinking);
634
605
  try {
635
606
  await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
636
607
  await reconcileStaleOracleJobs();
@@ -691,9 +662,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
691
662
  {
692
663
  prompt: params.prompt,
693
664
  files: params.files,
694
- modelFamily,
695
- effort,
696
- autoSwitchToThinking,
665
+ selection,
697
666
  followUpToJobId: followUp.followUpToJobId,
698
667
  chatUrl: followUp.chatUrl,
699
668
  requestSource: "tool",
@@ -736,9 +705,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
736
705
  {
737
706
  prompt: params.prompt,
738
707
  files: params.files,
739
- modelFamily,
740
- effort,
741
- autoSwitchToThinking,
708
+ selection,
742
709
  followUpToJobId: followUp.followUpToJobId,
743
710
  chatUrl: followUp.chatUrl,
744
711
  requestSource: "tool",
@@ -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];
@@ -807,7 +807,7 @@ function matchesModelFamilyButton(candidate, family) {
807
807
  }
808
808
 
809
809
  function requestedEffortLabel(job) {
810
- return job.effort ? titleCase(job.effort) : undefined;
810
+ return job.selection?.effort ? titleCase(job.selection.effort) : undefined;
811
811
  }
812
812
 
813
813
  function effortSelectionVisible(snapshot, effortLabel) {
@@ -852,12 +852,12 @@ function snapshotHasModelConfigurationUi(snapshot) {
852
852
 
853
853
  function snapshotStronglyMatchesRequestedModel(snapshot, job) {
854
854
  const entries = parseSnapshotEntries(snapshot);
855
- const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.chatModelFamily));
855
+ const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.selection.modelFamily));
856
856
  const effortLabel = requestedEffortLabel(job);
857
- if (job.chatModelFamily === "thinking") {
857
+ if (job.selection.modelFamily === "thinking") {
858
858
  return familyMatched || effortSelectionVisible(snapshot, effortLabel);
859
859
  }
860
- if (job.chatModelFamily === "pro") {
860
+ if (job.selection.modelFamily === "pro") {
861
861
  return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
862
862
  }
863
863
  return familyMatched;
@@ -880,13 +880,13 @@ function composerControlsVisible(snapshot) {
880
880
  }
881
881
 
882
882
  function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
883
- if (job.chatModelFamily === "thinking") {
883
+ if (job.selection.modelFamily === "thinking") {
884
884
  return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
885
885
  }
886
- if (job.chatModelFamily === "pro") {
886
+ if (job.selection.modelFamily === "pro") {
887
887
  return !thinkingChipVisible(snapshot);
888
888
  }
889
- if (job.chatModelFamily === "instant") {
889
+ if (job.selection.modelFamily === "instant") {
890
890
  return !thinkingChipVisible(snapshot);
891
891
  }
892
892
  return false;
@@ -1214,7 +1214,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1214
1214
  if (options.stronglyVerified) {
1215
1215
  if (!fallbackLogged) {
1216
1216
  fallbackLogged = true;
1217
- await log(`Model configuration closed after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
1217
+ await log(`Model configuration closed after strong in-dialog verification for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1218
1218
  }
1219
1219
  return;
1220
1220
  }
@@ -1223,7 +1223,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1223
1223
  if (!configurationUiVisible && composerControlsVisible(snapshot) && options.stronglyVerified) {
1224
1224
  if (!fallbackLogged) {
1225
1225
  fallbackLogged = true;
1226
- await log(`Composer became usable after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
1226
+ await log(`Composer became usable after strong in-dialog verification for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1227
1227
  }
1228
1228
  return;
1229
1229
  }
@@ -1239,30 +1239,30 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1239
1239
  }
1240
1240
 
1241
1241
  if (options.stronglyVerified && lastSnapshot && !snapshotHasModelConfigurationUi(lastSnapshot)) {
1242
- await log(`Model configuration closed only after settle-timeout for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
1242
+ await log(`Model configuration closed only after settle-timeout for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1243
1243
  return;
1244
1244
  }
1245
1245
 
1246
- throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
1246
+ throw new Error(`Could not verify requested model settings after configuration for ${job.selection.modelFamily}`);
1247
1247
  }
1248
1248
 
1249
1249
  async function configureModel(job) {
1250
1250
  const initialSnapshot = await snapshotText(job);
1251
1251
  if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
1252
- await log(`Model already appears configured for family=${job.chatModelFamily} effort=${job.effort || "(none)"}; skipping reconfiguration`);
1252
+ await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
1253
1253
  return;
1254
1254
  }
1255
1255
 
1256
- await log(`Configuring model family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
1256
+ await log(`Configuring model family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}`);
1257
1257
  let familySnapshot = await openModelConfiguration(job);
1258
1258
  let verificationSnapshot = familySnapshot;
1259
1259
 
1260
- let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.chatModelFamily));
1260
+ let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1261
1261
  if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1262
1262
  await log("Model configuration UI opened with requested settings already selected");
1263
1263
  }
1264
1264
  if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1265
- throw new Error(`Could not find model family button for ${job.chatModelFamily}`);
1265
+ throw new Error(`Could not find model family button for ${job.selection.modelFamily}`);
1266
1266
  }
1267
1267
 
1268
1268
  if (familyEntry) {
@@ -1272,7 +1272,7 @@ async function configureModel(job) {
1272
1272
  verificationSnapshot = familySnapshot;
1273
1273
  }
1274
1274
 
1275
- if (job.chatModelFamily === "thinking" || job.chatModelFamily === "pro") {
1275
+ if (job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") {
1276
1276
  const effortLabel = requestedEffortLabel(job);
1277
1277
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1278
1278
  const opened = await openEffortDropdown(job);
@@ -1294,14 +1294,14 @@ async function configureModel(job) {
1294
1294
  }
1295
1295
  }
1296
1296
 
1297
- if (job.chatModelFamily === "instant" && job.autoSwitchToThinking) {
1297
+ if (job.selection.modelFamily === "instant" && job.selection.autoSwitchToThinking) {
1298
1298
  await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1299
1299
  verificationSnapshot = await snapshotText(job);
1300
1300
  }
1301
1301
 
1302
1302
  const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
1303
1303
  if (!stronglyVerified) {
1304
- throw new Error(`Could not verify requested model settings in configuration UI for ${job.chatModelFamily}`);
1304
+ throw new Error(`Could not verify requested model settings in configuration UI for ${job.selection.modelFamily}`);
1305
1305
  }
1306
1306
 
1307
1307
  if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
@@ -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.1",
3
+ "version": "0.3.0",
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",
package/prompts/oracle.md CHANGED
@@ -13,6 +13,12 @@ Required workflow:
13
13
  5. Call oracle_submit with the prompt and exact archive inputs.
14
14
  6. Stop immediately after dispatching the oracle job.
15
15
 
16
+ Oracle model (`oracle_submit`):
17
+ - To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the tool schema enum / canonical preset registry.
18
+ - **Or** omit **`preset`** entirely to use the configured default model (from oracle config).
19
+ - **`preset`** is the only model-selection parameter on `oracle_submit`. Do not pass `modelFamily`, `effort`, or `autoSwitchToThinking`.
20
+ - If unsure which preset fits the task, ask the user.
21
+
16
22
  Rules:
17
23
  - Always include an archive. Do not submit without context files.
18
24
  - By default, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and root `secrets/` directories.
@@ -21,8 +27,7 @@ Rules:
21
27
  - If the request depends on git state or pending changes (for example code review, ship readiness, or release approval), create a tracked diff bundle file inside the repo (for example under `.pi/`) containing `git status` plus `git diff` output, include that file in the archive, and tell the oracle to use it because the `.git` directory is not included in oracle exports.
22
28
  - When `files=["."]` and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like `build/`, `dist/`, `out/`, `coverage/`, and `tmp/` outside obvious source roots like `src/` and `lib/` until the archive fits or no candidate remains. Successful submissions report what was pruned.
23
29
  - If a submitted oracle job later fails because upload is rejected, retry with a smaller archive in this order: (1) remove the largest obviously irrelevant/generated content, (2) if still too large, include modified files plus adjacent files plus directly relevant subtrees, (3) if still too large, explain the cut or ask the user.
24
- - Prefer the configured default model/effort unless the task clearly needs something else.
25
- - Only use autoSwitchToThinking with the instant model family.
30
+ - Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model; then choose a **`preset`** id from the tool schema enum.
26
31
  - If `oracle_submit` itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error. Do not retry automatically.
27
32
  - If `oracle_submit` returns a queued job instead of an immediately dispatched one, treat that as success and end your turn exactly the same way.
28
33
  - After oracle_submit returns, end your turn. Do not keep working while the oracle runs.