@steipete/oracle 0.10.0 → 0.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 +55 -10
- package/dist/bin/oracle-cli.js +104 -16
- package/dist/src/browser/actions/archiveConversation.js +224 -0
- package/dist/src/browser/actions/assistantResponse.js +26 -0
- package/dist/src/browser/actions/deepResearch.js +662 -0
- package/dist/src/browser/actions/modelSelection.js +78 -13
- package/dist/src/browser/actions/navigation.js +22 -0
- package/dist/src/browser/actions/projectSources.js +491 -0
- package/dist/src/browser/actions/promptComposer.js +52 -27
- package/dist/src/browser/actions/thinkingStatus.js +391 -0
- package/dist/src/browser/artifacts.js +150 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptImages.js +315 -0
- package/dist/src/browser/chromeLifecycle.js +214 -3
- package/dist/src/browser/config.js +26 -2
- package/dist/src/browser/constants.js +8 -0
- package/dist/src/browser/controlPlan.js +81 -0
- package/dist/src/browser/detect.js +206 -33
- package/dist/src/browser/domDebug.js +49 -0
- package/dist/src/browser/index.js +1257 -485
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/profileState.js +83 -3
- package/dist/src/browser/projectSourcesRunner.js +366 -0
- package/dist/src/browser/reattach.js +117 -45
- package/dist/src/browser/reattachHelpers.js +1 -1
- package/dist/src/browser/sessionRunner.js +53 -1
- package/dist/src/browser/tabLeaseRegistry.js +182 -0
- package/dist/src/cli/bridge/claudeConfig.js +12 -8
- package/dist/src/cli/bridge/codexConfig.js +2 -2
- package/dist/src/cli/browserConfig.js +40 -0
- package/dist/src/cli/browserDefaults.js +31 -7
- package/dist/src/cli/browserTabs.js +228 -0
- package/dist/src/cli/dryRun.js +33 -1
- package/dist/src/cli/duplicatePromptGuard.js +10 -2
- package/dist/src/cli/help.js +1 -1
- package/dist/src/cli/options.js +4 -0
- package/dist/src/cli/projectSources.js +116 -0
- package/dist/src/cli/sessionCommand.js +51 -0
- package/dist/src/cli/sessionDisplay.js +121 -9
- package/dist/src/cli/sessionRunner.js +51 -7
- package/dist/src/mcp/consultPresets.js +19 -0
- package/dist/src/mcp/server.js +2 -0
- package/dist/src/mcp/tools/consult.js +201 -26
- package/dist/src/mcp/tools/projectSources.js +123 -0
- package/dist/src/mcp/types.js +7 -0
- package/dist/src/mcp/utils.js +6 -1
- package/dist/src/oracle/run.js +4 -1
- package/dist/src/projectSources/plan.js +27 -0
- package/dist/src/projectSources/types.js +1 -0
- package/dist/src/projectSources/url.js +23 -0
- package/dist/src/sessionManager.js +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import CDP from "chrome-remote-interface";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
|
|
4
|
+
import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js";
|
|
5
|
+
import { delay } from "./utils.js";
|
|
6
|
+
export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
|
|
7
|
+
export const DEFAULT_REMOTE_CHROME_PORT = 9222;
|
|
8
|
+
const LOGIN_CTA_PATTERN = /\b(log in|login|sign up|sign in|continue with google|continue with microsoft)\b/i;
|
|
9
|
+
const noopLogger = Object.assign((_message) => { }, {});
|
|
10
|
+
function trimToSnippet(text, max = 140) {
|
|
11
|
+
const normalized = String(text ?? "")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
if (normalized.length <= max) {
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
18
|
+
}
|
|
19
|
+
function normalizeHostPort(input = {}) {
|
|
20
|
+
return {
|
|
21
|
+
host: input.host ?? DEFAULT_REMOTE_CHROME_HOST,
|
|
22
|
+
port: input.port ?? DEFAULT_REMOTE_CHROME_PORT,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function normalizeUrl(value) {
|
|
26
|
+
return String(value ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
function normalizeTitle(value) {
|
|
29
|
+
return String(value ?? "")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
function buildTargetFingerprint(summary) {
|
|
34
|
+
return createHash("sha1")
|
|
35
|
+
.update(`${summary.targetId ?? ""}|${summary.url ?? ""}|${summary.lastAssistantText ?? ""}`)
|
|
36
|
+
.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
function isChatGptUrl(url) {
|
|
39
|
+
const normalized = normalizeUrl(url).toLowerCase();
|
|
40
|
+
return (normalized.startsWith("https://chatgpt.com") || normalized.startsWith("https://chat.openai.com"));
|
|
41
|
+
}
|
|
42
|
+
function isChatGptConversationUrl(url) {
|
|
43
|
+
return /\/c\//.test(normalizeUrl(url));
|
|
44
|
+
}
|
|
45
|
+
function isChatGptTarget(target) {
|
|
46
|
+
if (!target || target.type !== "page") {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return isChatGptUrl(target.url ?? "") || /chatgpt/i.test(target.title ?? "");
|
|
50
|
+
}
|
|
51
|
+
function extractTargetId(target) {
|
|
52
|
+
return target?.targetId ?? target?.id ?? null;
|
|
53
|
+
}
|
|
54
|
+
function escapeLiteral(value) {
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
function buildTabInspectionExpression() {
|
|
58
|
+
const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
|
|
59
|
+
const sendSelectorsLiteral = JSON.stringify(SEND_BUTTON_SELECTORS);
|
|
60
|
+
const answerSelectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
|
|
61
|
+
const turnSelectorLiteral = escapeLiteral(CONVERSATION_TURN_SELECTOR);
|
|
62
|
+
const assistantRoleLiteral = escapeLiteral(ASSISTANT_ROLE_SELECTOR);
|
|
63
|
+
const modelButtonSelectorLiteral = escapeLiteral(MODEL_BUTTON_SELECTOR);
|
|
64
|
+
const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTOR);
|
|
65
|
+
return `(() => {
|
|
66
|
+
const INPUT_SELECTORS = ${inputSelectorsLiteral};
|
|
67
|
+
const SEND_SELECTORS = ${sendSelectorsLiteral};
|
|
68
|
+
const ANSWER_SELECTORS = ${answerSelectorsLiteral};
|
|
69
|
+
const TURN_SELECTOR = ${turnSelectorLiteral};
|
|
70
|
+
const ASSISTANT_ROLE_SELECTOR = ${assistantRoleLiteral};
|
|
71
|
+
const MODEL_BUTTON_SELECTOR = ${modelButtonSelectorLiteral};
|
|
72
|
+
const STOP_BUTTON_SELECTOR = ${stopSelectorLiteral};
|
|
73
|
+
const LOGIN_CTA = ${LOGIN_CTA_PATTERN.toString()};
|
|
74
|
+
const normalize = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
|
|
75
|
+
const isVisible = (node) => {
|
|
76
|
+
if (!(node instanceof Element)) return false;
|
|
77
|
+
const style = window.getComputedStyle(node);
|
|
78
|
+
if (!style) return false;
|
|
79
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
|
80
|
+
const rect = node.getBoundingClientRect();
|
|
81
|
+
return rect.width > 0 && rect.height > 0;
|
|
82
|
+
};
|
|
83
|
+
const firstVisible = (selectors) => {
|
|
84
|
+
for (const selector of selectors) {
|
|
85
|
+
const node = document.querySelector(selector);
|
|
86
|
+
if (node && isVisible(node)) return node;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
const loginButtonExists = Array.from(document.querySelectorAll('button,a,[role="button"]')).some((node) => {
|
|
91
|
+
const label = normalize(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title'));
|
|
92
|
+
return LOGIN_CTA.test(label);
|
|
93
|
+
});
|
|
94
|
+
const stopButton = document.querySelector(STOP_BUTTON_SELECTOR);
|
|
95
|
+
const stopExists = Boolean(stopButton && isVisible(stopButton));
|
|
96
|
+
const sendButton = firstVisible(SEND_SELECTORS);
|
|
97
|
+
const sendExists = Boolean(sendButton);
|
|
98
|
+
const promptNode = firstVisible(INPUT_SELECTORS);
|
|
99
|
+
const promptReady = Boolean(promptNode);
|
|
100
|
+
const turns = Array.from(document.querySelectorAll(TURN_SELECTOR));
|
|
101
|
+
const assistantTurns = turns.filter((turn) => {
|
|
102
|
+
const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
|
|
103
|
+
if (role === 'assistant') return true;
|
|
104
|
+
return Boolean(turn.querySelector(ASSISTANT_ROLE_SELECTOR));
|
|
105
|
+
});
|
|
106
|
+
const userTurns = turns.filter((turn) => {
|
|
107
|
+
const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
|
|
108
|
+
return role === 'user';
|
|
109
|
+
});
|
|
110
|
+
const answerNode = ANSWER_SELECTORS
|
|
111
|
+
.map((selector) => document.querySelectorAll(selector))
|
|
112
|
+
.find((matches) => matches && matches.length > 0);
|
|
113
|
+
const currentModelButton = document.querySelector(MODEL_BUTTON_SELECTOR);
|
|
114
|
+
const hasProPill = Boolean(document.querySelector('button.__composer-pill, button[aria-label="Pro, click to remove"]'));
|
|
115
|
+
let currentModelLabel = normalize(currentModelButton?.textContent || currentModelButton?.getAttribute?.('aria-label') || '');
|
|
116
|
+
if (currentModelLabel === 'ChatGPT' && hasProPill) {
|
|
117
|
+
currentModelLabel = 'ChatGPT + Pro';
|
|
118
|
+
}
|
|
119
|
+
const assistantTexts = assistantTurns
|
|
120
|
+
.map((node) => normalize(node.textContent))
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
const userTexts = userTurns
|
|
123
|
+
.map((node) => normalize(node.textContent))
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
const answerTexts = Array.from(answerNode || []).map((node) => normalize(node.textContent)).filter(Boolean);
|
|
126
|
+
const assistantCount = assistantTurns.length > 0 ? assistantTurns.length : answerTexts.length;
|
|
127
|
+
const lastAssistantText = assistantTexts[assistantTexts.length - 1] || answerTexts[answerTexts.length - 1] || '';
|
|
128
|
+
const lastUserText = userTexts[userTexts.length - 1] || '';
|
|
129
|
+
const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
|
|
130
|
+
return {
|
|
131
|
+
title: normalize(document.title),
|
|
132
|
+
url: location.href,
|
|
133
|
+
currentModelLabel,
|
|
134
|
+
stopExists,
|
|
135
|
+
sendExists,
|
|
136
|
+
promptReady,
|
|
137
|
+
loginButtonExists,
|
|
138
|
+
authenticated,
|
|
139
|
+
assistantCount,
|
|
140
|
+
lastAssistantText,
|
|
141
|
+
lastUserText,
|
|
142
|
+
visibilityState: document.visibilityState,
|
|
143
|
+
focused: Boolean(document.hasFocus?.()),
|
|
144
|
+
};
|
|
145
|
+
})()`;
|
|
146
|
+
}
|
|
147
|
+
export async function listChatGptTargets(options = {}) {
|
|
148
|
+
const { host, port } = normalizeHostPort(options);
|
|
149
|
+
const targets = (await CDP.List({ host, port }));
|
|
150
|
+
return targets.filter(isChatGptTarget);
|
|
151
|
+
}
|
|
152
|
+
export async function openChatGptTarget(options = {}) {
|
|
153
|
+
const { host, port } = normalizeHostPort(options);
|
|
154
|
+
const url = options.url ?? "https://chatgpt.com/";
|
|
155
|
+
const target = await CDP.New({ host, port, url });
|
|
156
|
+
return target.id;
|
|
157
|
+
}
|
|
158
|
+
async function connectToTarget(host, port, targetId) {
|
|
159
|
+
const client = await CDP({ host, port, target: targetId });
|
|
160
|
+
const { Runtime, DOM } = client;
|
|
161
|
+
if (Runtime?.enable) {
|
|
162
|
+
await Runtime.enable();
|
|
163
|
+
}
|
|
164
|
+
if (DOM?.enable) {
|
|
165
|
+
await DOM.enable();
|
|
166
|
+
}
|
|
167
|
+
return client;
|
|
168
|
+
}
|
|
169
|
+
export async function inspectChatGptTab(options) {
|
|
170
|
+
const { host, port } = normalizeHostPort(options);
|
|
171
|
+
const target = options.target;
|
|
172
|
+
const targetId = extractTargetId(target);
|
|
173
|
+
if (!targetId) {
|
|
174
|
+
throw new Error("inspectChatGptTab requires a target with targetId.");
|
|
175
|
+
}
|
|
176
|
+
const client = await connectToTarget(host, port, targetId);
|
|
177
|
+
try {
|
|
178
|
+
const { Runtime } = client;
|
|
179
|
+
const evaluation = await Runtime.evaluate({
|
|
180
|
+
expression: buildTabInspectionExpression(),
|
|
181
|
+
returnByValue: true,
|
|
182
|
+
awaitPromise: true,
|
|
183
|
+
});
|
|
184
|
+
const info = (evaluation.result?.value ?? {});
|
|
185
|
+
const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
186
|
+
const lastAssistantText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
|
|
187
|
+
? snapshot.text.trim()
|
|
188
|
+
: String(info.lastAssistantText ?? "").trim();
|
|
189
|
+
const lastUserText = String(info.lastUserText ?? "").trim();
|
|
190
|
+
const summary = {
|
|
191
|
+
host,
|
|
192
|
+
port,
|
|
193
|
+
targetId,
|
|
194
|
+
title: normalizeTitle(info.title ?? target.title ?? ""),
|
|
195
|
+
url: normalizeUrl(info.url ?? target.url ?? ""),
|
|
196
|
+
currentModelLabel: normalizeTitle(info.currentModelLabel ?? ""),
|
|
197
|
+
stopExists: Boolean(info.stopExists),
|
|
198
|
+
sendExists: Boolean(info.sendExists),
|
|
199
|
+
promptReady: Boolean(info.promptReady),
|
|
200
|
+
loginButtonExists: Boolean(info.loginButtonExists),
|
|
201
|
+
authenticated: Boolean(info.authenticated),
|
|
202
|
+
assistantCount: Number.isFinite(info.assistantCount) ? Number(info.assistantCount) : 0,
|
|
203
|
+
lastAssistantText,
|
|
204
|
+
lastAssistantSnippet: trimToSnippet(lastAssistantText),
|
|
205
|
+
lastUserText,
|
|
206
|
+
lastUserSnippet: trimToSnippet(lastUserText),
|
|
207
|
+
focused: Boolean(info.focused),
|
|
208
|
+
visibilityState: typeof info.visibilityState === "string" ? info.visibilityState : "",
|
|
209
|
+
conversationId: extractConversationIdFromUrl(info.url ?? target.url ?? ""),
|
|
210
|
+
fingerprint: "",
|
|
211
|
+
state: "detached",
|
|
212
|
+
lastAssistantMarkdown: null,
|
|
213
|
+
lastAssistantMessageId: typeof snapshot?.messageId === "string" ? snapshot.messageId : undefined,
|
|
214
|
+
lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : undefined,
|
|
215
|
+
};
|
|
216
|
+
summary.state = classifyTabState(summary);
|
|
217
|
+
summary.fingerprint = buildTargetFingerprint(summary);
|
|
218
|
+
return summary;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await client.close().catch(() => undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export function classifyTabState(summary) {
|
|
225
|
+
if (!summary?.authenticated) {
|
|
226
|
+
return "detached";
|
|
227
|
+
}
|
|
228
|
+
if (summary.stopExists) {
|
|
229
|
+
return "running";
|
|
230
|
+
}
|
|
231
|
+
if (summary.sendExists || summary.promptReady || summary.assistantCount > 0) {
|
|
232
|
+
return "completed";
|
|
233
|
+
}
|
|
234
|
+
return "detached";
|
|
235
|
+
}
|
|
236
|
+
export async function collectChatGptTabs(options = {}) {
|
|
237
|
+
const { host, port } = normalizeHostPort(options);
|
|
238
|
+
const targets = await listChatGptTargets({ host, port });
|
|
239
|
+
const summaries = [];
|
|
240
|
+
for (const target of targets) {
|
|
241
|
+
try {
|
|
242
|
+
const summary = await inspectChatGptTab({ host, port, target });
|
|
243
|
+
summaries.push(summary);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
summaries.push({
|
|
247
|
+
host,
|
|
248
|
+
port,
|
|
249
|
+
targetId: extractTargetId(target) ?? "",
|
|
250
|
+
title: normalizeTitle(target.title ?? ""),
|
|
251
|
+
url: normalizeUrl(target.url ?? ""),
|
|
252
|
+
currentModelLabel: "",
|
|
253
|
+
stopExists: false,
|
|
254
|
+
sendExists: false,
|
|
255
|
+
promptReady: false,
|
|
256
|
+
loginButtonExists: false,
|
|
257
|
+
authenticated: false,
|
|
258
|
+
assistantCount: 0,
|
|
259
|
+
lastAssistantText: "",
|
|
260
|
+
lastAssistantSnippet: "",
|
|
261
|
+
lastUserText: "",
|
|
262
|
+
lastUserSnippet: "",
|
|
263
|
+
focused: false,
|
|
264
|
+
visibilityState: "",
|
|
265
|
+
conversationId: extractConversationIdFromUrl(target.url ?? ""),
|
|
266
|
+
fingerprint: "",
|
|
267
|
+
state: "detached",
|
|
268
|
+
error: error instanceof Error ? error.message : String(error),
|
|
269
|
+
lastAssistantMarkdown: null,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return summaries.sort((left, right) => {
|
|
274
|
+
const leftScore = (left.focused ? 100 : 0) + (isChatGptConversationUrl(left.url) ? 10 : 0);
|
|
275
|
+
const rightScore = (right.focused ? 100 : 0) + (isChatGptConversationUrl(right.url) ? 10 : 0);
|
|
276
|
+
return rightScore - leftScore;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function resolveChatGptTabFromSummaries(summaries, ref) {
|
|
280
|
+
if (!Array.isArray(summaries) || summaries.length === 0) {
|
|
281
|
+
throw new Error("No live ChatGPT tabs found on the configured Chrome DevTools endpoint.");
|
|
282
|
+
}
|
|
283
|
+
const trimmedRef = String(ref ?? "").trim();
|
|
284
|
+
if (!trimmedRef || trimmedRef.toLowerCase() === "current") {
|
|
285
|
+
return summaries[0];
|
|
286
|
+
}
|
|
287
|
+
const exactId = summaries.find((tab) => tab.targetId === trimmedRef);
|
|
288
|
+
if (exactId) {
|
|
289
|
+
return exactId;
|
|
290
|
+
}
|
|
291
|
+
const exactUrl = summaries.find((tab) => tab.url === trimmedRef);
|
|
292
|
+
if (exactUrl) {
|
|
293
|
+
return exactUrl;
|
|
294
|
+
}
|
|
295
|
+
const lower = trimmedRef.toLowerCase();
|
|
296
|
+
const titleMatches = summaries.filter((tab) => tab.title.toLowerCase().includes(lower));
|
|
297
|
+
if (titleMatches.length === 1) {
|
|
298
|
+
return titleMatches[0];
|
|
299
|
+
}
|
|
300
|
+
if (titleMatches.length > 1) {
|
|
301
|
+
const details = titleMatches
|
|
302
|
+
.map((tab) => `${tab.targetId}: ${tab.title || "(untitled)"} — ${tab.url}`)
|
|
303
|
+
.join("\n");
|
|
304
|
+
throw new Error(`Multiple ChatGPT tabs match "${trimmedRef}":\n${details}`);
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`No ChatGPT tab matched "${trimmedRef}". Use "oracle-tabs" or "oracle status --browser-tabs" to inspect live targets.`);
|
|
307
|
+
}
|
|
308
|
+
export function resolveChatGptTabFromSummariesForTest(summaries, ref) {
|
|
309
|
+
return resolveChatGptTabFromSummaries(summaries, ref);
|
|
310
|
+
}
|
|
311
|
+
export async function resolveChatGptTab(options = {}) {
|
|
312
|
+
const { host, port } = normalizeHostPort(options);
|
|
313
|
+
const summaries = await collectChatGptTabs({ host, port });
|
|
314
|
+
return resolveChatGptTabFromSummaries(summaries, options.ref);
|
|
315
|
+
}
|
|
316
|
+
export async function connectToExistingChatGptTab(options = {}) {
|
|
317
|
+
const { host, port } = normalizeHostPort(options);
|
|
318
|
+
const tab = await resolveChatGptTab({ host, port, ref: options.ref });
|
|
319
|
+
const client = await connectToTarget(host, port, tab.targetId);
|
|
320
|
+
return { client, targetId: tab.targetId, tab };
|
|
321
|
+
}
|
|
322
|
+
export async function harvestChatGptTab(options = {}) {
|
|
323
|
+
const { host, port } = normalizeHostPort(options);
|
|
324
|
+
const resolved = options.target
|
|
325
|
+
? await inspectChatGptTab({ host, port, target: options.target })
|
|
326
|
+
: await resolveChatGptTab({ host, port, ref: options.ref });
|
|
327
|
+
const client = await connectToTarget(host, port, resolved.targetId);
|
|
328
|
+
try {
|
|
329
|
+
const { Runtime } = client;
|
|
330
|
+
const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
331
|
+
let assistantMarkdown = null;
|
|
332
|
+
if (snapshot?.messageId || snapshot?.turnId) {
|
|
333
|
+
assistantMarkdown = await captureAssistantMarkdown(Runtime, {
|
|
334
|
+
messageId: snapshot.messageId,
|
|
335
|
+
turnId: snapshot.turnId,
|
|
336
|
+
}, noopLogger).catch(() => null);
|
|
337
|
+
}
|
|
338
|
+
const latestText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
|
|
339
|
+
? snapshot.text.trim()
|
|
340
|
+
: resolved.lastAssistantText;
|
|
341
|
+
const lastAssistantText = latestText ?? "";
|
|
342
|
+
const nowSummary = await inspectChatGptTab({
|
|
343
|
+
host,
|
|
344
|
+
port,
|
|
345
|
+
target: {
|
|
346
|
+
targetId: resolved.targetId,
|
|
347
|
+
title: resolved.title,
|
|
348
|
+
url: resolved.url,
|
|
349
|
+
type: "page",
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
const harvested = {
|
|
353
|
+
...nowSummary,
|
|
354
|
+
lastAssistantText,
|
|
355
|
+
lastAssistantSnippet: trimToSnippet(lastAssistantText),
|
|
356
|
+
lastAssistantMarkdown: assistantMarkdown ?? (lastAssistantText || null),
|
|
357
|
+
lastAssistantMessageId: typeof snapshot?.messageId === "string"
|
|
358
|
+
? snapshot.messageId
|
|
359
|
+
: nowSummary.lastAssistantMessageId,
|
|
360
|
+
lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : nowSummary.lastAssistantTurnId,
|
|
361
|
+
};
|
|
362
|
+
if (harvested.stopExists && options.stallWindowMs && options.stallWindowMs > 0) {
|
|
363
|
+
const firstFingerprint = harvested.fingerprint;
|
|
364
|
+
await delay(options.stallWindowMs);
|
|
365
|
+
const followup = await inspectChatGptTab({
|
|
366
|
+
host,
|
|
367
|
+
port,
|
|
368
|
+
target: {
|
|
369
|
+
targetId: harvested.targetId,
|
|
370
|
+
title: harvested.title,
|
|
371
|
+
url: harvested.url,
|
|
372
|
+
type: "page",
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
harvested.stopExists = followup.stopExists;
|
|
376
|
+
harvested.sendExists = followup.sendExists;
|
|
377
|
+
harvested.promptReady = followup.promptReady;
|
|
378
|
+
harvested.currentModelLabel = followup.currentModelLabel;
|
|
379
|
+
harvested.focused = followup.focused;
|
|
380
|
+
harvested.visibilityState = followup.visibilityState;
|
|
381
|
+
harvested.assistantCount = followup.assistantCount;
|
|
382
|
+
harvested.authenticated = followup.authenticated;
|
|
383
|
+
harvested.loginButtonExists = followup.loginButtonExists;
|
|
384
|
+
harvested.lastUserText = followup.lastUserText;
|
|
385
|
+
harvested.lastUserSnippet = followup.lastUserSnippet;
|
|
386
|
+
harvested.fingerprint = followup.fingerprint;
|
|
387
|
+
harvested.state =
|
|
388
|
+
harvested.stopExists && firstFingerprint === followup.fingerprint
|
|
389
|
+
? "stalled"
|
|
390
|
+
: classifyTabState(harvested);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
harvested.state = classifyTabState(harvested);
|
|
394
|
+
}
|
|
395
|
+
return harvested;
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
await client.close().catch(() => undefined);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export function extractConversationIdFromUrl(url) {
|
|
402
|
+
const match = normalizeUrl(url).match(/\/c\/([^/?#]+)/);
|
|
403
|
+
return match?.[1] ?? undefined;
|
|
404
|
+
}
|
|
405
|
+
export function formatBrowserTabState(tab) {
|
|
406
|
+
return tab.state ?? classifyTabState(tab);
|
|
407
|
+
}
|
|
408
|
+
export function sessionMatchesTab(meta, tab) {
|
|
409
|
+
const runtime = meta?.browser?.runtime ?? {};
|
|
410
|
+
const harvest = meta?.browser?.harvest ?? {};
|
|
411
|
+
const conversationId = tab.conversationId ?? extractConversationIdFromUrl(tab.url ?? "");
|
|
412
|
+
const portMatches = [runtime.chromePort, meta?.browser?.config?.remoteChrome?.port]
|
|
413
|
+
.filter(Boolean)
|
|
414
|
+
.some((port) => Number(port) === Number(DEFAULT_REMOTE_CHROME_PORT) ||
|
|
415
|
+
Number(port) === Number(tab.port ?? port));
|
|
416
|
+
const hostMatches = [runtime.chromeHost, meta?.browser?.config?.remoteChrome?.host]
|
|
417
|
+
.filter(Boolean)
|
|
418
|
+
.every((host) => !host || host === (tab.host ?? host));
|
|
419
|
+
if (!hostMatches) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
const matches = [
|
|
423
|
+
runtime.chromeTargetId && runtime.chromeTargetId === tab.targetId,
|
|
424
|
+
harvest.targetId && harvest.targetId === tab.targetId,
|
|
425
|
+
runtime.tabUrl && runtime.tabUrl === tab.url,
|
|
426
|
+
harvest.url && harvest.url === tab.url,
|
|
427
|
+
conversationId && runtime.conversationId && runtime.conversationId === conversationId,
|
|
428
|
+
conversationId && harvest.conversationId && harvest.conversationId === conversationId,
|
|
429
|
+
].some(Boolean);
|
|
430
|
+
return Boolean(matches ||
|
|
431
|
+
(portMatches &&
|
|
432
|
+
conversationId &&
|
|
433
|
+
(runtime.conversationId === conversationId || harvest.conversationId === conversationId)));
|
|
434
|
+
}
|
|
@@ -69,6 +69,77 @@ export async function writeChromePid(userDataDir, pid) {
|
|
|
69
69
|
// best effort
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
export async function findRunningChromeDebugTargetForProfile(userDataDir) {
|
|
73
|
+
if (process.platform === "win32") {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await execFileAsync("ps", ["-ax", "-o", "pid=", "-o", "command="], {
|
|
78
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
return findChromeDebugTargetForProfileFromProcessList(String(stdout ?? ""), userDataDir);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function findChromeDebugTargetForProfileFromProcessList(processList, userDataDir) {
|
|
87
|
+
for (const line of processList.split("\n")) {
|
|
88
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
89
|
+
if (!match)
|
|
90
|
+
continue;
|
|
91
|
+
const pid = Number.parseInt(match[1] ?? "", 10);
|
|
92
|
+
const command = match[2] ?? "";
|
|
93
|
+
const lower = command.toLowerCase();
|
|
94
|
+
if (!Number.isFinite(pid) || pid <= 0)
|
|
95
|
+
continue;
|
|
96
|
+
if (!lower.includes("chrome") && !lower.includes("chromium"))
|
|
97
|
+
continue;
|
|
98
|
+
if (!lower.includes("user-data-dir") || !command.includes(userDataDir))
|
|
99
|
+
continue;
|
|
100
|
+
const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/);
|
|
101
|
+
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
|
102
|
+
if (!Number.isFinite(port) || port <= 0)
|
|
103
|
+
continue;
|
|
104
|
+
return { pid, port };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
export function findChromeDebugTargetForProfileFromProcessListForTest(processList, userDataDir) {
|
|
109
|
+
return findChromeDebugTargetForProfileFromProcessList(processList, userDataDir);
|
|
110
|
+
}
|
|
111
|
+
export async function terminateRecordedChromeForProfile(userDataDir, logger) {
|
|
112
|
+
const pid = await readChromePid(userDataDir);
|
|
113
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const command = await readProcessCommand(pid);
|
|
117
|
+
if (!isChromeCommandForUserDataDir(command, userDataDir)) {
|
|
118
|
+
logger?.(`Recorded Chrome pid ${pid} does not match ${userDataDir}; skipping termination`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, "SIGTERM");
|
|
123
|
+
logger?.(`Terminated shared manual-login Chrome pid ${pid}`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
logger?.(`Failed to terminate shared manual-login Chrome pid ${pid}: ${message}`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function isChromeCommandForUserDataDir(command, userDataDir) {
|
|
133
|
+
if (!command)
|
|
134
|
+
return false;
|
|
135
|
+
const lower = command.toLowerCase();
|
|
136
|
+
return ((lower.includes("chrome") || lower.includes("chromium")) &&
|
|
137
|
+
lower.includes("user-data-dir") &&
|
|
138
|
+
command.includes(userDataDir));
|
|
139
|
+
}
|
|
140
|
+
export function isChromeCommandForUserDataDirForTest(command, userDataDir) {
|
|
141
|
+
return isChromeCommandForUserDataDir(command, userDataDir);
|
|
142
|
+
}
|
|
72
143
|
export function isProcessAlive(pid) {
|
|
73
144
|
if (!Number.isFinite(pid) || pid <= 0)
|
|
74
145
|
return false;
|
|
@@ -202,9 +273,6 @@ export async function verifyDevToolsReachable({ port, host = "127.0.0.1", attemp
|
|
|
202
273
|
return { ok: false, error: "unreachable" };
|
|
203
274
|
}
|
|
204
275
|
export async function shouldCleanupManualLoginProfileState(userDataDir, logger, options = {}) {
|
|
205
|
-
if (!options.connectionClosedUnexpectedly) {
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
276
|
const port = await readDevToolsPort(userDataDir);
|
|
209
277
|
if (!port) {
|
|
210
278
|
return true;
|
|
@@ -283,3 +351,15 @@ async function isChromeUsingUserDataDir(userDataDir) {
|
|
|
283
351
|
}
|
|
284
352
|
return false;
|
|
285
353
|
}
|
|
354
|
+
async function readProcessCommand(pid) {
|
|
355
|
+
try {
|
|
356
|
+
const { stdout } = await execFileAsync("ps", ["-p", String(Math.trunc(pid)), "-o", "command="], {
|
|
357
|
+
maxBuffer: 1024 * 1024,
|
|
358
|
+
});
|
|
359
|
+
const command = String(stdout ?? "").trim();
|
|
360
|
+
return command || null;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|