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.
@@ -0,0 +1,355 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+ import { isAbsolute, join, normalize } from "node:path";
6
+
7
+ export const MODEL_FAMILIES = ["instant", "thinking", "pro"] as const;
8
+ export type OracleModelFamily = (typeof MODEL_FAMILIES)[number];
9
+
10
+ export const EFFORTS = ["light", "standard", "extended", "heavy"] as const;
11
+ export type OracleEffort = (typeof EFFORTS)[number];
12
+
13
+ export const BROWSER_RUN_MODES = ["headless", "headed"] as const;
14
+ export type OracleBrowserRunMode = (typeof BROWSER_RUN_MODES)[number];
15
+
16
+ export const CLONE_STRATEGIES = ["apfs-clone", "copy"] as const;
17
+ export type OracleCloneStrategy = (typeof CLONE_STRATEGIES)[number];
18
+
19
+ const PRO_EFFORTS = ["standard", "extended"] as const satisfies readonly OracleEffort[];
20
+ const ALLOWED_CHATGPT_ORIGINS = new Set(["https://chatgpt.com", "https://chat.openai.com"]);
21
+ const PROJECT_OVERRIDE_KEYS = new Set(["defaults", "worker", "poller", "artifacts"]);
22
+ const DEFAULT_MAC_CHROME_EXECUTABLE = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
23
+ const DEFAULT_MAC_CHROME_USER_DATA_DIR = join(homedir(), "Library", "Application Support", "Google", "Chrome");
24
+
25
+ export interface OracleConfig {
26
+ defaults: {
27
+ modelFamily: OracleModelFamily;
28
+ effort: OracleEffort;
29
+ autoSwitchToThinking: boolean;
30
+ };
31
+ browser: {
32
+ sessionPrefix: string;
33
+ authSeedProfileDir: string;
34
+ runtimeProfilesDir: string;
35
+ maxConcurrentJobs: number;
36
+ cloneStrategy: OracleCloneStrategy;
37
+ chatUrl: string;
38
+ authUrl: string;
39
+ runMode: OracleBrowserRunMode;
40
+ executablePath?: string;
41
+ userAgent?: string;
42
+ args: string[];
43
+ };
44
+ auth: {
45
+ pollMs: number;
46
+ bootstrapTimeoutMs: number;
47
+ chromeProfile: string;
48
+ chromeCookiePath?: string;
49
+ };
50
+ worker: {
51
+ pollMs: number;
52
+ completionTimeoutMs: number;
53
+ };
54
+ poller: {
55
+ intervalMs: number;
56
+ };
57
+ artifacts: {
58
+ capture: boolean;
59
+ };
60
+ }
61
+
62
+ function detectDefaultChromeExecutablePath(): string | undefined {
63
+ return existsSync(DEFAULT_MAC_CHROME_EXECUTABLE) ? DEFAULT_MAC_CHROME_EXECUTABLE : undefined;
64
+ }
65
+
66
+ function detectDefaultChromeUserAgent(executablePath: string | undefined): string | undefined {
67
+ if (!executablePath) return undefined;
68
+ try {
69
+ const versionOutput = execFileSync(executablePath, ["--version"], { encoding: "utf8" }).trim();
70
+ const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
71
+ if (!versionMatch) return undefined;
72
+ return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${versionMatch[1]} Safari/537.36`;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function detectDefaultChromeProfileName(): string {
79
+ const localStatePath = join(DEFAULT_MAC_CHROME_USER_DATA_DIR, "Local State");
80
+ if (!existsSync(localStatePath)) return "Default";
81
+ try {
82
+ const localState = JSON.parse(readFileSync(localStatePath, "utf8")) as { profile?: { last_used?: string } };
83
+ const lastUsed = localState?.profile?.last_used;
84
+ return typeof lastUsed === "string" && lastUsed.trim() ? lastUsed.trim() : "Default";
85
+ } catch {
86
+ return "Default";
87
+ }
88
+ }
89
+
90
+ const detectedChromeExecutablePath = detectDefaultChromeExecutablePath();
91
+ const detectedChromeUserAgent = detectDefaultChromeUserAgent(detectedChromeExecutablePath);
92
+ const agentExtensionsDir = join(getAgentDir(), "extensions");
93
+ const detectedChromeProfileName = detectDefaultChromeProfileName();
94
+
95
+ export const DEFAULT_CONFIG: OracleConfig = {
96
+ defaults: {
97
+ modelFamily: "pro",
98
+ effort: "extended",
99
+ autoSwitchToThinking: false,
100
+ },
101
+ browser: {
102
+ sessionPrefix: "oracle",
103
+ authSeedProfileDir: join(agentExtensionsDir, "oracle-auth-seed-profile"),
104
+ runtimeProfilesDir: join(agentExtensionsDir, "oracle-runtime-profiles"),
105
+ maxConcurrentJobs: 2,
106
+ cloneStrategy: "apfs-clone",
107
+ chatUrl: "https://chatgpt.com/",
108
+ authUrl: "https://chatgpt.com/auth/login",
109
+ runMode: "headless",
110
+ executablePath: detectedChromeExecutablePath,
111
+ userAgent: detectedChromeUserAgent,
112
+ args: ["--disable-blink-features=AutomationControlled"],
113
+ },
114
+ auth: {
115
+ pollMs: 1000,
116
+ bootstrapTimeoutMs: 10 * 60 * 1000,
117
+ chromeProfile: detectedChromeProfileName,
118
+ chromeCookiePath: undefined,
119
+ },
120
+ worker: {
121
+ pollMs: 5000,
122
+ completionTimeoutMs: 90 * 60 * 1000,
123
+ },
124
+ poller: {
125
+ intervalMs: 5000,
126
+ },
127
+ artifacts: {
128
+ capture: true,
129
+ },
130
+ };
131
+
132
+ function isObject(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ function deepMerge<T>(base: T, override: unknown): T {
137
+ if (!isObject(base) || !isObject(override)) {
138
+ return (override as T) ?? base;
139
+ }
140
+
141
+ const result: Record<string, unknown> = { ...base };
142
+ for (const [key, value] of Object.entries(override)) {
143
+ const existing = result[key];
144
+ result[key] = isObject(existing) && isObject(value) ? deepMerge(existing, value) : value;
145
+ }
146
+ return result as T;
147
+ }
148
+
149
+ function readJson(path: string): unknown {
150
+ if (!existsSync(path)) return undefined;
151
+ try {
152
+ return JSON.parse(readFileSync(path, "utf8"));
153
+ } catch (error) {
154
+ throw new Error(`Failed to parse oracle config ${path}: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ }
157
+
158
+ function expectObject(value: unknown, path: string): Record<string, unknown> {
159
+ if (!isObject(value)) {
160
+ throw new Error(`Invalid oracle config: ${path} must be an object`);
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function expectString(value: unknown, path: string): string {
166
+ if (typeof value !== "string" || value.trim() === "") {
167
+ throw new Error(`Invalid oracle config: ${path} must be a non-empty string`);
168
+ }
169
+ return value;
170
+ }
171
+
172
+ function expandHomePath(value: string): string {
173
+ if (value === "~") return homedir();
174
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
175
+ return value;
176
+ }
177
+
178
+ function expectAbsoluteNormalizedPath(value: unknown, path: string): string {
179
+ const expanded = expandHomePath(expectString(value, path));
180
+ if (!isAbsolute(expanded)) {
181
+ throw new Error(`Invalid oracle config: ${path} must be an absolute path`);
182
+ }
183
+ return normalize(expanded);
184
+ }
185
+
186
+ function expectSafeProfilePath(pathValue: string, path: string): string {
187
+ if (pathValue === "/" || pathValue === homedir()) {
188
+ throw new Error(`Invalid oracle config: ${path} points to an unsafe directory`);
189
+ }
190
+ if (pathValue === DEFAULT_MAC_CHROME_USER_DATA_DIR || pathValue.startsWith(`${DEFAULT_MAC_CHROME_USER_DATA_DIR}/`)) {
191
+ throw new Error(`Invalid oracle config: ${path} must not point into the real Chrome user-data directory`);
192
+ }
193
+ return pathValue;
194
+ }
195
+
196
+ function expectSafeProfileDir(value: unknown, path: string): string {
197
+ return expectSafeProfilePath(expectAbsoluteNormalizedPath(value, path), path);
198
+ }
199
+
200
+ function expectBoolean(value: unknown, path: string): boolean {
201
+ if (typeof value !== "boolean") {
202
+ throw new Error(`Invalid oracle config: ${path} must be a boolean`);
203
+ }
204
+ return value;
205
+ }
206
+
207
+ function expectOptionalString(value: unknown, path: string): string | undefined {
208
+ if (value === undefined) return undefined;
209
+ return expectString(value, path);
210
+ }
211
+
212
+ function expectOptionalAbsoluteNormalizedPath(value: unknown, path: string): string | undefined {
213
+ if (value === undefined) return undefined;
214
+ return expectAbsoluteNormalizedPath(value, path);
215
+ }
216
+
217
+ function expectStringArray(value: unknown, path: string): string[] {
218
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim() === "")) {
219
+ throw new Error(`Invalid oracle config: ${path} must be an array of non-empty strings`);
220
+ }
221
+ return value;
222
+ }
223
+
224
+ function expectInteger(value: unknown, path: string, minimum: number, maximum?: number): number {
225
+ if (typeof value !== "number" || !Number.isInteger(value) || value < minimum || (maximum !== undefined && value > maximum)) {
226
+ const range = maximum === undefined ? `>= ${minimum}` : `between ${minimum} and ${maximum}`;
227
+ throw new Error(`Invalid oracle config: ${path} must be an integer ${range}`);
228
+ }
229
+ return value;
230
+ }
231
+
232
+ function expectEnum<T extends readonly string[]>(value: unknown, path: string, allowed: T): T[number] {
233
+ if (typeof value !== "string" || !allowed.includes(value)) {
234
+ throw new Error(`Invalid oracle config: ${path} must be one of ${allowed.join(", ")}`);
235
+ }
236
+ return value as T[number];
237
+ }
238
+
239
+ function expectChatGptUrl(value: unknown, path: string): string {
240
+ const url = expectString(value, path);
241
+ try {
242
+ const parsed = new URL(url);
243
+ if (parsed.protocol !== "https:" || !ALLOWED_CHATGPT_ORIGINS.has(parsed.origin)) {
244
+ throw new Error("unsupported origin");
245
+ }
246
+ return parsed.toString();
247
+ } catch {
248
+ throw new Error(`Invalid oracle config: ${path} must be an https ChatGPT URL on ${Array.from(ALLOWED_CHATGPT_ORIGINS).join(", ")}`);
249
+ }
250
+ }
251
+
252
+ function filterProjectConfig(value: unknown): unknown {
253
+ if (value === undefined) return undefined;
254
+ const root = expectObject(value, "project config root");
255
+ for (const key of Object.keys(root)) {
256
+ if (!PROJECT_OVERRIDE_KEYS.has(key)) {
257
+ throw new Error(`Invalid oracle project config: ${key} cannot be overridden at the project level`);
258
+ }
259
+ }
260
+ return root;
261
+ }
262
+
263
+ function normalizeLegacyBrowserConfig(root: Record<string, unknown>): Record<string, unknown> {
264
+ const browser = expectObject(root.browser, "browser");
265
+ const legacySessionName = browser.sessionName;
266
+ const legacyProfileDir = browser.profileDir;
267
+ if (legacySessionName !== undefined && browser.sessionPrefix === undefined) {
268
+ browser.sessionPrefix = legacySessionName;
269
+ }
270
+ if (legacyProfileDir !== undefined && browser.authSeedProfileDir === undefined) {
271
+ browser.authSeedProfileDir = legacyProfileDir;
272
+ }
273
+ if (browser.runtimeProfilesDir === undefined) {
274
+ const baseProfileDir = typeof browser.authSeedProfileDir === "string" ? expandHomePath(browser.authSeedProfileDir) : DEFAULT_CONFIG.browser.authSeedProfileDir;
275
+ browser.runtimeProfilesDir = join(normalize(baseProfileDir), "..", "oracle-runtime-profiles");
276
+ }
277
+ if (browser.maxConcurrentJobs === undefined) {
278
+ browser.maxConcurrentJobs = DEFAULT_CONFIG.browser.maxConcurrentJobs;
279
+ }
280
+ if (browser.cloneStrategy === undefined) {
281
+ browser.cloneStrategy = DEFAULT_CONFIG.browser.cloneStrategy;
282
+ }
283
+ root.browser = browser;
284
+ return root;
285
+ }
286
+
287
+ function validateOracleConfig(value: unknown): OracleConfig {
288
+ const root = normalizeLegacyBrowserConfig(expectObject(value, "root"));
289
+
290
+ const defaults = expectObject(root.defaults, "defaults");
291
+ const modelFamily = expectEnum(defaults.modelFamily, "defaults.modelFamily", MODEL_FAMILIES);
292
+ const effort = expectEnum(defaults.effort, "defaults.effort", EFFORTS);
293
+ const autoSwitchToThinking = expectBoolean(defaults.autoSwitchToThinking, "defaults.autoSwitchToThinking");
294
+ if (modelFamily === "pro" && !PRO_EFFORTS.includes(effort)) {
295
+ throw new Error(`Invalid oracle config: defaults.effort must be one of ${PRO_EFFORTS.join(", ")} for pro`);
296
+ }
297
+ if (modelFamily !== "instant" && autoSwitchToThinking) {
298
+ throw new Error("Invalid oracle config: defaults.autoSwitchToThinking is only valid for instant");
299
+ }
300
+
301
+ const browser = expectObject(root.browser, "browser");
302
+ const auth = expectObject(root.auth, "auth");
303
+ const worker = expectObject(root.worker, "worker");
304
+ const poller = expectObject(root.poller, "poller");
305
+ const artifacts = expectObject(root.artifacts, "artifacts");
306
+
307
+ const authSeedProfileDir = expectSafeProfileDir(browser.authSeedProfileDir, "browser.authSeedProfileDir");
308
+ const runtimeProfilesDir = expectSafeProfileDir(browser.runtimeProfilesDir, "browser.runtimeProfilesDir");
309
+ if (runtimeProfilesDir === authSeedProfileDir || runtimeProfilesDir.startsWith(`${authSeedProfileDir}/`)) {
310
+ throw new Error("Invalid oracle config: browser.runtimeProfilesDir must be separate from browser.authSeedProfileDir");
311
+ }
312
+
313
+ return {
314
+ defaults: {
315
+ modelFamily,
316
+ effort,
317
+ autoSwitchToThinking,
318
+ },
319
+ browser: {
320
+ sessionPrefix: expectString(browser.sessionPrefix, "browser.sessionPrefix"),
321
+ authSeedProfileDir,
322
+ runtimeProfilesDir,
323
+ maxConcurrentJobs: expectInteger(browser.maxConcurrentJobs, "browser.maxConcurrentJobs", 1, 32),
324
+ cloneStrategy: expectEnum(browser.cloneStrategy, "browser.cloneStrategy", CLONE_STRATEGIES),
325
+ chatUrl: expectChatGptUrl(browser.chatUrl, "browser.chatUrl"),
326
+ authUrl: expectChatGptUrl(browser.authUrl, "browser.authUrl"),
327
+ runMode: expectEnum(browser.runMode, "browser.runMode", BROWSER_RUN_MODES),
328
+ executablePath: expectOptionalAbsoluteNormalizedPath(browser.executablePath, "browser.executablePath"),
329
+ userAgent: expectOptionalString(browser.userAgent, "browser.userAgent"),
330
+ args: expectStringArray(browser.args, "browser.args"),
331
+ },
332
+ auth: {
333
+ pollMs: expectInteger(auth.pollMs, "auth.pollMs", 100),
334
+ bootstrapTimeoutMs: expectInteger(auth.bootstrapTimeoutMs, "auth.bootstrapTimeoutMs", 1000),
335
+ chromeProfile: expectString(auth.chromeProfile, "auth.chromeProfile"),
336
+ chromeCookiePath: expectOptionalAbsoluteNormalizedPath(auth.chromeCookiePath, "auth.chromeCookiePath"),
337
+ },
338
+ worker: {
339
+ pollMs: expectInteger(worker.pollMs, "worker.pollMs", 100),
340
+ completionTimeoutMs: expectInteger(worker.completionTimeoutMs, "worker.completionTimeoutMs", 1000),
341
+ },
342
+ poller: {
343
+ intervalMs: expectInteger(poller.intervalMs, "poller.intervalMs", 100),
344
+ },
345
+ artifacts: {
346
+ capture: expectBoolean(artifacts.capture, "artifacts.capture"),
347
+ },
348
+ };
349
+ }
350
+
351
+ export function loadOracleConfig(cwd: string): OracleConfig {
352
+ const globalConfig = readJson(join(getAgentDir(), "extensions", "oracle.json"));
353
+ const projectConfig = filterProjectConfig(readJson(join(cwd, ".pi", "extensions", "oracle.json")));
354
+ return validateOracleConfig(deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig));
355
+ }
@@ -0,0 +1,24 @@
1
+ export function buildOracleDispatchPrompt(request: string): string {
2
+ return [
3
+ "You are preparing an /oracle job.",
4
+ "",
5
+ "Do not answer the user's request directly yet.",
6
+ "",
7
+ "Required workflow:",
8
+ "1. Understand the request.",
9
+ "2. Gather repo context first by reading files and searching the codebase.",
10
+ "3. Select the exact relevant files/directories for the oracle archive.",
11
+ "4. Craft a concise but complete oracle prompt for ChatGPT web.",
12
+ "5. Call oracle_submit with the prompt and exact archive inputs.",
13
+ "6. Stop immediately after dispatching the oracle job.",
14
+ "",
15
+ "Rules:",
16
+ "- Always include an archive. Do not submit without context files.",
17
+ "- Keep the archive narrowly scoped and relevant.",
18
+ "- Prefer the configured default model/effort unless the task clearly needs something else.",
19
+ "- After oracle_submit returns, end your turn. Do not keep working while the oracle runs.",
20
+ "",
21
+ "User request:",
22
+ request,
23
+ ].join("\n");
24
+ }