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 +4 -1
- package/extensions/oracle/lib/tools.ts +370 -63
- package/extensions/oracle/worker/run-job.mjs +3 -0
- package/package.json +1 -1
- package/prompts/oracle.md +7 -3
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.
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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: {
|
|
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
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.
|
|
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
|
-
-
|
|
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:
|