pi-oracle 0.3.3 → 0.3.4

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 - 2026-04-11
4
+
5
+ ### Changed
6
+ - oracle archive defaults now exclude nested `secrets/` and `.secrets/` directories anywhere in the repo unless they are explicitly requested
7
+ - package metadata now reflects the current runtime floor and platform support (`node >=22`, `darwin`) and local release automation runs `verify:oracle` through `npm test` / `prepublishOnly`
8
+
9
+ ### Fixed
10
+ - model verification now distinguishes `thinking`, `pro`, `instant`, and `instant_auto_switch` more conservatively instead of accepting mismatched presets on partial UI evidence
11
+ - artifact-only assistant responses can now complete without timing out on missing plain-text bodies
12
+ - `/oracle-auth` diagnostics now write into a unique private temp directory per run instead of fixed `/tmp/oracle-auth.*` paths
13
+ - sanity coverage now exercises shared ChatGPT UI helpers directly, verifies nested secret exclusion, and guards the new auth diagnostics path handling
14
+
3
15
  ## 0.3.3 - 2026-04-11
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -147,6 +147,7 @@ Project config should only override safe, non-privileged settings.
147
147
  ## Requirements
148
148
 
149
149
  - macOS
150
+ - Node.js 22 or newer
150
151
  - Google Chrome installed
151
152
  - ChatGPT already signed into a local Chrome profile
152
153
  - `pi` 0.65.0 or newer
@@ -211,10 +212,14 @@ npm run check:oracle-extension
211
212
  npm run typecheck
212
213
  npm run sanity:oracle
213
214
  npm run pack:check
215
+ # conventional local gate
216
+ npm test
214
217
  # or all at once
215
218
  npm run verify:oracle
216
219
  ```
217
220
 
221
+ `npm publish` is also guarded locally via `prepublishOnly` and will run `npm run verify:oracle` before publishing.
222
+
218
223
  ## Beta caveats
219
224
 
220
225
  The highest-risk areas to monitor are:
@@ -585,7 +585,7 @@ Remaining non-blocking hardening work:
585
585
 
586
586
  Recent proof points:
587
587
  - expired-auth drill fail path: `a2460bc1-7d89-4041-b67d-39680d310325`
588
- - `/oracle-auth` repair evidence: `/tmp/oracle-auth.log`
588
+ - `/oracle-auth` repair evidence: the per-run `/tmp/pi-oracle-auth-*/oracle-auth.log` bundle path printed by `/oracle-auth`
589
589
  - expired-auth drill post-repair success: `fa26a2a7-0057-4a21-b3e0-71c1d020facf`
590
590
  - successful multi-artifact completion: `b6b3599c-6b91-4315-adfa-8a83aa5eda9b`
591
591
  - repo-owned sanity harness: `npm run sanity:oracle`
@@ -105,10 +105,11 @@ For the failed run:
105
105
  - any failure diagnostics under that job dir
106
106
 
107
107
  For the repair:
108
- - `/tmp/oracle-auth.log`
109
- - `/tmp/oracle-auth.url.txt`
110
- - `/tmp/oracle-auth.snapshot.txt`
111
- - `/tmp/oracle-auth.body.txt`
108
+ - the per-run `/tmp/pi-oracle-auth-*/` diagnostics directory printed by `/oracle-auth`
109
+ - `oracle-auth.log`
110
+ - `oracle-auth.url.txt`
111
+ - `oracle-auth.snapshot.txt`
112
+ - `oracle-auth.body.txt`
112
113
 
113
114
  For the successful rerun:
114
115
  - `/tmp/oracle-<job-id>/job.json`
@@ -107,8 +107,10 @@ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
107
107
  ".pnpm-store",
108
108
  ".serverless",
109
109
  ".aws-sam",
110
+ "secrets",
111
+ ".secrets",
110
112
  ]);
111
- const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out", "secrets", ".secrets"]);
113
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
112
114
  const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
113
115
  ".coverage",
114
116
  ".DS_Store",
@@ -583,7 +585,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
583
585
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
584
586
  promptGuidelines: [
585
587
  "Gather context before calling oracle_submit.",
586
- "By default, archive the whole repo 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.",
588
+ "By default, archive the whole repo 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 nested secrets directories anywhere in the repo.",
587
589
  "Only narrow file selection when the user explicitly asks, the task is clearly scoped smaller, or privacy/sensitivity requires it.",
588
590
  "For very targeted asks like a single function or stack trace, a smaller archive is preferable.",
589
591
  "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.",
@@ -1,11 +1,12 @@
1
1
  import { withLock } from "./state-locks.mjs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { appendFile, chmod, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
4
+ import { appendFile, chmod, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
7
  import { getCookies } from "@steipete/sweet-cookie";
8
8
  import { ensureAccountCookie, filterImportableAuthCookies } from "./auth-cookie-policy.mjs";
9
+ import { buildAllowedChatGptOrigins } from "./chatgpt-ui-helpers.mjs";
9
10
 
10
11
  const rawConfig = process.argv[2];
11
12
  if (!rawConfig) {
@@ -27,11 +28,12 @@ const CHATGPT_COOKIE_ORIGINS = [
27
28
  "https://sentinel.openai.com",
28
29
  "https://ws.chatgpt.com",
29
30
  ];
30
- const LOG_PATH = "/tmp/oracle-auth.log";
31
- const URL_PATH = "/tmp/oracle-auth.url.txt";
32
- const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
33
- const BODY_PATH = "/tmp/oracle-auth.body.txt";
34
- const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
31
+ let DIAGNOSTICS_DIR;
32
+ let LOG_PATH = "(oracle-auth log path unavailable)";
33
+ let URL_PATH = "(oracle-auth url path unavailable)";
34
+ let SNAPSHOT_PATH = "(oracle-auth snapshot path unavailable)";
35
+ let BODY_PATH = "(oracle-auth body path unavailable)";
36
+ let SCREENSHOT_PATH = "(oracle-auth screenshot path unavailable)";
35
37
  const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
36
38
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
37
39
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -50,12 +52,25 @@ function sleep(ms) {
50
52
  return new Promise((resolve) => setTimeout(resolve, ms));
51
53
  }
52
54
 
55
+ async function initDiagnosticsBundle() {
56
+ if (DIAGNOSTICS_DIR) return;
57
+ DIAGNOSTICS_DIR = await mkdtemp(join(tmpdir(), "pi-oracle-auth-"));
58
+ await chmod(DIAGNOSTICS_DIR, 0o700).catch(() => undefined);
59
+ LOG_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.log");
60
+ URL_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.url.txt");
61
+ SNAPSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.snapshot.txt");
62
+ BODY_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.body.txt");
63
+ SCREENSHOT_PATH = join(DIAGNOSTICS_DIR, "oracle-auth.png");
64
+ }
65
+
53
66
  async function initLog() {
67
+ await initDiagnosticsBundle();
54
68
  await writeFile(LOG_PATH, "", { mode: 0o600 });
55
69
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
56
70
  }
57
71
 
58
72
  async function log(message) {
73
+ await initDiagnosticsBundle();
59
74
  const line = `[${new Date().toISOString()}] ${message}\n`;
60
75
  await appendFile(LOG_PATH, line, { encoding: "utf8", mode: 0o600 });
61
76
  await chmod(LOG_PATH, 0o600).catch(() => undefined);
@@ -566,7 +581,7 @@ async function captureDiagnostics(reason) {
566
581
 
567
582
  function classifyChatPage({ url, snapshot, body, probe }) {
568
583
  const text = `${snapshot}\n${body}`;
569
- const allowedOrigins = [new URL(config.browser.chatUrl).origin, new URL(config.browser.authUrl).origin, "https://auth.openai.com"];
584
+ const allowedOrigins = buildAllowedChatGptOrigins(config.browser.chatUrl, config.browser.authUrl);
570
585
 
571
586
  const challengePatterns = [
572
587
  /just a moment/i,
@@ -804,7 +819,7 @@ async function run() {
804
819
  await writeFile(join(profilePlan.targetDir, ".oracle-seed-generation"), `${generation}\n`, { encoding: "utf8", mode: 0o600 });
805
820
  committedProfile = true;
806
821
  process.stdout.write(
807
- `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}`,
822
+ `${classification.message} Synced ${appliedCount} cookies into ${profilePlan.targetDir}. Diagnostics: ${DIAGNOSTICS_DIR}`,
808
823
  );
809
824
  } catch (error) {
810
825
  shouldPreserveBrowser = Boolean(error && typeof error === "object" && error.preserveBrowser === true);
@@ -822,7 +837,7 @@ async function run() {
822
837
 
823
838
  run().catch((error) => {
824
839
  process.stderr.write(
825
- `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in /tmp/oracle-auth.*\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
840
+ `${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
826
841
  );
827
842
  process.exit(1);
828
843
  });
@@ -0,0 +1,33 @@
1
+ export type OracleUiModelFamily = "instant" | "thinking" | "pro";
2
+ export type OracleUiEffort = "light" | "standard" | "extended" | "heavy";
3
+
4
+ export interface OracleUiSelection {
5
+ modelFamily: OracleUiModelFamily;
6
+ effort?: OracleUiEffort;
7
+ autoSwitchToThinking?: boolean;
8
+ }
9
+
10
+ export declare const CHATGPT_CANONICAL_APP_ORIGINS: readonly string[];
11
+
12
+ export declare function buildAllowedChatGptOrigins(chatUrl: string, authUrl?: string): string[];
13
+ export declare function matchesModelFamilyLabel(label: string | undefined, family: OracleUiModelFamily): boolean;
14
+ export declare function requestedEffortLabel(selection: OracleUiSelection): string | undefined;
15
+ export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
16
+ export declare function thinkingChipVisible(snapshot: string): boolean;
17
+ export declare function snapshotHasModelConfigurationUi(snapshot: string): boolean;
18
+ export declare function autoSwitchToThinkingSelectionVisible(snapshot: string): boolean | undefined;
19
+ export declare function snapshotCanSafelySkipModelConfiguration(snapshot: string, selection: OracleUiSelection): boolean;
20
+ export declare function snapshotStronglyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
21
+ export declare function snapshotWeaklyMatchesRequestedModel(snapshot: string, selection: OracleUiSelection): boolean;
22
+ export declare function buildAssistantCompletionSignature(args: {
23
+ responseText: string;
24
+ artifactLabels?: string[];
25
+ suspiciousArtifactLabels?: string[];
26
+ }): string | undefined;
27
+ export declare function deriveAssistantCompletionSignature(args: {
28
+ hasStopStreaming: boolean;
29
+ hasTargetCopyResponse: boolean;
30
+ responseText: string;
31
+ artifactLabels?: string[];
32
+ suspiciousArtifactLabels?: string[];
33
+ }): string | undefined;
@@ -0,0 +1,208 @@
1
+ import { parseSnapshotEntries } from "./artifact-heuristics.mjs";
2
+
3
+ export const CHATGPT_CANONICAL_APP_ORIGINS = Object.freeze([
4
+ "https://chatgpt.com",
5
+ "https://chat.openai.com",
6
+ ]);
7
+
8
+ const MODEL_FAMILY_PREFIX = {
9
+ instant: "Instant ",
10
+ thinking: "Thinking ",
11
+ pro: "Pro ",
12
+ };
13
+
14
+ const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
15
+
16
+ function originFromUrl(url) {
17
+ if (typeof url !== "string" || !url.trim()) return undefined;
18
+ try {
19
+ return new URL(url).origin;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ function uniqueStrings(values) {
26
+ return [...new Set(values.filter((value) => typeof value === "string" && value))];
27
+ }
28
+
29
+ function titleCase(value) {
30
+ return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
31
+ }
32
+
33
+ function normalizeText(value) {
34
+ return String(value || "").replace(/\s+/g, " ").trim();
35
+ }
36
+
37
+ export function buildAllowedChatGptOrigins(chatUrl, authUrl) {
38
+ return uniqueStrings([
39
+ ...CHATGPT_CANONICAL_APP_ORIGINS,
40
+ originFromUrl(chatUrl),
41
+ originFromUrl(authUrl),
42
+ "https://auth.openai.com",
43
+ ]);
44
+ }
45
+
46
+ export function matchesModelFamilyLabel(label, family) {
47
+ const normalized = String(label || "");
48
+ const prefix = MODEL_FAMILY_PREFIX[family];
49
+ const exact = prefix.trim();
50
+ return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
51
+ }
52
+
53
+ export function requestedEffortLabel(selection) {
54
+ return selection?.effort ? titleCase(selection.effort) : undefined;
55
+ }
56
+
57
+ export function effortSelectionVisible(snapshot, effortLabel) {
58
+ if (!effortLabel) return true;
59
+ const entries = parseSnapshotEntries(snapshot);
60
+ return entries.some((entry) => {
61
+ if (entry.disabled) return false;
62
+ if (entry.kind === "combobox" && entry.value === effortLabel) return true;
63
+ if (entry.kind !== "button") return false;
64
+ const label = String(entry.label || "").toLowerCase();
65
+ const normalizedEffort = effortLabel.toLowerCase();
66
+ return (
67
+ label === normalizedEffort ||
68
+ label === `${normalizedEffort} thinking` ||
69
+ label === `${normalizedEffort}, click to remove` ||
70
+ label === `${normalizedEffort} thinking, click to remove`
71
+ );
72
+ });
73
+ }
74
+
75
+ export function thinkingChipVisible(snapshot) {
76
+ return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
77
+ }
78
+
79
+ export function snapshotHasModelConfigurationUi(snapshot) {
80
+ const entries = parseSnapshotEntries(snapshot);
81
+ const visibleFamilies = new Set(
82
+ entries
83
+ .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
84
+ .flatMap((entry) =>
85
+ Object.keys(MODEL_FAMILY_PREFIX)
86
+ .filter((family) => matchesModelFamilyLabel(entry.label, family))
87
+ .map((family) => family),
88
+ ),
89
+ );
90
+ const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === "Close" && !entry.disabled);
91
+ const hasEffortCombobox = entries.some(
92
+ (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
93
+ );
94
+ return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
95
+ }
96
+
97
+ export function autoSwitchToThinkingSelectionVisible(snapshot) {
98
+ const entries = parseSnapshotEntries(snapshot);
99
+ let foundControl = false;
100
+
101
+ for (const entry of entries) {
102
+ const controlText = normalizeText([entry.label, entry.value, entry.line].filter(Boolean).join(" "));
103
+ if (!controlText.toLowerCase().includes(AUTO_SWITCH_LABEL.toLowerCase())) continue;
104
+ foundControl = true;
105
+
106
+ if (/\b(?:checked|selected|enabled|on|active)\b/i.test(controlText)) return true;
107
+ if (/\b(?:unchecked|not checked|disabled|off)\b/i.test(controlText)) return false;
108
+ if (typeof entry.label === "string" && /click to remove/i.test(entry.label)) return true;
109
+ }
110
+
111
+ return foundControl ? false : undefined;
112
+ }
113
+
114
+ export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
115
+ if (!snapshotStronglyMatchesRequestedModel(snapshot, selection)) return false;
116
+
117
+ if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
118
+ const effortLabel = requestedEffortLabel(selection);
119
+ if (effortLabel && !effortSelectionVisible(snapshot, effortLabel)) return false;
120
+ }
121
+
122
+ if (selection.modelFamily === "instant" && selection.autoSwitchToThinking) {
123
+ return autoSwitchToThinkingSelectionVisible(snapshot) === true;
124
+ }
125
+
126
+ return true;
127
+ }
128
+
129
+ export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
130
+ const entries = parseSnapshotEntries(snapshot);
131
+ const familyMatched = entries.some((entry) => {
132
+ return !entry.disabled && matchesModelFamilyLabel(entry.label, selection.modelFamily);
133
+ });
134
+ if (!familyMatched) return false;
135
+
136
+ const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
137
+ const effortLabel = requestedEffortLabel(selection);
138
+
139
+ if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
140
+ if (!effortLabel) return true;
141
+ if (effortSelectionVisible(snapshot, effortLabel)) return true;
142
+ return !configurationUiVisible;
143
+ }
144
+
145
+ if (selection.modelFamily === "instant") {
146
+ const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
147
+ if (selection.autoSwitchToThinking) {
148
+ return autoSwitchState === true || (!configurationUiVisible && autoSwitchState === undefined);
149
+ }
150
+ return autoSwitchState !== true;
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ export function snapshotWeaklyMatchesRequestedModel(snapshot, selection) {
157
+ const entries = parseSnapshotEntries(snapshot);
158
+ const familyMatched = entries.some((entry) => {
159
+ return !entry.disabled && matchesModelFamilyLabel(entry.label, selection.modelFamily);
160
+ });
161
+
162
+ if (selection.modelFamily === "thinking") {
163
+ return familyMatched || effortSelectionVisible(snapshot, requestedEffortLabel(selection));
164
+ }
165
+
166
+ if (!familyMatched) return false;
167
+
168
+ if (selection.modelFamily === "pro") {
169
+ return !thinkingChipVisible(snapshot);
170
+ }
171
+
172
+ if (selection.modelFamily === "instant") {
173
+ const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
174
+ return selection.autoSwitchToThinking ? autoSwitchState !== false : autoSwitchState !== true;
175
+ }
176
+
177
+ return false;
178
+ }
179
+
180
+ export function buildAssistantCompletionSignature({ responseText, artifactLabels = [], suspiciousArtifactLabels = [] }) {
181
+ const normalizedResponse = normalizeText(responseText);
182
+ if (normalizedResponse) return `text:${normalizedResponse}`;
183
+
184
+ const labels = uniqueStrings([...artifactLabels, ...suspiciousArtifactLabels].map((value) => normalizeText(value))).sort((left, right) => left.localeCompare(right));
185
+ if (labels.length > 0) return `artifacts:${labels.join("|")}`;
186
+
187
+ return undefined;
188
+ }
189
+
190
+ export function deriveAssistantCompletionSignature({
191
+ hasStopStreaming,
192
+ hasTargetCopyResponse,
193
+ responseText,
194
+ artifactLabels = [],
195
+ suspiciousArtifactLabels = [],
196
+ }) {
197
+ if (hasStopStreaming) return undefined;
198
+
199
+ if (hasTargetCopyResponse && normalizeText(responseText)) {
200
+ return buildAssistantCompletionSignature({ responseText });
201
+ }
202
+
203
+ if (!normalizeText(responseText)) {
204
+ return buildAssistantCompletionSignature({ responseText, artifactLabels, suspiciousArtifactLabels });
205
+ }
206
+
207
+ return undefined;
208
+ }
@@ -5,6 +5,18 @@ import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawn, execFileSync } from "node:child_process";
7
7
  import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
8
+ import {
9
+ buildAllowedChatGptOrigins,
10
+ deriveAssistantCompletionSignature,
11
+ matchesModelFamilyLabel,
12
+ requestedEffortLabel,
13
+ effortSelectionVisible,
14
+ snapshotCanSafelySkipModelConfiguration,
15
+ snapshotHasModelConfigurationUi,
16
+ snapshotStronglyMatchesRequestedModel,
17
+ snapshotWeaklyMatchesRequestedModel,
18
+ autoSwitchToThinkingSelectionVisible,
19
+ } from "./chatgpt-ui-helpers.mjs";
8
20
  import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
9
21
 
10
22
  const jobId = process.argv[2];
@@ -25,12 +37,6 @@ const CHATGPT_LABELS = {
25
37
  autoSwitchToThinking: "Auto-switch to Thinking",
26
38
  configure: "Configure...",
27
39
  };
28
- const MODEL_FAMILY_PREFIX = {
29
- instant: "Instant ",
30
- thinking: "Thinking ",
31
- pro: "Pro ",
32
- };
33
-
34
40
  const WORKER_SCRIPT_PATH = fileURLToPath(import.meta.url);
35
41
  const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
36
42
  const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
@@ -791,83 +797,10 @@ function findLastEntry(snapshot, predicate) {
791
797
  return undefined;
792
798
  }
793
799
 
794
- function titleCase(value) {
795
- return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
796
- }
797
-
798
- function matchesModelFamilyLabel(label, family) {
799
- const normalized = String(label || "");
800
- const prefix = MODEL_FAMILY_PREFIX[family];
801
- const exact = prefix.trim();
802
- return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
803
- }
804
-
805
800
  function matchesModelFamilyButton(candidate, family) {
806
801
  return candidate.kind === "button" && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
807
802
  }
808
803
 
809
- function requestedEffortLabel(job) {
810
- return job.selection?.effort ? titleCase(job.selection.effort) : undefined;
811
- }
812
-
813
- function effortSelectionVisible(snapshot, effortLabel) {
814
- if (!effortLabel) return true;
815
- const entries = parseSnapshotEntries(snapshot);
816
- return entries.some((entry) => {
817
- if (entry.disabled) return false;
818
- if (entry.kind === "combobox" && entry.value === effortLabel) return true;
819
- if (entry.kind !== "button") return false;
820
- const label = String(entry.label || "").toLowerCase();
821
- const normalizedEffort = effortLabel.toLowerCase();
822
- return (
823
- label === normalizedEffort ||
824
- label === `${normalizedEffort} thinking` ||
825
- label === `${normalizedEffort}, click to remove` ||
826
- label === `${normalizedEffort} thinking, click to remove`
827
- );
828
- });
829
- }
830
-
831
- function thinkingChipVisible(snapshot) {
832
- return /button "(?:Light|Standard|Extended|Heavy)(?: thinking)?(?:, click to remove)?"/i.test(snapshot);
833
- }
834
-
835
- function snapshotHasModelConfigurationUi(snapshot) {
836
- const entries = parseSnapshotEntries(snapshot);
837
- const visibleFamilies = new Set(
838
- entries
839
- .filter((entry) => entry.kind === "button" && typeof entry.label === "string")
840
- .flatMap((entry) =>
841
- Object.keys(MODEL_FAMILY_PREFIX)
842
- .filter((family) => matchesModelFamilyLabel(entry.label, family))
843
- .map((family) => family),
844
- ),
845
- );
846
- const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
847
- const hasEffortCombobox = entries.some(
848
- (entry) => entry.kind === "combobox" && ["Light", "Standard", "Extended", "Heavy"].includes(entry.value || "") && !entry.disabled,
849
- );
850
- return visibleFamilies.size >= 2 || hasCloseButton || hasEffortCombobox;
851
- }
852
-
853
- function snapshotStronglyMatchesRequestedModel(snapshot, job) {
854
- const entries = parseSnapshotEntries(snapshot);
855
- const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.selection.modelFamily));
856
- const effortLabel = requestedEffortLabel(job);
857
- if (job.selection.modelFamily === "thinking") {
858
- return familyMatched || effortSelectionVisible(snapshot, effortLabel);
859
- }
860
- if (job.selection.modelFamily === "pro") {
861
- return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
862
- }
863
- return familyMatched;
864
- }
865
-
866
- function thinkingSelectionVisible(snapshot) {
867
- const entries = parseSnapshotEntries(snapshot);
868
- return entries.some((entry) => !entry.disabled && entry.kind === "button" && matchesModelFamilyLabel(entry.label, "thinking"));
869
- }
870
-
871
804
  function composerControlsVisible(snapshot) {
872
805
  const entries = parseSnapshotEntries(snapshot);
873
806
  const hasComposer = entries.some(
@@ -879,17 +812,15 @@ function composerControlsVisible(snapshot) {
879
812
  return hasComposer && hasAddFiles;
880
813
  }
881
814
 
882
- function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
883
- if (job.selection.modelFamily === "thinking") {
884
- return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
885
- }
886
- if (job.selection.modelFamily === "pro") {
887
- return !thinkingChipVisible(snapshot);
888
- }
889
- if (job.selection.modelFamily === "instant") {
890
- return !thinkingChipVisible(snapshot);
891
- }
892
- return false;
815
+ async function clickAutoSwitchToThinkingControl(job) {
816
+ const snapshot = await snapshotText(job);
817
+ const entry = findEntry(
818
+ snapshot,
819
+ (candidate) => candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(CHATGPT_LABELS.autoSwitchToThinking) && !candidate.disabled,
820
+ );
821
+ if (!entry) throw new Error(`Could not find ${CHATGPT_LABELS.autoSwitchToThinking} control`);
822
+ await clickRef(job, entry.ref);
823
+ return entry;
893
824
  }
894
825
 
895
826
  async function clickRef(job, ref) {
@@ -962,7 +893,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
962
893
  return { state: "transient_outage_error", message: "ChatGPT is showing a transient outage/error page" };
963
894
  }
964
895
 
965
- const allowedOrigins = [new URL(job.config.browser.chatUrl).origin, "https://auth.openai.com"];
896
+ const allowedOrigins = buildAllowedChatGptOrigins(job.config.browser.chatUrl, job.config.browser.authUrl);
966
897
  const onAllowedOrigin = typeof url === "string" && allowedOrigins.some((origin) => url.startsWith(origin));
967
898
  const onAuthPath = typeof url === "string" && url.includes("/auth/");
968
899
  const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
@@ -1210,7 +1141,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1210
1141
  const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
1211
1142
 
1212
1143
  if (!configurationUiVisible) {
1213
- if (snapshotWeaklyMatchesRequestedModel(snapshot, job)) return;
1144
+ if (snapshotWeaklyMatchesRequestedModel(snapshot, job.selection)) return;
1214
1145
  if (options.stronglyVerified) {
1215
1146
  if (!fallbackLogged) {
1216
1147
  fallbackLogged = true;
@@ -1248,7 +1179,7 @@ async function waitForModelConfigurationToSettle(job, options = {}) {
1248
1179
 
1249
1180
  async function configureModel(job) {
1250
1181
  const initialSnapshot = await snapshotText(job);
1251
- if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
1182
+ if (snapshotCanSafelySkipModelConfiguration(initialSnapshot, job.selection)) {
1252
1183
  await log(`Model already appears configured for family=${job.selection.modelFamily} effort=${job.selection?.effort || "(none)"}; skipping reconfiguration`);
1253
1184
  return;
1254
1185
  }
@@ -1257,23 +1188,27 @@ async function configureModel(job) {
1257
1188
  let familySnapshot = await openModelConfiguration(job);
1258
1189
  let verificationSnapshot = familySnapshot;
1259
1190
 
1191
+ const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
1260
1192
  let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1261
- if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1193
+ if (alreadyConfiguredInUi) {
1262
1194
  await log("Model configuration UI opened with requested settings already selected");
1263
- }
1264
- if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
1195
+ } else if (!familyEntry) {
1265
1196
  throw new Error(`Could not find model family button for ${job.selection.modelFamily}`);
1266
1197
  }
1267
1198
 
1268
- if (familyEntry) {
1199
+ if (!alreadyConfiguredInUi && familyEntry) {
1269
1200
  await clickRef(job, familyEntry.ref);
1270
1201
  await agentBrowser(job, "wait", "800");
1271
1202
  familySnapshot = await snapshotText(job);
1272
1203
  verificationSnapshot = familySnapshot;
1204
+ familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.selection.modelFamily));
1205
+ if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
1206
+ throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
1207
+ }
1273
1208
  }
1274
1209
 
1275
1210
  if (job.selection.modelFamily === "thinking" || job.selection.modelFamily === "pro") {
1276
- const effortLabel = requestedEffortLabel(job);
1211
+ const effortLabel = requestedEffortLabel(job.selection);
1277
1212
  if (effortLabel && !effortSelectionVisible(familySnapshot, effortLabel)) {
1278
1213
  const opened = await openEffortDropdown(job);
1279
1214
  if (!opened) {
@@ -1291,15 +1226,22 @@ async function configureModel(job) {
1291
1226
  if (!selectedEffort && !effortSelectionVisible(effortSnapshot, effortLabel)) {
1292
1227
  throw new Error(`Requested effort did not remain selected: ${effortLabel}`);
1293
1228
  }
1229
+ familySnapshot = effortSnapshot;
1294
1230
  }
1295
1231
  }
1296
1232
 
1297
- if (job.selection.modelFamily === "instant" && job.selection.autoSwitchToThinking) {
1298
- await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
1299
- verificationSnapshot = await snapshotText(job);
1233
+ if (job.selection.modelFamily === "instant") {
1234
+ const desiredAutoSwitchState = job.selection.autoSwitchToThinking === true;
1235
+ const currentAutoSwitchState = autoSwitchToThinkingSelectionVisible(familySnapshot);
1236
+ if (currentAutoSwitchState !== desiredAutoSwitchState && (desiredAutoSwitchState || currentAutoSwitchState === true)) {
1237
+ await clickAutoSwitchToThinkingControl(job);
1238
+ await agentBrowser(job, "wait", "400");
1239
+ verificationSnapshot = await snapshotText(job);
1240
+ familySnapshot = verificationSnapshot;
1241
+ }
1300
1242
  }
1301
1243
 
1302
- const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
1244
+ const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job.selection);
1303
1245
  if (!stronglyVerified) {
1304
1246
  throw new Error(`Could not verify requested model settings in configuration UI for ${job.selection.modelFamily}`);
1305
1247
  }
@@ -1427,7 +1369,7 @@ async function waitForStableChatUrl(job, previousChatUrl) {
1427
1369
 
1428
1370
  async function waitForChatCompletion(job, baselineAssistantCount) {
1429
1371
  const timeoutAt = Date.now() + job.config.worker.completionTimeoutMs;
1430
- let lastText = "";
1372
+ let lastCompletionSignature = "";
1431
1373
  let stableCount = 0;
1432
1374
  let retriedAfterFailure = false;
1433
1375
 
@@ -1451,7 +1393,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1451
1393
  );
1452
1394
  if (retryEntry) {
1453
1395
  retriedAfterFailure = true;
1454
- lastText = "";
1396
+ lastCompletionSignature = "";
1455
1397
  stableCount = 0;
1456
1398
  await log(`Response delivery failed (${responseFailureText}); clicking Retry once`);
1457
1399
  await clickRef(job, retryEntry.ref);
@@ -1462,13 +1404,34 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1462
1404
  throw new Error(`ChatGPT response failed: ${responseFailureText}`);
1463
1405
  }
1464
1406
 
1407
+ let completionSignature;
1465
1408
  if (!hasStopStreaming && hasTargetCopyResponse && targetText) {
1466
- if (targetText === lastText) stableCount += 1;
1409
+ completionSignature = deriveAssistantCompletionSignature({
1410
+ hasStopStreaming,
1411
+ hasTargetCopyResponse,
1412
+ responseText: targetText,
1413
+ });
1414
+ } else if (!hasStopStreaming && !targetText) {
1415
+ const artifactSignals = await collectArtifactCandidates(job, baselineAssistantCount, targetText).catch(() => ({ candidates: [], suspiciousLabels: [] }));
1416
+ completionSignature = deriveAssistantCompletionSignature({
1417
+ hasStopStreaming,
1418
+ hasTargetCopyResponse,
1419
+ responseText: targetText,
1420
+ artifactLabels: artifactSignals.candidates.map((candidate) => candidate.label),
1421
+ suspiciousArtifactLabels: artifactSignals.suspiciousLabels,
1422
+ });
1423
+ }
1424
+
1425
+ if (completionSignature) {
1426
+ if (completionSignature === lastCompletionSignature) stableCount += 1;
1467
1427
  else stableCount = 1;
1468
- lastText = targetText;
1428
+ lastCompletionSignature = completionSignature;
1469
1429
  if (stableCount >= 2) {
1470
1430
  return { responseIndex: baselineAssistantCount, responseText: targetText };
1471
1431
  }
1432
+ } else {
1433
+ lastCompletionSignature = "";
1434
+ stableCount = 0;
1472
1435
  }
1473
1436
 
1474
1437
  await sleep(job.config.worker.pollMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",
@@ -42,16 +42,21 @@
42
42
  ]
43
43
  },
44
44
  "scripts": {
45
- "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.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",
45
+ "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.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",
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
47
47
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
48
48
  "pack:check": "npm pack --dry-run",
49
- "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run sanity:oracle && npm run pack:check"
49
+ "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run sanity:oracle && npm run pack:check",
50
+ "test": "npm run verify:oracle",
51
+ "prepublishOnly": "npm run verify:oracle"
50
52
  },
51
53
  "dependencies": {
52
54
  "@sinclair/typebox": "^0.34.49",
53
55
  "@steipete/sweet-cookie": "^0.2.0"
54
56
  },
57
+ "overrides": {
58
+ "basic-ftp": "^5.2.2"
59
+ },
55
60
  "devDependencies": {
56
61
  "@mariozechner/pi-ai": "^0.65.2",
57
62
  "@mariozechner/pi-coding-agent": "^0.65.2",
@@ -61,6 +66,9 @@
61
66
  "typescript": "^5.9.3"
62
67
  },
63
68
  "engines": {
64
- "node": ">=20"
65
- }
69
+ "node": ">=22"
70
+ },
71
+ "os": [
72
+ "darwin"
73
+ ]
66
74
  }
package/prompts/oracle.md CHANGED
@@ -22,7 +22,7 @@ Oracle model (`oracle_submit`):
22
22
 
23
23
  Rules:
24
24
  - Always include an archive. Do not submit without context files.
25
- - 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.
25
+ - 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 nested `secrets/` directories anywhere in the repo.
26
26
  - Only limit file selection if the user explicitly requests it, if the task is clearly scoped to a smaller area, or if privacy/sensitivity requires it.
27
27
  - For very targeted asks like reviewing one function or explaining one stack trace, a smaller archive is preferable.
28
28
  - 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.