pi-oracle 0.1.0 → 0.1.2
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/README.md +6 -0
- package/extensions/oracle/lib/commands.ts +8 -4
- package/extensions/oracle/lib/instructions.ts +2 -0
- package/extensions/oracle/lib/locks.ts +31 -0
- package/extensions/oracle/lib/tools.ts +15 -7
- package/extensions/oracle/worker/auth-bootstrap.mjs +32 -1
- package/extensions/oracle/worker/run-job.mjs +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,12 @@ An oracle job:
|
|
|
32
32
|
5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
|
|
33
33
|
6. wakes the originating `pi` session on completion
|
|
34
34
|
|
|
35
|
+
## Example
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
/oracle Invoke the Oracle to have it generate a thorough code review of the current pending changes. Include all modified files, and adjacent files, in the archive. Use the Pro Model with Extended effort.
|
|
39
|
+
```
|
|
40
|
+
|
|
35
41
|
## Why this exists
|
|
36
42
|
|
|
37
43
|
The goal is to get strong ChatGPT web-model answers without:
|
|
@@ -6,7 +6,7 @@ import { loadOracleConfig } from "./config.js";
|
|
|
6
6
|
import { buildOracleDispatchPrompt } from "./instructions.js";
|
|
7
7
|
import { cancelOracleJob, isActiveOracleJob, listJobsForCwd, readJob, reconcileStaleOracleJobs } from "./jobs.js";
|
|
8
8
|
import { refreshOracleStatus } from "./poller.js";
|
|
9
|
-
import { withGlobalReconcileLock } from "./locks.js";
|
|
9
|
+
import { isLockTimeoutError, withGlobalReconcileLock } from "./locks.js";
|
|
10
10
|
import { getProjectId } from "./runtime.js";
|
|
11
11
|
|
|
12
12
|
function summarizeJob(jobId: string): string {
|
|
@@ -45,9 +45,13 @@ function readScopedJob(jobId: string, cwd: string) {
|
|
|
45
45
|
|
|
46
46
|
async function runAuthBootstrap(authWorkerPath: string, cwd: string): Promise<string> {
|
|
47
47
|
const config = loadOracleConfig(cwd);
|
|
48
|
-
|
|
49
|
-
await
|
|
50
|
-
|
|
48
|
+
try {
|
|
49
|
+
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_auth", cwd }, async () => {
|
|
50
|
+
await reconcileStaleOracleJobs();
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
|
|
54
|
+
}
|
|
51
55
|
|
|
52
56
|
return await new Promise<string>((resolve, reject) => {
|
|
53
57
|
const child = spawn(process.execPath, [authWorkerPath, JSON.stringify(config)], {
|
|
@@ -16,6 +16,8 @@ export function buildOracleDispatchPrompt(request: string): string {
|
|
|
16
16
|
"- Always include an archive. Do not submit without context files.",
|
|
17
17
|
"- Keep the archive narrowly scoped and relevant.",
|
|
18
18
|
"- Prefer the configured default model/effort unless the task clearly needs something else.",
|
|
19
|
+
"- Only use autoSwitchToThinking with the instant model family.",
|
|
20
|
+
"- If oracle_submit fails, stop and report the error. Do not retry automatically.",
|
|
19
21
|
"- After oracle_submit returns, end your turn. Do not keep working while the oracle runs.",
|
|
20
22
|
"",
|
|
21
23
|
"User request:",
|
|
@@ -53,6 +53,36 @@ async function writeMetadata(path: string, metadata: unknown): Promise<void> {
|
|
|
53
53
|
await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function readLockProcessPid(path: string): number | undefined {
|
|
57
|
+
const metadataPath = join(path, "metadata.json");
|
|
58
|
+
if (!existsSync(metadataPath)) return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const metadata = JSON.parse(readFileSync(metadataPath, "utf8")) as { processPid?: unknown };
|
|
61
|
+
return typeof metadata.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
62
|
+
? metadata.processPid
|
|
63
|
+
: undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isProcessAlive(pid: number): boolean {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function maybeReclaimStaleLock(path: string): Promise<boolean> {
|
|
80
|
+
const processPid = readLockProcessPid(path);
|
|
81
|
+
if (!processPid || isProcessAlive(processPid)) return false;
|
|
82
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
export async function acquireLock(
|
|
57
87
|
kind: string,
|
|
58
88
|
key: string,
|
|
@@ -70,6 +100,7 @@ export async function acquireLock(
|
|
|
70
100
|
return { path };
|
|
71
101
|
} catch (error) {
|
|
72
102
|
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
|
|
103
|
+
if (await maybeReclaimStaleLock(path)) continue;
|
|
73
104
|
}
|
|
74
105
|
await sleep(POLL_MS);
|
|
75
106
|
}
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
6
6
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
|
-
import { withGlobalReconcileLock, withLock } from "./locks.js";
|
|
8
|
+
import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
|
|
9
9
|
import { loadOracleConfig, EFFORTS, MODEL_FAMILIES, type OracleEffort, type OracleModelFamily } from "./config.js";
|
|
10
10
|
import {
|
|
11
11
|
cancelOracleJob,
|
|
@@ -37,9 +37,11 @@ const ORACLE_SUBMIT_PARAMS = Type.Object({
|
|
|
37
37
|
description: "Exact project-relative files/directories to include in the oracle archive.",
|
|
38
38
|
minItems: 1,
|
|
39
39
|
}),
|
|
40
|
-
modelFamily: Type.Optional(StringEnum(MODEL_FAMILIES)),
|
|
41
|
-
effort: Type.Optional(StringEnum(EFFORTS)),
|
|
42
|
-
autoSwitchToThinking: Type.Optional(
|
|
40
|
+
modelFamily: Type.Optional(StringEnum(MODEL_FAMILIES, { description: "ChatGPT model family: instant, thinking, or pro." })),
|
|
41
|
+
effort: Type.Optional(StringEnum(EFFORTS, { description: "Reasoning effort. Use only values supported by the chosen model family." })),
|
|
42
|
+
autoSwitchToThinking: Type.Optional(
|
|
43
|
+
Type.Boolean({ description: "Only valid when modelFamily is instant. Omit for thinking and pro." }),
|
|
44
|
+
),
|
|
43
45
|
followUpJobId: Type.Optional(Type.String({ description: "Earlier oracle job id whose chat thread should be continued." })),
|
|
44
46
|
});
|
|
45
47
|
|
|
@@ -209,6 +211,8 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
209
211
|
"Gather context before calling oracle_submit.",
|
|
210
212
|
"Always include a narrowly scoped archive of exact relevant files/directories.",
|
|
211
213
|
"Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
|
|
214
|
+
"If oracle_submit fails, stop and report the error instead of retrying automatically.",
|
|
215
|
+
"Only use autoSwitchToThinking with modelFamily=instant.",
|
|
212
216
|
],
|
|
213
217
|
parameters: ORACLE_SUBMIT_PARAMS,
|
|
214
218
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -224,9 +228,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
224
228
|
const followUp = resolveFollowUp(params.followUpJobId, ctx.cwd);
|
|
225
229
|
|
|
226
230
|
validateSubmissionOptions(params, modelFamily, effort, autoSwitchToThinking);
|
|
227
|
-
|
|
228
|
-
await
|
|
229
|
-
|
|
231
|
+
try {
|
|
232
|
+
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: ctx.cwd }, async () => {
|
|
233
|
+
await reconcileStaleOracleJobs();
|
|
234
|
+
});
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
|
|
237
|
+
}
|
|
230
238
|
|
|
231
239
|
const jobId = randomUUID();
|
|
232
240
|
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.tar.zst`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { appendFile, chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { appendFile, chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { getCookies } from "@steipete/sweet-cookie";
|
|
@@ -49,6 +49,36 @@ function leaseKey(kind, key) {
|
|
|
49
49
|
return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
async function readLockProcessPid(path) {
|
|
53
|
+
const metadataPath = join(path, "metadata.json");
|
|
54
|
+
if (!existsSync(metadataPath)) return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
57
|
+
return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
58
|
+
? metadata.processPid
|
|
59
|
+
: undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isProcessAlive(pid) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, 0);
|
|
68
|
+
return true;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function maybeReclaimStaleLock(path) {
|
|
76
|
+
const processPid = await readLockProcessPid(path);
|
|
77
|
+
if (!processPid || isProcessAlive(processPid)) return false;
|
|
78
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
|
|
53
83
|
const path = join(LOCKS_DIR, leaseKey(kind, key));
|
|
54
84
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -62,6 +92,7 @@ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
|
|
|
62
92
|
return path;
|
|
63
93
|
} catch (error) {
|
|
64
94
|
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
|
|
95
|
+
if (await maybeReclaimStaleLock(path)) continue;
|
|
65
96
|
}
|
|
66
97
|
await sleep(200);
|
|
67
98
|
}
|
|
@@ -53,6 +53,36 @@ function leaseKey(kind, key) {
|
|
|
53
53
|
return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
async function readLockProcessPid(path) {
|
|
57
|
+
const metadataPath = join(path, "metadata.json");
|
|
58
|
+
if (!existsSync(metadataPath)) return undefined;
|
|
59
|
+
try {
|
|
60
|
+
const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
61
|
+
return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
62
|
+
? metadata.processPid
|
|
63
|
+
: undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isProcessAlive(pid) {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, 0);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function maybeReclaimStaleLock(path) {
|
|
80
|
+
const processPid = await readLockProcessPid(path);
|
|
81
|
+
if (!processPid || isProcessAlive(processPid)) return false;
|
|
82
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
|
|
57
87
|
const path = join(LOCKS_DIR, leaseKey(kind, key));
|
|
58
88
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -66,6 +96,7 @@ async function acquireLock(kind, key, metadata, timeoutMs = 30_000) {
|
|
|
66
96
|
return path;
|
|
67
97
|
} catch (error) {
|
|
68
98
|
if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
|
|
99
|
+
if (await maybeReclaimStaleLock(path)) continue;
|
|
69
100
|
}
|
|
70
101
|
await sleep(200);
|
|
71
102
|
}
|