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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
6
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { withGlobalReconcileLock, withLock } from "./locks.js";
|
|
9
|
+
import { loadOracleConfig, EFFORTS, MODEL_FAMILIES, type OracleEffort, type OracleModelFamily } from "./config.js";
|
|
10
|
+
import {
|
|
11
|
+
cancelOracleJob,
|
|
12
|
+
createJob,
|
|
13
|
+
getSessionFile,
|
|
14
|
+
isActiveOracleJob,
|
|
15
|
+
readJob,
|
|
16
|
+
reconcileStaleOracleJobs,
|
|
17
|
+
resolveArchiveInputs,
|
|
18
|
+
sha256File,
|
|
19
|
+
spawnWorker,
|
|
20
|
+
updateJob,
|
|
21
|
+
withJobPhase,
|
|
22
|
+
} from "./jobs.js";
|
|
23
|
+
import { refreshOracleStatus } from "./poller.js";
|
|
24
|
+
import {
|
|
25
|
+
acquireConversationLease,
|
|
26
|
+
acquireRuntimeLease,
|
|
27
|
+
allocateRuntime,
|
|
28
|
+
cleanupRuntimeArtifacts,
|
|
29
|
+
getProjectId,
|
|
30
|
+
getSessionId,
|
|
31
|
+
parseConversationId,
|
|
32
|
+
} from "./runtime.js";
|
|
33
|
+
|
|
34
|
+
const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
35
|
+
prompt: Type.String({ description: "Prompt text to send to ChatGPT web." }),
|
|
36
|
+
files: Type.Array(Type.String({ description: "Project-relative file or directory path to include in the archive." }), {
|
|
37
|
+
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
38
|
+
minItems: 1,
|
|
39
|
+
}),
|
|
40
|
+
modelFamily: Type.Optional(StringEnum(MODEL_FAMILIES)),
|
|
41
|
+
effort: Type.Optional(StringEnum(EFFORTS)),
|
|
42
|
+
autoSwitchToThinking: Type.Optional(Type.Boolean()),
|
|
43
|
+
followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ORACLE_READ_PARAMS = Type.Object({
|
|
47
|
+
jobId: Type.String({ description: "Oracle job id." }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const ORACLE_CANCEL_PARAMS = Type.Object({
|
|
51
|
+
jobId: Type.String({ description: "Oracle job id." }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
|
|
55
|
+
instant: [],
|
|
56
|
+
thinking: ["light", "standard", "extended", "heavy"],
|
|
57
|
+
pro: ["standard", "extended"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
|
|
61
|
+
const entries = resolveArchiveInputs(cwd, files);
|
|
62
|
+
const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
|
|
63
|
+
const listPath = join(listDir, "files.list");
|
|
64
|
+
await writeFile(listPath, Buffer.from(`${entries.map((entry) => entry.relative).join("\0")}\0`), { mode: 0o600 });
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const { spawn } = await import("node:child_process");
|
|
68
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
69
|
+
const tar = spawn("tar", ["--null", "-cf", "-", "-T", listPath], {
|
|
70
|
+
cwd,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
const zstd = spawn("zstd", ["-19", "-T0", "-o", archivePath], {
|
|
74
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let settled = false;
|
|
79
|
+
let tarCode: number | null | undefined;
|
|
80
|
+
let zstdCode: number | null | undefined;
|
|
81
|
+
|
|
82
|
+
const finish = (error?: Error) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
if (error) {
|
|
85
|
+
settled = true;
|
|
86
|
+
tar.kill("SIGTERM");
|
|
87
|
+
zstd.kill("SIGTERM");
|
|
88
|
+
rejectPromise(error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (tarCode === undefined || zstdCode === undefined) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
if (tarCode === 0 && zstdCode === 0) resolvePromise();
|
|
94
|
+
else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
tar.stderr.on("data", (data) => {
|
|
98
|
+
stderr += String(data);
|
|
99
|
+
});
|
|
100
|
+
zstd.stderr.on("data", (data) => {
|
|
101
|
+
stderr += String(data);
|
|
102
|
+
});
|
|
103
|
+
tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
104
|
+
zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
105
|
+
tar.on("close", (code) => {
|
|
106
|
+
tarCode = code;
|
|
107
|
+
finish();
|
|
108
|
+
});
|
|
109
|
+
zstd.on("close", (code) => {
|
|
110
|
+
zstdCode = code;
|
|
111
|
+
finish();
|
|
112
|
+
});
|
|
113
|
+
tar.stdout.pipe(zstd.stdin);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const archiveStat = await stat(archivePath);
|
|
117
|
+
const maxBytes = 250 * 1024 * 1024;
|
|
118
|
+
if (archiveStat.size >= maxBytes) {
|
|
119
|
+
throw new Error(`Oracle archive exceeds ChatGPT upload limit: ${archiveStat.size} bytes`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sha256File(archivePath);
|
|
123
|
+
} finally {
|
|
124
|
+
await rm(listDir, { recursive: true, force: true }).catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateSubmissionOptions(
|
|
129
|
+
params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
|
|
130
|
+
modelFamily: OracleModelFamily,
|
|
131
|
+
effort: OracleEffort | undefined,
|
|
132
|
+
autoSwitchToThinking: boolean,
|
|
133
|
+
): void {
|
|
134
|
+
if (modelFamily === "instant" && params.effort !== undefined) {
|
|
135
|
+
throw new Error("Instant model family does not support effort selection");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (effort && !VALID_EFFORTS[modelFamily].includes(effort)) {
|
|
139
|
+
throw new Error(`Invalid effort for ${modelFamily}: ${effort}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (modelFamily !== "instant" && params.autoSwitchToThinking === true) {
|
|
143
|
+
throw new Error("autoSwitchToThinking is only valid for the instant model family");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (modelFamily !== "instant" && autoSwitchToThinking) {
|
|
147
|
+
throw new Error(`autoSwitchToThinking cannot be enabled for ${modelFamily}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveFollowUp(previousJobId: string | undefined, cwd: string): {
|
|
152
|
+
followUpToJobId?: string;
|
|
153
|
+
chatUrl?: string;
|
|
154
|
+
conversationId?: string;
|
|
155
|
+
} {
|
|
156
|
+
if (!previousJobId) return {};
|
|
157
|
+
const previous = readJob(previousJobId);
|
|
158
|
+
if (!previous) {
|
|
159
|
+
throw new Error(`Follow-up oracle job not found: ${previousJobId}`);
|
|
160
|
+
}
|
|
161
|
+
if (previous.projectId !== getProjectId(cwd)) {
|
|
162
|
+
throw new Error(`Follow-up oracle job ${previousJobId} belongs to a different project`);
|
|
163
|
+
}
|
|
164
|
+
if (previous.status !== "complete") {
|
|
165
|
+
throw new Error(`Follow-up oracle job ${previousJobId} is not complete`);
|
|
166
|
+
}
|
|
167
|
+
if (!previous.chatUrl) {
|
|
168
|
+
throw new Error(`Follow-up oracle job ${previousJobId} has no persisted chat URL`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
followUpToJobId: previous.id,
|
|
172
|
+
chatUrl: previous.chatUrl,
|
|
173
|
+
conversationId: previous.conversationId || parseConversationId(previous.chatUrl),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function redactJobDetails(job: NonNullable<ReturnType<typeof readJob>>) {
|
|
178
|
+
return {
|
|
179
|
+
id: job.id,
|
|
180
|
+
status: job.status,
|
|
181
|
+
phase: job.phase,
|
|
182
|
+
projectId: job.projectId,
|
|
183
|
+
sessionId: job.sessionId,
|
|
184
|
+
createdAt: job.createdAt,
|
|
185
|
+
submittedAt: job.submittedAt,
|
|
186
|
+
completedAt: job.completedAt,
|
|
187
|
+
followUpToJobId: job.followUpToJobId,
|
|
188
|
+
chatUrl: job.chatUrl,
|
|
189
|
+
conversationId: job.conversationId,
|
|
190
|
+
responsePath: job.responsePath,
|
|
191
|
+
responseFormat: job.responseFormat,
|
|
192
|
+
artifactPaths: job.artifactPaths,
|
|
193
|
+
artifactFailureCount: job.artifactFailureCount,
|
|
194
|
+
artifactsManifestPath: job.artifactsManifestPath,
|
|
195
|
+
archiveDeletedAfterUpload: job.archiveDeletedAfterUpload,
|
|
196
|
+
runtimeId: job.runtimeId,
|
|
197
|
+
error: job.error,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void {
|
|
202
|
+
pi.registerTool({
|
|
203
|
+
name: "oracle_submit",
|
|
204
|
+
label: "Oracle Submit",
|
|
205
|
+
description:
|
|
206
|
+
"Dispatch a background ChatGPT web oracle job after gathering context. Always pass a prompt and exact project-relative archive inputs.",
|
|
207
|
+
promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
|
|
208
|
+
promptGuidelines: [
|
|
209
|
+
"Gather context before calling oracle_submit.",
|
|
210
|
+
"Always include a narrowly scoped archive of exact relevant files/directories.",
|
|
211
|
+
"Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
|
|
212
|
+
],
|
|
213
|
+
parameters: ORACLE_SUBMIT_PARAMS,
|
|
214
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
215
|
+
const config = loadOracleConfig(ctx.cwd);
|
|
216
|
+
const originSessionFile = getSessionFile(ctx);
|
|
217
|
+
const projectId = getProjectId(ctx.cwd);
|
|
218
|
+
const sessionId = getSessionId(originSessionFile, projectId);
|
|
219
|
+
const modelFamily = params.modelFamily ?? config.defaults.modelFamily;
|
|
220
|
+
const requestedEffort = params.effort ?? config.defaults.effort;
|
|
221
|
+
const effort = modelFamily === "instant" ? undefined : requestedEffort;
|
|
222
|
+
const rawAutoSwitchToThinking = params.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking;
|
|
223
|
+
const autoSwitchToThinking = modelFamily === "instant" ? rawAutoSwitchToThinking : false;
|
|
224
|
+
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
225
|
+
|
|
226
|
+
validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
|
|
227
|
+
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
|
|
228
|
+
await reconcileStaleOracleJobs();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const jobId = randomUUID();
|
|
232
|
+
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
|
|
233
|
+
const runtime = allocateRuntime(config);
|
|
234
|
+
let job;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const archiveSha256 = await createArchive(ctx.cwd, params.files, tempArchivePath);
|
|
238
|
+
await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
|
|
239
|
+
await acquireRuntimeLease(config, {
|
|
240
|
+
jobId,
|
|
241
|
+
runtimeId: runtime.runtimeId,
|
|
242
|
+
runtimeSessionName: runtime.runtimeSessionName,
|
|
243
|
+
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
244
|
+
projectId,
|
|
245
|
+
sessionId,
|
|
246
|
+
createdAt: new Date().toISOString(),
|
|
247
|
+
});
|
|
248
|
+
if (followUp.conversationId) {
|
|
249
|
+
await acquireConversationLease({
|
|
250
|
+
jobId,
|
|
251
|
+
conversationId: followUp.conversationId,
|
|
252
|
+
projectId,
|
|
253
|
+
sessionId,
|
|
254
|
+
createdAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
job = await createJob(
|
|
258
|
+
jobId,
|
|
259
|
+
{
|
|
260
|
+
prompt: params.prompt,
|
|
261
|
+
files: params.files,
|
|
262
|
+
modelFamily,
|
|
263
|
+
effort,
|
|
264
|
+
autoSwitchToThinking,
|
|
265
|
+
followUpToJobId: followUp.followUpToJobId,
|
|
266
|
+
chatUrl: followUp.chatUrl,
|
|
267
|
+
requestSource: "tool",
|
|
268
|
+
},
|
|
269
|
+
ctx.cwd,
|
|
270
|
+
originSessionFile,
|
|
271
|
+
config,
|
|
272
|
+
runtime,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
await rename(tempArchivePath, job.archivePath);
|
|
276
|
+
const worker = await spawnWorker(workerPath, job.id);
|
|
277
|
+
await updateJob(job.id, (current) => ({
|
|
278
|
+
...current,
|
|
279
|
+
archiveSha256,
|
|
280
|
+
workerPid: worker.pid,
|
|
281
|
+
workerNonce: worker.nonce,
|
|
282
|
+
workerStartedAt: worker.startedAt,
|
|
283
|
+
}));
|
|
284
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
content: [
|
|
288
|
+
{
|
|
289
|
+
type: "text",
|
|
290
|
+
text: [
|
|
291
|
+
`Oracle job dispatched: ${job.id}`,
|
|
292
|
+
followUp.followUpToJobId ? `Follow-up to: ${followUp.followUpToJobId}` : undefined,
|
|
293
|
+
`Prompt: ${job.promptPath}`,
|
|
294
|
+
`Archive: ${job.archivePath}`,
|
|
295
|
+
`Response will be written to: ${job.responsePath}`,
|
|
296
|
+
"Stop now and wait for the oracle completion wake-up.",
|
|
297
|
+
]
|
|
298
|
+
.filter(Boolean)
|
|
299
|
+
.join("\n"),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
details: { jobId: job.id, archiveSha256, runtimeId: job.runtimeId, followUpToJobId: followUp.followUpToJobId },
|
|
303
|
+
};
|
|
304
|
+
} catch (error) {
|
|
305
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
306
|
+
if (job) {
|
|
307
|
+
const failedAt = new Date().toISOString();
|
|
308
|
+
await updateJob(job.id, (current) => ({
|
|
309
|
+
...current,
|
|
310
|
+
...withJobPhase("failed", {
|
|
311
|
+
status: "failed",
|
|
312
|
+
completedAt: failedAt,
|
|
313
|
+
error: message,
|
|
314
|
+
}, failedAt),
|
|
315
|
+
})).catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
await cleanupRuntimeArtifacts({
|
|
318
|
+
runtimeId: runtime.runtimeId,
|
|
319
|
+
runtimeProfileDir: runtime.runtimeProfileDir,
|
|
320
|
+
runtimeSessionName: runtime.runtimeSessionName,
|
|
321
|
+
conversationId: followUp.conversationId,
|
|
322
|
+
}).catch(() => undefined);
|
|
323
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
324
|
+
throw error;
|
|
325
|
+
} finally {
|
|
326
|
+
await rm(tempArchivePath, { force: true }).catch(() => undefined);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
pi.registerTool({
|
|
332
|
+
name: "oracle_read",
|
|
333
|
+
label: "Oracle Read",
|
|
334
|
+
description: "Read the status and outputs of a previously dispatched oracle job.",
|
|
335
|
+
parameters: ORACLE_READ_PARAMS,
|
|
336
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
337
|
+
const job = readJob(params.jobId);
|
|
338
|
+
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
339
|
+
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let responsePreview = "";
|
|
343
|
+
try {
|
|
344
|
+
const response = await import("node:fs/promises").then((fs) => fs.readFile(job.responsePath || "", "utf8"));
|
|
345
|
+
responsePreview = response.slice(0, 4000);
|
|
346
|
+
} catch {
|
|
347
|
+
responsePreview = "(response not available yet)";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
content: [
|
|
352
|
+
{
|
|
353
|
+
type: "text",
|
|
354
|
+
text: [
|
|
355
|
+
`job: ${job.id}`,
|
|
356
|
+
`status: ${job.status}`,
|
|
357
|
+
job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
|
|
358
|
+
job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
|
|
359
|
+
job.responsePath ? `response: ${job.responsePath}` : undefined,
|
|
360
|
+
job.responseFormat ? `response-format: ${job.responseFormat}` : undefined,
|
|
361
|
+
`artifacts: /tmp/oracle-${job.id}/artifacts`,
|
|
362
|
+
job.error ? `error: ${job.error}` : undefined,
|
|
363
|
+
"",
|
|
364
|
+
responsePreview,
|
|
365
|
+
]
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.join("\n"),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
details: { job: redactJobDetails(job) },
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
pi.registerTool({
|
|
376
|
+
name: "oracle_cancel",
|
|
377
|
+
label: "Oracle Cancel",
|
|
378
|
+
description: "Cancel an active oracle job.",
|
|
379
|
+
parameters: ORACLE_CANCEL_PARAMS,
|
|
380
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
381
|
+
const job = readJob(params.jobId);
|
|
382
|
+
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
383
|
+
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
384
|
+
}
|
|
385
|
+
if (!isActiveOracleJob(job)) {
|
|
386
|
+
return {
|
|
387
|
+
content: [{ type: "text", text: `Oracle job ${job.id} is not active (${job.status}).` }],
|
|
388
|
+
details: { job: redactJobDetails(job) },
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const cancelled = await cancelOracleJob(params.jobId);
|
|
393
|
+
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
394
|
+
return {
|
|
395
|
+
content: [{ type: "text", text: `Cancelled oracle job ${cancelled.id}.` }],
|
|
396
|
+
details: { job: redactJobDetails(cancelled) },
|
|
397
|
+
};
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|