pi-oracle 0.1.9 → 0.1.11
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 +1 -0
- package/extensions/oracle/lib/jobs.ts +18 -7
- package/extensions/oracle/lib/locks.ts +3 -1
- package/extensions/oracle/lib/runtime.ts +3 -1
- package/extensions/oracle/lib/tools.ts +130 -4
- package/extensions/oracle/worker/artifact-heuristics.mjs +76 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +2 -1
- package/extensions/oracle/worker/run-job.mjs +168 -89
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ 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
|
|
38
39
|
6. wakes the originating `pi` session on completion
|
|
39
40
|
|
|
40
41
|
## Example
|
|
@@ -30,6 +30,9 @@ export const ORACLE_STALE_HEARTBEAT_MS = 3 * 60 * 1000;
|
|
|
30
30
|
export const ORACLE_NOTIFICATION_CLAIM_TTL_MS = 60_000;
|
|
31
31
|
const ORACLE_COMPLETE_JOB_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
32
32
|
const ORACLE_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
33
|
+
export const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
|
|
34
|
+
export const ORACLE_JOBS_DIR_ENV = "PI_ORACLE_JOBS_DIR";
|
|
35
|
+
const ORACLE_JOBS_DIR = process.env[ORACLE_JOBS_DIR_ENV]?.trim() || DEFAULT_ORACLE_JOBS_DIR;
|
|
33
36
|
|
|
34
37
|
export function isActiveOracleJob(job: Pick<OracleJob, "status">): boolean {
|
|
35
38
|
return ACTIVE_ORACLE_JOB_STATUSES.includes(job.status);
|
|
@@ -148,20 +151,24 @@ export function getSessionFile(ctx: ExtensionContext): string | undefined {
|
|
|
148
151
|
return manager.getSessionFile?.();
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
export function getOracleJobsDir(): string {
|
|
155
|
+
return ORACLE_JOBS_DIR;
|
|
156
|
+
}
|
|
157
|
+
|
|
151
158
|
export function getJobDir(id: string): string {
|
|
152
|
-
return join(
|
|
159
|
+
return join(ORACLE_JOBS_DIR, `oracle-${id}`);
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
export function listOracleJobDirs(): string[] {
|
|
156
|
-
if (!existsSync(
|
|
157
|
-
return readdirSync(
|
|
163
|
+
if (!existsSync(ORACLE_JOBS_DIR)) return [];
|
|
164
|
+
return readdirSync(ORACLE_JOBS_DIR)
|
|
158
165
|
.filter((name) => name.startsWith("oracle-"))
|
|
159
|
-
.map((name) => join(
|
|
166
|
+
.map((name) => join(ORACLE_JOBS_DIR, name))
|
|
160
167
|
.filter((path) => existsSync(join(path, "job.json")));
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
export function readJob(jobDirOrId: string): OracleJob | undefined {
|
|
164
|
-
const jobDir = jobDirOrId.
|
|
171
|
+
const jobDir = existsSync(join(jobDirOrId, "job.json")) ? jobDirOrId : getJobDir(jobDirOrId);
|
|
165
172
|
const jobPath = join(jobDir, "job.json");
|
|
166
173
|
if (!existsSync(jobPath)) return undefined;
|
|
167
174
|
try {
|
|
@@ -528,6 +535,10 @@ export async function createJob(
|
|
|
528
535
|
await chmod(promptPath, 0o600).catch(() => undefined);
|
|
529
536
|
|
|
530
537
|
const now = new Date().toISOString();
|
|
538
|
+
const normalizedEffort = input.modelFamily === "instant" ? undefined : (input.effort ?? config.defaults.effort);
|
|
539
|
+
const normalizedAutoSwitchToThinking = input.modelFamily === "instant"
|
|
540
|
+
? (input.autoSwitchToThinking ?? config.defaults.autoSwitchToThinking)
|
|
541
|
+
: false;
|
|
531
542
|
const job: OracleJob = {
|
|
532
543
|
id,
|
|
533
544
|
status: "submitted",
|
|
@@ -541,8 +552,8 @@ export async function createJob(
|
|
|
541
552
|
originSessionFile,
|
|
542
553
|
requestSource: input.requestSource,
|
|
543
554
|
chatModelFamily: input.modelFamily,
|
|
544
|
-
effort:
|
|
545
|
-
autoSwitchToThinking:
|
|
555
|
+
effort: normalizedEffort,
|
|
556
|
+
autoSwitchToThinking: normalizedAutoSwitchToThinking,
|
|
546
557
|
followUpToJobId: input.followUpToJobId,
|
|
547
558
|
chatUrl: input.followUpToJobId ? input.chatUrl : undefined,
|
|
548
559
|
conversationId,
|
|
@@ -4,7 +4,9 @@ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
export const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
8
|
+
export const ORACLE_STATE_DIR_ENV = "PI_ORACLE_STATE_DIR";
|
|
9
|
+
const ORACLE_STATE_DIR = process.env[ORACLE_STATE_DIR_ENV]?.trim() || DEFAULT_ORACLE_STATE_DIR;
|
|
8
10
|
const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
|
|
9
11
|
const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
|
|
10
12
|
const DEFAULT_WAIT_MS = 30_000;
|
|
@@ -7,6 +7,8 @@ import type { OracleConfig } from "./config.js";
|
|
|
7
7
|
import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
|
|
8
8
|
|
|
9
9
|
const SEED_GENERATION_FILE = ".oracle-seed-generation";
|
|
10
|
+
const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
|
|
11
|
+
const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
|
|
10
12
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
11
13
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
12
14
|
) || "agent-browser";
|
|
@@ -83,7 +85,7 @@ export async function writeSeedGeneration(config: OracleConfig, value = new Date
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
function activeJobExists(jobId: string): boolean {
|
|
86
|
-
const path = join(
|
|
88
|
+
const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
|
|
87
89
|
if (!existsSync(path)) return false;
|
|
88
90
|
try {
|
|
89
91
|
const job = JSON.parse(readFileSync(path, "utf8")) as { status?: string };
|
|
@@ -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,11 +60,137 @@ const VALID_EFFORTS: Record<OracleModelFamily, readonly OracleEffort[]> = {
|
|
|
60
60
|
pro: ["standard", "extended"],
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
64
|
+
".git",
|
|
65
|
+
".hg",
|
|
66
|
+
".svn",
|
|
67
|
+
"node_modules",
|
|
68
|
+
"target",
|
|
69
|
+
".venv",
|
|
70
|
+
"venv",
|
|
71
|
+
"__pycache__",
|
|
72
|
+
".pytest_cache",
|
|
73
|
+
".mypy_cache",
|
|
74
|
+
".ruff_cache",
|
|
75
|
+
".tox",
|
|
76
|
+
".nox",
|
|
77
|
+
".hypothesis",
|
|
78
|
+
".next",
|
|
79
|
+
".nuxt",
|
|
80
|
+
".svelte-kit",
|
|
81
|
+
".turbo",
|
|
82
|
+
".parcel-cache",
|
|
83
|
+
".cache",
|
|
84
|
+
".gradle",
|
|
85
|
+
".terraform",
|
|
86
|
+
"DerivedData",
|
|
87
|
+
".build",
|
|
88
|
+
".pnpm-store",
|
|
89
|
+
".serverless",
|
|
90
|
+
".aws-sam",
|
|
91
|
+
]);
|
|
92
|
+
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
|
|
93
|
+
const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([".coverage", ".DS_Store", "Thumbs.db"]);
|
|
94
|
+
const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".pyc", ".pyo", ".pyd", ".tsbuildinfo", ".tfstate"];
|
|
95
|
+
const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
|
|
96
|
+
const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
|
|
97
|
+
|
|
98
|
+
function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
|
|
99
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
100
|
+
if (sequence.length === 0 || segments.length < sequence.length) return false;
|
|
101
|
+
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
|
|
102
|
+
if (sequence.every((segment, offset) => segments[index + offset] === segment)) return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getRelativeDepth(relativePath: string): number {
|
|
108
|
+
return relativePath.split("/").filter(Boolean).length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
|
|
112
|
+
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
113
|
+
if (!normalized || normalized === ".") return false;
|
|
114
|
+
if (options?.forceInclude) return false;
|
|
115
|
+
const name = basename(normalized);
|
|
116
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES.some((sequence) => pathContainsSequence(normalized, sequence))) return true;
|
|
117
|
+
if (isDirectory) {
|
|
118
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE.has(name)) return true;
|
|
119
|
+
if (getRelativeDepth(normalized) === 1 && DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT.has(name)) return true;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
|
|
123
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
124
|
+
if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function isSymlinkToDirectory(path: string): Promise<boolean> {
|
|
129
|
+
try {
|
|
130
|
+
return (await stat(path)).isDirectory();
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function shouldExcludeArchiveChild(
|
|
137
|
+
absolutePath: string,
|
|
138
|
+
relativePath: string,
|
|
139
|
+
child: { isDirectory(): boolean; isSymbolicLink(): boolean },
|
|
140
|
+
options?: { forceInclude?: boolean },
|
|
141
|
+
): Promise<boolean> {
|
|
142
|
+
const isDirectoryLike = child.isDirectory() || (child.isSymbolicLink() && await isSymlinkToDirectory(absolutePath));
|
|
143
|
+
return shouldExcludeArchivePath(relativePath, isDirectoryLike, options);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function expandArchiveEntries(cwd: string, relativePath: string, options?: { forceIncludeSubtree?: boolean }): Promise<string[]> {
|
|
147
|
+
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
148
|
+
if (normalized === ".") {
|
|
149
|
+
const children = await readdir(cwd, { withFileTypes: true });
|
|
150
|
+
const results: string[] = [];
|
|
151
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
152
|
+
const childRelative = child.name;
|
|
153
|
+
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child)) continue;
|
|
154
|
+
if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative));
|
|
155
|
+
else results.push(childRelative);
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const absolute = join(cwd, normalized);
|
|
161
|
+
const entry = await lstat(absolute);
|
|
162
|
+
if (!entry.isDirectory()) return [normalized];
|
|
163
|
+
if (shouldExcludeArchivePath(normalized, true, { forceInclude: options?.forceIncludeSubtree })) return [];
|
|
164
|
+
|
|
165
|
+
const children = await readdir(absolute, { withFileTypes: true });
|
|
166
|
+
const results: string[] = [];
|
|
167
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
168
|
+
const childRelative = posix.join(normalized, child.name);
|
|
169
|
+
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child, { forceInclude: options?.forceIncludeSubtree })) continue;
|
|
170
|
+
if (child.isDirectory()) results.push(...await expandArchiveEntries(cwd, childRelative, { forceIncludeSubtree: options?.forceIncludeSubtree }));
|
|
171
|
+
else results.push(childRelative);
|
|
172
|
+
}
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
|
|
64
177
|
const entries = resolveArchiveInputs(cwd, files);
|
|
178
|
+
return Array.from(new Set((await Promise.all(entries.map(async (entry) => {
|
|
179
|
+
const absolute = join(cwd, entry.relative);
|
|
180
|
+
const statEntry = await lstat(absolute);
|
|
181
|
+
const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
|
|
182
|
+
return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
|
|
183
|
+
}))).flat())).sort();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function createArchive(cwd: string, files: string[], archivePath: string): Promise<string> {
|
|
187
|
+
const expandedEntries = await resolveExpandedArchiveEntries(cwd, files);
|
|
188
|
+
if (expandedEntries.length === 0) {
|
|
189
|
+
throw new Error("Oracle archive inputs are empty after default exclusions");
|
|
190
|
+
}
|
|
65
191
|
const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
|
|
66
192
|
const listPath = join(listDir, "files.list");
|
|
67
|
-
await writeFile(listPath, Buffer.from(`${
|
|
193
|
+
await writeFile(listPath, Buffer.from(`${expandedEntries.join("\0")}\0`), { mode: 0o600 });
|
|
68
194
|
|
|
69
195
|
try {
|
|
70
196
|
const { spawn } = await import("node:child_process");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])`;
|
|
2
|
+
const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE);
|
|
3
|
+
export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
|
|
4
|
+
const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
|
|
5
|
+
|
|
6
|
+
export function parseSnapshotEntries(snapshot) {
|
|
7
|
+
return String(snapshot || "")
|
|
8
|
+
.split("\n")
|
|
9
|
+
.map((line, lineIndex) => {
|
|
10
|
+
const refMatch = line.match(/\bref=(e\d+)\b/);
|
|
11
|
+
if (!refMatch) return undefined;
|
|
12
|
+
const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
|
|
13
|
+
const quotedMatch = line.match(/"([^"]*)"/);
|
|
14
|
+
const valueMatch = line.match(/:\s*(.+)$/);
|
|
15
|
+
return {
|
|
16
|
+
line,
|
|
17
|
+
lineIndex,
|
|
18
|
+
ref: `@${refMatch[1]}`,
|
|
19
|
+
kind: kindMatch ? kindMatch[1] : undefined,
|
|
20
|
+
label: quotedMatch ? quotedMatch[1] : undefined,
|
|
21
|
+
value: valueMatch ? valueMatch[1].trim() : undefined,
|
|
22
|
+
disabled: /\bdisabled\b/.test(line),
|
|
23
|
+
};
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeText(value) {
|
|
29
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isLikelyArtifactLabel(label) {
|
|
33
|
+
const normalized = normalizeText(label);
|
|
34
|
+
if (!normalized) return false;
|
|
35
|
+
if (GENERIC_ARTIFACT_LABEL_SET.has(normalized.toUpperCase())) return true;
|
|
36
|
+
return FILE_LABEL_PATTERN.test(normalized);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isStructuralArtifactCandidate(candidate) {
|
|
40
|
+
const label = normalizeText(candidate?.label);
|
|
41
|
+
if (!isLikelyArtifactLabel(label)) return false;
|
|
42
|
+
|
|
43
|
+
const listItemText = normalizeText(candidate?.listItemText);
|
|
44
|
+
const listItemFileButtonCount = Number(candidate?.listItemFileButtonCount || 0);
|
|
45
|
+
const paragraphFileButtonCount = Number(candidate?.paragraphFileButtonCount || 0);
|
|
46
|
+
const paragraphOtherTextLength = Number(candidate?.paragraphOtherTextLength ?? Number.POSITIVE_INFINITY);
|
|
47
|
+
const focusableFileButtonCount = Number(candidate?.focusableFileButtonCount || 0);
|
|
48
|
+
const focusableOtherTextLength = Number(candidate?.focusableOtherTextLength ?? Number.POSITIVE_INFINITY);
|
|
49
|
+
|
|
50
|
+
if (listItemText === label && listItemFileButtonCount === 1) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (paragraphFileButtonCount === 1 && paragraphOtherTextLength <= 32) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (focusableFileButtonCount >= 1 && focusableOtherTextLength <= 64) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function filterStructuralArtifactCandidates(candidates) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const filtered = [];
|
|
68
|
+
for (const candidate of candidates || []) {
|
|
69
|
+
const label = normalizeText(candidate?.label);
|
|
70
|
+
if (!label || seen.has(label)) continue;
|
|
71
|
+
if (!isStructuralArtifactCandidate(candidate)) continue;
|
|
72
|
+
seen.add(label);
|
|
73
|
+
filtered.push({ label });
|
|
74
|
+
}
|
|
75
|
+
return filtered;
|
|
76
|
+
}
|
|
@@ -33,7 +33,8 @@ const SNAPSHOT_PATH = "/tmp/oracle-auth.snapshot.txt";
|
|
|
33
33
|
const BODY_PATH = "/tmp/oracle-auth.body.txt";
|
|
34
34
|
const SCREENSHOT_PATH = "/tmp/oracle-auth.png";
|
|
35
35
|
const REAL_CHROME_USER_DATA_DIR = resolve(homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
36
|
-
const
|
|
36
|
+
const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
37
|
+
const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
|
|
37
38
|
const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
|
|
38
39
|
const STALE_STAGING_PROFILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
39
40
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { FILE_LABEL_PATTERN_SOURCE, filterStructuralArtifactCandidates, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries } from "./artifact-heuristics.mjs";
|
|
6
7
|
|
|
7
8
|
const jobId = process.argv[2];
|
|
8
9
|
if (!jobId) {
|
|
@@ -10,7 +11,9 @@ if (!jobId) {
|
|
|
10
11
|
process.exit(1);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const DEFAULT_ORACLE_JOBS_DIR = "/tmp";
|
|
15
|
+
const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE_JOBS_DIR;
|
|
16
|
+
const jobDir = join(ORACLE_JOBS_DIR, `oracle-${jobId}`);
|
|
14
17
|
const jobPath = `${jobDir}/job.json`;
|
|
15
18
|
const CHATGPT_LABELS = {
|
|
16
19
|
composer: "Chat with ChatGPT",
|
|
@@ -26,7 +29,8 @@ const MODEL_FAMILY_PREFIX = {
|
|
|
26
29
|
pro: "Pro ",
|
|
27
30
|
};
|
|
28
31
|
|
|
29
|
-
const
|
|
32
|
+
const DEFAULT_ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
|
|
33
|
+
const ORACLE_STATE_DIR = process.env.PI_ORACLE_STATE_DIR?.trim() || DEFAULT_ORACLE_STATE_DIR;
|
|
30
34
|
const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
|
|
31
35
|
const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
|
|
32
36
|
const SEED_GENERATION_FILE = ".oracle-seed-generation";
|
|
@@ -37,6 +41,9 @@ const ARTIFACT_DOWNLOAD_HEARTBEAT_MS = 10_000;
|
|
|
37
41
|
const ARTIFACT_DOWNLOAD_TIMEOUT_MS = 90_000;
|
|
38
42
|
const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 2;
|
|
39
43
|
const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
|
|
44
|
+
const MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS = 20_000;
|
|
45
|
+
const MODEL_CONFIGURATION_SETTLE_POLL_MS = 250;
|
|
46
|
+
const MODEL_CONFIGURATION_CLOSE_RETRY_MS = 1_000;
|
|
40
47
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
41
48
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
42
49
|
) || "agent-browser";
|
|
@@ -539,27 +546,6 @@ function buildLoginProbeScript(timeoutMs) {
|
|
|
539
546
|
`);
|
|
540
547
|
}
|
|
541
548
|
|
|
542
|
-
function parseSnapshotEntries(snapshot) {
|
|
543
|
-
return snapshot
|
|
544
|
-
.split("\n")
|
|
545
|
-
.map((line) => {
|
|
546
|
-
const refMatch = line.match(/\bref=(e\d+)\b/);
|
|
547
|
-
if (!refMatch) return undefined;
|
|
548
|
-
const kindMatch = line.match(/^\s*-\s*([^\s]+)/);
|
|
549
|
-
const quotedMatch = line.match(/"([^"]*)"/);
|
|
550
|
-
const valueMatch = line.match(/:\s*(.+)$/);
|
|
551
|
-
return {
|
|
552
|
-
line,
|
|
553
|
-
ref: `@${refMatch[1]}`,
|
|
554
|
-
kind: kindMatch ? kindMatch[1] : undefined,
|
|
555
|
-
label: quotedMatch ? quotedMatch[1] : undefined,
|
|
556
|
-
value: valueMatch ? valueMatch[1].trim() : undefined,
|
|
557
|
-
disabled: /\bdisabled\b/.test(line),
|
|
558
|
-
};
|
|
559
|
-
})
|
|
560
|
-
.filter(Boolean);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
549
|
function findEntry(snapshot, predicate) {
|
|
564
550
|
return parseSnapshotEntries(snapshot).find(predicate);
|
|
565
551
|
}
|
|
@@ -572,14 +558,21 @@ function findLastEntry(snapshot, predicate) {
|
|
|
572
558
|
return undefined;
|
|
573
559
|
}
|
|
574
560
|
|
|
575
|
-
function matchesModelFamilyButton(candidate, family) {
|
|
576
|
-
return candidate.kind === "button" && typeof candidate.label === "string" && candidate.label.startsWith(MODEL_FAMILY_PREFIX[family]) && !candidate.disabled;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
561
|
function titleCase(value) {
|
|
580
562
|
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : value;
|
|
581
563
|
}
|
|
582
564
|
|
|
565
|
+
function matchesModelFamilyLabel(label, family) {
|
|
566
|
+
const normalized = String(label || "");
|
|
567
|
+
const prefix = MODEL_FAMILY_PREFIX[family];
|
|
568
|
+
const exact = prefix.trim();
|
|
569
|
+
return normalized === exact || normalized.startsWith(prefix) || normalized.startsWith(`${exact},`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function matchesModelFamilyButton(candidate, family) {
|
|
573
|
+
return candidate.kind === "button" && typeof candidate.label === "string" && matchesModelFamilyLabel(candidate.label, family) && !candidate.disabled;
|
|
574
|
+
}
|
|
575
|
+
|
|
583
576
|
function requestedEffortLabel(job) {
|
|
584
577
|
return job.effort ? titleCase(job.effort) : undefined;
|
|
585
578
|
}
|
|
@@ -612,9 +605,9 @@ function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
612
605
|
entries
|
|
613
606
|
.filter((entry) => entry.kind === "button" && typeof entry.label === "string")
|
|
614
607
|
.flatMap((entry) =>
|
|
615
|
-
Object.
|
|
616
|
-
.filter((
|
|
617
|
-
.map((
|
|
608
|
+
Object.keys(MODEL_FAMILY_PREFIX)
|
|
609
|
+
.filter((family) => matchesModelFamilyLabel(entry.label, family))
|
|
610
|
+
.map((family) => family),
|
|
618
611
|
),
|
|
619
612
|
);
|
|
620
613
|
const hasCloseButton = entries.some((entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.close && !entry.disabled);
|
|
@@ -627,18 +620,35 @@ function snapshotHasModelConfigurationUi(snapshot) {
|
|
|
627
620
|
function snapshotStronglyMatchesRequestedModel(snapshot, job) {
|
|
628
621
|
const entries = parseSnapshotEntries(snapshot);
|
|
629
622
|
const familyMatched = entries.some((entry) => matchesModelFamilyButton(entry, job.chatModelFamily));
|
|
623
|
+
const effortLabel = requestedEffortLabel(job);
|
|
630
624
|
if (job.chatModelFamily === "thinking") {
|
|
631
|
-
return familyMatched || effortSelectionVisible(snapshot,
|
|
625
|
+
return familyMatched || effortSelectionVisible(snapshot, effortLabel);
|
|
632
626
|
}
|
|
633
627
|
if (job.chatModelFamily === "pro") {
|
|
634
|
-
return familyMatched;
|
|
628
|
+
return effortLabel ? familyMatched && effortSelectionVisible(snapshot, effortLabel) : familyMatched;
|
|
635
629
|
}
|
|
636
630
|
return familyMatched;
|
|
637
631
|
}
|
|
638
632
|
|
|
633
|
+
function thinkingSelectionVisible(snapshot) {
|
|
634
|
+
const entries = parseSnapshotEntries(snapshot);
|
|
635
|
+
return entries.some((entry) => !entry.disabled && entry.kind === "button" && matchesModelFamilyLabel(entry.label, "thinking"));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function composerControlsVisible(snapshot) {
|
|
639
|
+
const entries = parseSnapshotEntries(snapshot);
|
|
640
|
+
const hasComposer = entries.some(
|
|
641
|
+
(entry) => entry.kind === "textbox" && entry.label === CHATGPT_LABELS.composer && !entry.disabled,
|
|
642
|
+
);
|
|
643
|
+
const hasAddFiles = entries.some(
|
|
644
|
+
(entry) => entry.kind === "button" && entry.label === CHATGPT_LABELS.addFiles && !entry.disabled,
|
|
645
|
+
);
|
|
646
|
+
return hasComposer && hasAddFiles;
|
|
647
|
+
}
|
|
648
|
+
|
|
639
649
|
function snapshotWeaklyMatchesRequestedModel(snapshot, job) {
|
|
640
650
|
if (job.chatModelFamily === "thinking") {
|
|
641
|
-
return effortSelectionVisible(snapshot, requestedEffortLabel(job));
|
|
651
|
+
return effortSelectionVisible(snapshot, requestedEffortLabel(job)) || thinkingSelectionVisible(snapshot);
|
|
642
652
|
}
|
|
643
653
|
if (job.chatModelFamily === "pro") {
|
|
644
654
|
return !thinkingChipVisible(snapshot);
|
|
@@ -955,6 +965,54 @@ async function openModelConfiguration(job) {
|
|
|
955
965
|
throw new Error("Could not open model configuration UI");
|
|
956
966
|
}
|
|
957
967
|
|
|
968
|
+
async function waitForModelConfigurationToSettle(job, options = {}) {
|
|
969
|
+
const deadline = Date.now() + MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS;
|
|
970
|
+
let lastCloseAttemptAt = 0;
|
|
971
|
+
let fallbackLogged = false;
|
|
972
|
+
let lastSnapshot = "";
|
|
973
|
+
|
|
974
|
+
while (Date.now() < deadline) {
|
|
975
|
+
const snapshot = await snapshotText(job);
|
|
976
|
+
lastSnapshot = snapshot;
|
|
977
|
+
const configurationUiVisible = snapshotHasModelConfigurationUi(snapshot);
|
|
978
|
+
|
|
979
|
+
if (!configurationUiVisible) {
|
|
980
|
+
if (snapshotWeaklyMatchesRequestedModel(snapshot, job)) return;
|
|
981
|
+
if (options.stronglyVerified) {
|
|
982
|
+
if (!fallbackLogged) {
|
|
983
|
+
fallbackLogged = true;
|
|
984
|
+
await log(`Model configuration closed after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (!configurationUiVisible && composerControlsVisible(snapshot) && options.stronglyVerified) {
|
|
991
|
+
if (!fallbackLogged) {
|
|
992
|
+
fallbackLogged = true;
|
|
993
|
+
await log(`Composer became usable after strong in-dialog verification for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
|
|
994
|
+
}
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (Date.now() - lastCloseAttemptAt >= MODEL_CONFIGURATION_CLOSE_RETRY_MS) {
|
|
999
|
+
lastCloseAttemptAt = Date.now();
|
|
1000
|
+
if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
|
|
1001
|
+
await agentBrowser(job, "press", "Escape").catch(() => undefined);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
await sleep(MODEL_CONFIGURATION_SETTLE_POLL_MS);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (options.stronglyVerified && lastSnapshot && !snapshotHasModelConfigurationUi(lastSnapshot)) {
|
|
1009
|
+
await log(`Model configuration closed only after settle-timeout for family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
958
1016
|
async function configureModel(job) {
|
|
959
1017
|
const initialSnapshot = await snapshotText(job);
|
|
960
1018
|
if (snapshotStronglyMatchesRequestedModel(initialSnapshot, job)) {
|
|
@@ -964,6 +1022,7 @@ async function configureModel(job) {
|
|
|
964
1022
|
|
|
965
1023
|
await log(`Configuring model family=${job.chatModelFamily} effort=${job.effort || "(none)"}`);
|
|
966
1024
|
let familySnapshot = await openModelConfiguration(job);
|
|
1025
|
+
let verificationSnapshot = familySnapshot;
|
|
967
1026
|
|
|
968
1027
|
let familyEntry = findEntry(familySnapshot, (candidate) => matchesModelFamilyButton(candidate, job.chatModelFamily));
|
|
969
1028
|
if (!familyEntry && snapshotStronglyMatchesRequestedModel(familySnapshot, job)) {
|
|
@@ -977,6 +1036,7 @@ async function configureModel(job) {
|
|
|
977
1036
|
await clickRef(job, familyEntry.ref);
|
|
978
1037
|
await agentBrowser(job, "wait", "800");
|
|
979
1038
|
familySnapshot = await snapshotText(job);
|
|
1039
|
+
verificationSnapshot = familySnapshot;
|
|
980
1040
|
}
|
|
981
1041
|
|
|
982
1042
|
if (job.chatModelFamily === "thinking" || job.chatModelFamily === "pro") {
|
|
@@ -990,6 +1050,7 @@ async function configureModel(job) {
|
|
|
990
1050
|
await clickLabeledEntry(job, effortLabel, { kind: "option" });
|
|
991
1051
|
await agentBrowser(job, "wait", "400");
|
|
992
1052
|
const effortSnapshot = await snapshotText(job);
|
|
1053
|
+
verificationSnapshot = effortSnapshot;
|
|
993
1054
|
const selectedEffort = findEntry(
|
|
994
1055
|
effortSnapshot,
|
|
995
1056
|
(candidate) => candidate.kind === "combobox" && candidate.value === effortLabel && !candidate.disabled,
|
|
@@ -1002,17 +1063,18 @@ async function configureModel(job) {
|
|
|
1002
1063
|
|
|
1003
1064
|
if (job.chatModelFamily === "instant" && job.autoSwitchToThinking) {
|
|
1004
1065
|
await maybeClickLabeledEntry(job, CHATGPT_LABELS.autoSwitchToThinking);
|
|
1066
|
+
verificationSnapshot = await snapshotText(job);
|
|
1005
1067
|
}
|
|
1006
1068
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1069
|
+
const stronglyVerified = snapshotStronglyMatchesRequestedModel(verificationSnapshot, job);
|
|
1070
|
+
if (!stronglyVerified) {
|
|
1071
|
+
throw new Error(`Could not verify requested model settings in configuration UI for ${job.chatModelFamily}`);
|
|
1009
1072
|
}
|
|
1010
|
-
await agentBrowser(job, "wait", "500");
|
|
1011
1073
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
throw new Error(`Could not verify requested model settings after configuration for ${job.chatModelFamily}`);
|
|
1074
|
+
if (!(await maybeClickLabeledEntry(job, CHATGPT_LABELS.close, { kind: "button" }))) {
|
|
1075
|
+
await agentBrowser(job, "press", "Escape").catch(() => undefined);
|
|
1015
1076
|
}
|
|
1077
|
+
await waitForModelConfigurationToSettle(job, { stronglyVerified });
|
|
1016
1078
|
}
|
|
1017
1079
|
|
|
1018
1080
|
async function uploadArchive(job) {
|
|
@@ -1192,14 +1254,6 @@ async function detectType(path) {
|
|
|
1192
1254
|
return result.stdout || "unknown";
|
|
1193
1255
|
}
|
|
1194
1256
|
|
|
1195
|
-
function isLikelyArtifactLabel(label) {
|
|
1196
|
-
const normalized = String(label || "").trim();
|
|
1197
|
-
if (!normalized) return false;
|
|
1198
|
-
const upper = normalized.toUpperCase();
|
|
1199
|
-
if (upper === "ATTACHED" || upper === "DONE") return true;
|
|
1200
|
-
return /(?:^|[^\w])[^\n]*\.[A-Za-z0-9]{1,12}(?:$|[^\w])/.test(normalized);
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
1257
|
function preferredArtifactName(label, index) {
|
|
1204
1258
|
const normalized = String(label || "").trim();
|
|
1205
1259
|
const fileNameMatch = normalized.match(/([A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})(?!.*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,12})/);
|
|
@@ -1207,57 +1261,82 @@ function preferredArtifactName(label, index) {
|
|
|
1207
1261
|
return `artifact-${String(index + 1).padStart(2, "0")}`;
|
|
1208
1262
|
}
|
|
1209
1263
|
|
|
1210
|
-
function
|
|
1211
|
-
const excluded = new Set([
|
|
1212
|
-
"Copy response",
|
|
1213
|
-
"Good response",
|
|
1214
|
-
"Bad response",
|
|
1215
|
-
"Share",
|
|
1216
|
-
"Switch model",
|
|
1217
|
-
"More actions",
|
|
1218
|
-
CHATGPT_LABELS.addFiles,
|
|
1219
|
-
"Start dictation",
|
|
1220
|
-
"Start Voice",
|
|
1221
|
-
"Model selector",
|
|
1222
|
-
"Open conversation options",
|
|
1223
|
-
"Scroll to bottom",
|
|
1224
|
-
CHATGPT_LABELS.close,
|
|
1225
|
-
]);
|
|
1226
|
-
|
|
1227
|
-
const seen = new Set();
|
|
1228
|
-
const candidates = [];
|
|
1229
|
-
for (const entry of entries) {
|
|
1230
|
-
if (!entry.label) continue;
|
|
1231
|
-
if (excluded.has(entry.label)) continue;
|
|
1232
|
-
if (entry.label.startsWith("Thought for ")) continue;
|
|
1233
|
-
if (entry.kind !== "button" && entry.kind !== "link") continue;
|
|
1234
|
-
if (!isLikelyArtifactLabel(entry.label)) continue;
|
|
1235
|
-
if (seen.has(entry.label)) continue;
|
|
1236
|
-
seen.add(entry.label);
|
|
1237
|
-
candidates.push({ label: entry.label });
|
|
1238
|
-
}
|
|
1239
|
-
return candidates;
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
async function collectArtifactCandidates(job, responseIndex) {
|
|
1264
|
+
async function collectArtifactCandidates(job, responseIndex, responseText = "") {
|
|
1243
1265
|
const snapshot = await snapshotText(job);
|
|
1244
1266
|
const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
|
|
1245
1267
|
if (!targetSlice) return { snapshot, targetSlice, candidates: [] };
|
|
1268
|
+
|
|
1269
|
+
const structural = await evalPage(
|
|
1270
|
+
job,
|
|
1271
|
+
toJsonScript(`
|
|
1272
|
+
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
1273
|
+
const genericArtifactLabels = new Set(${JSON.stringify(GENERIC_ARTIFACT_LABELS)});
|
|
1274
|
+
const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)});
|
|
1275
|
+
const isFileLabel = (value) => {
|
|
1276
|
+
const normalized = normalize(value);
|
|
1277
|
+
if (!normalized) return false;
|
|
1278
|
+
if (genericArtifactLabels.has(normalized.toUpperCase())) return true;
|
|
1279
|
+
return fileLabelPattern.test(normalized);
|
|
1280
|
+
};
|
|
1281
|
+
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
|
|
1282
|
+
.filter((el) => normalize(el.textContent) === 'ChatGPT said:');
|
|
1283
|
+
const host = headings[${responseIndex}]?.nextElementSibling;
|
|
1284
|
+
if (!host) return { candidates: [] };
|
|
1285
|
+
|
|
1286
|
+
const fileButtons = (node) => node
|
|
1287
|
+
? Array.from(node.querySelectorAll('button, a')).map((candidate) => normalize(candidate.textContent)).filter(isFileLabel)
|
|
1288
|
+
: [];
|
|
1289
|
+
const otherTextLength = (text, labels) => {
|
|
1290
|
+
let remaining = normalize(text);
|
|
1291
|
+
for (const label of labels || []) {
|
|
1292
|
+
remaining = normalize(remaining.replaceAll(label, ' '));
|
|
1293
|
+
}
|
|
1294
|
+
remaining = normalize(remaining.replaceAll('Coding Citation', ' '));
|
|
1295
|
+
return remaining.length;
|
|
1296
|
+
};
|
|
1297
|
+
const focusableFor = (node) => node?.closest('[tabindex]');
|
|
1298
|
+
|
|
1299
|
+
const candidates = Array.from(host.querySelectorAll('button, a'))
|
|
1300
|
+
.map((button) => {
|
|
1301
|
+
const label = normalize(button.textContent);
|
|
1302
|
+
if (!isFileLabel(label)) return null;
|
|
1303
|
+
const paragraph = button.closest('p');
|
|
1304
|
+
const listItem = button.closest('li');
|
|
1305
|
+
const focusable = focusableFor(button);
|
|
1306
|
+
const paragraphFileLabels = fileButtons(paragraph);
|
|
1307
|
+
const focusableFileLabels = fileButtons(focusable);
|
|
1308
|
+
return {
|
|
1309
|
+
label,
|
|
1310
|
+
paragraphText: normalize(paragraph?.textContent),
|
|
1311
|
+
listItemText: normalize(listItem?.textContent),
|
|
1312
|
+
paragraphFileButtonCount: paragraphFileLabels.length,
|
|
1313
|
+
paragraphOtherTextLength: otherTextLength(paragraph?.textContent, paragraphFileLabels),
|
|
1314
|
+
listItemFileButtonCount: fileButtons(listItem).length,
|
|
1315
|
+
focusableFileButtonCount: focusableFileLabels.length,
|
|
1316
|
+
focusableOtherTextLength: otherTextLength(focusable?.textContent, focusableFileLabels),
|
|
1317
|
+
};
|
|
1318
|
+
})
|
|
1319
|
+
.filter(Boolean);
|
|
1320
|
+
|
|
1321
|
+
return { candidates };
|
|
1322
|
+
`),
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1246
1325
|
return {
|
|
1247
1326
|
snapshot,
|
|
1248
1327
|
targetSlice,
|
|
1249
|
-
candidates:
|
|
1328
|
+
candidates: filterStructuralArtifactCandidates(structural?.candidates || []),
|
|
1250
1329
|
};
|
|
1251
1330
|
}
|
|
1252
1331
|
|
|
1253
|
-
async function waitForStableArtifactCandidates(job, responseIndex) {
|
|
1332
|
+
async function waitForStableArtifactCandidates(job, responseIndex, responseText = "") {
|
|
1254
1333
|
const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
|
|
1255
1334
|
let lastSignature;
|
|
1256
1335
|
let stablePolls = 0;
|
|
1257
1336
|
let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
|
|
1258
1337
|
|
|
1259
1338
|
while (Date.now() < deadline) {
|
|
1260
|
-
latest = await collectArtifactCandidates(job, responseIndex);
|
|
1339
|
+
latest = await collectArtifactCandidates(job, responseIndex, responseText);
|
|
1261
1340
|
const signature = latest.candidates.map((candidate) => candidate.label).join("\n");
|
|
1262
1341
|
if (signature === lastSignature) stablePolls += 1;
|
|
1263
1342
|
else {
|
|
@@ -1272,12 +1351,12 @@ async function waitForStableArtifactCandidates(job, responseIndex) {
|
|
|
1272
1351
|
return latest;
|
|
1273
1352
|
}
|
|
1274
1353
|
|
|
1275
|
-
async function reopenConversationForArtifacts(job, responseIndex, reason) {
|
|
1354
|
+
async function reopenConversationForArtifacts(job, responseIndex, responseText, reason) {
|
|
1276
1355
|
const targetUrl = job.chatUrl || stripQuery(await currentUrl(job));
|
|
1277
1356
|
await log(`Reopening conversation before artifact capture (${reason}): ${targetUrl}`);
|
|
1278
1357
|
await agentBrowser(job, "open", targetUrl);
|
|
1279
1358
|
await agentBrowser(job, "wait", "1500");
|
|
1280
|
-
return waitForStableArtifactCandidates(job, responseIndex);
|
|
1359
|
+
return waitForStableArtifactCandidates(job, responseIndex, responseText);
|
|
1281
1360
|
}
|
|
1282
1361
|
|
|
1283
1362
|
async function withHeartbeatWhile(task, intervalMs = ARTIFACT_DOWNLOAD_HEARTBEAT_MS) {
|
|
@@ -1309,14 +1388,14 @@ async function flushArtifactsState(artifacts) {
|
|
|
1309
1388
|
}));
|
|
1310
1389
|
}
|
|
1311
1390
|
|
|
1312
|
-
async function downloadArtifacts(job, responseIndex) {
|
|
1391
|
+
async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
1313
1392
|
if (!job.config.artifacts.capture) {
|
|
1314
1393
|
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
1315
1394
|
await mutateJob((current) => ({ ...current, artifactPaths: [] }));
|
|
1316
1395
|
return [];
|
|
1317
1396
|
}
|
|
1318
1397
|
|
|
1319
|
-
const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, "initial");
|
|
1398
|
+
const { targetSlice, candidates } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
|
|
1320
1399
|
if (!targetSlice) {
|
|
1321
1400
|
await log(`No assistant response found in snapshot for response index ${responseIndex}`);
|
|
1322
1401
|
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
@@ -1378,7 +1457,7 @@ async function downloadArtifacts(job, responseIndex) {
|
|
|
1378
1457
|
if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
|
|
1379
1458
|
artifacts.push({ displayName: candidate.label, unconfirmed: true, error: message });
|
|
1380
1459
|
} else {
|
|
1381
|
-
await reopenConversationForArtifacts(job, responseIndex, `retry ${attempt + 1} for ${candidate.label}`);
|
|
1460
|
+
await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${candidate.label}`);
|
|
1382
1461
|
await sleep(1_000);
|
|
1383
1462
|
}
|
|
1384
1463
|
} finally {
|
|
@@ -1443,7 +1522,7 @@ async function run() {
|
|
|
1443
1522
|
currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("extracting_response", { heartbeatAt: new Date().toISOString() }) }));
|
|
1444
1523
|
await secureWriteText(currentJob.responsePath, `${completion.responseText.trim()}\n`);
|
|
1445
1524
|
currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
|
|
1446
|
-
const artifacts = await downloadArtifacts(currentJob, completion.responseIndex);
|
|
1525
|
+
const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
|
|
1447
1526
|
const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
|
|
1448
1527
|
|
|
1449
1528
|
await heartbeat(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
]
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
|
-
"check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
|
|
44
|
-
"sanity:oracle": "
|
|
43
|
+
"check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
|
|
44
|
+
"sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
|
|
45
45
|
"pack:check": "npm pack --dry-run"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|