pi-oracle 0.1.8 → 0.1.10

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.
@@ -30,6 +30,9 @@ export const ORACLE_STALE_HEARTBEAT_MS = 3 * 60 * 1000;
30
30
  export const ORACLE_NOTIFICATION_CLAIM_TTL_MS = 60_000;
31
31
  const ORACLE_COMPLETE_JOB_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
32
32
  const ORACLE_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
33
+ export const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
34
+ export const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
35
+ const ORACLE_JOBS_DIR = process.env[ORACLE_JOBS_DIR_ENV]?.trim() || DEFAULT_ORACLE_JOBS_DIR;
33
36
 
34
37
  export function isActiveOracleJob(job: Pick<OracleJob, "status">): boolean {
35
38
  return ACTIVE_ORACLE_JOB_STATUSES.includes(job.status);
@@ -148,20 +151,24 @@ export function getSessionFile(ctx: ExtensionContext): string | undefined {
148
151
  return manager.getSessionFile?.();
149
152
  }
150
153
 
154
+ export function getOracleJobsDir(): string {
155
+ return ORACLE_JOBS_DIR;
156
+ }
157
+
151
158
  export function getJobDir(id: string): string {
152
- return join("/tmp", `oracle-${id}`);
159
+ return join(ORACLE_JOBS_DIR, `oracle-${id}`);
153
160
  }
154
161
 
155
162
  export function listOracleJobDirs(): string[] {
156
- if (!existsSync("/tmp")) return [];
157
- return readdirSync("/tmp")
163
+ if (!existsSync(ORACLE_JOBS_DIR)) return [];
164
+ return readdirSync(ORACLE_JOBS_DIR)
158
165
  .filter((name) => name.startsWith("oracle-"))
159
- .map((name) => join("/tmp", name))
166
+ .map((name) => join(ORACLE_JOBS_DIR, name))
160
167
  .filter((path) => existsSync(join(path, "job.json")));
161
168
  }
162
169
 
163
170
  export function readJob(jobDirOrId: string): OracleJob | undefined {
164
- const jobDir = jobDirOrId.startsWith("/tmp/oracle-") ? jobDirOrId : getJobDir(jobDirOrId);
171
+ const jobDir = existsSync(join(jobDirOrId, "job.json")) ? jobDirOrId : getJobDir(jobDirOrId);
165
172
  const jobPath = join(jobDir, "job.json");
166
173
  if (!existsSync(jobPath)) return undefined;
167
174
  try {
@@ -528,6 +535,10 @@ export async function createJob(
528
535
  await chmod(promptPath, 0o600).catch(() => undefined);
529
536
 
530
537
  const now = new Date().toISOString();
538
+ const normalizedEffort = input.modelFamily === "instant" ? undefined : (input.effort ?? config.defaults.effort);
539
+ const normalizedAutoSwitchToThinking = input.modelFamily === "instant"
540
+ ? (input.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking)
541
+ : false;
531
542
  const job: OracleJob = {
532
543
  id,
533
544
  status: "submitted",
@@ -541,8 +552,8 @@ export async function createJob(
541
552
  originSessionFile,
542
553
  requestSource: input.requestSource,
543
554
  chatModelFamily: input.modelFamily,
544
- effort: input.effort,
545
- autoSwitchToThinking: input.autoSwitchToThinking,
555
+ effort: normalizedEffort,
556
+ autoSwitchToThinking: normalizedAutoSwitchToThinking,
546
557
  followUpToJobId: input.followUpToJobId,
547
558
  chatUrl: input.followUpToJobId ? input.chatUrl : undefined,
548
559
  conversationId,
@@ -4,7 +4,9 @@ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { join } from "node:path";
6
6
 
7
- const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
7
+ export const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
8
+ export const ORACLE_STATE_DIR_ENV = "PI_ORACLE_STATE_DIR";
9
+ const ORACLE_STATE_DIR = process.env[ORACLE_STATE_DIR_ENV]?.trim() || DEFAULT_ORACLE_STATE_DIR;
8
10
  const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
9
11
  const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
10
12
  const DEFAULT_WAIT_MS = 30_000;
@@ -7,6 +7,11 @@ import type { OracleConfig } from "./config.js";
7
7
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
8
8
 
9
9
  const SEED_GENERATION_FILE = ".oracle-seed-generation";
10
+ const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
11
+ const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
12
+ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
13
+ (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
14
+ ) || "agent-browser";
10
15
 
11
16
  export interface OracleRuntimeLeaseMetadata {
12
17
  jobId: string;
@@ -80,7 +85,7 @@ export async function writeSeedGeneration(config: OracleConfig, value = new Date
80
85
  }
81
86
 
82
87
  function activeJobExists(jobId: string): boolean {
83
- const path = join("/tmp", `oracle-${jobId}`, "job.json");
88
+ const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
84
89
  if (!existsSync(path)) return false;
85
90
  try {
86
91
  const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string };
@@ -181,7 +186,7 @@ export interface OracleCleanupReport {
181
186
 
182
187
  async function closeRuntimeBrowserSession(runtimeSessionName: string): Promise<string | undefined> {
183
188
  return new Promise<string | undefined>((resolve) => {
184
- const child = spawn("agent-browser", ["--session", runtimeSessionName, "close"], { stdio: "ignore" });
189
+ const child = spawn(AGENT_BROWSER_BIN, ["--session", runtimeSessionName, "close"], { stdio: "ignore" });
185
190
  let settled = false;
186
191
  let timeout: NodeJS.Timeout | undefined;
187
192
  let timedOut = false;
@@ -0,0 +1,76 @@
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);
3
+ export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
4
+ const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
5
+
6
+ export function parseSnapshotEntries(snapshot) {
7
+ return String(snapshot || "")
8
+ .split("\n")
9
+ .map((line, lineIndex) => {
10
+ const refMatch = line.match(/\bref=(e\d+)\b/);
11
+ if (!refMatch) return undefined;
12
+ const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
13
+ const quotedMatch = line.match(/"([^"]*)"/);
14
+ const valueMatch = line.match(/:\s*(.+)$/);
15
+ return {
16
+ line,
17
+ lineIndex,
18
+ ref: `@${refMatch[1]}`,
19
+ kind: kindMatch ? kindMatch[1] : undefined,
20
+ label: quotedMatch ? quotedMatch[1] : undefined,
21
+ value: valueMatch ? valueMatch[1].trim() : undefined,
22
+ disabled: /\bdisabled\b/.test(line),
23
+ };
24
+ })
25
+ .filter(Boolean);
26
+ }
27
+
28
+ function normalizeText(value) {
29
+ return String(value || "").replace(/\s+/g, " ").trim();
30
+ }
31
+
32
+ export function isLikelyArtifactLabel(label) {
33
+ const normalized = normalizeText(label);
34
+ if (!normalized) return false;
35
+ if (GENERIC_ARTIFACT_LABEL_SET.has(normalized.toUpperCase())) return true;
36
+ return FILE_LABEL_PATTERN.test(normalized);
37
+ }
38
+
39
+ export function isStructuralArtifactCandidate(candidate) {
40
+ const label = normalizeText(candidate?.label);
41
+ if (!isLikelyArtifactLabel(label)) return false;
42
+
43
+ const listItemText = normalizeText(candidate?.listItemText);
44
+ const listItemFileButtonCount = Number(candidate?.listItemFileButtonCount || 0);
45
+ const paragraphFileButtonCount = Number(candidate?.paragraphFileButtonCount || 0);
46
+ const paragraphOtherTextLength = Number(candidate?.paragraphOtherTextLength ?? Number.POSITIVE_INFINITY);
47
+ const focusableFileButtonCount = Number(candidate?.focusableFileButtonCount || 0);
48
+ const focusableOtherTextLength = Number(candidate?.focusableOtherTextLength ?? Number.POSITIVE_INFINITY);
49
+
50
+ if (listItemText === label && listItemFileButtonCount === 1) {
51
+ return true;
52
+ }
53
+
54
+ if (paragraphFileButtonCount === 1 && paragraphOtherTextLength <= 32) {
55
+ return true;
56
+ }
57
+
58
+ if (focusableFileButtonCount >= 1 && focusableOtherTextLength <= 64) {
59
+ return true;
60
+ }
61
+
62
+ return false;
63
+ }
64
+
65
+ export function filterStructuralArtifactCandidates(candidates) {
66
+ const seen = new Set();
67
+ const filtered = [];
68
+ 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 });
74
+ }
75
+ return filtered;
76
+ }
@@ -33,9 +33,13 @@ const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
33
33
  const BODY_PATH = "/tmp/oracle-auth.body.txt";
34
34
  const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
35
35
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
36
- const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
+ const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
37
+ const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
37
38
  const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
38
39
  const STALE_STAGING_PROFILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
+ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
41
+ (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
42
+ ) || "agent-browser";
39
43
 
40
44
  let runtimeProfileDir = config.browser.authSeedProfileDir;
41
45
 
@@ -165,7 +169,7 @@ function targetBrowserBaseArgs(options = {}) {
165
169
 
166
170
  async function closeTargetBrowser() {
167
171
  await log(`Closing target browser session ${authSessionName()} if present`);
168
- const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "close"], { allowFailure: true });
172
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "close"], { allowFailure: true });
169
173
  await log(`close result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
170
174
  }
171
175
 
@@ -182,7 +186,7 @@ async function ensureNotSymlink(path, label) {
182
186
  }
183
187
 
184
188
  async function isAuthBrowserConnected() {
185
- const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
189
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
186
190
  try {
187
191
  const parsed = JSON.parse(result.stdout || "{}");
188
192
  return parsed?.data?.connected === true;
@@ -274,7 +278,7 @@ async function launchTargetBrowser() {
274
278
  await closeTargetBrowser();
275
279
  const args = [...targetBrowserBaseArgs({ withLaunchOptions: true, mode: "headed" }), "open", "about:blank"];
276
280
  await log(`Launching isolated browser: agent-browser ${JSON.stringify(args)}`);
277
- const result = await spawnCommand("agent-browser", args, { allowFailure: true });
281
+ const result = await spawnCommand(AGENT_BROWSER_BIN, args, { allowFailure: true });
278
282
  await log(`launch result: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
279
283
  if (result.code !== 0) {
280
284
  throw new Error(result.stderr || result.stdout || "Failed to launch isolated oracle browser");
@@ -282,7 +286,7 @@ async function launchTargetBrowser() {
282
286
  }
283
287
 
284
288
  async function streamStatus() {
285
- const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
289
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), "--json", "stream", "status"], { allowFailure: true });
286
290
  await log(`stream status: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
287
291
  try {
288
292
  const parsed = JSON.parse(result.stdout || "{}");
@@ -311,7 +315,7 @@ async function targetCommand(...args) {
311
315
  options = args.pop();
312
316
  }
313
317
  await ensureBrowserConnected();
314
- const result = await spawnCommand("agent-browser", [...targetBrowserBaseArgs(), ...args], options);
318
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...targetBrowserBaseArgs(), ...args], options);
315
319
  const label = options?.logLabel || `agent-browser ${args.join(" ")}`;
316
320
  await log(`${label}: code=${result.code} stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
317
321
  return result;
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ import { FILE_LABEL_PATTERN_SOURCE, filterStructuralArtifactCandidates, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries } from "./artifact-heuristics.mjs";
6
7
 
7
8
  const jobId = process.argv[2];
8
9
  if (!jobId) {
@@ -10,7 +11,9 @@ if (!jobId) {
10
11
  process.exit(1);
11
12
  }
12
13
 
13
- const jobDir = `/tmp/oracle-${jobId}`;
14
+ const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
15
+ const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
16
+ const jobDir = join(ORACLE_JOBS_DIR, `oracle-${jobId}`);
14
17
  const jobPath = `${jobDir}/job.json`;
15
18
  const CHATGPT_LABELS = {
16
19
  composer: "Chat with ChatGPT",
@@ -26,7 +29,8 @@ const MODEL_FAMILY_PREFIX = {
26
29
  pro: "Pro ",
27
30
  };
28
31
 
29
- const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
32
+ const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
33
+ const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
30
34
  const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
31
35
  const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
32
36
  const SEED_GENERATION_FILE = ".oracle-seed-generation";
@@ -37,6 +41,12 @@ const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
37
41
  const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
38
42
  const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
39
43
  const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
44
+ const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
45
+ const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
46
+ const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
47
+ const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
48
+ (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
49
+ ) || "agent-browser";
40
50
 
41
51
  let currentJob;
42
52
  let browserStarted = false;
@@ -321,7 +331,7 @@ async function closeBrowser(job) {
321
331
  if (cleaningUpBrowser) return;
322
332
  cleaningUpBrowser = true;
323
333
  try {
324
- const result = await spawnCommand("agent-browser", [...browserBaseArgs(job), "close"], {
334
+ const result = await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "close"], {
325
335
  allowFailure: true,
326
336
  timeoutMs: AGENT_BROWSER_CLOSE_TIMEOUT_MS,
327
337
  });
@@ -337,12 +347,12 @@ async function closeBrowser(job) {
337
347
  async function launchBrowser(job, url) {
338
348
  await closeBrowser(job);
339
349
  const mode = job.config.browser.runMode;
340
- await spawnCommand("agent-browser", [...browserBaseArgs(job, { withLaunchOptions: true, mode }), "open", url]);
350
+ await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job, { withLaunchOptions: true, mode }), "open", url]);
341
351
  browserStarted = true;
342
352
  }
343
353
 
344
354
  async function streamStatus(job) {
345
- const { stdout } = await spawnCommand("agent-browser", [...browserBaseArgs(job), "--json", "stream", "status"], { allowFailure: true });
355
+ const { stdout } = await spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), "--json", "stream", "status"], { allowFailure: true });
346
356
  try {
347
357
  const parsed = JSON.parse(stdout || "{}");
348
358
  return parsed?.data || {};
@@ -374,7 +384,7 @@ async function agentBrowser(job, ...args) {
374
384
  options = args.pop();
375
385
  }
376
386
  await ensureBrowserConnected(job);
377
- return spawnCommand("agent-browser", [...browserBaseArgs(job), ...args], options);
387
+ return spawnCommand(AGENT_BROWSER_BIN, [...browserBaseArgs(job), ...args], options);
378
388
  }
379
389
 
380
390
  function parseEvalResult(stdout) {
@@ -536,27 +546,6 @@ function buildLoginProbeScript(timeoutMs) {
536
546
  `);
537
547
  }
538
548
 
539
- function parseSnapshotEntries(snapshot) {
540
- return snapshot
541
- .split("\n")
542
- .map((line) => {
543
- const refMatch = line.match(/\bref=(e\d+)\b/);
544
- if (!refMatch) return undefined;
545
- const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
546
- const quotedMatch = line.match(/"([^"]*)"/);
547
- const valueMatch = line.match(/:\s*(.+)$/);
548
- return {
549
- line,
550
- ref: `@${refMatch[1]}`,
551
- kind: kindMatch ? kindMatch[1] : undefined,
552
- label: quotedMatch ? quotedMatch[1] : undefined,
553
- value: valueMatch ? valueMatch[1].trim() : undefined,
554
- disabled: /\bdisabled\b/.test(line),
555
- };
556
- })
557
- .filter(Boolean);
558
- }
559
-
560
549
  function findEntry(snapshot, predicate) {
561
550
  return parseSnapshotEntries(snapshot).find(predicate);
562
551
  }
@@ -569,14 +558,21 @@ function findLastEntry(snapshot, predicate) {
569
558
  return undefined;
570
559
  }
571
560
 
572
- function matchesModelFamilyButton(candidate, family) {
573
- return candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(MODEL_FAMILY_PREFIX[family]) && !candidate.disabled;
574
- }
575
-
576
561
  function titleCase(value) {
577
562
  return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
578
563
  }
579
564
 
565
+ function matchesModelFamilyLabel(label, family) {
566
+ const normalized = String(label || "");
567
+ const prefix = MODEL_FAMILY_PREFIX[family];
568
+ const exact = prefix.trim();
569
+ return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
570
+ }
571
+
572
+ function matchesModelFamilyButton(candidate, family) {
573
+ return candidate.kind === "button" && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
574
+ }
575
+
580
576
  function requestedEffortLabel(job) {
581
577
  return job.effort ? titleCase(job.effort) : undefined;
582
578
  }
@@ -609,9 +605,9 @@ function snapshotHasModelConfigurationUi(snapshot) {
609
605
  entries
610
606
  .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
611
607
  .flatMap((entry) =>
612
- Object.entries(MODEL_FAMILY_PREFIX)
613
- .filter(([, prefix]) => entry.label.startsWith(prefix))
614
- .map(([family]) => family),
608
+ Object.keys(MODEL_FAMILY_PREFIX)
609
+ .filter((family) => matchesModelFamilyLabel(entry.label, family))
610
+ .map((family) => family),
615
611
  ),
616
612
  );
617
613
  const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
@@ -624,18 +620,35 @@ function snapshotHasModelConfigurationUi(snapshot) {
624
620
  function snapshotStronglyMatchesRequestedModel(snapshot, job) {
625
621
  const entries = parseSnapshotEntries(snapshot);
626
622
  const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.chatModelFamily));
623
+ const effortLabel = requestedEffortLabel(job);
627
624
  if (job.chatModelFamily === "thinking") {
628
- return familyMatched || effortSelectionVisible(snapshot, requestedEffortLabel(job));
625
+ return familyMatched || effortSelectionVisible(snapshot, effortLabel);
629
626
  }
630
627
  if (job.chatModelFamily === "pro") {
631
- return familyMatched;
628
+ return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
632
629
  }
633
630
  return familyMatched;
634
631
  }
635
632
 
633
+ function thinkingSelectionVisible(snapshot) {
634
+ const entries = parseSnapshotEntries(snapshot);
635
+ return entries.some((entry) => !entry.disabled && entry.kind === "button" && matchesModelFamilyLabel(entry.label, "thinking"));
636
+ }
637
+
638
+ function composerControlsVisible(snapshot) {
639
+ const entries = parseSnapshotEntries(snapshot);
640
+ const hasComposer = entries.some(
641
+ (entry) => entry.kind === "textbox" && entry.label === CHATGPT_LABELS.composer && !entry.disabled,
642
+ );
643
+ const hasAddFiles = entries.some(
644
+ (entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.addFiles && !entry.disabled,
645
+ );
646
+ return hasComposer && hasAddFiles;
647
+ }
648
+
636
649
  function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
637
650
  if (job.chatModelFamily === "thinking") {
638
- return effortSelectionVisible(snapshot, requestedEffortLabel(job));
651
+ return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
639
652
  }
640
653
  if (job.chatModelFamily === "pro") {
641
654
  return !thinkingChipVisible(snapshot);
@@ -829,6 +842,17 @@ function detectUploadErrorText(text) {
829
842
  return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
830
843
  }
831
844
 
845
+ function detectResponseFailureText(text) {
846
+ const patterns = [
847
+ "Message delivery timed out",
848
+ "A network error occurred",
849
+ "An error occurred while connecting to the websocket",
850
+ "There was an error generating a response",
851
+ "Something went wrong while generating the response",
852
+ ];
853
+ return patterns.find((pattern) => text.toLowerCase().includes(pattern.toLowerCase()));
854
+ }
855
+
832
856
  function composerSnapshotSlice(snapshot) {
833
857
  const lines = snapshot.split("\n");
834
858
  let composerIndex = -1;
@@ -941,6 +965,54 @@ async function openModelConfiguration(job) {
941
965
  throw new Error("Could not open model configuration UI");
942
966
  }
943
967
 
968
+ async function waitForModelConfigurationToSettle(job, options = {}) {
969
+ const deadline = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
970
+ let lastCloseAttemptAt = 0;
971
+ let fallbackLogged = false;
972
+ let lastSnapshot = "";
973
+
974
+ while (Date.now() < deadline) {
975
+ const snapshot = await snapshotText(job);
976
+ lastSnapshot = snapshot;
977
+ const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
978
+
979
+ if (!configurationUiVisible) {
980
+ if (snapshotWeaklyMatchesRequestedModel(snapshot, job)) return;
981
+ if (options.stronglyVerified) {
982
+ if (!fallbackLogged) {
983
+ fallbackLogged = true;
984
+ await log(`Model configuration closed after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
985
+ }
986
+ return;
987
+ }
988
+ }
989
+
990
+ if (!configurationUiVisible && composerControlsVisible(snapshot) && options.stronglyVerified) {
991
+ if (!fallbackLogged) {
992
+ fallbackLogged = true;
993
+ await log(`Composer became usable after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
994
+ }
995
+ return;
996
+ }
997
+
998
+ if (Date.now() - lastCloseAttemptAt >= MODEL_CONFIGURATION_CLOSE_RETRY_MS) {
999
+ lastCloseAttemptAt = Date.now();
1000
+ if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
1001
+ await agentBrowser(job, "press", "Escape").catch(() => undefined);
1002
+ }
1003
+ }
1004
+
1005
+ await sleep(MODEL_CONFIGURATION_SETTLE_POLL_MS);
1006
+ }
1007
+
1008
+ if (options.stronglyVerified && lastSnapshot && !snapshotHasModelConfigurationUi(lastSnapshot)) {
1009
+ await log(`Model configuration closed only after settle-timeout for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
1010
+ return;
1011
+ }
1012
+
1013
+ throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
1014
+ }
1015
+
944
1016
  async function configureModel(job) {
945
1017
  const initialSnapshot = await snapshotText(job);
946
1018
  if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
@@ -950,6 +1022,7 @@ async function configureModel(job) {
950
1022
 
951
1023
  await log(`Configuring model family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
952
1024
  let familySnapshot = await openModelConfiguration(job);
1025
+ let verificationSnapshot = familySnapshot;
953
1026
 
954
1027
  let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.chatModelFamily));
955
1028
  if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
@@ -963,6 +1036,7 @@ async function configureModel(job) {
963
1036
  await clickRef(job, familyEntry.ref);
964
1037
  await agentBrowser(job, "wait", "800");
965
1038
  familySnapshot = await snapshotText(job);
1039
+ verificationSnapshot = familySnapshot;
966
1040
  }
967
1041
 
968
1042
  if (job.chatModelFamily === "thinking" || job.chatModelFamily === "pro") {
@@ -976,6 +1050,7 @@ async function configureModel(job) {
976
1050
  await clickLabeledEntry(job, effortLabel, { kind: "option" });
977
1051
  await agentBrowser(job, "wait", "400");
978
1052
  const effortSnapshot = await snapshotText(job);
1053
+ verificationSnapshot = effortSnapshot;
979
1054
  const selectedEffort = findEntry(
980
1055
  effortSnapshot,
981
1056
  (candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
@@ -988,17 +1063,18 @@ async function configureModel(job) {
988
1063
 
989
1064
  if (job.chatModelFamily === "instant" && job.autoSwitchToThinking) {
990
1065
  await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1066
+ verificationSnapshot = await snapshotText(job);
991
1067
  }
992
1068
 
993
- if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
994
- await agentBrowser(job, "press", "Escape").catch(() => undefined);
1069
+ const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
1070
+ if (!stronglyVerified) {
1071
+ throw new Error(`Could not verify requested model settings in configuration UI for ${job.chatModelFamily}`);
995
1072
  }
996
- await agentBrowser(job, "wait", "500");
997
1073
 
998
- const postCloseSnapshot = await snapshotText(job);
999
- if (!snapshotWeaklyMatchesRequestedModel(postCloseSnapshot, job)) {
1000
- throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
1074
+ if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
1075
+ await agentBrowser(job, "press", "Escape").catch(() => undefined);
1001
1076
  }
1077
+ await waitForModelConfigurationToSettle(job, { stronglyVerified });
1002
1078
  }
1003
1079
 
1004
1080
  async function uploadArchive(job) {
@@ -1120,17 +1196,39 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1120
1196
  const timeoutAt = Date.now() + job.config.worker.completionTimeoutMs;
1121
1197
  let lastText = "";
1122
1198
  let stableCount = 0;
1199
+ let retriedAfterFailure = false;
1123
1200
 
1124
1201
  while (Date.now() < timeoutAt) {
1125
1202
  await heartbeat();
1126
- const snapshot = await snapshotText(job);
1203
+ const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
1127
1204
  const hasStopStreaming = snapshot.includes("Stop streaming");
1205
+ const hasRetryButton = snapshot.includes('button "Retry"');
1128
1206
  const copyResponseCount = (snapshot.match(/Copy response/g) || []).length;
1207
+ const responseFailureText = detectResponseFailureText(`${snapshot}\n${body}`);
1129
1208
  const messages = await assistantMessages(job);
1130
1209
  const targetMessage = messages[baselineAssistantCount];
1131
1210
  const targetText = targetMessage?.text || "";
1132
1211
  const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount;
1133
1212
 
1213
+ if (!hasStopStreaming && hasRetryButton && responseFailureText) {
1214
+ if (!retriedAfterFailure) {
1215
+ const retryEntry = findEntry(
1216
+ snapshot,
1217
+ (candidate) => candidate.kind === "button" && candidate.label === "Retry" && !candidate.disabled,
1218
+ );
1219
+ if (retryEntry) {
1220
+ retriedAfterFailure = true;
1221
+ lastText = "";
1222
+ stableCount = 0;
1223
+ await log(`Response delivery failed (${responseFailureText}); clicking Retry once`);
1224
+ await clickRef(job, retryEntry.ref);
1225
+ await agentBrowser(job, "wait", "1000").catch(() => undefined);
1226
+ continue;
1227
+ }
1228
+ }
1229
+ throw new Error(`ChatGPT response failed: ${responseFailureText}`);
1230
+ }
1231
+
1134
1232
  if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1135
1233
  if (targetText === lastText) stableCount += 1;
1136
1234
  else stableCount = 1;
@@ -1156,14 +1254,6 @@ async function detectType(path) {
1156
1254
  return result.stdout || "unknown";
1157
1255
  }
1158
1256
 
1159
- function isLikelyArtifactLabel(label) {
1160
- const normalized = String(label || "").trim();
1161
- if (!normalized) return false;
1162
- const upper = normalized.toUpperCase();
1163
- if (upper === "ATTACHED" || upper === "DONE") return true;
1164
- return /(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])/.test(normalized);
1165
- }
1166
-
1167
1257
  function preferredArtifactName(label, index) {
1168
1258
  const normalized = String(label || "").trim();
1169
1259
  const fileNameMatch = normalized.match(/([A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})(?!.*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})/);
@@ -1171,57 +1261,82 @@ function preferredArtifactName(label, index) {
1171
1261
  return `artifact-${String(index + 1).padStart(2, "0")}`;
1172
1262
  }
1173
1263
 
1174
- function artifactCandidatesFromEntries(entries) {
1175
- const excluded = new Set([
1176
- "Copy response",
1177
- "Good response",
1178
- "Bad response",
1179
- "Share",
1180
- "Switch model",
1181
- "More actions",
1182
- CHATGPT_LABELS.addFiles,
1183
- "Start dictation",
1184
- "Start Voice",
1185
- "Model selector",
1186
- "Open conversation options",
1187
- "Scroll to bottom",
1188
- CHATGPT_LABELS.close,
1189
- ]);
1190
-
1191
- const seen = new Set();
1192
- const candidates = [];
1193
- for (const entry of entries) {
1194
- if (!entry.label) continue;
1195
- if (excluded.has(entry.label)) continue;
1196
- if (entry.label.startsWith("Thought for ")) continue;
1197
- if (entry.kind !== "button" && entry.kind !== "link") continue;
1198
- if (!isLikelyArtifactLabel(entry.label)) continue;
1199
- if (seen.has(entry.label)) continue;
1200
- seen.add(entry.label);
1201
- candidates.push({ label: entry.label });
1202
- }
1203
- return candidates;
1204
- }
1205
-
1206
- async function collectArtifactCandidates(job, responseIndex) {
1264
+ async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1207
1265
  const snapshot = await snapshotText(job);
1208
1266
  const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1209
1267
  if (!targetSlice) return { snapshot, targetSlice, candidates: [] };
1268
+
1269
+ const structural = await evalPage(
1270
+ job,
1271
+ toJsonScript(`
1272
+ const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
1273
+ const genericArtifactLabels = new Set(${JSON.stringify(GENERIC_ARTIFACT_LABELS)});
1274
+ const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)});
1275
+ const isFileLabel = (value) => {
1276
+ const normalized = normalize(value);
1277
+ if (!normalized) return false;
1278
+ if (genericArtifactLabels.has(normalized.toUpperCase())) return true;
1279
+ return fileLabelPattern.test(normalized);
1280
+ };
1281
+ const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
1282
+ .filter((el) => normalize(el.textContent) === 'ChatGPT said:');
1283
+ const host = headings[${responseIndex}]?.nextElementSibling;
1284
+ if (!host) return { candidates: [] };
1285
+
1286
+ const fileButtons = (node) => node
1287
+ ? Array.from(node.querySelectorAll('button, a')).map((candidate) => normalize(candidate.textContent)).filter(isFileLabel)
1288
+ : [];
1289
+ const otherTextLength = (text, labels) => {
1290
+ let remaining = normalize(text);
1291
+ for (const label of labels || []) {
1292
+ remaining = normalize(remaining.replaceAll(label, ' '));
1293
+ }
1294
+ remaining = normalize(remaining.replaceAll('Coding Citation', ' '));
1295
+ return remaining.length;
1296
+ };
1297
+ const focusableFor = (node) => node?.closest('[tabindex]');
1298
+
1299
+ const candidates = Array.from(host.querySelectorAll('button, a'))
1300
+ .map((button) => {
1301
+ const label = normalize(button.textContent);
1302
+ if (!isFileLabel(label)) return null;
1303
+ const paragraph = button.closest('p');
1304
+ const listItem = button.closest('li');
1305
+ const focusable = focusableFor(button);
1306
+ const paragraphFileLabels = fileButtons(paragraph);
1307
+ const focusableFileLabels = fileButtons(focusable);
1308
+ return {
1309
+ label,
1310
+ paragraphText: normalize(paragraph?.textContent),
1311
+ listItemText: normalize(listItem?.textContent),
1312
+ paragraphFileButtonCount: paragraphFileLabels.length,
1313
+ paragraphOtherTextLength: otherTextLength(paragraph?.textContent, paragraphFileLabels),
1314
+ listItemFileButtonCount: fileButtons(listItem).length,
1315
+ focusableFileButtonCount: focusableFileLabels.length,
1316
+ focusableOtherTextLength: otherTextLength(focusable?.textContent, focusableFileLabels),
1317
+ };
1318
+ })
1319
+ .filter(Boolean);
1320
+
1321
+ return { candidates };
1322
+ `),
1323
+ );
1324
+
1210
1325
  return {
1211
1326
  snapshot,
1212
1327
  targetSlice,
1213
- candidates: artifactCandidatesFromEntries(parseSnapshotEntries(targetSlice)),
1328
+ candidates: filterStructuralArtifactCandidates(structural?.candidates || []),
1214
1329
  };
1215
1330
  }
1216
1331
 
1217
- async function waitForStableArtifactCandidates(job, responseIndex) {
1332
+ async function waitForStableArtifactCandidates(job, responseIndex, responseText = "") {
1218
1333
  const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
1219
1334
  let lastSignature;
1220
1335
  let stablePolls = 0;
1221
1336
  let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
1222
1337
 
1223
1338
  while (Date.now() < deadline) {
1224
- latest = await collectArtifactCandidates(job, responseIndex);
1339
+ latest = await collectArtifactCandidates(job, responseIndex, responseText);
1225
1340
  const signature = latest.candidates.map((candidate) => candidate.label).join("\n");
1226
1341
  if (signature === lastSignature) stablePolls += 1;
1227
1342
  else {
@@ -1236,12 +1351,12 @@ async function waitForStableArtifactCandidates(job, responseIndex) {
1236
1351
  return latest;
1237
1352
  }
1238
1353
 
1239
- async function reopenConversationForArtifacts(job, responseIndex, reason) {
1354
+ async function reopenConversationForArtifacts(job, responseIndex, responseText, reason) {
1240
1355
  const targetUrl = job.chatUrl || stripQuery(await currentUrl(job));
1241
1356
  await log(`Reopening conversation before artifact capture (${reason}): ${targetUrl}`);
1242
1357
  await agentBrowser(job, "open", targetUrl);
1243
1358
  await agentBrowser(job, "wait", "1500");
1244
- return waitForStableArtifactCandidates(job, responseIndex);
1359
+ return waitForStableArtifactCandidates(job, responseIndex, responseText);
1245
1360
  }
1246
1361
 
1247
1362
  async function withHeartbeatWhile(task, intervalMs = ARTIFACT_DOWNLOAD_HEARTBEAT_MS) {
@@ -1273,14 +1388,14 @@ async function flushArtifactsState(artifacts) {
1273
1388
  }));
1274
1389
  }
1275
1390
 
1276
- async function downloadArtifacts(job, responseIndex) {
1391
+ async function downloadArtifacts(job, responseIndex, responseText = "") {
1277
1392
  if (!job.config.artifacts.capture) {
1278
1393
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1279
1394
  await mutateJob((current) => ({ ...current, artifactPaths: [] }));
1280
1395
  return [];
1281
1396
  }
1282
1397
 
1283
- const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, "initial");
1398
+ const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
1284
1399
  if (!targetSlice) {
1285
1400
  await log(`No assistant response found in snapshot for response index ${responseIndex}`);
1286
1401
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
@@ -1342,7 +1457,7 @@ async function downloadArtifacts(job, responseIndex) {
1342
1457
  if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
1343
1458
  artifacts.push({ displayName: candidate.label, unconfirmed: true, error: message });
1344
1459
  } else {
1345
- await reopenConversationForArtifacts(job, responseIndex, `retry ${attempt + 1} for ${candidate.label}`);
1460
+ await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${candidate.label}`);
1346
1461
  await sleep(1_000);
1347
1462
  }
1348
1463
  } finally {
@@ -1407,7 +1522,7 @@ async function run() {
1407
1522
  currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("extracting_response", { heartbeatAt: new Date().toISOString() }) }));
1408
1523
  await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
1409
1524
  currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
1410
- const artifacts = await downloadArtifacts(currentJob, completion.responseIndex);
1525
+ const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
1411
1526
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1412
1527
 
1413
1528
  await heartbeat(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -40,8 +40,8 @@
40
40
  ]
41
41
  },
42
42
  "scripts": {
43
- "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
44
- "sanity:oracle": "tsx scripts/oracle-sanity.ts",
43
+ "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
44
+ "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
45
45
  "pack:check": "npm pack --dry-run"
46
46
  },
47
47
  "dependencies": {