pi-oracle 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +43 -12
- package/docs/ORACLE_DESIGN.md +32 -16
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +28 -1
- package/extensions/oracle/index.ts +1 -1
- package/extensions/oracle/lib/auth.ts +50 -0
- package/extensions/oracle/lib/commands.ts +57 -47
- package/extensions/oracle/lib/config.ts +53 -2
- package/extensions/oracle/lib/jobs.ts +31 -5
- package/extensions/oracle/lib/poller.ts +33 -4
- package/extensions/oracle/lib/runtime.ts +171 -7
- package/extensions/oracle/lib/tools.ts +726 -253
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +1 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +13 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +2 -1
- package/extensions/oracle/shared/job-observability-helpers.mjs +28 -10
- package/extensions/oracle/worker/auth-bootstrap.mjs +49 -4
- package/extensions/oracle/worker/auth-flow-helpers.mjs +1 -1
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +106 -41
- package/extensions/oracle/worker/run-job.mjs +17 -13
- package/package.json +6 -2
- package/prompts/oracle-followup.md +48 -0
- package/prompts/oracle.md +18 -11
|
@@ -104,6 +104,7 @@ export declare function appendOracleJobLifecycleEvent<TJob extends OracleLifecyc
|
|
|
104
104
|
event: Omit<OracleJobLifecycleEvent, "status" | "phase"> & { status?: OracleJobStatus; phase?: OracleJobPhase },
|
|
105
105
|
): TJob;
|
|
106
106
|
export declare function getLatestOracleJobLifecycleEvent(job: Pick<OracleLifecycleTrackedJobLike, "lifecycleEvents">): OracleJobLifecycleEvent | undefined;
|
|
107
|
+
export declare function getLatestOracleTerminalLifecycleEvent(job: Pick<OracleLifecycleTrackedJobLike, "lifecycleEvents">): OracleJobLifecycleEvent | undefined;
|
|
107
108
|
export declare function markOracleJobCreated<TJob extends OracleLifecycleTrackedJobLike>(job: TJob, options?: { at?: string; source?: string; message?: string }): TJob;
|
|
108
109
|
export declare function transitionOracleJobPhase<TJob extends OracleLifecycleTrackedJobLike>(
|
|
109
110
|
job: TJob,
|
|
@@ -111,6 +111,19 @@ export function getLatestOracleJobLifecycleEvent(job) {
|
|
|
111
111
|
return events.length > 0 ? events[events.length - 1] : undefined;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* @param {Pick<OracleLifecycleTrackedJobLike, "lifecycleEvents">} job
|
|
116
|
+
* @returns {OracleJobLifecycleEvent | undefined}
|
|
117
|
+
*/
|
|
118
|
+
export function getLatestOracleTerminalLifecycleEvent(job) {
|
|
119
|
+
const events = job.lifecycleEvents || [];
|
|
120
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const event = events[index];
|
|
122
|
+
if (event?.kind === "phase" && TERMINAL_ORACLE_JOB_STATUSES.includes(event.status)) return event;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
114
127
|
/**
|
|
115
128
|
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
116
129
|
* @param {TJob} job
|
|
@@ -32,6 +32,7 @@ export interface OracleJobSummaryOptions {
|
|
|
32
32
|
queuePosition?: OracleQueuePositionLike;
|
|
33
33
|
artifactsPath?: string;
|
|
34
34
|
responsePreview?: string;
|
|
35
|
+
responseAvailable?: boolean;
|
|
35
36
|
includeLatestEvent?: boolean;
|
|
36
37
|
includeWorkerLogPath?: boolean;
|
|
37
38
|
}
|
|
@@ -53,7 +54,7 @@ export declare function formatOracleLifecycleEvent(event: OracleJobLifecycleEven
|
|
|
53
54
|
export declare function formatOracleJobSummary(job: OracleJobSummaryLike, options?: OracleJobSummaryOptions): string;
|
|
54
55
|
export declare function buildOracleWakeupNotificationContent(
|
|
55
56
|
job: OracleJobSummaryLike,
|
|
56
|
-
options?: { responsePath?: string; artifactsPath?: string },
|
|
57
|
+
options?: { responsePath?: string; responseAvailable?: boolean; artifactsPath?: string },
|
|
57
58
|
): string;
|
|
58
59
|
export declare function formatOracleSubmitResponse(job: OracleJobSummaryLike & { promptPath: string; archivePath: string }, options: OracleSubmitResponseOptions): string;
|
|
59
60
|
export declare function buildOracleStatusText(counts: OracleStatusCounts): string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Usage: Imported by commands, tools, poller, and extension startup/status code to keep detached-oracle messaging consistent.
|
|
5
5
|
// Invariants/Assumptions: Job summaries read from durable job state, and lifecycle event trails are bounded and already normalized by shared lifecycle helpers.
|
|
6
6
|
|
|
7
|
-
import { getLatestOracleJobLifecycleEvent } from "./job-lifecycle-helpers.mjs";
|
|
7
|
+
import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent } from "./job-lifecycle-helpers.mjs";
|
|
8
8
|
|
|
9
9
|
/** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryLike} OracleJobSummaryLike */
|
|
10
10
|
/** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryOptions} OracleJobSummaryOptions */
|
|
@@ -52,7 +52,22 @@ function formatAutoPrunedArchiveMessage(autoPrunedPrefixes) {
|
|
|
52
52
|
* @returns {string}
|
|
53
53
|
*/
|
|
54
54
|
export function formatOracleJobSummary(job, options = {}) {
|
|
55
|
-
const
|
|
55
|
+
const latestEventRaw = options.includeLatestEvent === false ? undefined : getLatestOracleJobLifecycleEvent(job);
|
|
56
|
+
const terminalEventRaw = getLatestOracleTerminalLifecycleEvent(job);
|
|
57
|
+
const latestEvent = formatOracleLifecycleEvent(latestEventRaw);
|
|
58
|
+
const terminalEvent = formatOracleLifecycleEvent(terminalEventRaw);
|
|
59
|
+
const sameEvent = Boolean(
|
|
60
|
+
latestEventRaw && terminalEventRaw &&
|
|
61
|
+
latestEventRaw.at === terminalEventRaw.at &&
|
|
62
|
+
latestEventRaw.source === terminalEventRaw.source &&
|
|
63
|
+
latestEventRaw.kind === terminalEventRaw.kind &&
|
|
64
|
+
latestEventRaw.message === terminalEventRaw.message,
|
|
65
|
+
);
|
|
66
|
+
const responseLine = options.responseAvailable === true
|
|
67
|
+
? job.responsePath ? `response: ${job.responsePath}` : undefined
|
|
68
|
+
: job.responsePath ? "response: unavailable yet" : undefined;
|
|
69
|
+
const responseFormatLine = options.responseAvailable === true && job.responseFormat ? `response-format: ${job.responseFormat}` : undefined;
|
|
70
|
+
const latestEventLabel = latestEventRaw?.kind === "wakeup" ? "wakeup-event" : "last-event";
|
|
56
71
|
return [
|
|
57
72
|
`job: ${job.id}`,
|
|
58
73
|
`status: ${job.status}`,
|
|
@@ -67,14 +82,15 @@ export function formatOracleJobSummary(job, options = {}) {
|
|
|
67
82
|
job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
|
|
68
83
|
job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
|
|
69
84
|
job.conversationId ? `conversation: ${job.conversationId}` : undefined,
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
responseLine,
|
|
86
|
+
responseFormatLine,
|
|
72
87
|
options.artifactsPath ? `artifacts: ${options.artifactsPath}` : undefined,
|
|
73
88
|
typeof job.artifactFailureCount === "number" ? `artifact-failures: ${job.artifactFailureCount}` : undefined,
|
|
74
89
|
options.includeWorkerLogPath === false ? undefined : job.workerLogPath ? `worker-log: ${job.workerLogPath}` : undefined,
|
|
75
90
|
job.lastCleanupAt ? `last-cleanup: ${job.lastCleanupAt}` : undefined,
|
|
76
91
|
job.cleanupWarnings?.length ? `cleanup-warnings: ${job.cleanupWarnings.join(" | ")}` : undefined,
|
|
77
|
-
|
|
92
|
+
terminalEvent ? `terminal-event: ${terminalEvent}` : undefined,
|
|
93
|
+
latestEvent && !sameEvent ? `${latestEventLabel}: ${latestEvent}` : undefined,
|
|
78
94
|
job.error ? `error: ${job.error}` : undefined,
|
|
79
95
|
options.responsePreview ? "" : undefined,
|
|
80
96
|
options.responsePreview,
|
|
@@ -85,19 +101,21 @@ export function formatOracleJobSummary(job, options = {}) {
|
|
|
85
101
|
|
|
86
102
|
/**
|
|
87
103
|
* @param {OracleJobSummaryLike} job
|
|
88
|
-
* @param {{ responsePath?: string; artifactsPath?: string }} [options]
|
|
104
|
+
* @param {{ responsePath?: string; responseAvailable?: boolean; artifactsPath?: string }} [options]
|
|
89
105
|
* @returns {string}
|
|
90
106
|
*/
|
|
91
107
|
export function buildOracleWakeupNotificationContent(job, options = {}) {
|
|
92
|
-
const
|
|
108
|
+
const responseLine = options.responseAvailable === false
|
|
109
|
+
? "Response file: unavailable yet"
|
|
110
|
+
: `Response file: ${options.responsePath ?? job.responsePath ?? `response unavailable for ${job.id}`}`;
|
|
93
111
|
const artifactsPath = options.artifactsPath ?? `artifacts unavailable for ${job.id}`;
|
|
94
112
|
return [
|
|
95
113
|
`Oracle job ${job.id} is ${job.status}.`,
|
|
96
|
-
`Use
|
|
97
|
-
|
|
114
|
+
`Use /oracle-read ${job.id} to inspect the saved response preview. /oracle-status ${job.id} still shows saved job metadata. Agent callers can use oracle_read({ jobId: "${job.id}" }) if they need tool output in the current turn.`,
|
|
115
|
+
responseLine,
|
|
98
116
|
`Artifacts: ${artifactsPath}`,
|
|
99
117
|
formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
|
|
100
|
-
job.error ? `Error: ${job.error}` : "After
|
|
118
|
+
job.error ? `Error: ${job.error}` : "After opening the saved result, continue from the oracle output.",
|
|
101
119
|
].filter(Boolean).join("\n");
|
|
102
120
|
}
|
|
103
121
|
|
|
@@ -20,7 +20,23 @@ if (!rawConfig) {
|
|
|
20
20
|
process.exit(1);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const parsedConfigPayload = JSON.parse(rawConfig);
|
|
24
|
+
const config =
|
|
25
|
+
parsedConfigPayload &&
|
|
26
|
+
typeof parsedConfigPayload === "object" &&
|
|
27
|
+
!Array.isArray(parsedConfigPayload) &&
|
|
28
|
+
Object.hasOwn(parsedConfigPayload, "config")
|
|
29
|
+
? parsedConfigPayload.config
|
|
30
|
+
: parsedConfigPayload;
|
|
31
|
+
const configLoad =
|
|
32
|
+
parsedConfigPayload &&
|
|
33
|
+
typeof parsedConfigPayload === "object" &&
|
|
34
|
+
!Array.isArray(parsedConfigPayload) &&
|
|
35
|
+
Object.hasOwn(parsedConfigPayload, "configLoad") &&
|
|
36
|
+
parsedConfigPayload.configLoad &&
|
|
37
|
+
typeof parsedConfigPayload.configLoad === "object"
|
|
38
|
+
? parsedConfigPayload.configLoad
|
|
39
|
+
: undefined;
|
|
24
40
|
const CHATGPT_LABELS = {
|
|
25
41
|
composer: "Chat with ChatGPT",
|
|
26
42
|
addFiles: "Add files and more",
|
|
@@ -65,6 +81,34 @@ function authSessionName() {
|
|
|
65
81
|
return `${config.browser.sessionPrefix}-auth`;
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
function effectiveAuthConfigPath() {
|
|
85
|
+
return typeof configLoad?.effectiveAuthConfigPath === "string" && configLoad.effectiveAuthConfigPath
|
|
86
|
+
? configLoad.effectiveAuthConfigPath
|
|
87
|
+
: join(process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"), "extensions", "oracle.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function authConfigRemediation() {
|
|
91
|
+
if (typeof configLoad?.remediation === "string" && configLoad.remediation) return configLoad.remediation;
|
|
92
|
+
if (typeof configLoad?.projectConfigPath === "string" && configLoad.projectConfigPath && configLoad.projectConfigExists) {
|
|
93
|
+
return (
|
|
94
|
+
`Set auth.chromeProfile / auth.chromeCookiePath in ${effectiveAuthConfigPath()}. ` +
|
|
95
|
+
`Project overrides are also read from ${configLoad.projectConfigPath}, but auth.* is loaded from ${effectiveAuthConfigPath()}.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return `Set auth.chromeProfile / auth.chromeCookiePath in ${effectiveAuthConfigPath()}.`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function authConfigSummary() {
|
|
102
|
+
if (typeof configLoad?.summary === "string" && configLoad.summary) return configLoad.summary;
|
|
103
|
+
const agentDirSuffix = typeof configLoad?.agentDir === "string" && configLoad.agentDir ? ` (agent dir: ${configLoad.agentDir})` : "";
|
|
104
|
+
const createSuffix = configLoad?.agentConfigExists === false ? " [create this file to override auth.*]" : "";
|
|
105
|
+
const lines = [`Effective oracle auth config: ${effectiveAuthConfigPath()}${agentDirSuffix}${createSuffix}`];
|
|
106
|
+
if (typeof configLoad?.projectConfigPath === "string" && configLoad.projectConfigPath && configLoad.projectConfigExists) {
|
|
107
|
+
lines.push(`Project oracle config also loaded: ${configLoad.projectConfigPath} (auth.* still comes from ${effectiveAuthConfigPath()}).`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
68
112
|
function sleep(ms) {
|
|
69
113
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
70
114
|
}
|
|
@@ -457,7 +501,7 @@ async function readSourceCookies() {
|
|
|
457
501
|
|
|
458
502
|
if (!hasSessionToken) {
|
|
459
503
|
throw new Error(
|
|
460
|
-
`No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that Chrome profile
|
|
504
|
+
`No ChatGPT session-token cookies were found in ${cookieSourceLabel()}. Make sure ChatGPT is logged into that Chrome profile. ${authConfigRemediation()}`,
|
|
461
505
|
);
|
|
462
506
|
}
|
|
463
507
|
|
|
@@ -731,7 +775,7 @@ async function waitForImportedAuthReady() {
|
|
|
731
775
|
}
|
|
732
776
|
if (classification.state === "login_required") {
|
|
733
777
|
await captureDiagnostics("login-required");
|
|
734
|
-
throw new Error(classification.message);
|
|
778
|
+
throw new Error(`${classification.message} ${authConfigRemediation()}`);
|
|
735
779
|
}
|
|
736
780
|
await sleep(config.auth.pollMs);
|
|
737
781
|
}
|
|
@@ -751,6 +795,7 @@ async function run() {
|
|
|
751
795
|
await log(
|
|
752
796
|
`Config summary: session=${authSessionName()} seedProfileDir=${profilePlan.targetDir} stagingProfileDir=${profilePlan.stagingDir} executable=${config.browser.executablePath || "(default)"} source=${cookieSourceLabel()}`,
|
|
753
797
|
);
|
|
798
|
+
await log(authConfigSummary());
|
|
754
799
|
const cookies = await readSourceCookies();
|
|
755
800
|
await prepareStagedProfile(profilePlan);
|
|
756
801
|
await launchTargetBrowser();
|
|
@@ -783,7 +828,7 @@ async function run() {
|
|
|
783
828
|
|
|
784
829
|
run().catch((error) => {
|
|
785
830
|
process.stderr.write(
|
|
786
|
-
`${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.`,
|
|
831
|
+
`${error instanceof Error ? error.message : String(error)}\nSee ${LOG_PATH} and diagnostics in ${DIAGNOSTICS_DIR || "(oracle-auth diagnostics dir unavailable)"}\n${authConfigSummary()}\nIf needed, ensure the configured real Chrome profile is already logged into ChatGPT and grant macOS Keychain access when prompted.`,
|
|
787
832
|
);
|
|
788
833
|
process.exit(1);
|
|
789
834
|
});
|
|
@@ -69,7 +69,7 @@ export function classifyChatAuthPage(args) {
|
|
|
69
69
|
const hasAddFiles = args.snapshot.includes(`button "${addFilesLabel}"`);
|
|
70
70
|
const hasModelControl =
|
|
71
71
|
args.snapshot.includes('button "Model selector"') ||
|
|
72
|
-
/button "(Instant|Thinking|Pro)(
|
|
72
|
+
/button "(?:Instant|(?:(?:Light|Standard|Extended|Heavy) )?Thinking|(?:(?:Light|Standard|Extended|Heavy) )?Pro)(?:, click to remove)?"/i.test(args.snapshot);
|
|
73
73
|
|
|
74
74
|
const challengePatterns = [
|
|
75
75
|
/just a moment/i,
|
|
@@ -26,6 +26,11 @@ const MODEL_FAMILY_PREFIX = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
const AUTO_SWITCH_LABEL = "Auto-switch to Thinking";
|
|
29
|
+
const THINKING_EFFORT_COMBOBOX_LABEL = "Thinking effort";
|
|
30
|
+
const PRO_THINKING_EFFORT_COMBOBOX_LABEL = "Pro thinking effort";
|
|
31
|
+
const THINKING_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?thinking(?:, click to remove)?$/i;
|
|
32
|
+
const PRO_CHIP_PATTERN = /^(?:(light|standard|extended|heavy)\s+)?pro(?:, click to remove)?$/i;
|
|
33
|
+
const MODEL_FAMILY_CONTROL_KINDS = new Set(["button", "radio", "menuitemradio"]);
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* @param {string | undefined} url
|
|
@@ -99,26 +104,101 @@ export function requestedEffortLabel(selection) {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
102
|
-
* @param {string}
|
|
103
|
-
* @
|
|
104
|
-
* @returns {boolean}
|
|
107
|
+
* @param {string | undefined} label
|
|
108
|
+
* @returns {string}
|
|
105
109
|
*/
|
|
110
|
+
function normalizeChipLabel(label) {
|
|
111
|
+
return normalizeText(label).replace(/, click to remove$/i, "").trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseComposerChipSelection(label) {
|
|
115
|
+
const normalized = normalizeChipLabel(label).toLowerCase();
|
|
116
|
+
if (!normalized) return undefined;
|
|
117
|
+
|
|
118
|
+
const thinkingMatch = normalized.match(THINKING_CHIP_PATTERN);
|
|
119
|
+
if (thinkingMatch) {
|
|
120
|
+
return {
|
|
121
|
+
modelFamily: /** @type {OracleUiModelFamily} */ ("thinking"),
|
|
122
|
+
effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ ((thinkingMatch[1] || "standard").toLowerCase()),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const proMatch = normalized.match(PRO_CHIP_PATTERN);
|
|
127
|
+
if (proMatch) {
|
|
128
|
+
return {
|
|
129
|
+
modelFamily: /** @type {OracleUiModelFamily} */ ("pro"),
|
|
130
|
+
effort: /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiEffort} */ ((proMatch[1] || "standard").toLowerCase()),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function detectComposerChipSelection(entries) {
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (entry.disabled || entry.kind !== "button") continue;
|
|
140
|
+
const selection = parseComposerChipSelection(entry.label);
|
|
141
|
+
if (selection) return selection;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function checkedState(entry) {
|
|
147
|
+
const line = String(entry?.line || "");
|
|
148
|
+
if (/\bchecked=true\b/.test(line) || /\bselected\b/.test(line)) return true;
|
|
149
|
+
if (/\bchecked=false\b/.test(line)) return false;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function detectSelectedModelFamily(entries) {
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (entry.disabled || !MODEL_FAMILY_CONTROL_KINDS.has(entry.kind || "") || checkedState(entry) !== true) continue;
|
|
156
|
+
for (const family of /** @type {OracleUiModelFamily[]} */ (["instant", "thinking", "pro"])) {
|
|
157
|
+
if (matchesModelFamilyLabel(entry.label, family)) return family;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hasProEffortCombobox = entries.some(
|
|
162
|
+
(entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === PRO_THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase(),
|
|
163
|
+
);
|
|
164
|
+
if (hasProEffortCombobox) return "pro";
|
|
165
|
+
|
|
166
|
+
const hasAutoSwitchControl = entries.some((entry) => {
|
|
167
|
+
if (entry.disabled || !["button", "switch"].includes(entry.kind || "")) return false;
|
|
168
|
+
const controlText = normalizeText([entry.label, entry.value, entry.line].filter(Boolean).join(" "));
|
|
169
|
+
return controlText.toLowerCase().includes(AUTO_SWITCH_LABEL.toLowerCase());
|
|
170
|
+
});
|
|
171
|
+
if (hasAutoSwitchControl) return "instant";
|
|
172
|
+
|
|
173
|
+
const hasThinkingEffortCombobox = entries.some(
|
|
174
|
+
(entry) => !entry.disabled && entry.kind === "combobox" && normalizeText(entry.label).toLowerCase() === THINKING_EFFORT_COMBOBOX_LABEL.toLowerCase(),
|
|
175
|
+
);
|
|
176
|
+
if (hasThinkingEffortCombobox) return "thinking";
|
|
177
|
+
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function selectionMatchesChipSelection(selection, chipSelection) {
|
|
182
|
+
if (!chipSelection || chipSelection.modelFamily !== selection.modelFamily) return false;
|
|
183
|
+
if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
|
|
184
|
+
return chipSelection.effort === (selection.effort || "standard");
|
|
185
|
+
}
|
|
186
|
+
return selection.autoSwitchToThinking !== true;
|
|
187
|
+
}
|
|
188
|
+
|
|
106
189
|
export function effortSelectionVisible(snapshot, effortLabel) {
|
|
107
190
|
if (!effortLabel) return true;
|
|
108
191
|
/** @type {SnapshotEntry[]} */
|
|
109
192
|
const entries = parseSnapshotEntries(snapshot);
|
|
193
|
+
const normalizedEffort = effortLabel.toLowerCase();
|
|
110
194
|
return entries.some((entry) => {
|
|
111
195
|
if (entry.disabled) return false;
|
|
112
|
-
if (entry.kind === "combobox" && entry.value ===
|
|
196
|
+
if (entry.kind === "combobox" && normalizeText(entry.value).toLowerCase() === normalizedEffort) return true;
|
|
197
|
+
const chipSelection = entry.kind === "button" ? parseComposerChipSelection(entry.label) : undefined;
|
|
198
|
+
if (chipSelection?.effort === normalizedEffort) return true;
|
|
113
199
|
if (entry.kind !== "button") return false;
|
|
114
|
-
const label =
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
label === normalizedEffort ||
|
|
118
|
-
label === `${normalizedEffort} thinking` ||
|
|
119
|
-
label === `${normalizedEffort}, click to remove` ||
|
|
120
|
-
label === `${normalizedEffort} thinking, click to remove`
|
|
121
|
-
);
|
|
200
|
+
const label = normalizeChipLabel(entry.label).toLowerCase();
|
|
201
|
+
return label === normalizedEffort || label === `${normalizedEffort} thinking` || label === `${normalizedEffort} pro`;
|
|
122
202
|
});
|
|
123
203
|
}
|
|
124
204
|
|
|
@@ -166,7 +246,9 @@ export function autoSwitchToThinkingSelectionVisible(snapshot) {
|
|
|
166
246
|
if (!controlText.toLowerCase().includes(AUTO_SWITCH_LABEL.toLowerCase())) continue;
|
|
167
247
|
foundControl = true;
|
|
168
248
|
|
|
169
|
-
if (/\
|
|
249
|
+
if (/\bchecked=true\b/i.test(String(entry.line || ""))) return true;
|
|
250
|
+
if (/\bchecked=false\b/i.test(String(entry.line || ""))) return false;
|
|
251
|
+
if (/\b(?:selected|enabled|on|active)\b/i.test(controlText)) return true;
|
|
170
252
|
if (/\b(?:unchecked|not checked|disabled|off)\b/i.test(controlText)) return false;
|
|
171
253
|
if (typeof entry.label === "string" && /click to remove/i.test(entry.label)) return true;
|
|
172
254
|
}
|
|
@@ -181,16 +263,9 @@ export function autoSwitchToThinkingSelectionVisible(snapshot) {
|
|
|
181
263
|
*/
|
|
182
264
|
export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
|
|
183
265
|
if (!snapshotStronglyMatchesRequestedModel(snapshot, selection)) return false;
|
|
184
|
-
|
|
185
|
-
if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
|
|
186
|
-
const effortLabel = requestedEffortLabel(selection);
|
|
187
|
-
if (effortLabel && !effortSelectionVisible(snapshot, effortLabel)) return false;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
266
|
if (selection.modelFamily === "instant" && selection.autoSwitchToThinking) {
|
|
191
267
|
return autoSwitchToThinkingSelectionVisible(snapshot) === true;
|
|
192
268
|
}
|
|
193
|
-
|
|
194
269
|
return true;
|
|
195
270
|
}
|
|
196
271
|
|
|
@@ -202,23 +277,19 @@ export function snapshotCanSafelySkipModelConfiguration(snapshot, selection) {
|
|
|
202
277
|
export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
|
|
203
278
|
/** @type {SnapshotEntry[]} */
|
|
204
279
|
const entries = parseSnapshotEntries(snapshot);
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
280
|
+
const chipSelection = detectComposerChipSelection(entries);
|
|
281
|
+
if (chipSelection) return selectionMatchesChipSelection(selection, chipSelection);
|
|
207
282
|
|
|
208
|
-
const
|
|
209
|
-
|
|
283
|
+
const selectedModelFamily = detectSelectedModelFamily(entries);
|
|
284
|
+
if (!selectedModelFamily || selectedModelFamily !== selection.modelFamily) return false;
|
|
210
285
|
|
|
211
286
|
if (selection.modelFamily === "thinking" || selection.modelFamily === "pro") {
|
|
212
|
-
|
|
213
|
-
if (effortSelectionVisible(snapshot, effortLabel)) return true;
|
|
214
|
-
return !configurationUiVisible;
|
|
287
|
+
return effortSelectionVisible(snapshot, requestedEffortLabel(selection));
|
|
215
288
|
}
|
|
216
289
|
|
|
217
290
|
if (selection.modelFamily === "instant") {
|
|
218
291
|
const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
|
|
219
|
-
if (selection.autoSwitchToThinking)
|
|
220
|
-
return autoSwitchState === true || (!configurationUiVisible && autoSwitchState === undefined);
|
|
221
|
-
}
|
|
292
|
+
if (selection.autoSwitchToThinking) return autoSwitchState === true;
|
|
222
293
|
return autoSwitchState !== true;
|
|
223
294
|
}
|
|
224
295
|
|
|
@@ -233,24 +304,18 @@ export function snapshotStronglyMatchesRequestedModel(snapshot, selection) {
|
|
|
233
304
|
export function snapshotWeaklyMatchesRequestedModel(snapshot, selection) {
|
|
234
305
|
/** @type {SnapshotEntry[]} */
|
|
235
306
|
const entries = parseSnapshotEntries(snapshot);
|
|
236
|
-
const
|
|
307
|
+
const chipSelection = detectComposerChipSelection(entries);
|
|
308
|
+
if (chipSelection) return selectionMatchesChipSelection(selection, chipSelection);
|
|
237
309
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!familyMatched) return false;
|
|
243
|
-
|
|
244
|
-
if (selection.modelFamily === "pro") {
|
|
245
|
-
return !thinkingChipVisible(snapshot);
|
|
246
|
-
}
|
|
310
|
+
const selectedModelFamily = detectSelectedModelFamily(entries);
|
|
311
|
+
if (!selectedModelFamily || selectedModelFamily !== selection.modelFamily) return false;
|
|
247
312
|
|
|
248
313
|
if (selection.modelFamily === "instant") {
|
|
249
314
|
const autoSwitchState = autoSwitchToThinkingSelectionVisible(snapshot);
|
|
250
315
|
return selection.autoSwitchToThinking ? autoSwitchState !== false : autoSwitchState !== true;
|
|
251
316
|
}
|
|
252
317
|
|
|
253
|
-
return
|
|
318
|
+
return true;
|
|
254
319
|
}
|
|
255
320
|
|
|
256
321
|
/**
|
|
@@ -679,8 +679,17 @@ function findLastEntry(snapshot, predicate) {
|
|
|
679
679
|
return undefined;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
-
function
|
|
683
|
-
return candidate.kind
|
|
682
|
+
function matchesModelFamilyControl(candidate, family) {
|
|
683
|
+
return ["button", "radio", "menuitemradio"].includes(candidate.kind || "") && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function matchesModelConfigurationOpener(candidate) {
|
|
687
|
+
if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
|
|
688
|
+
const label = String(candidate.label || "");
|
|
689
|
+
return candidate.label === "Model selector"
|
|
690
|
+
|| ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
|
|
691
|
+
|| /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
|
|
692
|
+
|| /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
|
|
684
693
|
}
|
|
685
694
|
|
|
686
695
|
function composerControlsVisible(snapshot) {
|
|
@@ -698,7 +707,7 @@ async function clickAutoSwitchToThinkingControl(job) {
|
|
|
698
707
|
const snapshot = await snapshotText(job);
|
|
699
708
|
const entry = findEntry(
|
|
700
709
|
snapshot,
|
|
701
|
-
(candidate) => candidate.kind
|
|
710
|
+
(candidate) => ["button", "switch"].includes(candidate.kind || "") && typeof candidate.label === "string" && candidate.label.startsWith(CHATGPT_LABELS.autoSwitchToThinking) && !candidate.disabled,
|
|
702
711
|
);
|
|
703
712
|
if (!entry) throw new Error(`Could not find ${CHATGPT_LABELS.autoSwitchToThinking} control`);
|
|
704
713
|
await clickRef(job, entry.ref);
|
|
@@ -780,7 +789,7 @@ function classifyChatPage({ job, url, snapshot, body, probe }) {
|
|
|
780
789
|
const onAuthPath = typeof url === "string" && url.includes("/auth/");
|
|
781
790
|
const hasComposer = snapshot.includes(`textbox "${CHATGPT_LABELS.composer}"`);
|
|
782
791
|
const hasAddFiles = snapshot.includes(`button "${CHATGPT_LABELS.addFiles}"`);
|
|
783
|
-
const hasModelControl = snapshot.includes('button "Model selector"') || /button "(Instant|Thinking|Pro)(
|
|
792
|
+
const hasModelControl = snapshot.includes('button "Model selector"') || /button "(?:Instant|(?:(?:Light|Standard|Extended|Heavy) )?Thinking|(?:(?:Light|Standard|Extended|Heavy) )?Pro)(?:, click to remove)?"/i.test(snapshot);
|
|
784
793
|
|
|
785
794
|
if (probe?.status === 401 || probe?.status === 403) {
|
|
786
795
|
return { state: "login_required", message: "ChatGPT login is required. Run /oracle-auth." };
|
|
@@ -978,15 +987,10 @@ async function clickSend(job) {
|
|
|
978
987
|
}
|
|
979
988
|
|
|
980
989
|
async function openModelConfiguration(job) {
|
|
981
|
-
const openerPredicates = [
|
|
982
|
-
(candidate) => candidate.kind === "button" && candidate.label === "Model selector" && !candidate.disabled,
|
|
983
|
-
(candidate) => candidate.kind === "button" && ["Instant", "Thinking", "Pro"].includes(candidate.label || "") && !candidate.disabled,
|
|
984
|
-
];
|
|
985
|
-
|
|
986
990
|
const initialSnapshot = await snapshotText(job);
|
|
987
991
|
if (snapshotHasModelConfigurationUi(initialSnapshot)) return initialSnapshot;
|
|
988
992
|
|
|
989
|
-
for (const predicate of
|
|
993
|
+
for (const predicate of [matchesModelConfigurationOpener]) {
|
|
990
994
|
const snapshot = await snapshotText(job);
|
|
991
995
|
const entry = findEntry(snapshot, predicate);
|
|
992
996
|
if (!entry) continue;
|
|
@@ -1071,11 +1075,11 @@ async function configureModel(job) {
|
|
|
1071
1075
|
let verificationSnapshot = familySnapshot;
|
|
1072
1076
|
|
|
1073
1077
|
const alreadyConfiguredInUi = snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection);
|
|
1074
|
-
let familyEntry = findEntry(familySnapshot, (candidate) =>
|
|
1078
|
+
let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyControl(candidate, job.selection.modelFamily));
|
|
1075
1079
|
if (alreadyConfiguredInUi) {
|
|
1076
1080
|
await log("Model configuration UI opened with requested settings already selected");
|
|
1077
1081
|
} else if (!familyEntry) {
|
|
1078
|
-
throw new Error(`Could not find model family
|
|
1082
|
+
throw new Error(`Could not find model family control for ${job.selection.modelFamily}`);
|
|
1079
1083
|
}
|
|
1080
1084
|
|
|
1081
1085
|
if (!alreadyConfiguredInUi && familyEntry) {
|
|
@@ -1083,7 +1087,7 @@ async function configureModel(job) {
|
|
|
1083
1087
|
await agentBrowser(job, "wait", "800");
|
|
1084
1088
|
familySnapshot = await snapshotText(job);
|
|
1085
1089
|
verificationSnapshot = familySnapshot;
|
|
1086
|
-
familyEntry = findEntry(familySnapshot, (candidate) =>
|
|
1090
|
+
familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyControl(candidate, job.selection.modelFamily));
|
|
1087
1091
|
if (!familyEntry && !snapshotStronglyMatchesRequestedModel(familySnapshot, job.selection)) {
|
|
1088
1092
|
throw new Error(`Requested model family did not remain selected: ${job.selection.modelFamily}`);
|
|
1089
1093
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,15 +52,19 @@
|
|
|
52
52
|
"prepublishOnly": "npm run verify:oracle"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@sinclair/typebox": "^0.34.49",
|
|
56
55
|
"@steipete/sweet-cookie": "^0.2.0"
|
|
57
56
|
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
59
|
+
"@sinclair/typebox": "*"
|
|
60
|
+
},
|
|
58
61
|
"overrides": {
|
|
59
62
|
"basic-ftp": "^5.2.2"
|
|
60
63
|
},
|
|
61
64
|
"devDependencies": {
|
|
62
65
|
"@mariozechner/pi-ai": "^0.65.2",
|
|
63
66
|
"@mariozechner/pi-coding-agent": "^0.65.2",
|
|
67
|
+
"@sinclair/typebox": "^0.34.49",
|
|
64
68
|
"@types/node": "^24.6.0",
|
|
65
69
|
"esbuild": "^0.27.0",
|
|
66
70
|
"tsx": "^4.20.6",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Continue an earlier oracle job in the same ChatGPT thread
|
|
3
|
+
---
|
|
4
|
+
You are preparing an `/oracle-followup` job.
|
|
5
|
+
|
|
6
|
+
Do not answer the user's request directly yet.
|
|
7
|
+
|
|
8
|
+
Required workflow:
|
|
9
|
+
1. Call `oracle_preflight` immediately.
|
|
10
|
+
2. If `oracle_preflight` reports `ready: false`, stop before any expensive prep. Do not read files, search the codebase, or prepare archive inputs first. If the blocker is an auth seed or stale-auth issue that `oracle_auth` can repair, call `oracle_auth` once, rerun `oracle_preflight`, and continue only if it becomes `ready: true`. Otherwise stop immediately and report the blocking issue plus the suggested next step.
|
|
11
|
+
3. Parse the user request as `<job-id> <follow-up request>`.
|
|
12
|
+
4. If the request does not include both a prior oracle job id and a follow-up request, stop and report: `Usage: /oracle-followup <job-id> <request>. Find the job id in the earlier oracle response or via /oracle-status.`
|
|
13
|
+
5. Treat the parsed job id as `followUpJobId` for `oracle_submit`.
|
|
14
|
+
6. Understand whether the follow-up request is explicitly narrow or genuinely broad.
|
|
15
|
+
7. Gather enough repo context to choose archive inputs and write a strong oracle prompt. Bias toward context-rich submissions when they fit within the 250 MB archive ceiling.
|
|
16
|
+
8. If the follow-up request is explicit and narrow, start from the directly relevant area but still include nearby files, tests, docs, configs, and adjacent modules when they may improve answer quality. Keep the archive tightly minimal only when the user explicitly asks for that, privacy/sensitivity requires it, or size pressure forces it.
|
|
17
|
+
9. If the follow-up request is broad, architectural, release-oriented, or otherwise repo-wide, gather broader context and usually archive `.`.
|
|
18
|
+
10. Choose archive inputs for the follow-up oracle job.
|
|
19
|
+
11. Craft a concise but complete follow-up prompt for ChatGPT web.
|
|
20
|
+
12. Call `oracle_submit` with the prompt, exact archive inputs, and the parsed `followUpJobId`.
|
|
21
|
+
13. Stop immediately after dispatching the oracle job.
|
|
22
|
+
|
|
23
|
+
Oracle model (`oracle_submit`):
|
|
24
|
+
- To choose a specific ChatGPT model, pass **`preset`** with one of the allowed ids from the canonical preset registry.
|
|
25
|
+
- Matching human-readable preset labels and common hyphen/space variants are also accepted and normalized automatically, but prefer canonical ids when readily available.
|
|
26
|
+
- **Or** omit **`preset`** entirely to use the configured default model (from oracle config).
|
|
27
|
+
- **`preset`** is the only model-selection parameter on `oracle_submit`. Do not pass `modelFamily`, `effort`, or `autoSwitchToThinking`.
|
|
28
|
+
- If unsure, omit **`preset`** and use the configured default. Ask the user about model choice only when they explicitly want model control or the choice would materially change the result.
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
- Use `oracle_preflight` before any expensive `/oracle-followup` preparation so missing persisted-session or local auth/config blockers fail fast.
|
|
32
|
+
- If the immediately preceding oracle run for this follow-up failed because ChatGPT login is required, the worker said to rerun `/oracle-auth`, or stale auth clearly blocked execution, call `oracle_auth` once and then retry the follow-up submission once. Do not loop auth refreshes.
|
|
33
|
+
- This prompt exists so normal users can continue the same ChatGPT thread without manually constructing `followUpJobId` tool calls.
|
|
34
|
+
- Always include an archive. Do not submit without context files.
|
|
35
|
+
- By default, prefer context-rich archives up to the 250 MB ceiling because more relevant context is usually better than less. For broad or unclear follow-up requests, 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.
|
|
36
|
+
- Only limit file selection if the user explicitly requests a tight archive, if privacy/sensitivity requires it, or if the archive would otherwise exceed the size limit after exclusions/pruning.
|
|
37
|
+
- For targeted follow-ups, still include directly related surrounding files, tests, docs, configs, and adjacent modules when they may improve answer quality. Do not default to a one-file archive just because the user mentioned one file, one function, or one stack trace.
|
|
38
|
+
- Do not keep exploring once you already have enough context to submit well.
|
|
39
|
+
- 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.
|
|
40
|
+
- 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.
|
|
41
|
+
- If a submitted oracle job later fails because upload is rejected, retry with a smaller archive in this order: (1) remove the largest obviously irrelevant/generated content, (2) if still too large, include modified files plus adjacent files plus directly relevant subtrees, (3) if still too large, explain the cut or ask the user.
|
|
42
|
+
- Prefer the configured default (omit **`preset`**) unless the task clearly needs a different model or the user explicitly asked for one; then choose a canonical **`preset`** id.
|
|
43
|
+
- If `oracle_submit` itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error. Do not retry automatically.
|
|
44
|
+
- If `oracle_submit` returns a queued job instead of an immediately dispatched one, treat that as success and end your turn exactly the same way.
|
|
45
|
+
- After `oracle_submit` returns, end your turn. Do not keep working while the oracle runs.
|
|
46
|
+
|
|
47
|
+
User request:
|
|
48
|
+
$@
|