surf-cli 2.16.1 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/native/chatgpt-client-ui.cjs +314 -0
- package/native/chatgpt-client.cjs +58 -4
- package/native/file-transfer.cjs +5 -0
- package/native/oracle-cli.cjs +66 -5
- package/native/oracle-host.cjs +46 -2
- package/native/oracle-jobs.cjs +5 -3
- package/package.json +1 -1
- package/pi-extension/surf.ts +31 -2
- package/skills/surf/SKILL.md +5 -5
package/README.md
CHANGED
|
@@ -526,13 +526,13 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
|
|
|
526
526
|
|
|
527
527
|
#### Oracle
|
|
528
528
|
|
|
529
|
-
Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
|
|
529
|
+
Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, one direct local attachment with `--file`, and verifies requested model and reasoning effort before submission. Add `--github` when the consult needs the ChatGPT Chat tab and connected GitHub tool. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort.
|
|
530
530
|
|
|
531
531
|
```bash
|
|
532
|
-
surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
|
|
532
|
+
surf oracle ask "review this change" --files "src/**/*.ts" --file ./design.md --model gpt-5.5 --effort pro --github --detach --json
|
|
533
533
|
surf oracle status <job-id> --json
|
|
534
534
|
surf oracle result <job-id> --wait --json
|
|
535
|
-
surf oracle follow <job-id> "challenge that recommendation" --detach --json
|
|
535
|
+
surf oracle follow <job-id> "challenge that recommendation" --file ./follow-up.md --github --detach --json
|
|
536
536
|
```
|
|
537
537
|
|
|
538
538
|
Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
|
|
@@ -996,7 +996,7 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
|
|
|
996
996
|
|
|
997
997
|
It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
|
|
998
998
|
|
|
999
|
-
The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model` and `options.
|
|
999
|
+
The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so a Pi profile can request `model: gpt-5.6-sol` plus `effort: pro` and reach ChatGPT GPT-5.6 Sol with Pro effort through Surf, while `github: true` requires Chat mode and the connected GitHub tool. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
|
|
1000
1000
|
|
|
1001
1001
|
When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, `options.model: gpt-5.6-sol`, and `options.effort: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
|
|
1002
1002
|
|
|
@@ -29,6 +29,8 @@ const SELECTORS = {
|
|
|
29
29
|
effortMenuItem: 'button, [role="menuitem"], [role="menuitemradio"]',
|
|
30
30
|
effortMenuLabel: '.__menu-label, [class*="menu-label"]',
|
|
31
31
|
effortSubmenuTrigger: '[role="menuitem"][aria-haspopup="menu"], button[aria-haspopup="menu"]',
|
|
32
|
+
toolsButton:
|
|
33
|
+
'button[data-testid="composer-plus-btn"], button[aria-label="Add files and more"]',
|
|
32
34
|
selectedMenuIndicator:
|
|
33
35
|
'[aria-checked="true"], [aria-selected="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
|
|
34
36
|
assistantMessage:
|
|
@@ -170,6 +172,315 @@ async function waitForPromptReady(cdp, timeoutMs = 30000, signal) {
|
|
|
170
172
|
return false;
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
function uiError(code, message) {
|
|
176
|
+
const error = new Error(message);
|
|
177
|
+
error.code = code;
|
|
178
|
+
return error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readChatTabState(cdp, signal) {
|
|
182
|
+
return evaluate(
|
|
183
|
+
cdp,
|
|
184
|
+
`(() => {
|
|
185
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
186
|
+
const visible = (node) => {
|
|
187
|
+
if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
|
|
188
|
+
const style = window.getComputedStyle?.(node);
|
|
189
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
|
|
190
|
+
const rect = node.getBoundingClientRect?.();
|
|
191
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
192
|
+
};
|
|
193
|
+
const selected = (node) => node.getAttribute?.('aria-selected') === 'true' ||
|
|
194
|
+
node.getAttribute?.('aria-current') === 'page' ||
|
|
195
|
+
node.getAttribute?.('data-state') === 'active' ||
|
|
196
|
+
node.getAttribute?.('data-state') === 'selected' ||
|
|
197
|
+
node.getAttribute?.('data-active') === 'true' ||
|
|
198
|
+
/\\b(active|selected)\\b/i.test(String(node.className || ''));
|
|
199
|
+
const labelFor = (node) => [
|
|
200
|
+
node.innerText || node.textContent || '',
|
|
201
|
+
node.getAttribute?.('aria-label') || '',
|
|
202
|
+
node.getAttribute?.('title') || '',
|
|
203
|
+
].join(' ').replace(/\\s+/g, ' ').trim();
|
|
204
|
+
const matchesName = (node, name) => [
|
|
205
|
+
node.innerText || node.textContent || '',
|
|
206
|
+
node.getAttribute?.('aria-label') || '',
|
|
207
|
+
node.getAttribute?.('title') || '',
|
|
208
|
+
].some((value) => {
|
|
209
|
+
const normalized = normalize(value);
|
|
210
|
+
return normalized === name || normalized === name + ' tab' || normalized === 'switch to ' + name;
|
|
211
|
+
});
|
|
212
|
+
const nodes = Array.from(new Set([
|
|
213
|
+
...document.querySelectorAll('[role="tab"], button, a'),
|
|
214
|
+
])).filter(visible);
|
|
215
|
+
const details = (node) => ({ label: labelFor(node).slice(0, 120), selected: selected(node) });
|
|
216
|
+
const matches = (name) => nodes
|
|
217
|
+
.filter((node) => matchesName(node, name))
|
|
218
|
+
.map(details);
|
|
219
|
+
return { chat: matches('chat'), work: matches('work') };
|
|
220
|
+
})()`,
|
|
221
|
+
signal,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function selectChatTab(cdp, timeoutMs = 8000, signal) {
|
|
226
|
+
throwIfAborted(signal);
|
|
227
|
+
const read = () => readChatTabState(cdp, signal);
|
|
228
|
+
let state = await read();
|
|
229
|
+
if (state?.chat?.length === 1 && state.chat[0].selected) return state.chat[0].label;
|
|
230
|
+
if (state?.chat?.length === 0 && state?.work?.length > 0) {
|
|
231
|
+
throw uiError(
|
|
232
|
+
"chat_mode_unavailable",
|
|
233
|
+
"ChatGPT only exposes Work mode; the Chat tab is required for Oracle",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (state?.chat?.length !== 1) {
|
|
237
|
+
throw uiError(
|
|
238
|
+
"chat_mode_selector_drift",
|
|
239
|
+
state?.chat?.length
|
|
240
|
+
? "ChatGPT Chat tab selector drift: Chat tab is ambiguous"
|
|
241
|
+
: "ChatGPT Chat tab selector drift: Chat tab was not found",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const clicked = await evaluate(
|
|
246
|
+
cdp,
|
|
247
|
+
`(() => {
|
|
248
|
+
${buildClickDispatcher()}
|
|
249
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
250
|
+
const matchesName = (node) => [
|
|
251
|
+
node.innerText || node.textContent || '',
|
|
252
|
+
node.getAttribute?.('aria-label') || '',
|
|
253
|
+
node.getAttribute?.('title') || '',
|
|
254
|
+
].some((value) => {
|
|
255
|
+
const normalized = normalize(value);
|
|
256
|
+
return normalized === 'chat' || normalized === 'chat tab' || normalized === 'switch to chat';
|
|
257
|
+
});
|
|
258
|
+
const nodes = Array.from(new Set([
|
|
259
|
+
...document.querySelectorAll('[role="tab"], button, a'),
|
|
260
|
+
])).filter(matchesName);
|
|
261
|
+
return nodes.length === 1 ? dispatchClickSequence(nodes[0]) : false;
|
|
262
|
+
})()`,
|
|
263
|
+
signal,
|
|
264
|
+
);
|
|
265
|
+
if (!clicked) {
|
|
266
|
+
throw uiError("chat_mode_selector_drift", "ChatGPT Chat tab selector drift: Chat tab could not be clicked");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const deadline = Date.now() + timeoutMs;
|
|
270
|
+
while (Date.now() < deadline) {
|
|
271
|
+
await delay(100, signal);
|
|
272
|
+
state = await read();
|
|
273
|
+
if (state?.chat?.length === 1 && state.chat[0].selected) return state.chat[0].label;
|
|
274
|
+
if (state?.chat?.length === 0 && state?.work?.length > 0) {
|
|
275
|
+
throw uiError(
|
|
276
|
+
"chat_mode_unavailable",
|
|
277
|
+
"ChatGPT only exposes Work mode; the Chat tab is required for Oracle",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
throw uiError("chat_mode_selection_failed", "ChatGPT Chat tab could not be selected before timeout");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readGitHubToolState(cdp, signal) {
|
|
285
|
+
return evaluate(
|
|
286
|
+
cdp,
|
|
287
|
+
`(() => {
|
|
288
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
289
|
+
const visible = (node) => {
|
|
290
|
+
if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
|
|
291
|
+
const style = window.getComputedStyle?.(node);
|
|
292
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;
|
|
293
|
+
const rect = node.getBoundingClientRect?.();
|
|
294
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
295
|
+
};
|
|
296
|
+
const labelFor = (node) => [
|
|
297
|
+
node.innerText || node.textContent || '',
|
|
298
|
+
node.getAttribute?.('aria-label') || '',
|
|
299
|
+
node.getAttribute?.('title') || '',
|
|
300
|
+
node.getAttribute?.('data-testid') || '',
|
|
301
|
+
node.getAttribute?.('data-tool') || '',
|
|
302
|
+
].join(' ').replace(/\\s+/g, ' ').trim();
|
|
303
|
+
const selected = (node, label) => node.getAttribute?.('aria-checked') === 'true' ||
|
|
304
|
+
node.getAttribute?.('aria-selected') === 'true' ||
|
|
305
|
+
node.getAttribute?.('aria-pressed') === 'true' ||
|
|
306
|
+
node.getAttribute?.('data-selected') === 'true' ||
|
|
307
|
+
node.getAttribute?.('data-active') === 'true' ||
|
|
308
|
+
['checked', 'selected', 'on', 'active'].includes(normalize(node.getAttribute?.('data-state'))) ||
|
|
309
|
+
/\\b(active|selected)\\b/i.test(label);
|
|
310
|
+
const controls = Array.from(new Set([
|
|
311
|
+
...document.querySelectorAll('[role="menuitem"], [role="option"], [role="button"], button, [data-testid*="github" i], [data-tool*="github" i], [aria-label*="github" i], [title*="github" i]'),
|
|
312
|
+
])).filter(visible);
|
|
313
|
+
const github = controls.filter((node) => normalize(labelFor(node)).includes('github'));
|
|
314
|
+
return {
|
|
315
|
+
controls: github.map((node) => {
|
|
316
|
+
const label = labelFor(node).slice(0, 160);
|
|
317
|
+
return {
|
|
318
|
+
label,
|
|
319
|
+
role: node.getAttribute?.('role') || (node.tagName === 'BUTTON' ? 'button' : null),
|
|
320
|
+
selected: selected(node, label),
|
|
321
|
+
disconnected: /\\b(disconnected|not connected|connect|authorize|sign in)\\b/i.test(label),
|
|
322
|
+
testId: node.getAttribute?.('data-testid') || null,
|
|
323
|
+
};
|
|
324
|
+
}),
|
|
325
|
+
};
|
|
326
|
+
})()`,
|
|
327
|
+
signal,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function openToolsMenu(cdp, signal) {
|
|
332
|
+
return evaluate(
|
|
333
|
+
cdp,
|
|
334
|
+
`(() => {
|
|
335
|
+
${buildClickDispatcher()}
|
|
336
|
+
const selectors = ${JSON.stringify(SELECTORS.toolsButton.split(", "))};
|
|
337
|
+
const visible = (node) => {
|
|
338
|
+
if (!node || node.hasAttribute?.('hidden') || node.getAttribute?.('aria-hidden') === 'true') return false;
|
|
339
|
+
const style = window.getComputedStyle?.(node);
|
|
340
|
+
return !style || (style.display !== 'none' && style.visibility !== 'hidden');
|
|
341
|
+
};
|
|
342
|
+
for (const selector of selectors) {
|
|
343
|
+
const node = Array.from(document.querySelectorAll(selector)).find(visible);
|
|
344
|
+
if (node) return dispatchClickSequence(node);
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
})()`,
|
|
348
|
+
signal,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function clickGitHubTool(cdp, signal) {
|
|
353
|
+
return evaluate(
|
|
354
|
+
cdp,
|
|
355
|
+
`(() => {
|
|
356
|
+
${buildClickDispatcher()}
|
|
357
|
+
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
|
|
358
|
+
const labelFor = (node) => [
|
|
359
|
+
node.innerText || node.textContent || '',
|
|
360
|
+
node.getAttribute?.('aria-label') || '',
|
|
361
|
+
node.getAttribute?.('title') || '',
|
|
362
|
+
node.getAttribute?.('data-testid') || '',
|
|
363
|
+
node.getAttribute?.('data-tool') || '',
|
|
364
|
+
].join(' ').replace(/\\s+/g, ' ').trim();
|
|
365
|
+
const controls = Array.from(new Set([
|
|
366
|
+
...document.querySelectorAll('[role="menuitem"], [role="option"], [role="button"], button, [data-testid*="github" i], [data-tool*="github" i], [aria-label*="github" i], [title*="github" i]'),
|
|
367
|
+
])).filter((node) => normalize(labelFor(node)).includes('github'));
|
|
368
|
+
const menuItems = controls.filter((node) => ['menuitem', 'option'].includes(node.getAttribute?.('role')));
|
|
369
|
+
const candidates = menuItems.length > 0 ? menuItems : controls;
|
|
370
|
+
return candidates.length === 1 ? dispatchClickSequence(candidates[0]) : false;
|
|
371
|
+
})()`,
|
|
372
|
+
signal,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function selectGitHubTool(cdp, timeoutMs = 10000, signal) {
|
|
377
|
+
throwIfAborted(signal);
|
|
378
|
+
let state = await readGitHubToolState(cdp, signal);
|
|
379
|
+
const findConnected = () => state?.controls?.filter((control) => !control.disconnected) || [];
|
|
380
|
+
if (state?.controls?.some((control) => control.disconnected)) {
|
|
381
|
+
throw uiError(
|
|
382
|
+
"github_tool_disconnected",
|
|
383
|
+
"ChatGPT GitHub tool is missing or disconnected; connect GitHub before running Oracle with --github",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (findConnected().length === 1 && state.controls[0].selected) return state.controls[0].label;
|
|
387
|
+
if (state?.controls?.length > 1) {
|
|
388
|
+
throw uiError("github_tool_selector_drift", "ChatGPT GitHub tool selector drift: GitHub tool is ambiguous");
|
|
389
|
+
}
|
|
390
|
+
if (state?.controls?.length === 0) {
|
|
391
|
+
const opened = await openToolsMenu(cdp, signal);
|
|
392
|
+
if (!opened) {
|
|
393
|
+
throw uiError("github_tool_selector_drift", "ChatGPT tools selector drift: tools menu could not be opened");
|
|
394
|
+
}
|
|
395
|
+
const menuDeadline = Date.now() + timeoutMs;
|
|
396
|
+
while (Date.now() < menuDeadline) {
|
|
397
|
+
await delay(100, signal);
|
|
398
|
+
state = await readGitHubToolState(cdp, signal);
|
|
399
|
+
if (state?.controls?.some((control) => control.disconnected)) {
|
|
400
|
+
throw uiError(
|
|
401
|
+
"github_tool_disconnected",
|
|
402
|
+
"ChatGPT GitHub tool is missing or disconnected; connect GitHub before running Oracle with --github",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (state?.controls?.length > 0) break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!state?.controls?.length) {
|
|
409
|
+
throw uiError("github_tool_missing", "ChatGPT GitHub tool is not available in the tools menu");
|
|
410
|
+
}
|
|
411
|
+
if (state.controls.length === 1 && state.controls[0].selected) return state.controls[0].label;
|
|
412
|
+
if (state.controls.length !== 1 || !(await clickGitHubTool(cdp, signal))) {
|
|
413
|
+
throw uiError("github_tool_selector_drift", "ChatGPT GitHub tool selector drift: GitHub tool could not be selected");
|
|
414
|
+
}
|
|
415
|
+
const deadline = Date.now() + timeoutMs;
|
|
416
|
+
while (Date.now() < deadline) {
|
|
417
|
+
await delay(100, signal);
|
|
418
|
+
state = await readGitHubToolState(cdp, signal);
|
|
419
|
+
if (state?.controls?.some((control) => control.disconnected)) {
|
|
420
|
+
throw uiError(
|
|
421
|
+
"github_tool_disconnected",
|
|
422
|
+
"ChatGPT GitHub tool became disconnected while selecting it",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (state?.controls?.length === 1 && state.controls[0].selected) return state.controls[0].label;
|
|
426
|
+
}
|
|
427
|
+
throw uiError("github_tool_selection_failed", "ChatGPT GitHub tool could not be verified after selection");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function waitForChatGPTAttachment(cdp, filePaths, timeoutMs = 30000, signal) {
|
|
431
|
+
const expectedNames = (Array.isArray(filePaths) ? filePaths : [filePaths])
|
|
432
|
+
.map((filePath) => String(filePath).split(/[\\/]/).pop().toLowerCase());
|
|
433
|
+
const deadline = Date.now() + timeoutMs;
|
|
434
|
+
while (Date.now() < deadline) {
|
|
435
|
+
const state = await evaluate(
|
|
436
|
+
cdp,
|
|
437
|
+
`(() => {
|
|
438
|
+
const scope = document.querySelector('form') || document.querySelector('[data-testid*="composer"]') || document;
|
|
439
|
+
const text = (scope.innerText || scope.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
440
|
+
const inputs = Array.from(scope.querySelectorAll?.('input[type="file"]') || []);
|
|
441
|
+
const fileCount = inputs.reduce((count, input) => count + (input.files?.length || 0), 0);
|
|
442
|
+
const attachmentNodes = Array.from(scope.querySelectorAll?.('[data-testid*="attachment" i], [data-testid*="file" i], [aria-label*="remove attachment" i], [aria-label*="remove file" i]') || [])
|
|
443
|
+
.filter((node) => node.tagName !== 'INPUT' || node.type !== 'file');
|
|
444
|
+
const hasAttachmentNode = attachmentNodes.some((node) => {
|
|
445
|
+
const rect = node.getBoundingClientRect?.();
|
|
446
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
447
|
+
});
|
|
448
|
+
const attachmentLabels = attachmentNodes
|
|
449
|
+
.filter((node) => {
|
|
450
|
+
const rect = node.getBoundingClientRect?.();
|
|
451
|
+
return !rect || (rect.width > 0 && rect.height > 0);
|
|
452
|
+
})
|
|
453
|
+
.map((node) => [
|
|
454
|
+
node.innerText || node.textContent || '',
|
|
455
|
+
node.getAttribute?.('aria-label') || '',
|
|
456
|
+
node.getAttribute?.('title') || '',
|
|
457
|
+
].join(' ').replace(/\\s+/g, ' ').trim().toLowerCase())
|
|
458
|
+
.filter(Boolean);
|
|
459
|
+
const processingError = /\\b(upload failed|failed to upload|couldn.t upload|unsupported file|file too large|processing failed|error processing)\\b/i.test(text);
|
|
460
|
+
return { fileCount, hasAttachmentNode, attachmentLabels, processingError, text: text.slice(-300) };
|
|
461
|
+
})()`,
|
|
462
|
+
signal,
|
|
463
|
+
);
|
|
464
|
+
if (state?.processingError) {
|
|
465
|
+
throw uiError(
|
|
466
|
+
"attachment_processing",
|
|
467
|
+
`ChatGPT attachment processing failed${state.text ? `: ${state.text}` : ""}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const attachmentLabels = Array.isArray(state?.attachmentLabels) ? state.attachmentLabels : [];
|
|
471
|
+
const hasExpectedName = expectedNames.every((expected) =>
|
|
472
|
+
attachmentLabels.some((label) => label.includes(expected)),
|
|
473
|
+
);
|
|
474
|
+
const hasPotentialFilename = attachmentLabels.some((label) => /(?:^|\\s)[\\w.-]+\\.[a-z0-9]{1,8}(?:$|\\s|[)\\]])/i.test(label));
|
|
475
|
+
if (state?.hasAttachmentNode && (hasExpectedName || !hasPotentialFilename)) return true;
|
|
476
|
+
await delay(250, signal);
|
|
477
|
+
}
|
|
478
|
+
throw uiError(
|
|
479
|
+
"attachment_processing",
|
|
480
|
+
`ChatGPT attachment processing did not complete for ${expectedNames.filter(Boolean).join(", ") || "the selected file"}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
173
484
|
function verificationError(kind, requested, items = [], invalid = false) {
|
|
174
485
|
const safeRequested = String(requested || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
175
486
|
const available = boundedOptionLabels(items);
|
|
@@ -552,10 +863,13 @@ module.exports = {
|
|
|
552
863
|
normalizeChatGPTModelChoice,
|
|
553
864
|
resolveChatGPTEffortMenuOption,
|
|
554
865
|
resolveChatGPTModelMenuOption,
|
|
866
|
+
selectChatTab,
|
|
555
867
|
selectEffort,
|
|
868
|
+
selectGitHubTool,
|
|
556
869
|
selectModel,
|
|
557
870
|
verifyChatGPTEffortSelection,
|
|
558
871
|
verifyChatGPTModelSelection,
|
|
872
|
+
waitForChatGPTAttachment,
|
|
559
873
|
typePrompt,
|
|
560
874
|
waitForPageLoad,
|
|
561
875
|
waitForPromptReady,
|
|
@@ -11,10 +11,13 @@ const {
|
|
|
11
11
|
resolveChatGPTEffortMenuOption,
|
|
12
12
|
resolveChatGPTModelMenuOption,
|
|
13
13
|
selectEffort,
|
|
14
|
+
selectChatTab,
|
|
15
|
+
selectGitHubTool,
|
|
14
16
|
selectModel,
|
|
15
17
|
typePrompt,
|
|
16
18
|
verifyChatGPTEffortSelection,
|
|
17
19
|
verifyChatGPTModelSelection,
|
|
20
|
+
waitForChatGPTAttachment,
|
|
18
21
|
waitForPageLoad,
|
|
19
22
|
waitForPromptReady,
|
|
20
23
|
} = require("./chatgpt-client-ui.cjs");
|
|
@@ -32,6 +35,12 @@ const {
|
|
|
32
35
|
|
|
33
36
|
const CHATGPT_URL = "https://chatgpt.com/";
|
|
34
37
|
const RESPONSE_STARTED_AT = Symbol("responseStartedAt");
|
|
38
|
+
const ATTACHMENT_ERROR_CODES = new Set([
|
|
39
|
+
"attachment_chooser_interception",
|
|
40
|
+
"attachment_file_access",
|
|
41
|
+
"attachment_processing",
|
|
42
|
+
"attachment_selector_drift",
|
|
43
|
+
]);
|
|
35
44
|
|
|
36
45
|
function hasRequiredCookies(cookies) {
|
|
37
46
|
if (!cookies || !Array.isArray(cookies)) return false;
|
|
@@ -93,6 +102,7 @@ async function dispatch(options) {
|
|
|
93
102
|
cdpEvaluate,
|
|
94
103
|
cdpCommand,
|
|
95
104
|
uploadFile,
|
|
105
|
+
github = false,
|
|
96
106
|
beforeSubmit,
|
|
97
107
|
afterSubmit,
|
|
98
108
|
startUrl,
|
|
@@ -141,6 +151,12 @@ async function dispatch(options) {
|
|
|
141
151
|
throw codedError("ChatGPT login required", "auth");
|
|
142
152
|
}
|
|
143
153
|
log("Login verified");
|
|
154
|
+
if (github) {
|
|
155
|
+
await selectChatTab(cdp, 10000, signal);
|
|
156
|
+
log("Verified Chat tab");
|
|
157
|
+
await selectGitHubTool(cdp, 10000, signal);
|
|
158
|
+
log("Verified GitHub tool");
|
|
159
|
+
}
|
|
144
160
|
const promptReady = await waitForPromptReady(cdp, 30000, signal);
|
|
145
161
|
if (!promptReady) {
|
|
146
162
|
throw new Error("Prompt textarea not ready");
|
|
@@ -154,22 +170,46 @@ async function dispatch(options) {
|
|
|
154
170
|
}
|
|
155
171
|
if (file) {
|
|
156
172
|
if (!uploadFile) {
|
|
157
|
-
throw
|
|
173
|
+
throw codedError(
|
|
158
174
|
"ChatGPT file upload unavailable: native host did not provide upload callback",
|
|
175
|
+
"attachment_chooser_interception",
|
|
159
176
|
);
|
|
160
177
|
}
|
|
161
178
|
const files = Array.isArray(file) ? file : [file];
|
|
162
179
|
const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
|
|
163
180
|
log(`Uploading ${absFiles.length} file(s) to ChatGPT...`);
|
|
164
|
-
|
|
181
|
+
let uploadResult;
|
|
182
|
+
try {
|
|
183
|
+
uploadResult = await guardedUploadFile(tabId, absFiles);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw classifyError(
|
|
186
|
+
error,
|
|
187
|
+
"attachment_chooser_interception",
|
|
188
|
+
["attachment_file_access", "attachment_processing", "attachment_selector_drift", "attachment_chooser_interception"],
|
|
189
|
+
);
|
|
190
|
+
}
|
|
165
191
|
if (uploadResult?.error) {
|
|
166
|
-
|
|
192
|
+
const uploadCode = ATTACHMENT_ERROR_CODES.has(uploadResult.errorCode)
|
|
193
|
+
? uploadResult.errorCode
|
|
194
|
+
: "attachment_chooser_interception";
|
|
195
|
+
throw codedError(
|
|
196
|
+
`ChatGPT file upload failed: ${uploadResult.error}`,
|
|
197
|
+
uploadCode,
|
|
198
|
+
);
|
|
167
199
|
}
|
|
168
200
|
if (!uploadResult?.success) {
|
|
169
|
-
throw
|
|
201
|
+
throw codedError(
|
|
202
|
+
"ChatGPT attachment processing failed: upload did not report success",
|
|
203
|
+
"attachment_processing",
|
|
204
|
+
);
|
|
170
205
|
}
|
|
171
206
|
log("File uploaded, waiting for ChatGPT attachment processing...");
|
|
172
207
|
await delay(1500, signal);
|
|
208
|
+
try {
|
|
209
|
+
await waitForChatGPTAttachment(cdp, absFiles, 30000, signal);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw classifyError(error, "attachment_processing", ["attachment_processing"]);
|
|
212
|
+
}
|
|
173
213
|
}
|
|
174
214
|
await typePrompt(cdp, inputCdp, prompt, signal);
|
|
175
215
|
log("Prompt typed");
|
|
@@ -201,6 +241,17 @@ async function dispatch(options) {
|
|
|
201
241
|
throw classifyError(error, "dispatch_failed", [
|
|
202
242
|
"auth",
|
|
203
243
|
"cloudflare",
|
|
244
|
+
"attachment_chooser_interception",
|
|
245
|
+
"attachment_file_access",
|
|
246
|
+
"attachment_processing",
|
|
247
|
+
"attachment_selector_drift",
|
|
248
|
+
"chat_mode_selection_failed",
|
|
249
|
+
"chat_mode_selector_drift",
|
|
250
|
+
"chat_mode_unavailable",
|
|
251
|
+
"github_tool_disconnected",
|
|
252
|
+
"github_tool_missing",
|
|
253
|
+
"github_tool_selection_failed",
|
|
254
|
+
"github_tool_selector_drift",
|
|
204
255
|
"model_verification_failed",
|
|
205
256
|
]);
|
|
206
257
|
}
|
|
@@ -353,5 +404,8 @@ module.exports = {
|
|
|
353
404
|
extractConversationUrl,
|
|
354
405
|
verifyChatGPTEffortSelection,
|
|
355
406
|
verifyChatGPTModelSelection,
|
|
407
|
+
selectChatTab,
|
|
408
|
+
selectGitHubTool,
|
|
409
|
+
waitForChatGPTAttachment,
|
|
356
410
|
CHATGPT_URL,
|
|
357
411
|
};
|
package/native/file-transfer.cjs
CHANGED
|
@@ -270,6 +270,11 @@ function validateLocalToolPaths(tool, args = {}) {
|
|
|
270
270
|
? args.files.map((value) => normalize("files", value))
|
|
271
271
|
: String(args.files).split(",").map((value) => normalize("files", value.trim())).join(",");
|
|
272
272
|
}
|
|
273
|
+
if (tool === "oracle.ask" && args.file !== undefined) {
|
|
274
|
+
normalized.file = Array.isArray(args.file)
|
|
275
|
+
? args.file.map((value) => normalize("file", value))
|
|
276
|
+
: normalize("file", args.file);
|
|
277
|
+
}
|
|
273
278
|
if (tool === "screenshot") {
|
|
274
279
|
if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
|
|
275
280
|
for (const field of ["savePath", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
|
package/native/oracle-cli.cjs
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
const { openClientTransport } = require("./client-transport.cjs");
|
|
2
2
|
const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
|
|
3
3
|
const { assembleContext } = require("./oracle-context.cjs");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
4
6
|
|
|
5
7
|
const RESULT_TIMEOUT_SECONDS = 20;
|
|
6
8
|
const POLL_DELAYS_MS = [5000, 10000, 20000, 40000, 60000];
|
|
7
9
|
const ORACLE_ERROR_CODES = new Set([
|
|
8
10
|
"auth",
|
|
11
|
+
"attachment_chooser_interception",
|
|
12
|
+
"attachment_file_access",
|
|
13
|
+
"attachment_processing",
|
|
14
|
+
"attachment_selector_drift",
|
|
9
15
|
"capacity",
|
|
16
|
+
"chat_mode_selection_failed",
|
|
17
|
+
"chat_mode_selector_drift",
|
|
18
|
+
"chat_mode_unavailable",
|
|
10
19
|
"cloudflare",
|
|
11
20
|
"context_incomplete",
|
|
12
21
|
"dispatch_failed",
|
|
22
|
+
"github_tool_disconnected",
|
|
23
|
+
"github_tool_missing",
|
|
24
|
+
"github_tool_selection_failed",
|
|
25
|
+
"github_tool_selector_drift",
|
|
13
26
|
"harvest_failed",
|
|
27
|
+
"invalid_request",
|
|
14
28
|
"invalid_transition",
|
|
15
29
|
"model_verification_failed",
|
|
16
30
|
"not_found",
|
|
@@ -30,8 +44,10 @@ Commands:
|
|
|
30
44
|
|
|
31
45
|
Ask/follow options:
|
|
32
46
|
--files <glob> Add context files (repeatable)
|
|
47
|
+
--file <path> Attach one local file
|
|
33
48
|
--model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
|
|
34
49
|
--effort <effort> Select effort: light, standard, extended, heavy, pro
|
|
50
|
+
--github Require the ChatGPT Chat tab and GitHub tool
|
|
35
51
|
--detach Return after dispatch
|
|
36
52
|
--allow-sensitive Allow deny-listed context files
|
|
37
53
|
|
|
@@ -56,11 +72,12 @@ function requireOptionValue(argv, index, name) {
|
|
|
56
72
|
|
|
57
73
|
function parseOptions(argv) {
|
|
58
74
|
const positional = [];
|
|
59
|
-
const options = { files: [] };
|
|
60
|
-
const valueOptions = new Set(["files", "model", "effort"]);
|
|
75
|
+
const options = { files: [], file: [] };
|
|
76
|
+
const valueOptions = new Set(["files", "file", "model", "effort"]);
|
|
61
77
|
const booleanOptions = new Set([
|
|
62
78
|
"allow-sensitive",
|
|
63
79
|
"detach",
|
|
80
|
+
"github",
|
|
64
81
|
"json",
|
|
65
82
|
"no-lock",
|
|
66
83
|
"wait",
|
|
@@ -75,7 +92,7 @@ function parseOptions(argv) {
|
|
|
75
92
|
const name = value.slice(2);
|
|
76
93
|
if (valueOptions.has(name)) {
|
|
77
94
|
const optionValue = requireOptionValue(argv, index, name);
|
|
78
|
-
if (name === "files") options.
|
|
95
|
+
if (name === "files" || name === "file") options[name].push(optionValue);
|
|
79
96
|
else options[name] = optionValue;
|
|
80
97
|
index += 1;
|
|
81
98
|
} else if (booleanOptions.has(name)) {
|
|
@@ -94,14 +111,16 @@ function assertAllowedOptions(command, options) {
|
|
|
94
111
|
"allow-sensitive",
|
|
95
112
|
"detach",
|
|
96
113
|
"effort",
|
|
114
|
+
"file",
|
|
97
115
|
"files",
|
|
116
|
+
"github",
|
|
98
117
|
"model",
|
|
99
118
|
]);
|
|
100
119
|
const allowed = command === "ask" || command === "follow"
|
|
101
120
|
? ask
|
|
102
121
|
: command === "result" ? new Set([...common, "wait"]) : common;
|
|
103
122
|
for (const [name, value] of Object.entries(options)) {
|
|
104
|
-
if (name === "files" && value.length === 0) continue;
|
|
123
|
+
if ((name === "files" || name === "file") && value.length === 0) continue;
|
|
105
124
|
if (value !== undefined && !allowed.has(name)) {
|
|
106
125
|
throw codedError("invalid_transition", `--${name} is not supported by oracle ${command}`);
|
|
107
126
|
}
|
|
@@ -131,8 +150,12 @@ function parseOracleCommand(argv) {
|
|
|
131
150
|
command,
|
|
132
151
|
prompt,
|
|
133
152
|
files: parsed.options.files,
|
|
153
|
+
...(parsed.options.file.length > 0
|
|
154
|
+
? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
|
|
155
|
+
: {}),
|
|
134
156
|
model: parsed.options.model,
|
|
135
157
|
effort: parsed.options.effort,
|
|
158
|
+
github: parsed.options.github === true,
|
|
136
159
|
detach: parsed.options.detach === true,
|
|
137
160
|
allowSensitive: parsed.options["allow-sensitive"] === true,
|
|
138
161
|
json,
|
|
@@ -151,8 +174,12 @@ function parseOracleCommand(argv) {
|
|
|
151
174
|
id,
|
|
152
175
|
prompt,
|
|
153
176
|
files: parsed.options.files,
|
|
177
|
+
...(parsed.options.file.length > 0
|
|
178
|
+
? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
|
|
179
|
+
: {}),
|
|
154
180
|
model: parsed.options.model,
|
|
155
181
|
effort: parsed.options.effort,
|
|
182
|
+
github: parsed.options.github === true,
|
|
156
183
|
detach: parsed.options.detach === true,
|
|
157
184
|
allowSensitive: parsed.options["allow-sensitive"] === true,
|
|
158
185
|
json,
|
|
@@ -191,12 +218,44 @@ function composeAskRequest(spec, context) {
|
|
|
191
218
|
prompt,
|
|
192
219
|
...(spec.model ? { model: spec.model } : {}),
|
|
193
220
|
...(spec.effort ? { effort: spec.effort } : {}),
|
|
221
|
+
...(spec.file ? { file: spec.file } : {}),
|
|
222
|
+
...(spec.github ? { github: true } : {}),
|
|
194
223
|
...(context ? { contextManifest: context.manifest } : {}),
|
|
195
224
|
...(context?.bundlePath ? { bundlePath: context.bundlePath } : {}),
|
|
196
225
|
...(spec.id ? { follow: spec.id } : {}),
|
|
197
226
|
};
|
|
198
227
|
}
|
|
199
228
|
|
|
229
|
+
async function resolveOracleAttachment(value, cwd = process.cwd()) {
|
|
230
|
+
const values = value === undefined || value === null
|
|
231
|
+
? []
|
|
232
|
+
: Array.isArray(value) ? value : [value];
|
|
233
|
+
if (values.length > 1) {
|
|
234
|
+
throw codedError(
|
|
235
|
+
"attachment_file_access",
|
|
236
|
+
"Oracle supports one explicit local attachment; provide a single --file path",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (values.length === 0) return undefined;
|
|
240
|
+
const requested = values[0];
|
|
241
|
+
if (typeof requested !== "string" || !requested.trim()) {
|
|
242
|
+
throw codedError("attachment_file_access", "Oracle attachment file access failed: --file must be a path");
|
|
243
|
+
}
|
|
244
|
+
const resolved = path.resolve(cwd, requested);
|
|
245
|
+
try {
|
|
246
|
+
const stats = await fs.promises.stat(resolved);
|
|
247
|
+
if (!stats.isFile()) throw new Error("not a regular file");
|
|
248
|
+
await fs.promises.access(resolved, fs.constants.R_OK);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
throw codedError(
|
|
251
|
+
"attachment_file_access",
|
|
252
|
+
`Oracle attachment file access failed for ${resolved}: ${error?.message || error}`,
|
|
253
|
+
{ path: resolved },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return resolved;
|
|
257
|
+
}
|
|
258
|
+
|
|
200
259
|
function unwrapResponse(response) {
|
|
201
260
|
if (response?.error) {
|
|
202
261
|
const message = response.error.message
|
|
@@ -389,6 +448,7 @@ async function handleOracleCli(argv, {
|
|
|
389
448
|
}
|
|
390
449
|
}
|
|
391
450
|
|
|
451
|
+
const attachment = await resolveOracleAttachment(spec.file, cwd);
|
|
392
452
|
const context = spec.files.length > 0
|
|
393
453
|
? await assembleContext({
|
|
394
454
|
files: spec.files,
|
|
@@ -396,7 +456,7 @@ async function handleOracleCli(argv, {
|
|
|
396
456
|
allowSensitive: spec.allowSensitive,
|
|
397
457
|
})
|
|
398
458
|
: null;
|
|
399
|
-
const request = composeAskRequest(spec, context);
|
|
459
|
+
const request = composeAskRequest({ ...spec, ...(attachment ? { file: attachment } : {}) }, context);
|
|
400
460
|
const dispatchInterrupt = () => {
|
|
401
461
|
stderr.write(
|
|
402
462
|
"Interrupted during dispatch. A job may already have been created. Run surf oracle status or surf oracle list to find it.\n",
|
|
@@ -430,5 +490,6 @@ module.exports = {
|
|
|
430
490
|
formatOracleOutput,
|
|
431
491
|
handleOracleCli,
|
|
432
492
|
parseOracleCommand,
|
|
493
|
+
resolveOracleAttachment,
|
|
433
494
|
shapeOracleError,
|
|
434
495
|
};
|
package/native/oracle-host.cjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
1
3
|
const chatgptClient = require("./chatgpt-client.cjs");
|
|
2
4
|
const oracleJobs = require("./oracle-jobs.cjs");
|
|
3
5
|
|
|
@@ -23,6 +25,39 @@ function withJobId(error, jobId, fallbackCode) {
|
|
|
23
25
|
return result;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
async function resolveOracleAttachments(args) {
|
|
29
|
+
const explicit = args?.file === undefined || args?.file === null
|
|
30
|
+
? []
|
|
31
|
+
: Array.isArray(args.file) ? args.file : [args.file];
|
|
32
|
+
if (explicit.length > 1) {
|
|
33
|
+
throw codedError(
|
|
34
|
+
"attachment_file_access",
|
|
35
|
+
"Oracle supports one explicit local attachment; provide a single --file path",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const requested = [...explicit, ...(args?.bundlePath ? [args.bundlePath] : [])];
|
|
39
|
+
const resolved = [];
|
|
40
|
+
for (const filePath of requested) {
|
|
41
|
+
if (typeof filePath !== "string" || !filePath.trim()) {
|
|
42
|
+
throw codedError("attachment_file_access", "Oracle attachment file access failed: file path is required");
|
|
43
|
+
}
|
|
44
|
+
const absolutePath = path.resolve(filePath);
|
|
45
|
+
try {
|
|
46
|
+
const stats = await fs.promises.stat(absolutePath);
|
|
47
|
+
if (!stats.isFile()) throw new Error("not a regular file");
|
|
48
|
+
await fs.promises.access(absolutePath, fs.constants.R_OK);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw codedError(
|
|
51
|
+
"attachment_file_access",
|
|
52
|
+
`Oracle attachment file access failed for ${absolutePath}: ${error?.message || error}`,
|
|
53
|
+
{ path: absolutePath },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
resolved.push(absolutePath);
|
|
57
|
+
}
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderUploadMessage, log }) {
|
|
27
62
|
const closeTab = (request, tabId) => requestCallExtension(
|
|
28
63
|
request,
|
|
@@ -65,6 +100,7 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
65
100
|
async function ask(request, args) {
|
|
66
101
|
assertLocalOracleRequest(request);
|
|
67
102
|
const model = args.model ? chatgptClient.normalizeChatGPTModelChoice(args.model) : null;
|
|
103
|
+
const explicitAttachmentPaths = await resolveOracleAttachments({ ...args, bundlePath: undefined });
|
|
68
104
|
const created = oracleJobs.createJob({
|
|
69
105
|
prompt: args.prompt,
|
|
70
106
|
contextManifest: args.contextManifest,
|
|
@@ -72,11 +108,16 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
72
108
|
effortRequested: args.effort ?? null,
|
|
73
109
|
follow: args.follow ?? null,
|
|
74
110
|
requestId: args.requestId ?? null,
|
|
111
|
+
attachmentPaths: explicitAttachmentPaths,
|
|
112
|
+
github: args.github === true,
|
|
75
113
|
});
|
|
76
114
|
if (created.requestDeduped) return oracleJobs.getJob(created.id);
|
|
77
115
|
let createdTabId = null;
|
|
78
116
|
|
|
79
117
|
try {
|
|
118
|
+
const attachmentPaths = args?.bundlePath
|
|
119
|
+
? [...explicitAttachmentPaths, ...(await resolveOracleAttachments({ bundlePath: args.bundlePath }))]
|
|
120
|
+
: explicitAttachmentPaths;
|
|
80
121
|
let parent = null;
|
|
81
122
|
if (args.follow) {
|
|
82
123
|
parent = oracleJobs.getJob(args.follow);
|
|
@@ -93,7 +134,10 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
93
134
|
prompt: args.prompt,
|
|
94
135
|
model,
|
|
95
136
|
effort: args.effort,
|
|
96
|
-
file:
|
|
137
|
+
file: attachmentPaths.length > 0
|
|
138
|
+
? attachmentPaths.length === 1 ? attachmentPaths[0] : attachmentPaths
|
|
139
|
+
: undefined,
|
|
140
|
+
...(args.github === true ? { github: true } : {}),
|
|
97
141
|
startUrl: parent?.conversationUrl,
|
|
98
142
|
createTab: async () => {
|
|
99
143
|
const tabInfo = await browserOptions(request).createTab();
|
|
@@ -304,4 +348,4 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
|
|
|
304
348
|
};
|
|
305
349
|
}
|
|
306
350
|
|
|
307
|
-
module.exports = { assertLocalOracleRequest, createOracleHost };
|
|
351
|
+
module.exports = { assertLocalOracleRequest, createOracleHost, resolveOracleAttachments };
|
package/native/oracle-jobs.cjs
CHANGED
|
@@ -47,13 +47,15 @@ function stableJson(value) {
|
|
|
47
47
|
return JSON.stringify(value) ?? "null";
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow }) {
|
|
50
|
+
function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github }) {
|
|
51
51
|
return promptDigest(stableJson({
|
|
52
52
|
promptDigest: promptDigest(prompt),
|
|
53
53
|
contextManifest: contextManifest ?? {},
|
|
54
54
|
model: model ?? null,
|
|
55
55
|
effortRequested: effortRequested ?? null,
|
|
56
56
|
follow: follow ?? null,
|
|
57
|
+
attachmentPaths: attachmentPaths ?? [],
|
|
58
|
+
github: github === true,
|
|
57
59
|
}));
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -92,13 +94,13 @@ function readJobs(root = getPrivateStateRoot()) {
|
|
|
92
94
|
.map((job) => hydrateJobMetadata(job, root));
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null }) {
|
|
97
|
+
function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null, attachmentPaths = [], github = false }) {
|
|
96
98
|
const root = getPrivateStateRoot();
|
|
97
99
|
const base = oracleRoot(root);
|
|
98
100
|
ensurePrivateDir(base, root);
|
|
99
101
|
const safeRequestId = normalizedRequestId(requestId);
|
|
100
102
|
const fingerprint = safeRequestId
|
|
101
|
-
? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow })
|
|
103
|
+
? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github })
|
|
102
104
|
: null;
|
|
103
105
|
if (safeRequestId) {
|
|
104
106
|
const existing = readJobs(root).find((job) => job.requestId === safeRequestId);
|
package/package.json
CHANGED
package/pi-extension/surf.ts
CHANGED
|
@@ -375,6 +375,27 @@ function oracleOption(input: Record<string, unknown>, key: "model" | "effort"):
|
|
|
375
375
|
return typeof direct === "string" ? direct : undefined;
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
function oracleAttachmentOption(input: Record<string, unknown>): string | string[] | undefined {
|
|
379
|
+
const options = input.options;
|
|
380
|
+
const optionRecord = options && typeof options === "object" && !Array.isArray(options)
|
|
381
|
+
? options as Record<string, unknown>
|
|
382
|
+
: undefined;
|
|
383
|
+
const optionValue = optionRecord?.file;
|
|
384
|
+
const value = optionValue === undefined ? input.file : optionValue;
|
|
385
|
+
if (typeof value === "string") return value;
|
|
386
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value as string[];
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function oracleBooleanOption(input: Record<string, unknown>, key: string): boolean {
|
|
391
|
+
const options = input.options;
|
|
392
|
+
const optionValue = options && typeof options === "object" && !Array.isArray(options)
|
|
393
|
+
? (options as Record<string, unknown>)[key]
|
|
394
|
+
: undefined;
|
|
395
|
+
const value = optionValue === undefined ? input[key] : optionValue;
|
|
396
|
+
return value === true;
|
|
397
|
+
}
|
|
398
|
+
|
|
378
399
|
function optionalString(input: Record<string, unknown>, key: string): string | undefined {
|
|
379
400
|
const value = input[key];
|
|
380
401
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
@@ -426,10 +447,14 @@ export function createOracleExternalJobProvider(
|
|
|
426
447
|
if (!prompt.trim()) throw new Error("prompt required");
|
|
427
448
|
const model = oracleOption(input, "model");
|
|
428
449
|
const effort = oracleOption(input, "effort");
|
|
450
|
+
const file = oracleAttachmentOption(input);
|
|
451
|
+
const github = oracleBooleanOption(input, "github");
|
|
429
452
|
const job = await requestOracleJob(request, "oracle.ask", {
|
|
430
453
|
prompt,
|
|
431
454
|
...(model !== undefined ? { model } : {}),
|
|
432
455
|
...(effort !== undefined ? { effort } : {}),
|
|
456
|
+
...(file !== undefined ? { file } : {}),
|
|
457
|
+
...(github ? { github: true } : {}),
|
|
433
458
|
});
|
|
434
459
|
rememberJob(job.id);
|
|
435
460
|
return piExternalJobHandle(job);
|
|
@@ -470,12 +495,16 @@ export function createOracleExternalJobProvider(
|
|
|
470
495
|
const parentId = parentProviderJobId(input);
|
|
471
496
|
const model = oracleOption(input, "model");
|
|
472
497
|
const effort = oracleOption(input, "effort");
|
|
498
|
+
const file = oracleAttachmentOption(input);
|
|
499
|
+
const github = oracleBooleanOption(input, "github");
|
|
473
500
|
const requestId = optionalString(input, "requestId");
|
|
474
501
|
const job = await requestOracleJob(request, "oracle.ask", {
|
|
475
502
|
prompt,
|
|
476
503
|
follow: parentId,
|
|
477
504
|
...(model !== undefined ? { model } : {}),
|
|
478
505
|
...(effort !== undefined ? { effort } : {}),
|
|
506
|
+
...(file !== undefined ? { file } : {}),
|
|
507
|
+
...(github ? { github: true } : {}),
|
|
479
508
|
...(requestId !== undefined ? { requestId } : {}),
|
|
480
509
|
});
|
|
481
510
|
assertFollowJob(job, parentId);
|
|
@@ -590,8 +619,8 @@ export default function surfExtension(pi: Pi) {
|
|
|
590
619
|
pi.registerTool({
|
|
591
620
|
name: "surf_oracle_ask",
|
|
592
621
|
label: "surf_oracle_ask",
|
|
593
|
-
description: "Start a durable local Surf ChatGPT oracle job.",
|
|
594
|
-
parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), follow: Type.Optional(Type.String()) }),
|
|
622
|
+
description: "Start a durable local Surf ChatGPT oracle job, optionally with one local file and GitHub context.",
|
|
623
|
+
parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), file: Type.Optional(Type.String()), github: Type.Optional(Type.Boolean()), follow: Type.Optional(Type.String()) }),
|
|
595
624
|
async execute(_id: string, args: Record<string, unknown>) {
|
|
596
625
|
const requestGeneration = sessionGeneration;
|
|
597
626
|
try {
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -95,14 +95,14 @@ surf chatgpt "analyze" --file document.pdf # With file attachment
|
|
|
95
95
|
|
|
96
96
|
### Oracle
|
|
97
97
|
|
|
98
|
-
Use `surf chatgpt` for quick one-shot questions. Use `surf oracle` for long-running or Pro coding consults that need a durable job, explicit model and effort selection, file context, recovery, or follow-up turns. Oracle is local-only.
|
|
98
|
+
Use `surf chatgpt` for quick one-shot questions. Use `surf oracle` for long-running or Pro coding consults that need a durable job, explicit model and effort selection, file context, a direct local attachment, recovery, or follow-up turns. Oracle is local-only.
|
|
99
99
|
|
|
100
100
|
For agent workflows, detach after dispatch and keep the returned `.id`:
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
103
|
surf oracle ask "Review this change and identify release risks" \
|
|
104
104
|
--files "src/**/*.ts" --files "package.json" \
|
|
105
|
-
--model gpt-5.5 --effort pro --detach --json
|
|
105
|
+
--model gpt-5.5 --effort pro --file ./design.md --github --detach --json
|
|
106
106
|
|
|
107
107
|
surf oracle status <job-id> --json
|
|
108
108
|
surf oracle result <job-id> --json
|
|
@@ -114,16 +114,16 @@ surf oracle result <job-id> --wait --json
|
|
|
114
114
|
|
|
115
115
|
Treat Pro quota as scarce. Oracle never selects Pro effort implicitly; request it with `--effort pro`. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Accepted `--effort` values are `light`, `standard`, `extended`, `heavy`, and `pro`. Use `--model gpt-5.6-sol --effort pro` for GPT-5.6 Sol with Pro effort. Requested model and effort selections are read back before submission, and an unverifiable selection fails with `model_verification_failed` instead of silently continuing. Capacity is one non-terminal oracle job. A `capacity` error includes the in-flight job ID; poll that job or wait for it to finish rather than submitting the same consult again.
|
|
116
116
|
|
|
117
|
-
When loaded as a Pi extension, Surf also registers a `surf-oracle` external-job provider when the runtime exposes that bridge. The provider maps `start`, `status`, `result`, and `reattach` to durable Surf Oracle jobs and returns pi-subagents' external-job contract shape: `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the conversation URL, the captured result text as `output`, and failure code and message. It honors `options.model` and `options.
|
|
117
|
+
When loaded as a Pi extension, Surf also registers a `surf-oracle` external-job provider when the runtime exposes that bridge. The provider maps `start`, `status`, `result`, and `reattach` to durable Surf Oracle jobs and returns pi-subagents' external-job contract shape: `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the conversation URL, the captured result text as `output`, and failure code and message. It honors `options.model`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so `model: gpt-5.6-sol` plus `effort: pro` selects ChatGPT GPT-5.6 Sol with Pro effort through the browser, while `github: true` requires Chat mode and the connected GitHub tool. `reattach` only harvests an existing job by ID; it never submits the prompt again.
|
|
118
118
|
|
|
119
119
|
When Surf is installed as a Pi package, it exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, `options.model: gpt-5.6-sol`, and `options.effort: pro`. Surf remains useful without Pi or `pi-subagents`.
|
|
120
120
|
|
|
121
|
-
Context comes from repeatable `--files` globs. Surf fails closed when a glob matches nothing or a matched file is unreadable, binary, or invalid UTF-8. It also blocks gitignored files and basenames matching `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*.p12`, `*.pfx`, `credentials*`, or `secrets*`. Use `--allow-sensitive` only after intentionally reviewing those files; it overrides the block rather than redacting content. Context up to 60,000 evidence characters is inserted inline, while larger context becomes one private text attachment. The assembly manifest records each path, byte count, SHA-256, inline or bundle disposition, and deny-list outcome.
|
|
121
|
+
Context comes from repeatable `--files` globs. Use `--file <path>` for one additional local attachment; `--github` requires Chat mode and a connected GitHub tool. Surf fails closed when a glob matches nothing or a matched file is unreadable, binary, or invalid UTF-8. It also blocks gitignored files and basenames matching `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*.p12`, `*.pfx`, `credentials*`, or `secrets*`. Use `--allow-sensitive` only after intentionally reviewing those files; it overrides the block rather than redacting content. Context up to 60,000 evidence characters is inserted inline, while larger context becomes one private text attachment. The assembly manifest records each path, byte count, SHA-256, inline or bundle disposition, and deny-list outcome.
|
|
122
122
|
|
|
123
123
|
Continue a captured consult with `follow`. Use the ID returned by each turn for the next turn:
|
|
124
124
|
|
|
125
125
|
```bash
|
|
126
|
-
surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --detach --json
|
|
126
|
+
surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --file ./follow-up.md --github --detach --json
|
|
127
127
|
surf oracle result <follow-job-id> --wait --json
|
|
128
128
|
surf oracle follow <follow-job-id> "Give the final decision and concrete next steps." --detach --json
|
|
129
129
|
```
|