surf-cli 2.10.0 → 2.11.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 CHANGED
@@ -492,6 +492,19 @@ surf aistudio.build "crm dashboard" --output ./out # Extract zip to d
492
492
  surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 10min timeout
493
493
  ```
494
494
 
495
+ #### Oracle
496
+
497
+ 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.
498
+
499
+ ```bash
500
+ surf oracle ask "review this change" --files "src/**/*.ts" --model pro --effort extended --detach --json
501
+ surf oracle status <job-id> --json
502
+ surf oracle result <job-id> --wait --json
503
+ surf oracle follow <job-id> "challenge that recommendation" --detach --json
504
+ ```
505
+
506
+ Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
507
+
495
508
  Each AI tool uses your existing browser login - no API keys needed. Just be logged into the respective service in Chrome (chatgpt.com, gemini.google.com, perplexity.ai, x.com, or aistudio.google.com).
496
509
 
497
510
  **Grok troubleshooting:** If queries fail, run `surf grok --validate` to check if the UI structure changed. Use `--save-models` to update the model cache in `surf.json`. Default model is `fast`.
@@ -0,0 +1,336 @@
1
+ const { throwIfAborted } = require("./abort.cjs");
2
+ const { SELECTORS, delay, evaluate } = require("./chatgpt-client-ui.cjs");
3
+
4
+ function normalizePromptEcho(prompt) {
5
+ return String(prompt || "")
6
+ .replace(/\s+/g, " ")
7
+ .trim()
8
+ .slice(0, 200);
9
+ }
10
+
11
+ function matchesPromptEcho(text, promptEcho) {
12
+ const expected = normalizePromptEcho(promptEcho);
13
+ return expected.length > 0 && normalizePromptEcho(text) === expected;
14
+ }
15
+
16
+ function cleanChatGPTResponseText(rawText) {
17
+ if (!rawText) return "";
18
+
19
+ const chromeLines = new Set([
20
+ "copy",
21
+ "good response",
22
+ "bad response",
23
+ "read aloud",
24
+ "edit",
25
+ "retry",
26
+ "continue generating",
27
+ "share",
28
+ ]);
29
+
30
+ const lines = [];
31
+ let inCodeFence = false;
32
+
33
+ for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
34
+ const trimmed = line.trim();
35
+ const isFenceLine = trimmed.startsWith("```");
36
+ const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
37
+
38
+ lines.push({
39
+ text: normalizedLine,
40
+ trimmed,
41
+ isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
42
+ inCodeFence,
43
+ isFenceLine,
44
+ });
45
+
46
+ if (isFenceLine) {
47
+ inCodeFence = !inCodeFence;
48
+ }
49
+ }
50
+
51
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
52
+ lines.shift();
53
+ }
54
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
55
+ lines.pop();
56
+ }
57
+
58
+ let trailingChromeStart = lines.length;
59
+ while (trailingChromeStart > 0) {
60
+ const line = lines[trailingChromeStart - 1];
61
+ if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
62
+ trailingChromeStart--;
63
+ }
64
+
65
+ const trailingChromeCount = lines.length - trailingChromeStart;
66
+ if (trailingChromeCount >= 2) {
67
+ lines.splice(trailingChromeStart);
68
+ }
69
+
70
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
71
+ lines.shift();
72
+ }
73
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
74
+ lines.pop();
75
+ }
76
+
77
+ return lines.map((line) => line.text).join("\n");
78
+ }
79
+
80
+ function extractLatestAssistantSnapshot(candidates) {
81
+ if (!Array.isArray(candidates)) return null;
82
+
83
+ let latestEmptyAssistant = null;
84
+
85
+ for (let i = candidates.length - 1; i >= 0; i--) {
86
+ const candidate = candidates[i];
87
+ if (!candidate?.isAssistant) continue;
88
+
89
+ const snapshot = {
90
+ ...candidate,
91
+ text: cleanChatGPTResponseText(candidate?.text || ""),
92
+ turnIndex: i,
93
+ };
94
+
95
+ if (snapshot.text) {
96
+ return snapshot;
97
+ }
98
+
99
+ if (!latestEmptyAssistant) {
100
+ latestEmptyAssistant = snapshot;
101
+ }
102
+ }
103
+
104
+ return latestEmptyAssistant;
105
+ }
106
+
107
+ function normalizeResponseSnapshot(rawSnapshot) {
108
+ const candidates = rawSnapshot?.candidates;
109
+ return {
110
+ latestAssistant: extractLatestAssistantSnapshot(candidates),
111
+ assistantCount: Array.isArray(candidates)
112
+ ? candidates.filter((candidate) => candidate?.isAssistant).length
113
+ : 0,
114
+ stopVisible: Boolean(rawSnapshot?.stopVisible),
115
+ };
116
+ }
117
+
118
+ function isNewAssistantContent(
119
+ latestAssistant,
120
+ baselineAssistant,
121
+ assistantCount = 0,
122
+ baselineAssistantCount = 0,
123
+ ) {
124
+ if (!latestAssistant) return false;
125
+ if (!baselineAssistant) return true;
126
+ if (
127
+ latestAssistant.messageId &&
128
+ baselineAssistant.messageId &&
129
+ latestAssistant.messageId !== baselineAssistant.messageId
130
+ ) {
131
+ return true;
132
+ }
133
+
134
+ const currentText = latestAssistant.text || "";
135
+ const baselineText = baselineAssistant.text || "";
136
+
137
+ if (assistantCount > baselineAssistantCount) {
138
+ if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
139
+ return true;
140
+ }
141
+ if (currentText !== baselineText) {
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ return currentText !== baselineText;
148
+ }
149
+
150
+ function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
151
+ if (!snapshot?.text) return false;
152
+ if (snapshot.stopVisible) return false;
153
+ if (snapshot.hasFinishedActions) return true;
154
+ return stableCycles >= 6 && stableMs >= 1200;
155
+ }
156
+
157
+ function scopeSnapshotToPrompt(rawSnapshot, promptEcho) {
158
+ const candidates = Array.isArray(rawSnapshot?.candidates) ? rawSnapshot.candidates : [];
159
+ let userIndex = -1;
160
+ for (let index = candidates.length - 1; index >= 0; index--) {
161
+ const candidate = candidates[index];
162
+ if (candidate?.isUser && matchesPromptEcho(candidate.text, promptEcho)) {
163
+ userIndex = index;
164
+ break;
165
+ }
166
+ }
167
+ if (userIndex === -1) return { ...rawSnapshot, candidates: [] };
168
+
169
+ const following = candidates.slice(userIndex + 1);
170
+ const nextUserIndex = following.findIndex((candidate) => candidate?.isUser);
171
+ return {
172
+ ...rawSnapshot,
173
+ candidates: nextUserIndex === -1 ? following : following.slice(0, nextUserIndex),
174
+ };
175
+ }
176
+
177
+ async function readChatGPTResponseSnapshot(cdp) {
178
+ return evaluate(
179
+ cdp,
180
+ `(() => {
181
+ const scope = document.querySelector('main') || document;
182
+ const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
183
+ const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
184
+ const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
185
+ const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
186
+ const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
187
+
188
+ const toCandidate = (turnNode, messageRoot = null) => {
189
+ const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
190
+ ? turnNode
191
+ : turnNode.querySelector(ASSISTANT_SELECTOR));
192
+ const authorRoot = turnNode.matches?.('[data-message-author-role], [data-turn]')
193
+ ? turnNode
194
+ : turnNode.querySelector('[data-message-author-role], [data-turn]');
195
+ const searchRoot = resolvedMessageRoot || authorRoot || turnNode;
196
+ let contentRoot = null;
197
+
198
+ for (const selector of CONTENT_SELECTORS) {
199
+ const match = selector === '[dir="auto"]'
200
+ ? (searchRoot.matches?.(selector) ? searchRoot : null)
201
+ : (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
202
+ if (match) {
203
+ contentRoot = match;
204
+ break;
205
+ }
206
+ }
207
+
208
+ const role =
209
+ resolvedMessageRoot?.getAttribute('data-message-author-role') ||
210
+ authorRoot?.getAttribute('data-message-author-role') ||
211
+ turnNode.getAttribute('data-message-author-role') ||
212
+ null;
213
+ const turn =
214
+ resolvedMessageRoot?.getAttribute('data-turn') ||
215
+ authorRoot?.getAttribute('data-turn') ||
216
+ turnNode.getAttribute('data-turn') ||
217
+ null;
218
+ const isAssistant =
219
+ role === 'assistant' ||
220
+ turn === 'assistant' ||
221
+ resolvedMessageRoot !== null;
222
+ const isUser = role === 'user' || turn === 'user';
223
+ const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
224
+ const messageId =
225
+ resolvedMessageRoot?.getAttribute('data-message-id') ||
226
+ turnNode.getAttribute('data-message-id') ||
227
+ null;
228
+ const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
229
+
230
+ return {
231
+ role,
232
+ turn,
233
+ isAssistant,
234
+ isUser,
235
+ text,
236
+ messageId,
237
+ hasFinishedActions,
238
+ };
239
+ };
240
+
241
+ let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
242
+ toCandidate(turnNode)
243
+ );
244
+
245
+ if (candidates.length === 0) {
246
+ candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
247
+ toCandidate(messageRoot, messageRoot)
248
+ );
249
+ }
250
+
251
+ return {
252
+ candidates,
253
+ stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
254
+ };
255
+ })()`,
256
+ );
257
+ }
258
+
259
+ async function waitForResponse(
260
+ cdp,
261
+ timeoutMs = 2700000,
262
+ baselineAssistant,
263
+ baselineAssistantCount,
264
+ signal,
265
+ promptEcho,
266
+ ) {
267
+ throwIfAborted(signal);
268
+ const deadline = Date.now() + timeoutMs;
269
+ let previousText = baselineAssistant?.text || "";
270
+ let stableCycles = 0;
271
+ let lastChangeAt = Date.now();
272
+
273
+ while (Date.now() < deadline) {
274
+ const rawSnapshot = await readChatGPTResponseSnapshot(cdp);
275
+ const snapshot = promptEcho ? scopeSnapshotToPrompt(rawSnapshot, promptEcho) : rawSnapshot;
276
+
277
+ if (!snapshot) {
278
+ await delay(400, signal);
279
+ continue;
280
+ }
281
+
282
+ const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
283
+ const currentText = latestAssistant?.text || "";
284
+ const hasNewAssistantContent = isNewAssistantContent(
285
+ latestAssistant,
286
+ baselineAssistant,
287
+ assistantCount,
288
+ baselineAssistantCount,
289
+ );
290
+
291
+ if (!hasNewAssistantContent) {
292
+ await delay(400, signal);
293
+ continue;
294
+ }
295
+
296
+ if (currentText !== previousText) {
297
+ previousText = currentText;
298
+ stableCycles = 0;
299
+ lastChangeAt = Date.now();
300
+ } else if (currentText) {
301
+ stableCycles++;
302
+ } else {
303
+ stableCycles = 0;
304
+ lastChangeAt = Date.now();
305
+ }
306
+
307
+ const stableMs = Date.now() - lastChangeAt;
308
+ const completionSnapshot = latestAssistant
309
+ ? { ...latestAssistant, stopVisible }
310
+ : { text: "", stopVisible, hasFinishedActions: false };
311
+
312
+ if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
313
+ return {
314
+ text: latestAssistant.text,
315
+ messageId: latestAssistant.messageId,
316
+ turnIndex: latestAssistant.turnIndex,
317
+ };
318
+ }
319
+
320
+ await delay(400, signal);
321
+ }
322
+
323
+ throw new Error("Response timeout");
324
+ }
325
+
326
+ module.exports = {
327
+ cleanChatGPTResponseText,
328
+ extractLatestAssistantSnapshot,
329
+ isChatGPTResponseComplete,
330
+ isNewAssistantContent,
331
+ matchesPromptEcho,
332
+ normalizePromptEcho,
333
+ normalizeResponseSnapshot,
334
+ readChatGPTResponseSnapshot,
335
+ waitForResponse,
336
+ };
@@ -0,0 +1,119 @@
1
+ const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy"];
2
+
3
+ function normalizeChatGPTModelChoice(desiredModel) {
4
+ const normalized = String(desiredModel || "")
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]/g, "");
7
+
8
+ if (["instant", "gpt53"].includes(normalized)) return "instant";
9
+ if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
10
+ if (["pro", "gpt54pro"].includes(normalized)) return "pro";
11
+
12
+ return normalized;
13
+ }
14
+
15
+ function normalizeChatGPTEffortChoice(desiredEffort) {
16
+ const normalized = String(desiredEffort || "").toLowerCase().trim();
17
+ return CHATGPT_EFFORT_CHOICES.includes(normalized) ? normalized : null;
18
+ }
19
+
20
+ function normalizedWords(value) {
21
+ return String(value || "")
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, " ")
24
+ .trim()
25
+ .split(/\s+/)
26
+ .filter(Boolean);
27
+ }
28
+
29
+ function modelCandidateMatches(item, targetModel) {
30
+ const values = [item?.label, item?.testId?.replace(/^model-switcher-/, "")].filter(Boolean);
31
+ return values.some((value) => {
32
+ if (normalizeChatGPTModelChoice(value) === targetModel) return true;
33
+ const variants = ["instant", "thinking", "pro"].filter((variant) =>
34
+ normalizedWords(value).includes(variant),
35
+ );
36
+ return variants.length === 1 && variants[0] === targetModel;
37
+ });
38
+ }
39
+
40
+ function effortCandidateMatches(item, targetEffort) {
41
+ const variants = new Set(
42
+ [item?.label, item?.testId]
43
+ .flatMap((value) => normalizedWords(value))
44
+ .filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
45
+ );
46
+ return variants.size === 1 && variants.has(targetEffort);
47
+ }
48
+
49
+ function uniqueMatch(items, matches) {
50
+ if (!Array.isArray(items)) return null;
51
+ const candidates = items.filter(matches);
52
+ return candidates.length === 1 ? candidates[0] : null;
53
+ }
54
+
55
+ function resolveChatGPTModelMenuOption(items, desiredModel) {
56
+ const targetModel = normalizeChatGPTModelChoice(desiredModel);
57
+ if (!targetModel) return null;
58
+ return uniqueMatch(
59
+ items,
60
+ (item) =>
61
+ item?.role === "menuitemradio" &&
62
+ typeof item?.testId === "string" &&
63
+ item.testId.startsWith("model-switcher-") &&
64
+ modelCandidateMatches(item, targetModel),
65
+ );
66
+ }
67
+
68
+ function verifyChatGPTModelSelection(items, desiredModel) {
69
+ const targetModel = normalizeChatGPTModelChoice(desiredModel);
70
+ if (!targetModel) return null;
71
+ return uniqueMatch(
72
+ items,
73
+ (item) =>
74
+ (typeof item?.label === "string" || typeof item?.testId === "string") &&
75
+ modelCandidateMatches(item, targetModel),
76
+ );
77
+ }
78
+
79
+ function resolveChatGPTEffortMenuOption(items, desiredEffort) {
80
+ const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
81
+ if (!targetEffort) return null;
82
+ return uniqueMatch(
83
+ items,
84
+ (item) =>
85
+ ["button", "menuitem", "menuitemradio"].includes(item?.role) &&
86
+ effortCandidateMatches(item, targetEffort),
87
+ );
88
+ }
89
+
90
+ function verifyChatGPTEffortSelection(items, desiredEffort) {
91
+ const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
92
+ if (!targetEffort) return null;
93
+ return uniqueMatch(
94
+ items,
95
+ (item) =>
96
+ (typeof item?.label === "string" || typeof item?.testId === "string") &&
97
+ effortCandidateMatches(item, targetEffort),
98
+ );
99
+ }
100
+
101
+ function boundedOptionLabels(items) {
102
+ if (!Array.isArray(items)) return [];
103
+ return items
104
+ .map((item) => String(item?.label || "").replace(/\s+/g, " ").trim().slice(0, 80))
105
+ .filter(Boolean)
106
+ .filter((label, index, labels) => labels.indexOf(label) === index)
107
+ .slice(0, 10);
108
+ }
109
+
110
+ module.exports = {
111
+ CHATGPT_EFFORT_CHOICES,
112
+ boundedOptionLabels,
113
+ normalizeChatGPTEffortChoice,
114
+ normalizeChatGPTModelChoice,
115
+ resolveChatGPTEffortMenuOption,
116
+ resolveChatGPTModelMenuOption,
117
+ verifyChatGPTEffortSelection,
118
+ verifyChatGPTModelSelection,
119
+ };