pi-oracle 0.1.9 → 0.1.11

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/README.md CHANGED
@@ -35,6 +35,7 @@ An oracle job:
35
35
  4. waits in the background
36
36
  5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
37
37
  - old terminal jobs are later pruned according to cleanup retention settings
38
+ - when directory inputs are expanded, project archives automatically skip common bulky generated caches and top-level build outputs such as `node_modules/`, `target/`, virtualenv caches, coverage outputs, and `dist/`/`build/`/`out/`, unless you explicitly pass those directories
38
39
  6. wakes the originating `pi` session on completion
39
40
 
40
41
  ## Example
@@ -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,8 @@ 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;
10
12
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
11
13
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
12
14
  ) || "agent-browser";
@@ -83,7 +85,7 @@ export async function writeSeedGeneration(config: OracleConfig, value = new Date
83
85
  }
84
86
 
85
87
  function activeJobExists(jobId: string): boolean {
86
- const path = join("/tmp", `oracle-${jobId}`, "job.json");
88
+ const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
87
89
  if (!existsSync(path)) return false;
88
90
  try {
89
91
  const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string };
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, join, posix } from "node:path";
5
5
  import { StringEnum } from "@mariozechner/pi-ai";
6
6
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
7
  import { Type } from "@sinclair/typebox";
@@ -60,11 +60,137 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
60
60
  pro: ["standard", "extended"],
61
61
  };
62
62
 
63
- async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
63
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
64
+ ".git",
65
+ ".hg",
66
+ ".svn",
67
+ "node_modules",
68
+ "target",
69
+ ".venv",
70
+ "venv",
71
+ "__pycache__",
72
+ ".pytest_cache",
73
+ ".mypy_cache",
74
+ ".ruff_cache",
75
+ ".tox",
76
+ ".nox",
77
+ ".hypothesis",
78
+ ".next",
79
+ ".nuxt",
80
+ ".svelte-kit",
81
+ ".turbo",
82
+ ".parcel-cache",
83
+ ".cache",
84
+ ".gradle",
85
+ ".terraform",
86
+ "DerivedData",
87
+ ".build",
88
+ ".pnpm-store",
89
+ ".serverless",
90
+ ".aws-sam",
91
+ ]);
92
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
93
+ const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([".coverage", ".DS_Store", "Thumbs.db"]);
94
+ const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".pyc", ".pyo", ".pyd", ".tsbuildinfo", ".tfstate"];
95
+ const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
96
+ const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
97
+
98
+ function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
99
+ const segments = relativePath.split("/").filter(Boolean);
100
+ if (sequence.length === 0 || segments.length < sequence.length) return false;
101
+ for (let index = 0; index <= segments.length - sequence.length; index += 1) {
102
+ if (sequence.every((segment, offset) => segments[index + offset] === segment)) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ function getRelativeDepth(relativePath: string): number {
108
+ return relativePath.split("/").filter(Boolean).length;
109
+ }
110
+
111
+ function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
112
+ const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
113
+ if (!normalized || normalized === ".") return false;
114
+ if (options?.forceInclude) return false;
115
+ const name = basename(normalized);
116
+ if (DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES.some((sequence) => pathContainsSequence(normalized, sequence))) return true;
117
+ if (isDirectory) {
118
+ if (DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE.has(name)) return true;
119
+ if (getRelativeDepth(normalized) === 1 && DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT.has(name)) return true;
120
+ return false;
121
+ }
122
+ if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
123
+ if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
124
+ if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
125
+ return false;
126
+ }
127
+
128
+ async function isSymlinkToDirectory(path: string): Promise<boolean> {
129
+ try {
130
+ return (await stat(path)).isDirectory();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ async function shouldExcludeArchiveChild(
137
+ absolutePath: string,
138
+ relativePath: string,
139
+ child: { isDirectory(): boolean; isSymbolicLink(): boolean },
140
+ options?: { forceInclude?: boolean },
141
+ ): Promise<boolean> {
142
+ const isDirectoryLike = child.isDirectory() || (child.isSymbolicLink() && await isSymlinkToDirectory(absolutePath));
143
+ return shouldExcludeArchivePath(relativePath, isDirectoryLike, options);
144
+ }
145
+
146
+ async function expandArchiveEntries(cwd: string, relativePath: string, options?: { forceIncludeSubtree?: boolean }): Promise<string[]> {
147
+ const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
148
+ if (normalized === ".") {
149
+ const children = await readdir(cwd, { withFileTypes: true });
150
+ const results: string[] = [];
151
+ for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
152
+ const childRelative = child.name;
153
+ if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child)) continue;
154
+ if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative));
155
+ else results.push(childRelative);
156
+ }
157
+ return results;
158
+ }
159
+
160
+ const absolute = join(cwd, normalized);
161
+ const entry = await lstat(absolute);
162
+ if (!entry.isDirectory()) return [normalized];
163
+ if (shouldExcludeArchivePath(normalized, true, { forceInclude: options?.forceIncludeSubtree })) return [];
164
+
165
+ const children = await readdir(absolute, { withFileTypes: true });
166
+ const results: string[] = [];
167
+ for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
168
+ const childRelative = posix.join(normalized, child.name);
169
+ if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child, { forceInclude: options?.forceIncludeSubtree })) continue;
170
+ if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative, { forceIncludeSubtree: options?.forceIncludeSubtree }));
171
+ else results.push(childRelative);
172
+ }
173
+ return results;
174
+ }
175
+
176
+ export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
64
177
  const entries = resolveArchiveInputs(cwd, files);
178
+ return Array.from(new Set((await Promise.all(entries.map(async (entry) => {
179
+ const absolute = join(cwd, entry.relative);
180
+ const statEntry = await lstat(absolute);
181
+ const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
182
+ return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
183
+ }))).flat())).sort();
184
+ }
185
+
186
+ async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
187
+ const expandedEntries = await resolveExpandedArchiveEntries(cwd, files);
188
+ if (expandedEntries.length === 0) {
189
+ throw new Error("Oracle archive inputs are empty after default exclusions");
190
+ }
65
191
  const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
66
192
  const listPath = join(listDir, "files.list");
67
- await writeFile(listPath, Buffer.from(`${entries.map((entry) => entry.relative).join("\0")}\0`), { mode: 0o600 });
193
+ await writeFile(listPath, Buffer.from(`${expandedEntries.join("\0")}\0`), { mode: 0o600 });
68
194
 
69
195
  try {
70
196
  const { spawn } = await import("node:child_process");
@@ -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,7 +33,8 @@ 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;
39
40
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
@@ -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,9 @@ 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;
40
47
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
41
48
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
42
49
  ) || "agent-browser";
@@ -539,27 +546,6 @@ function buildLoginProbeScript(timeoutMs) {
539
546
  `);
540
547
  }
541
548
 
542
- function parseSnapshotEntries(snapshot) {
543
- return snapshot
544
- .split("\n")
545
- .map((line) => {
546
- const refMatch = line.match(/\bref=(e\d+)\b/);
547
- if (!refMatch) return undefined;
548
- const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
549
- const quotedMatch = line.match(/"([^"]*)"/);
550
- const valueMatch = line.match(/:\s*(.+)$/);
551
- return {
552
- line,
553
- ref: `@${refMatch[1]}`,
554
- kind: kindMatch ? kindMatch[1] : undefined,
555
- label: quotedMatch ? quotedMatch[1] : undefined,
556
- value: valueMatch ? valueMatch[1].trim() : undefined,
557
- disabled: /\bdisabled\b/.test(line),
558
- };
559
- })
560
- .filter(Boolean);
561
- }
562
-
563
549
  function findEntry(snapshot, predicate) {
564
550
  return parseSnapshotEntries(snapshot).find(predicate);
565
551
  }
@@ -572,14 +558,21 @@ function findLastEntry(snapshot, predicate) {
572
558
  return undefined;
573
559
  }
574
560
 
575
- function matchesModelFamilyButton(candidate, family) {
576
- return candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(MODEL_FAMILY_PREFIX[family]) && !candidate.disabled;
577
- }
578
-
579
561
  function titleCase(value) {
580
562
  return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
581
563
  }
582
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
+
583
576
  function requestedEffortLabel(job) {
584
577
  return job.effort ? titleCase(job.effort) : undefined;
585
578
  }
@@ -612,9 +605,9 @@ function snapshotHasModelConfigurationUi(snapshot) {
612
605
  entries
613
606
  .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
614
607
  .flatMap((entry) =>
615
- Object.entries(MODEL_FAMILY_PREFIX)
616
- .filter(([, prefix]) => entry.label.startsWith(prefix))
617
- .map(([family]) => family),
608
+ Object.keys(MODEL_FAMILY_PREFIX)
609
+ .filter((family) => matchesModelFamilyLabel(entry.label, family))
610
+ .map((family) => family),
618
611
  ),
619
612
  );
620
613
  const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
@@ -627,18 +620,35 @@ function snapshotHasModelConfigurationUi(snapshot) {
627
620
  function snapshotStronglyMatchesRequestedModel(snapshot, job) {
628
621
  const entries = parseSnapshotEntries(snapshot);
629
622
  const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.chatModelFamily));
623
+ const effortLabel = requestedEffortLabel(job);
630
624
  if (job.chatModelFamily === "thinking") {
631
- return familyMatched || effortSelectionVisible(snapshot, requestedEffortLabel(job));
625
+ return familyMatched || effortSelectionVisible(snapshot, effortLabel);
632
626
  }
633
627
  if (job.chatModelFamily === "pro") {
634
- return familyMatched;
628
+ return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
635
629
  }
636
630
  return familyMatched;
637
631
  }
638
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
+
639
649
  function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
640
650
  if (job.chatModelFamily === "thinking") {
641
- return effortSelectionVisible(snapshot, requestedEffortLabel(job));
651
+ return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
642
652
  }
643
653
  if (job.chatModelFamily === "pro") {
644
654
  return !thinkingChipVisible(snapshot);
@@ -955,6 +965,54 @@ async function openModelConfiguration(job) {
955
965
  throw new Error("Could not open model configuration UI");
956
966
  }
957
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
+
958
1016
  async function configureModel(job) {
959
1017
  const initialSnapshot = await snapshotText(job);
960
1018
  if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
@@ -964,6 +1022,7 @@ async function configureModel(job) {
964
1022
 
965
1023
  await log(`Configuring model family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
966
1024
  let familySnapshot = await openModelConfiguration(job);
1025
+ let verificationSnapshot = familySnapshot;
967
1026
 
968
1027
  let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.chatModelFamily));
969
1028
  if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
@@ -977,6 +1036,7 @@ async function configureModel(job) {
977
1036
  await clickRef(job, familyEntry.ref);
978
1037
  await agentBrowser(job, "wait", "800");
979
1038
  familySnapshot = await snapshotText(job);
1039
+ verificationSnapshot = familySnapshot;
980
1040
  }
981
1041
 
982
1042
  if (job.chatModelFamily === "thinking" || job.chatModelFamily === "pro") {
@@ -990,6 +1050,7 @@ async function configureModel(job) {
990
1050
  await clickLabeledEntry(job, effortLabel, { kind: "option" });
991
1051
  await agentBrowser(job, "wait", "400");
992
1052
  const effortSnapshot = await snapshotText(job);
1053
+ verificationSnapshot = effortSnapshot;
993
1054
  const selectedEffort = findEntry(
994
1055
  effortSnapshot,
995
1056
  (candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
@@ -1002,17 +1063,18 @@ async function configureModel(job) {
1002
1063
 
1003
1064
  if (job.chatModelFamily === "instant" && job.autoSwitchToThinking) {
1004
1065
  await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1066
+ verificationSnapshot = await snapshotText(job);
1005
1067
  }
1006
1068
 
1007
- if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
1008
- 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}`);
1009
1072
  }
1010
- await agentBrowser(job, "wait", "500");
1011
1073
 
1012
- const postCloseSnapshot = await snapshotText(job);
1013
- if (!snapshotWeaklyMatchesRequestedModel(postCloseSnapshot, job)) {
1014
- 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);
1015
1076
  }
1077
+ await waitForModelConfigurationToSettle(job, { stronglyVerified });
1016
1078
  }
1017
1079
 
1018
1080
  async function uploadArchive(job) {
@@ -1192,14 +1254,6 @@ async function detectType(path) {
1192
1254
  return result.stdout || "unknown";
1193
1255
  }
1194
1256
 
1195
- function isLikelyArtifactLabel(label) {
1196
- const normalized = String(label || "").trim();
1197
- if (!normalized) return false;
1198
- const upper = normalized.toUpperCase();
1199
- if (upper === "ATTACHED" || upper === "DONE") return true;
1200
- return /(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])/.test(normalized);
1201
- }
1202
-
1203
1257
  function preferredArtifactName(label, index) {
1204
1258
  const normalized = String(label || "").trim();
1205
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})/);
@@ -1207,57 +1261,82 @@ function preferredArtifactName(label, index) {
1207
1261
  return `artifact-${String(index + 1).padStart(2, "0")}`;
1208
1262
  }
1209
1263
 
1210
- function artifactCandidatesFromEntries(entries) {
1211
- const excluded = new Set([
1212
- "Copy response",
1213
- "Good response",
1214
- "Bad response",
1215
- "Share",
1216
- "Switch model",
1217
- "More actions",
1218
- CHATGPT_LABELS.addFiles,
1219
- "Start dictation",
1220
- "Start Voice",
1221
- "Model selector",
1222
- "Open conversation options",
1223
- "Scroll to bottom",
1224
- CHATGPT_LABELS.close,
1225
- ]);
1226
-
1227
- const seen = new Set();
1228
- const candidates = [];
1229
- for (const entry of entries) {
1230
- if (!entry.label) continue;
1231
- if (excluded.has(entry.label)) continue;
1232
- if (entry.label.startsWith("Thought for ")) continue;
1233
- if (entry.kind !== "button" && entry.kind !== "link") continue;
1234
- if (!isLikelyArtifactLabel(entry.label)) continue;
1235
- if (seen.has(entry.label)) continue;
1236
- seen.add(entry.label);
1237
- candidates.push({ label: entry.label });
1238
- }
1239
- return candidates;
1240
- }
1241
-
1242
- async function collectArtifactCandidates(job, responseIndex) {
1264
+ async function collectArtifactCandidates(job, responseIndex, responseText = "") {
1243
1265
  const snapshot = await snapshotText(job);
1244
1266
  const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
1245
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
+
1246
1325
  return {
1247
1326
  snapshot,
1248
1327
  targetSlice,
1249
- candidates: artifactCandidatesFromEntries(parseSnapshotEntries(targetSlice)),
1328
+ candidates: filterStructuralArtifactCandidates(structural?.candidates || []),
1250
1329
  };
1251
1330
  }
1252
1331
 
1253
- async function waitForStableArtifactCandidates(job, responseIndex) {
1332
+ async function waitForStableArtifactCandidates(job, responseIndex, responseText = "") {
1254
1333
  const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
1255
1334
  let lastSignature;
1256
1335
  let stablePolls = 0;
1257
1336
  let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
1258
1337
 
1259
1338
  while (Date.now() < deadline) {
1260
- latest = await collectArtifactCandidates(job, responseIndex);
1339
+ latest = await collectArtifactCandidates(job, responseIndex, responseText);
1261
1340
  const signature = latest.candidates.map((candidate) => candidate.label).join("\n");
1262
1341
  if (signature === lastSignature) stablePolls += 1;
1263
1342
  else {
@@ -1272,12 +1351,12 @@ async function waitForStableArtifactCandidates(job, responseIndex) {
1272
1351
  return latest;
1273
1352
  }
1274
1353
 
1275
- async function reopenConversationForArtifacts(job, responseIndex, reason) {
1354
+ async function reopenConversationForArtifacts(job, responseIndex, responseText, reason) {
1276
1355
  const targetUrl = job.chatUrl || stripQuery(await currentUrl(job));
1277
1356
  await log(`Reopening conversation before artifact capture (${reason}): ${targetUrl}`);
1278
1357
  await agentBrowser(job, "open", targetUrl);
1279
1358
  await agentBrowser(job, "wait", "1500");
1280
- return waitForStableArtifactCandidates(job, responseIndex);
1359
+ return waitForStableArtifactCandidates(job, responseIndex, responseText);
1281
1360
  }
1282
1361
 
1283
1362
  async function withHeartbeatWhile(task, intervalMs = ARTIFACT_DOWNLOAD_HEARTBEAT_MS) {
@@ -1309,14 +1388,14 @@ async function flushArtifactsState(artifacts) {
1309
1388
  }));
1310
1389
  }
1311
1390
 
1312
- async function downloadArtifacts(job, responseIndex) {
1391
+ async function downloadArtifacts(job, responseIndex, responseText = "") {
1313
1392
  if (!job.config.artifacts.capture) {
1314
1393
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
1315
1394
  await mutateJob((current) => ({ ...current, artifactPaths: [] }));
1316
1395
  return [];
1317
1396
  }
1318
1397
 
1319
- const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, "initial");
1398
+ const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
1320
1399
  if (!targetSlice) {
1321
1400
  await log(`No assistant response found in snapshot for response index ${responseIndex}`);
1322
1401
  await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
@@ -1378,7 +1457,7 @@ async function downloadArtifacts(job, responseIndex) {
1378
1457
  if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
1379
1458
  artifacts.push({ displayName: candidate.label, unconfirmed: true, error: message });
1380
1459
  } else {
1381
- await reopenConversationForArtifacts(job, responseIndex, `retry ${attempt + 1} for ${candidate.label}`);
1460
+ await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${candidate.label}`);
1382
1461
  await sleep(1_000);
1383
1462
  }
1384
1463
  } finally {
@@ -1443,7 +1522,7 @@ async function run() {
1443
1522
  currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("extracting_response", { heartbeatAt: new Date().toISOString() }) }));
1444
1523
  await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
1445
1524
  currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
1446
- const artifacts = await downloadArtifacts(currentJob, completion.responseIndex);
1525
+ const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
1447
1526
  const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
1448
1527
 
1449
1528
  await heartbeat(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": {