pi-oracle 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mitch Fultz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # pi-oracle
2
+
3
+ `pi-oracle` is a `pi` extension that lets the agent use ChatGPT.com as a long-running web oracle instead of using the API.
4
+
5
+ It exists for the hard cases where you want:
6
+ - the user’s real ChatGPT account
7
+ - web-model behavior instead of API usage
8
+ - large project-context uploads
9
+ - async background execution that wakes the originating `pi` session when done
10
+
11
+ Normal oracle jobs run in an isolated browser profile, not in the user’s active Chrome window.
12
+
13
+ Status: experimental public beta, validated primarily on macOS.
14
+
15
+ ## What it does
16
+
17
+ The extension adds:
18
+ - `/oracle <request>`
19
+ - `/oracle-auth`
20
+ - `/oracle-status [job-id]`
21
+ - `/oracle-cancel [job-id]`
22
+ - `/oracle-clean <job-id|all>`
23
+ - `oracle_submit`
24
+ - `oracle_read`
25
+ - `oracle_cancel`
26
+
27
+ An oracle job:
28
+ 1. gathers a project archive
29
+ 2. opens ChatGPT in an isolated runtime profile
30
+ 3. uploads the archive and sends the prompt
31
+ 4. waits in the background
32
+ 5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
33
+ 6. wakes the originating `pi` session on completion
34
+
35
+ ## Why this exists
36
+
37
+ The goal is to get strong ChatGPT web-model answers without:
38
+ - paying API costs for every long review
39
+ - blocking the agent for 10–90 minutes
40
+ - stealing focus from the user’s active browser session
41
+
42
+ ## Current scope
43
+
44
+ Currently validated for:
45
+ - macOS
46
+ - local Google Chrome
47
+ - local ChatGPT web login in Chrome
48
+ - isolated auth seed profile + per-job runtime profile clones
49
+ - concurrent jobs across different projects/sessions
50
+ - same-conversation exclusion for follow-ups
51
+ - plain-text responses
52
+ - artifact capture, including multi-artifact runs
53
+
54
+ Not promised yet:
55
+ - cross-platform support
56
+ - immunity to future ChatGPT UI changes
57
+ - fully polished partial-artifact terminal semantics
58
+
59
+ ## Requirements
60
+
61
+ - macOS
62
+ - Google Chrome installed
63
+ - ChatGPT already signed into a local Chrome profile
64
+ - `pi` installed
65
+ - `agent-browser` available on the machine
66
+ - `tar` and `zstd` available
67
+
68
+ ## Install
69
+
70
+ Planned npm install path once published:
71
+
72
+ ```bash
73
+ pi install npm:pi-oracle
74
+ ```
75
+
76
+ Current GitHub install path:
77
+
78
+ ```bash
79
+ pi install https://github.com/fitchmultz/pi-oracle
80
+ ```
81
+
82
+ ## First-time setup
83
+
84
+ 1. Make sure ChatGPT already works in your local Chrome profile.
85
+ 2. Configure the oracle if needed via `~/.pi/agent/extensions/oracle.json`.
86
+ 3. Run `/oracle-auth`.
87
+ 4. Run a tiny `/oracle` smoke test.
88
+
89
+ ## Configuration
90
+
91
+ Config files:
92
+ - global: `~/.pi/agent/extensions/oracle.json`
93
+ - project: `.pi/extensions/oracle.json`
94
+
95
+ Common settings:
96
+ - `browser.args`
97
+ - `browser.executablePath`
98
+ - `browser.authSeedProfileDir`
99
+ - `browser.runtimeProfilesDir`
100
+ - `auth.chromeProfile`
101
+ - `auth.chromeCookiePath`
102
+
103
+ Project config should only override safe, non-privileged settings.
104
+
105
+ Detailed design and maintainer docs:
106
+ - `docs/ORACLE_DESIGN.md`
107
+ - `docs/ORACLE_RECOVERY_DRILL.md`
108
+
109
+ ## Privacy / local data
110
+
111
+ This extension is local-first, but it does read and persist local data:
112
+ - `/oracle-auth` reads ChatGPT cookies from a local Chrome profile
113
+ - job archives are uploaded to ChatGPT.com
114
+ - responses and artifacts are written under `/tmp/oracle-<job-id>/`
115
+
116
+ Review the code and design docs before using it with sensitive material.
117
+
118
+ ## Validation helpers
119
+
120
+ ```bash
121
+ npm run check:oracle-extension
122
+ npm run sanity:oracle
123
+ npm run pack:check
124
+ ```
125
+
126
+ ## Beta caveats
127
+
128
+ The highest-risk areas to monitor are:
129
+ - ChatGPT UI drift
130
+ - auth/bootstrap drift
131
+ - artifact download behavior
132
+ - local environment assumptions
133
+
134
+ ## License
135
+
136
+ MIT. See `LICENSE`.
@@ -0,0 +1,46 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { loadOracleConfig } from "./lib/config.js";
5
+ import { registerOracleCommands } from "./lib/commands.js";
6
+ import { refreshOracleStatus, startPoller, stopPoller, stopPollerForSession } from "./lib/poller.js";
7
+ import { registerOracleTools } from "./lib/tools.js";
8
+
9
+ export default function oracleExtension(pi: ExtensionAPI) {
10
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
11
+ const workerPath = join(extensionDir, "worker", "run-job.mjs");
12
+ const authWorkerPath = join(extensionDir, "worker", "auth-bootstrap.mjs");
13
+
14
+ registerOracleCommands(pi, authWorkerPath);
15
+ registerOracleTools(pi, workerPath);
16
+
17
+ function startPollerForContext(previousSessionFile: string | undefined, ctx: ExtensionContext) {
18
+ stopPollerForSession(previousSessionFile, ctx.cwd);
19
+ try {
20
+ const config = loadOracleConfig(ctx.cwd);
21
+ startPoller(pi, ctx, config.poller.intervalMs);
22
+ refreshOracleStatus(ctx);
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ stopPoller(ctx);
26
+ ctx.ui.setStatus("oracle", ctx.ui.theme.fg("danger", "oracle: config error"));
27
+ ctx.ui.notify(message, "warning");
28
+ }
29
+ }
30
+
31
+ pi.on("session_start", async (_event, ctx) => {
32
+ startPollerForContext(undefined, ctx);
33
+ });
34
+
35
+ pi.on("session_switch", async (event, ctx) => {
36
+ startPollerForContext(event.previousSessionFile, ctx);
37
+ });
38
+
39
+ pi.on("session_fork", async (event, ctx) => {
40
+ startPollerForContext(event.previousSessionFile, ctx);
41
+ });
42
+
43
+ pi.on("session_shutdown", async (_event, ctx) => {
44
+ stopPoller(ctx);
45
+ });
46
+ }
@@ -0,0 +1,184 @@
1
+ import { spawn } from "node:child_process";
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
+ import { loadOracleConfig } from "./config.js";
6
+ import { buildOracleDispatchPrompt } from "./instructions.js";
7
+ import { cancelOracleJob, isActiveOracleJob, listJobsForCwd, readJob, reconcileStaleOracleJobs } from "./jobs.js";
8
+ import { refreshOracleStatus } from "./poller.js";
9
+ import { withGlobalReconcileLock } from "./locks.js";
10
+ import { getProjectId } from "./runtime.js";
11
+
12
+ function summarizeJob(jobId: string): string {
13
+ const job = readJob(jobId);
14
+ if (!job) return `Oracle job ${jobId} not found.`;
15
+
16
+ return [
17
+ `job: ${job.id}`,
18
+ `status: ${job.status}`,
19
+ `phase: ${job.phase}`,
20
+ `created: ${job.createdAt}`,
21
+ `project: ${job.projectId}`,
22
+ `session: ${job.sessionId}`,
23
+ job.completedAt ? `completed: ${job.completedAt}` : undefined,
24
+ job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
25
+ job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
26
+ job.conversationId ? `conversation: ${job.conversationId}` : undefined,
27
+ job.responsePath ? `response: ${job.responsePath}` : undefined,
28
+ job.responseFormat ? `response-format: ${job.responseFormat}` : undefined,
29
+ typeof job.artifactFailureCount === "number" ? `artifact-failures: ${job.artifactFailureCount}` : undefined,
30
+ job.error ? `error: ${job.error}` : undefined,
31
+ ]
32
+ .filter(Boolean)
33
+ .join("\n");
34
+ }
35
+
36
+ function getLatestJobId(cwd: string): string | undefined {
37
+ return listJobsForCwd(cwd)[0]?.id;
38
+ }
39
+
40
+ function readScopedJob(jobId: string, cwd: string) {
41
+ const job = readJob(jobId);
42
+ if (!job || job.projectId !== getProjectId(cwd)) return undefined;
43
+ return job;
44
+ }
45
+
46
+ async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<string> {
47
+ const config = loadOracleConfig(cwd);
48
+ await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
49
+ await reconcileStaleOracleJobs();
50
+ });
51
+
52
+ return await new Promise<string>((resolve, reject) => {
53
+ const child = spawn(process.execPath, [authWorkerPath, JSON.stringify(config)], {
54
+ cwd,
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ });
57
+
58
+ let stdout = "";
59
+ let stderr = "";
60
+ child.stdout.on("data", (data) => {
61
+ stdout += String(data);
62
+ });
63
+ child.stderr.on("data", (data) => {
64
+ stderr += String(data);
65
+ });
66
+ child.on("error", (error) => reject(error));
67
+ child.on("close", (code) => {
68
+ const message = stdout.trim() || stderr.trim() || "Oracle auth bootstrap finished with no output.";
69
+ if (code === 0) resolve(message);
70
+ else reject(new Error(message));
71
+ });
72
+ });
73
+ }
74
+
75
+ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string): void {
76
+ pi.registerCommand("oracle", {
77
+ description: "Ask the agent to prepare and dispatch a ChatGPT web oracle job",
78
+ handler: async (args, ctx) => {
79
+ const request = args.trim();
80
+ if (!request) {
81
+ ctx.ui.notify("Usage: /oracle <request>", "warning");
82
+ return;
83
+ }
84
+
85
+ const message = buildOracleDispatchPrompt(request);
86
+ if (ctx.isIdle()) {
87
+ pi.sendUserMessage(message);
88
+ } else {
89
+ pi.sendUserMessage(message, { deliverAs: "followUp" });
90
+ ctx.ui.notify("Queued oracle preparation as a follow-up", "info");
91
+ }
92
+ },
93
+ });
94
+
95
+ pi.registerCommand("oracle-auth", {
96
+ description: "Sync ChatGPT cookies from real Chrome into the oracle auth seed profile",
97
+ handler: async (_args, ctx) => {
98
+ ctx.ui.notify("Syncing ChatGPT cookies from real Chrome into the oracle auth seed profile…", "info");
99
+ try {
100
+ const result = await runAuthBootstrap(authWorkerPath, ctx.cwd);
101
+ ctx.ui.notify(result, "info");
102
+ } catch (error) {
103
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
104
+ }
105
+ },
106
+ });
107
+
108
+ pi.registerCommand("oracle-status", {
109
+ description: "Show oracle job status",
110
+ handler: async (args, ctx) => {
111
+ const explicitJobId = args.trim();
112
+ const jobId = explicitJobId || getLatestJobId(ctx.cwd);
113
+ if (!jobId) {
114
+ ctx.ui.notify("No oracle jobs found for this project", "info");
115
+ return;
116
+ }
117
+ if (explicitJobId && !readScopedJob(jobId, ctx.cwd)) {
118
+ ctx.ui.notify(`Oracle job ${jobId} was not found in this project`, "warning");
119
+ return;
120
+ }
121
+ ctx.ui.notify(summarizeJob(jobId), "info");
122
+ },
123
+ });
124
+
125
+ pi.registerCommand("oracle-cancel", {
126
+ description: "Cancel an active oracle job",
127
+ handler: async (args, ctx) => {
128
+ const explicitJobId = args.trim();
129
+ const jobId = explicitJobId || getLatestJobId(ctx.cwd);
130
+ if (!jobId) {
131
+ ctx.ui.notify("No oracle jobs found for this project", "info");
132
+ return;
133
+ }
134
+
135
+ const job = explicitJobId ? readScopedJob(jobId, ctx.cwd) : readJob(jobId);
136
+ if (!job) {
137
+ ctx.ui.notify(`Oracle job ${jobId} not found in this project`, "warning");
138
+ return;
139
+ }
140
+ if (!isActiveOracleJob(job)) {
141
+ ctx.ui.notify(`Oracle job ${jobId} is not active (${job.status})`, "info");
142
+ return;
143
+ }
144
+
145
+ const cancelled = await cancelOracleJob(jobId);
146
+ refreshOracleStatus(ctx);
147
+ ctx.ui.notify(`Cancelled oracle job ${cancelled.id}`, "info");
148
+ },
149
+ });
150
+
151
+ pi.registerCommand("oracle-clean", {
152
+ description: "Remove oracle temp files for a job or all project jobs",
153
+ handler: async (args, ctx: ExtensionCommandContext) => {
154
+ const target = args.trim();
155
+ if (!target) {
156
+ ctx.ui.notify("Usage: /oracle-clean <job-id|all>", "warning");
157
+ return;
158
+ }
159
+
160
+ const jobs = target === "all" ? listJobsForCwd(ctx.cwd) : [readScopedJob(target, ctx.cwd)].filter(Boolean);
161
+ if (jobs.length === 0) {
162
+ ctx.ui.notify("No matching oracle jobs found", "warning");
163
+ return;
164
+ }
165
+
166
+ const activeJobs = jobs.filter((job): job is NonNullable<typeof job> => Boolean(job && isActiveOracleJob(job)));
167
+ if (activeJobs.length > 0) {
168
+ ctx.ui.notify(
169
+ `Refusing to remove active oracle job${activeJobs.length === 1 ? "" : "s"}: ${activeJobs.map((job) => job.id).join(", ")}`,
170
+ "warning",
171
+ );
172
+ return;
173
+ }
174
+
175
+ for (const job of jobs) {
176
+ if (!job) continue;
177
+ await rm(join("/tmp", `oracle-${job.id}`), { recursive: true, force: true });
178
+ }
179
+
180
+ refreshOracleStatus(ctx);
181
+ ctx.ui.notify(`Removed ${jobs.length} oracle job director${jobs.length === 1 ? "y" : "ies"}`, "info");
182
+ },
183
+ });
184
+ }