pi-oracle 0.1.10 → 0.1.12

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 CHANGED
@@ -35,12 +35,15 @@ An oracle job:
35
35
  4. waits in the background
36
36
  5. persists the response and any artifacts under `/tmp/oracle-<job-id>/`
37
37
  - old terminal jobs are later pruned according to cleanup retention settings
38
+ - when directory inputs are expanded, project archives automatically skip common bulky generated caches and top-level build outputs such as `node_modules/`, `target/`, virtualenv caches, coverage outputs, and `dist/`/`build/`/`out/`, unless you explicitly pass those directories
39
+ - whole-repo archive defaults also skip obvious credentials/private data such as `.env` files, key material, credential dotfiles, local database files, and root `secrets/` directories unless you explicitly pass them
40
+ - if a whole-repo archive is still too large after default exclusions, submit automatically prunes the largest nested directories with generic generated-output names like `build/`, `dist/`, `out/`, `coverage/`, and `tmp/` outside obvious source roots like `src/` and `lib/`, and successful submissions report what was pruned
38
41
  6. wakes the originating `pi` session on completion
39
42
 
40
43
  ## Example
41
44
 
42
45
  ```text
43
- /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.
46
+ /oracle Invoke the Oracle to have it generate a thorough code review of the current pending changes. By default include the whole repo archive unless the request clearly needs a narrower scope. Use the Pro Model with Extended effort.
44
47
  ```
45
48
 
46
49
  ## Why this exists
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, join, posix } 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";
@@ -60,74 +60,366 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
60
60
  pro: ["standard", "extended"],
61
61
  };
62
62
 
63
- async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
64
- const entries = resolveArchiveInputs(cwd, files);
65
- const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
66
- const listPath = join(listDir, "files.list");
67
- await writeFile(listPath, Buffer.from(`${entries.map((entry) => entry.relative).join("\0")}\0`), { mode: 0o600 });
63
+ const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
64
+
65
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
66
+ ".git",
67
+ ".hg",
68
+ ".svn",
69
+ "node_modules",
70
+ "target",
71
+ ".venv",
72
+ "venv",
73
+ "__pycache__",
74
+ ".pytest_cache",
75
+ ".mypy_cache",
76
+ ".ruff_cache",
77
+ ".tox",
78
+ ".nox",
79
+ ".hypothesis",
80
+ ".next",
81
+ ".nuxt",
82
+ ".svelte-kit",
83
+ ".turbo",
84
+ ".parcel-cache",
85
+ ".cache",
86
+ ".gradle",
87
+ ".terraform",
88
+ "DerivedData",
89
+ ".build",
90
+ ".pnpm-store",
91
+ ".serverless",
92
+ ".aws-sam",
93
+ ]);
94
+ const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out", "secrets", ".secrets"]);
95
+ const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
96
+ ".coverage",
97
+ ".DS_Store",
98
+ ".env",
99
+ ".netrc",
100
+ ".npmrc",
101
+ ".pypirc",
102
+ "Thumbs.db",
103
+ "id_dsa",
104
+ "id_ecdsa",
105
+ "id_ed25519",
106
+ "id_rsa",
107
+ ]);
108
+ const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".db", ".key", ".p12", ".pfx", ".pyc", ".pyd", ".pyo", ".pem", ".sqlite", ".sqlite3", ".tsbuildinfo", ".tfstate"];
109
+ const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
110
+ const DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST = new Set([".env.dist", ".env.example", ".env.sample", ".env.template"]);
111
+ const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
112
+ const ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE = new Set(["build", "dist", "out", "coverage", "htmlcov", "tmp", "temp", ".tmp"]);
113
+ const ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES = new Set(["src", "source", "sources", "lib"]);
114
+
115
+ type ArchiveSizeBreakdownRow = { relativePath: string; bytes: number };
116
+ type ArchiveCreationResult = {
117
+ sha256: string;
118
+ archiveBytes: number;
119
+ initialArchiveBytes?: number;
120
+ autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
121
+ includedEntries: string[];
122
+ };
123
+
124
+ function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
125
+ const segments = relativePath.split("/").filter(Boolean);
126
+ if (sequence.length === 0 || segments.length < sequence.length) return false;
127
+ for (let index = 0; index <= segments.length - sequence.length; index += 1) {
128
+ if (sequence.every((segment, offset) => segments[index + offset] === segment)) return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ function getRelativeDepth(relativePath: string): number {
134
+ return relativePath.split("/").filter(Boolean).length;
135
+ }
136
+
137
+ function formatBytes(bytes: number): string {
138
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
139
+ }
140
+
141
+ function formatDirectoryLabel(relativePath: string): string {
142
+ return relativePath.endsWith("/") ? relativePath : `${relativePath}/`;
143
+ }
144
+
145
+ function summarizeByKey(
146
+ entrySizes: ArchiveSizeBreakdownRow[],
147
+ keyForEntry: (relativePath: string) => string | undefined,
148
+ limit = 7,
149
+ ): ArchiveSizeBreakdownRow[] {
150
+ const totals = new Map<string, number>();
151
+ for (const entry of entrySizes) {
152
+ const key = keyForEntry(entry.relativePath);
153
+ if (!key) continue;
154
+ totals.set(key, (totals.get(key) ?? 0) + entry.bytes);
155
+ }
156
+ return [...totals.entries()]
157
+ .map(([relativePath, bytes]) => ({ relativePath, bytes }))
158
+ .sort((left, right) => right.bytes - left.bytes || left.relativePath.localeCompare(right.relativePath))
159
+ .slice(0, limit);
160
+ }
68
161
 
162
+ function summarizeTopLevelIncludedPaths(entrySizes: ArchiveSizeBreakdownRow[]): ArchiveSizeBreakdownRow[] {
163
+ return summarizeByKey(entrySizes, (relativePath) => {
164
+ const [topLevel, ...rest] = relativePath.split("/").filter(Boolean);
165
+ if (!topLevel) return undefined;
166
+ return rest.length > 0 ? `${topLevel}/` : topLevel;
167
+ });
168
+ }
169
+
170
+ function getAdaptivePrunePrefix(relativePath: string): string | undefined {
171
+ const segments = relativePath.split("/").filter(Boolean);
172
+ for (let index = 0; index < segments.length - 1; index += 1) {
173
+ const name = segments[index];
174
+ if (!ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE.has(name)) continue;
175
+ const ancestors = segments.slice(0, index);
176
+ if (ancestors.some((segment) => ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES.has(segment))) continue;
177
+ return segments.slice(0, index + 1).join("/");
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ function summarizeAdaptivePruneCandidates(
183
+ entrySizes: ArchiveSizeBreakdownRow[],
184
+ minimumBytes = 0,
185
+ ): ArchiveSizeBreakdownRow[] {
186
+ return summarizeByKey(entrySizes, getAdaptivePrunePrefix, Number.POSITIVE_INFINITY).filter((entry) => entry.bytes >= minimumBytes);
187
+ }
188
+
189
+ function pruneEntriesByPrefix(entries: string[], prefix: string): string[] {
190
+ return entries.filter((entry) => entry !== prefix && !entry.startsWith(`${prefix}/`));
191
+ }
192
+
193
+ function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
194
+ const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
195
+ if (!normalized || normalized === ".") return false;
196
+ if (options?.forceInclude) return false;
197
+ const name = basename(normalized);
198
+ if (DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES.some((sequence) => pathContainsSequence(normalized, sequence))) return true;
199
+ if (isDirectory) {
200
+ if (DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE.has(name)) return true;
201
+ if (getRelativeDepth(normalized) === 1 && DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT.has(name)) return true;
202
+ return false;
203
+ }
204
+ if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
205
+ if (name.startsWith(".env.") && !DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST.has(name)) return true;
206
+ if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
207
+ if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
208
+ return false;
209
+ }
210
+
211
+ async function isSymlinkToDirectory(path: string): Promise<boolean> {
69
212
  try {
70
- const { spawn } = await import("node:child_process");
71
- await new Promise<void>((resolvePromise, rejectPromise) => {
72
- const tar = spawn("tar", ["--null", "-cf", "-", "-T", listPath], {
73
- cwd,
74
- stdio: ["ignore", "pipe", "pipe"],
75
- });
76
- const zstd = spawn("zstd", ["-19", "-T0", "-o", archivePath], {
77
- stdio: ["pipe", "ignore", "pipe"],
78
- });
79
-
80
- let stderr = "";
81
- let settled = false;
82
- let tarCode: number | null | undefined;
83
- let zstdCode: number | null | undefined;
84
-
85
- const finish = (error?: Error) => {
86
- if (settled) return;
87
- if (error) {
88
- settled = true;
89
- tar.kill("SIGTERM");
90
- zstd.kill("SIGTERM");
91
- rejectPromise(error);
92
- return;
93
- }
94
- if (tarCode === undefined || zstdCode === undefined) return;
213
+ return (await stat(path)).isDirectory();
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ async function shouldExcludeArchiveChild(
220
+ absolutePath: string,
221
+ relativePath: string,
222
+ child: { isDirectory(): boolean; isSymbolicLink(): boolean },
223
+ options?: { forceInclude?: boolean },
224
+ ): Promise<boolean> {
225
+ const isDirectoryLike = child.isDirectory() || (child.isSymbolicLink() && await isSymlinkToDirectory(absolutePath));
226
+ return shouldExcludeArchivePath(relativePath, isDirectoryLike, options);
227
+ }
228
+
229
+ async function expandArchiveEntries(cwd: string, relativePath: string, options?: { forceIncludeSubtree?: boolean }): Promise<string[]> {
230
+ const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
231
+ if (normalized === ".") {
232
+ const children = await readdir(cwd, { withFileTypes: true });
233
+ const results: string[] = [];
234
+ for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
235
+ const childRelative = child.name;
236
+ if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child)) continue;
237
+ if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative));
238
+ else results.push(childRelative);
239
+ }
240
+ return results;
241
+ }
242
+
243
+ const absolute = join(cwd, normalized);
244
+ const entry = await lstat(absolute);
245
+ if (!entry.isDirectory()) return [normalized];
246
+ if (shouldExcludeArchivePath(normalized, true, { forceInclude: options?.forceIncludeSubtree })) return [];
247
+
248
+ const children = await readdir(absolute, { withFileTypes: true });
249
+ const results: string[] = [];
250
+ for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
251
+ const childRelative = posix.join(normalized, child.name);
252
+ if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child, { forceInclude: options?.forceIncludeSubtree })) continue;
253
+ if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative, { forceIncludeSubtree: options?.forceIncludeSubtree }));
254
+ else results.push(childRelative);
255
+ }
256
+ return results;
257
+ }
258
+
259
+ async function resolveExpandedArchiveEntriesFromInputs(
260
+ cwd: string,
261
+ entries: Array<{ absolute: string; relative: string }>,
262
+ ): Promise<string[]> {
263
+ return Array.from(new Set((await Promise.all(entries.map(async (entry) => {
264
+ const statEntry = await lstat(entry.absolute);
265
+ const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
266
+ return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
267
+ }))).flat())).sort();
268
+ }
269
+
270
+ export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
271
+ return resolveExpandedArchiveEntriesFromInputs(cwd, resolveArchiveInputs(cwd, files));
272
+ }
273
+
274
+ function isWholeRepoArchiveSelection(entries: Array<{ absolute: string; relative: string }>): boolean {
275
+ return entries.length === 1 && entries[0]?.relative === ".";
276
+ }
277
+
278
+ async function measureArchiveEntrySizes(cwd: string, entries: string[]): Promise<ArchiveSizeBreakdownRow[]> {
279
+ return Promise.all(entries.map(async (relativePath) => ({ relativePath, bytes: (await lstat(join(cwd, relativePath))).size })));
280
+ }
281
+
282
+ function formatArchiveOversizeError(args: {
283
+ archiveBytes: number;
284
+ maxBytes: number;
285
+ entrySizes: ArchiveSizeBreakdownRow[];
286
+ autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
287
+ adaptivePruneMinBytes?: number;
288
+ }): string {
289
+ const topLevel = summarizeTopLevelIncludedPaths(args.entrySizes);
290
+ const adaptiveCandidates = summarizeAdaptivePruneCandidates(args.entrySizes, args.adaptivePruneMinBytes).slice(0, 7);
291
+ return [
292
+ `Oracle archive exceeds ChatGPT upload limit after default exclusions${args.autoPrunedPrefixes.length > 0 ? " and automatic generic generated-output-dir pruning" : ""}: ${args.archiveBytes} bytes >= ${args.maxBytes} bytes`,
293
+ args.autoPrunedPrefixes.length > 0 ? "Automatically pruned generic generated-output paths before failing:" : undefined,
294
+ ...args.autoPrunedPrefixes.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
295
+ topLevel.length > 0 ? "Approx top-level included sizes:" : undefined,
296
+ ...topLevel.map((entry) => `- ${entry.relativePath} — ${formatBytes(entry.bytes)}`),
297
+ adaptiveCandidates.length > 0 ? "Largest remaining generic generated-output-dir candidates:" : undefined,
298
+ ...adaptiveCandidates.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
299
+ "Retry with narrower archive inputs, starting with modified files plus adjacent files plus directly relevant subtrees.",
300
+ ]
301
+ .filter(Boolean)
302
+ .join("\n");
303
+ }
304
+
305
+ async function writeArchiveFile(cwd: string, entries: string[], archivePath: string, listPath: string): Promise<number> {
306
+ await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
307
+ await rm(archivePath, { force: true }).catch(() => undefined);
308
+
309
+ const { spawn } = await import("node:child_process");
310
+ await new Promise<void>((resolvePromise, rejectPromise) => {
311
+ const tar = spawn("tar", ["--null", "-cf", "-", "-T", listPath], {
312
+ cwd,
313
+ stdio: ["ignore", "pipe", "pipe"],
314
+ });
315
+ const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
316
+ stdio: ["pipe", "ignore", "pipe"],
317
+ });
318
+
319
+ let stderr = "";
320
+ let settled = false;
321
+ let tarCode: number | null | undefined;
322
+ let zstdCode: number | null | undefined;
323
+
324
+ const finish = (error?: Error) => {
325
+ if (settled) return;
326
+ if (error) {
95
327
  settled = true;
96
- if (tarCode === 0 && zstdCode === 0) resolvePromise();
97
- else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
98
- };
328
+ tar.kill("SIGTERM");
329
+ zstd.kill("SIGTERM");
330
+ rejectPromise(error);
331
+ return;
332
+ }
333
+ if (tarCode === undefined || zstdCode === undefined) return;
334
+ settled = true;
335
+ if (tarCode === 0 && zstdCode === 0) resolvePromise();
336
+ else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
337
+ };
99
338
 
100
- tar.stderr.on("data", (data) => {
101
- stderr += String(data);
102
- });
103
- zstd.stderr.on("data", (data) => {
104
- stderr += String(data);
105
- });
106
- tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
107
- zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
108
- tar.on("close", (code) => {
109
- tarCode = code;
110
- finish();
111
- });
112
- zstd.on("close", (code) => {
113
- zstdCode = code;
114
- finish();
115
- });
116
- tar.stdout.pipe(zstd.stdin);
339
+ tar.stderr.on("data", (data) => {
340
+ stderr += String(data);
117
341
  });
342
+ zstd.stderr.on("data", (data) => {
343
+ stderr += String(data);
344
+ });
345
+ tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
346
+ zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
347
+ tar.on("close", (code) => {
348
+ tarCode = code;
349
+ finish();
350
+ });
351
+ zstd.on("close", (code) => {
352
+ zstdCode = code;
353
+ finish();
354
+ });
355
+ tar.stdout.pipe(zstd.stdin);
356
+ });
118
357
 
119
- const archiveStat = await stat(archivePath);
120
- const maxBytes = 250 * 1024 * 1024;
121
- if (archiveStat.size >= maxBytes) {
122
- throw new Error(`Oracle archive exceeds ChatGPT upload limit: ${archiveStat.size} bytes`);
123
- }
358
+ return (await stat(archivePath)).size;
359
+ }
360
+
361
+ export async function createArchiveForTesting(
362
+ cwd: string,
363
+ files: string[],
364
+ archivePath: string,
365
+ options?: { maxBytes?: number; adaptivePruneMinBytes?: number },
366
+ ): Promise<ArchiveCreationResult> {
367
+ const archiveInputs = resolveArchiveInputs(cwd, files);
368
+ const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
369
+ let expandedEntries = await resolveExpandedArchiveEntriesFromInputs(cwd, archiveInputs);
370
+ if (expandedEntries.length === 0) {
371
+ throw new Error("Oracle archive inputs are empty after default exclusions");
372
+ }
373
+
374
+ const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
375
+ const listPath = join(listDir, "files.list");
376
+ const maxBytes = options?.maxBytes ?? MAX_ARCHIVE_BYTES;
377
+ const adaptivePruneMinBytes = options?.adaptivePruneMinBytes ?? 0;
378
+ const autoPrunedPrefixes: ArchiveSizeBreakdownRow[] = [];
379
+ let initialArchiveBytes: number | undefined;
124
380
 
125
- return sha256File(archivePath);
381
+ try {
382
+ while (true) {
383
+ if (expandedEntries.length === 0) {
384
+ throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
385
+ }
386
+
387
+ const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath);
388
+ if (archiveBytes < maxBytes) {
389
+ return {
390
+ sha256: await sha256File(archivePath),
391
+ archiveBytes,
392
+ initialArchiveBytes,
393
+ autoPrunedPrefixes,
394
+ includedEntries: [...expandedEntries],
395
+ };
396
+ }
397
+
398
+ if (initialArchiveBytes === undefined) initialArchiveBytes = archiveBytes;
399
+ const entrySizes = await measureArchiveEntrySizes(cwd, expandedEntries);
400
+ if (!wholeRepoSelection) {
401
+ throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
402
+ }
403
+
404
+ const nextCandidate = summarizeAdaptivePruneCandidates(entrySizes, adaptivePruneMinBytes).find(
405
+ (entry) => !autoPrunedPrefixes.some((pruned) => pruned.relativePath === entry.relativePath),
406
+ );
407
+ if (!nextCandidate) {
408
+ throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
409
+ }
410
+
411
+ autoPrunedPrefixes.push(nextCandidate);
412
+ expandedEntries = pruneEntriesByPrefix(expandedEntries, nextCandidate.relativePath);
413
+ }
126
414
  } finally {
127
415
  await rm(listDir, { recursive: true, force: true }).catch(() => undefined);
128
416
  }
129
417
  }
130
418
 
419
+ async function createArchive(cwd: string, files: string[], archivePath: string): Promise<ArchiveCreationResult> {
420
+ return createArchiveForTesting(cwd, files, archivePath);
421
+ }
422
+
131
423
  function validateSubmissionOptions(
132
424
  params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
133
425
  modelFamily: OracleModelFamily,
@@ -212,9 +504,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
212
504
  promptSnippet: "Dispatch a background ChatGPT web oracle job after gathering repo context.",
213
505
  promptGuidelines: [
214
506
  "Gather context before calling oracle_submit.",
215
- "Always include a narrowly scoped archive of exact relevant files/directories.",
507
+ "By default, archive the whole repo by passing '.'; default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like .env files, key material, credential dotfiles, local database files, and root secrets directories.",
508
+ "Only narrow file selection when the user explicitly asks, the task is clearly scoped smaller, or privacy/sensitivity requires it.",
509
+ "For very targeted asks like a single function or stack trace, a smaller archive is preferable.",
510
+ "When files='.' and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like build/, dist/, out/, coverage/, and tmp/ outside obvious source roots like src/ and lib/ until the archive fits or no candidate remains; successful submissions report what was pruned.",
511
+ "If a submitted oracle job later fails because upload is rejected, retry smaller: remove the largest obviously irrelevant/generated content first, then narrow to modified files plus adjacent files plus directly relevant subtrees, then explain the cut or ask the user if still needed.",
512
+ "If oracle_submit itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error instead of retrying automatically.",
216
513
  "Stop after dispatching oracle_submit; do not continue the task while the oracle job is running.",
217
- "If oracle_submit fails, stop and report the error instead of retrying automatically.",
218
514
  "Only use autoSwitchToThinking with modelFamily=instant.",
219
515
  ],
220
516
  parameters: ORACLE_SUBMIT_PARAMS,
@@ -246,7 +542,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
246
542
  let job;
247
543
 
248
544
  try {
249
- const archiveSha256 = await createArchive(ctx.cwd, params.files, tempArchivePath);
545
+ const archive = await createArchive(ctx.cwd, params.files, tempArchivePath);
250
546
  await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
251
547
  await acquireRuntimeLease(config, {
252
548
  jobId,
@@ -288,7 +584,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
288
584
  const worker = await spawnWorker(workerPath, job.id);
289
585
  await updateJob(job.id, (current) => ({
290
586
  ...current,
291
- archiveSha256,
587
+ archiveSha256: archive.sha256,
292
588
  workerPid: worker.pid,
293
589
  workerNonce: worker.nonce,
294
590
  workerStartedAt: worker.startedAt,
@@ -304,6 +600,9 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
304
600
  followUp.followUpToJobId ? `Follow-up to: ${followUp.followUpToJobId}` : undefined,
305
601
  `Prompt: ${job.promptPath}`,
306
602
  `Archive: ${job.archivePath}`,
603
+ archive.autoPrunedPrefixes.length > 0
604
+ ? `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${archive.autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`
605
+ : undefined,
307
606
  `Response will be written to: ${job.responsePath}`,
308
607
  "Stop now and wait for the oracle completion wake-up.",
309
608
  ]
@@ -311,7 +610,15 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
311
610
  .join("\n"),
312
611
  },
313
612
  ],
314
- details: { jobId: job.id, archiveSha256, runtimeId: job.runtimeId, followUpToJobId: followUp.followUpToJobId },
613
+ details: {
614
+ jobId: job.id,
615
+ archiveSha256: archive.sha256,
616
+ archiveBytes: archive.archiveBytes,
617
+ initialArchiveBytes: archive.initialArchiveBytes,
618
+ autoPrunedArchivePaths: archive.autoPrunedPrefixes,
619
+ runtimeId: job.runtimeId,
620
+ followUpToJobId: followUp.followUpToJobId,
621
+ },
315
622
  };
316
623
  } catch (error) {
317
624
  const message = error instanceof Error ? error.message : String(error);
@@ -44,6 +44,7 @@ const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
44
44
  const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
45
45
  const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
46
46
  const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
47
+ const POST_SEND_SETTLE_MS = 15_000;
47
48
  const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
48
49
  (candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
49
50
  ) || "agent-browser";
@@ -1510,6 +1511,8 @@ async function run() {
1510
1511
  const baselineAssistantCount = (await assistantMessages(currentJob)).length;
1511
1512
  await log(`Assistant response count before send: ${baselineAssistantCount}`);
1512
1513
  await clickSend(currentJob);
1514
+ await log(`Waiting ${POST_SEND_SETTLE_MS}ms after send to avoid streaming interruption`);
1515
+ await sleep(POST_SEND_SETTLE_MS);
1513
1516
 
1514
1517
  const chatUrl = await waitForStableChatUrl(currentJob, currentJob.chatUrl);
1515
1518
  const conversationId = parseConversationId(chatUrl) || currentJob.conversationId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
package/prompts/oracle.md CHANGED
@@ -8,17 +8,21 @@ Do not answer the user's request directly yet.
8
8
  Required workflow:
9
9
  1. Understand the request.
10
10
  2. Gather repo context first by reading files and searching the codebase.
11
- 3. Select the exact relevant files/directories for the oracle archive.
11
+ 3. Choose archive inputs for the oracle job.
12
12
  4. Craft a concise but complete oracle prompt for ChatGPT web.
13
13
  5. Call oracle_submit with the prompt and exact archive inputs.
14
14
  6. Stop immediately after dispatching the oracle job.
15
15
 
16
16
  Rules:
17
17
  - Always include an archive. Do not submit without context files.
18
- - Keep the archive narrowly scoped and relevant.
18
+ - By default, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and root `secrets/` directories.
19
+ - Only limit file selection if the user explicitly requests it, if the task is clearly scoped to a smaller area, or if privacy/sensitivity requires it.
20
+ - For very targeted asks like reviewing one function or explaining one stack trace, a smaller archive is preferable.
21
+ - When `files=["."]` and the post-exclusion archive is still too large, submit automatically prunes the largest nested directories matching generic generated-output names like `build/`, `dist/`, `out/`, `coverage/`, and `tmp/` outside obvious source roots like `src/` and `lib/` until the archive fits or no candidate remains. Successful submissions report what was pruned.
22
+ - If a submitted oracle job later fails because upload is rejected, retry with a smaller archive in this order: (1) remove the largest obviously irrelevant/generated content, (2) if still too large, include modified files plus adjacent files plus directly relevant subtrees, (3) if still too large, explain the cut or ask the user.
19
23
  - Prefer the configured default model/effort unless the task clearly needs something else.
20
24
  - Only use autoSwitchToThinking with the instant model family.
21
- - If oracle_submit fails, stop and report the error. Do not retry automatically.
25
+ - If `oracle_submit` itself fails because the local archive still exceeds the upload limit after default exclusions and automatic generic generated-output-dir pruning, or for any other submit-time error, stop and report the error. Do not retry automatically.
22
26
  - After oracle_submit returns, end your turn. Do not keep working while the oracle runs.
23
27
 
24
28
  User request: