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 +21 -0
- package/README.md +136 -0
- package/extensions/oracle/index.ts +46 -0
- package/extensions/oracle/lib/commands.ts +184 -0
- package/extensions/oracle/lib/config.ts +355 -0
- package/extensions/oracle/lib/instructions.ts +24 -0
- package/extensions/oracle/lib/jobs.ts +534 -0
- package/extensions/oracle/lib/locks.ts +164 -0
- package/extensions/oracle/lib/poller.ts +156 -0
- package/extensions/oracle/lib/runtime.ts +197 -0
- package/extensions/oracle/lib/tools.ts +400 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +861 -0
- package/extensions/oracle/worker/run-job.mjs +1386 -0
- package/package.json +44 -0
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
|
+
}
|