lazy-gravity 0.0.2 → 0.0.3

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/dist/bin/cli.js +79 -0
  4. package/dist/bin/commands/doctor.js +156 -0
  5. package/dist/bin/commands/open.js +145 -0
  6. package/dist/bin/commands/setup.js +366 -0
  7. package/dist/bin/commands/start.js +15 -0
  8. package/dist/bot/index.js +914 -0
  9. package/dist/commands/chatCommandHandler.js +145 -0
  10. package/dist/commands/cleanupCommandHandler.js +396 -0
  11. package/dist/commands/messageParser.js +28 -0
  12. package/dist/commands/registerSlashCommands.js +149 -0
  13. package/dist/commands/slashCommandHandler.js +104 -0
  14. package/dist/commands/workspaceCommandHandler.js +230 -0
  15. package/dist/database/chatSessionRepository.js +88 -0
  16. package/dist/database/scheduleRepository.js +119 -0
  17. package/dist/database/templateRepository.js +103 -0
  18. package/dist/database/workspaceBindingRepository.js +109 -0
  19. package/dist/events/interactionCreateHandler.js +286 -0
  20. package/dist/events/messageCreateHandler.js +154 -0
  21. package/dist/index.js +10 -0
  22. package/dist/middleware/auth.js +10 -0
  23. package/dist/middleware/sanitize.js +20 -0
  24. package/dist/services/antigravityLauncher.js +89 -0
  25. package/dist/services/approvalDetector.js +384 -0
  26. package/dist/services/autoAcceptService.js +80 -0
  27. package/dist/services/cdpBridgeManager.js +204 -0
  28. package/dist/services/cdpConnectionPool.js +157 -0
  29. package/dist/services/cdpService.js +1311 -0
  30. package/dist/services/channelManager.js +118 -0
  31. package/dist/services/chatSessionService.js +516 -0
  32. package/dist/services/modeService.js +73 -0
  33. package/dist/services/modelService.js +63 -0
  34. package/dist/services/processManager.js +61 -0
  35. package/dist/services/progressSender.js +61 -0
  36. package/dist/services/promptDispatcher.js +17 -0
  37. package/dist/services/quotaService.js +185 -0
  38. package/dist/services/responseMonitor.js +645 -0
  39. package/dist/services/scheduleService.js +134 -0
  40. package/dist/services/screenshotService.js +85 -0
  41. package/dist/services/titleGeneratorService.js +113 -0
  42. package/dist/services/workspaceService.js +64 -0
  43. package/dist/ui/autoAcceptUi.js +34 -0
  44. package/dist/ui/modeUi.js +34 -0
  45. package/dist/ui/modelsUi.js +97 -0
  46. package/dist/ui/screenshotUi.js +51 -0
  47. package/dist/ui/templateUi.js +67 -0
  48. package/dist/utils/cdpPorts.js +5 -0
  49. package/dist/utils/config.js +20 -0
  50. package/dist/utils/configLoader.js +160 -0
  51. package/dist/utils/discordFormatter.js +167 -0
  52. package/dist/utils/i18n.js +77 -0
  53. package/dist/utils/imageHandler.js +154 -0
  54. package/dist/utils/lockfile.js +113 -0
  55. package/dist/utils/logger.js +32 -0
  56. package/dist/utils/logo.js +13 -0
  57. package/dist/utils/metadataExtractor.js +15 -0
  58. package/dist/utils/processLogBuffer.js +98 -0
  59. package/dist/utils/streamMessageFormatter.js +90 -0
  60. package/package.json +73 -5
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChannelManager = void 0;
4
+ const discord_js_1 = require("discord.js");
5
+ /** Category name prefix emoji */
6
+ const CATEGORY_PREFIX = '🗂️-';
7
+ /** Default channel name under the category */
8
+ const DEFAULT_CHANNEL_NAME = 'general';
9
+ /**
10
+ * Class that manages Discord categories and channels corresponding to workspace paths.
11
+ * Creates the category/channel if they don't exist for the given workspace name,
12
+ * or returns the existing channel ID if they do.
13
+ */
14
+ class ChannelManager {
15
+ /**
16
+ * Ensure a category exists for the given workspace path.
17
+ * Creates a new one if it doesn't exist, returns the existing ID otherwise.
18
+ */
19
+ async ensureCategory(guild, workspacePath) {
20
+ if (!workspacePath || workspacePath.trim() === '') {
21
+ throw new Error('Workspace path not specified');
22
+ }
23
+ const sanitizedName = this.sanitizeCategoryName(workspacePath);
24
+ const categoryName = `${CATEGORY_PREFIX}${sanitizedName}`;
25
+ // Search from cache first
26
+ let existingCategory = guild.channels.cache.find((ch) => ch.type === discord_js_1.ChannelType.GuildCategory && ch.name === categoryName);
27
+ // If not in cache, fetch all channels and retry
28
+ if (!existingCategory) {
29
+ const channels = await guild.channels.fetch();
30
+ // Collection.find doesn't return null, but fetch results may contain null entries
31
+ const found = channels.find((ch) => ch !== null && ch !== undefined && ch.type === discord_js_1.ChannelType.GuildCategory && ch.name === categoryName);
32
+ if (found) {
33
+ existingCategory = found;
34
+ }
35
+ }
36
+ if (existingCategory) {
37
+ return { categoryId: existingCategory.id, created: false };
38
+ }
39
+ const newCategory = await guild.channels.create({
40
+ name: categoryName,
41
+ type: discord_js_1.ChannelType.GuildCategory,
42
+ });
43
+ return { categoryId: newCategory.id, created: true };
44
+ }
45
+ /**
46
+ * Create a new session channel under the category.
47
+ */
48
+ async createSessionChannel(guild, categoryId, channelName) {
49
+ const newChannel = await guild.channels.create({
50
+ name: channelName,
51
+ type: discord_js_1.ChannelType.GuildText,
52
+ parent: categoryId,
53
+ });
54
+ return { channelId: newChannel.id };
55
+ }
56
+ /**
57
+ * Rename a channel.
58
+ */
59
+ async renameChannel(guild, channelId, newName) {
60
+ const channel = guild.channels.cache.get(channelId);
61
+ if (!channel) {
62
+ throw new Error(`Channel ${channelId} not found`);
63
+ }
64
+ await channel.setName(newName);
65
+ }
66
+ /**
67
+ * Ensure a category and text channel exist for the given workspace path.
68
+ * Kept for backward compatibility. Internally calls ensureCategory + createSessionChannel('general').
69
+ */
70
+ async ensureChannel(guild, workspacePath) {
71
+ if (!workspacePath || workspacePath.trim() === '') {
72
+ throw new Error('Workspace path not specified');
73
+ }
74
+ const categoryResult = await this.ensureCategory(guild, workspacePath);
75
+ const categoryId = categoryResult.categoryId;
76
+ // Search for existing default channel (under the category)
77
+ const existingTextChannel = guild.channels.cache.find((ch) => ch.type === discord_js_1.ChannelType.GuildText &&
78
+ 'parentId' in ch &&
79
+ ch.parentId === categoryId &&
80
+ ch.name === DEFAULT_CHANNEL_NAME);
81
+ if (existingTextChannel) {
82
+ return {
83
+ categoryId,
84
+ channelId: existingTextChannel.id,
85
+ created: false,
86
+ };
87
+ }
88
+ const sessionResult = await this.createSessionChannel(guild, categoryId, DEFAULT_CHANNEL_NAME);
89
+ return {
90
+ categoryId,
91
+ channelId: sessionResult.channelId,
92
+ created: true,
93
+ };
94
+ }
95
+ /**
96
+ * Sanitize text into a format suitable for Discord channel names (public utility).
97
+ */
98
+ sanitizeChannelName(name) {
99
+ return this.sanitizeCategoryName(name);
100
+ }
101
+ /**
102
+ * Sanitize a workspace path into a format suitable for Discord category names.
103
+ */
104
+ sanitizeCategoryName(name) {
105
+ let sanitized = name
106
+ .toLowerCase()
107
+ .replace(/\/+$/, '')
108
+ .replace(/\//g, '-')
109
+ .replace(/[^a-z0-9\-_\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf]/g, '-')
110
+ .replace(/-{2,}/g, '-')
111
+ .replace(/^-+|-+$/g, '');
112
+ if (sanitized.length > 100) {
113
+ sanitized = sanitized.substring(0, 100);
114
+ }
115
+ return sanitized;
116
+ }
117
+ }
118
+ exports.ChannelManager = ChannelManager;
@@ -0,0 +1,516 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatSessionService = void 0;
4
+ /** Script to get the state of the new chat button */
5
+ const GET_NEW_CHAT_BUTTON_SCRIPT = `(() => {
6
+ const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
7
+ if (!btn) return { found: false };
8
+ const cursor = window.getComputedStyle(btn).cursor;
9
+ const rect = btn.getBoundingClientRect();
10
+ return {
11
+ found: true,
12
+ enabled: cursor === 'pointer',
13
+ cursor,
14
+ x: Math.round(rect.x + rect.width / 2),
15
+ y: Math.round(rect.y + rect.height / 2),
16
+ };
17
+ })()`;
18
+ /**
19
+ * Script to get the chat title from the Cascade panel header.
20
+ * The title element is a div with the text-ellipsis class inside the header.
21
+ */
22
+ const GET_CHAT_TITLE_SCRIPT = `(() => {
23
+ const panel = document.querySelector('.antigravity-agent-side-panel');
24
+ if (!panel) return { title: '', hasActiveChat: false };
25
+ const header = panel.querySelector('div[class*="border-b"]');
26
+ if (!header) return { title: '', hasActiveChat: false };
27
+ const titleEl = header.querySelector('div[class*="text-ellipsis"]');
28
+ const title = titleEl ? (titleEl.textContent || '').trim() : '';
29
+ // "Agent" is the default empty chat title
30
+ const hasActiveChat = title.length > 0 && title !== 'Agent';
31
+ return { title: title || '(Untitled)', hasActiveChat };
32
+ })()`;
33
+ /**
34
+ * Build a script that activates an existing chat in the side panel by its title.
35
+ * Uses broad selector fallbacks because Antigravity's DOM structure can vary across versions.
36
+ */
37
+ function buildActivateChatByTitleScript(title) {
38
+ const safeTitle = JSON.stringify(title);
39
+ return `(() => {
40
+ const wantedRaw = ${safeTitle};
41
+ const wanted = (wantedRaw || '').toLowerCase().replace(/\\s+/g, ' ').trim();
42
+ if (!wanted) return { ok: false, error: 'Empty target title' };
43
+
44
+ const panel = document.querySelector('.antigravity-agent-side-panel') || document;
45
+ const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
46
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
47
+ const clickTarget = (el) => {
48
+ const clickable = el.closest('button, [role="button"], a, li, [data-testid*="conversation"]') || el;
49
+ if (!(clickable instanceof HTMLElement)) return false;
50
+ clickable.click();
51
+ return true;
52
+ };
53
+
54
+ const nodes = Array.from(panel.querySelectorAll('button, [role="button"], a, li, div, span'))
55
+ .filter(isVisible);
56
+
57
+ const exact = [];
58
+ const includes = [];
59
+ for (const node of nodes) {
60
+ const text = normalize(node.textContent || '');
61
+ if (!text) continue;
62
+ if (text === wanted) {
63
+ exact.push({ node, textLength: text.length });
64
+ } else if (text.includes(wanted)) {
65
+ includes.push({ node, textLength: text.length });
66
+ }
67
+ }
68
+
69
+ const pick = (list) => {
70
+ if (list.length === 0) return null;
71
+ list.sort((a, b) => a.textLength - b.textLength);
72
+ return list[0].node;
73
+ };
74
+
75
+ const target = pick(exact) || pick(includes);
76
+ if (!target) return { ok: false, error: 'Chat title not found in side panel' };
77
+ if (!clickTarget(target)) return { ok: false, error: 'Matched element is not clickable' };
78
+ return { ok: true };
79
+ })()`;
80
+ }
81
+ /**
82
+ * Build a script that opens Past Conversations and selects a conversation by title.
83
+ * This path is required for older chats that are not visible in the current side panel.
84
+ */
85
+ function buildActivateViaPastConversationsScript(title) {
86
+ const safeTitle = JSON.stringify(title);
87
+ return `(() => {
88
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
89
+ const wantedRaw = ${safeTitle};
90
+ const normalize = (text) => (text || '')
91
+ .normalize('NFKC')
92
+ .toLowerCase()
93
+ .replace(/[\\u2018\\u2019\\u201C\\u201D'"\`]/g, '')
94
+ .replace(/\\s+/g, ' ')
95
+ .trim();
96
+ const normalizeLoose = (text) => normalize(text).replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9faf\\s]/g, '').replace(/\\s+/g, ' ').trim();
97
+
98
+ const wanted = normalize(wantedRaw || '');
99
+ const wantedLoose = normalizeLoose(wantedRaw || '');
100
+ if (!wanted) return { ok: false, error: 'Empty target title' };
101
+
102
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
103
+ const asArray = (nodeList) => Array.from(nodeList || []);
104
+ const getLabelText = (el) => {
105
+ if (!el || !(el instanceof Element)) return '';
106
+ const parts = [
107
+ el.textContent || '',
108
+ el.getAttribute('aria-label') || '',
109
+ el.getAttribute('title') || '',
110
+ el.getAttribute('placeholder') || '',
111
+ el.getAttribute('data-tooltip-content') || '',
112
+ el.getAttribute('data-testid') || '',
113
+ ];
114
+ return parts.filter(Boolean).join(' ');
115
+ };
116
+ const getClickable = (el) => {
117
+ if (!el || !(el instanceof Element)) return null;
118
+ const clickable = el.closest('button, [role="button"], a, li, [role="option"], [data-testid*="conversation"]');
119
+ return clickable instanceof HTMLElement ? clickable : (el instanceof HTMLElement ? el : null);
120
+ };
121
+ const pickBest = (elements, patterns) => {
122
+ const matched = [];
123
+ for (const el of elements) {
124
+ if (!isVisible(el)) continue;
125
+ const text = normalize(getLabelText(el));
126
+ const textLoose = normalizeLoose(getLabelText(el));
127
+ if (!text) continue;
128
+ for (const pattern of patterns) {
129
+ if (!pattern) continue;
130
+ const p = normalize(pattern);
131
+ const pLoose = normalizeLoose(pattern);
132
+ if (
133
+ text === p ||
134
+ text.includes(p) ||
135
+ (pLoose && (textLoose === pLoose || textLoose.includes(pLoose)))
136
+ ) {
137
+ matched.push({ el, score: Math.abs(text.length - pattern.length) });
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ if (matched.length === 0) return null;
143
+ matched.sort((a, b) => a.score - b.score);
144
+ return matched[0].el;
145
+ };
146
+ const clickByPatterns = (patterns, selector) => {
147
+ const nodes = asArray(document.querySelectorAll('button, [role="button"], a, li, div, span'));
148
+ const scopedNodes = selector ? asArray(document.querySelectorAll(selector)) : [];
149
+ const source = scopedNodes.length > 0 ? scopedNodes : nodes;
150
+ const target = pickBest(source, patterns);
151
+ const clickable = getClickable(target);
152
+ if (!clickable) return false;
153
+ clickable.click();
154
+ return true;
155
+ };
156
+ const setInputValue = (el, value) => {
157
+ if (!el) return false;
158
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
159
+ el.focus();
160
+ el.value = value;
161
+ el.dispatchEvent(new Event('input', { bubbles: true }));
162
+ el.dispatchEvent(new Event('change', { bubbles: true }));
163
+ return true;
164
+ }
165
+ if (el instanceof HTMLElement) {
166
+ el.focus();
167
+ if (el.isContentEditable) {
168
+ el.textContent = value;
169
+ el.dispatchEvent(new Event('input', { bubbles: true }));
170
+ return true;
171
+ }
172
+ }
173
+ return false;
174
+ };
175
+ const clickIconHistoryButton = () => {
176
+ const iconTargets = asArray(document.querySelectorAll('svg, i, span, div'));
177
+ const patterns = ['history', 'clock', 'conversation', 'past'];
178
+ for (const icon of iconTargets) {
179
+ const descriptor = normalize([
180
+ icon.getAttribute?.('class') || '',
181
+ icon.getAttribute?.('data-testid') || '',
182
+ icon.getAttribute?.('data-icon') || '',
183
+ icon.getAttribute?.('aria-label') || '',
184
+ icon.getAttribute?.('title') || '',
185
+ icon.getAttribute?.('data-tooltip-id') || '',
186
+ ].join(' '));
187
+ if (!descriptor) continue;
188
+ if (!patterns.some((p) => descriptor.includes(p))) continue;
189
+ const clickable = getClickable(icon);
190
+ if (clickable && isVisible(clickable)) {
191
+ clickable.click();
192
+ return true;
193
+ }
194
+ }
195
+ return false;
196
+ };
197
+ const openMenuThenClickPast = async () => {
198
+ const openedMenu = clickByPatterns(
199
+ ['more', 'options', 'menu', 'actions', '...', 'ellipsis', '設定', '操作'],
200
+ 'button[aria-haspopup], [role="button"][aria-haspopup], button, [role="button"]',
201
+ );
202
+ if (!openedMenu) return false;
203
+ await wait(180);
204
+ return clickByPatterns([
205
+ 'past conversations',
206
+ 'past conversation',
207
+ 'conversation history',
208
+ 'past chats',
209
+ '過去の会話',
210
+ 'chat history',
211
+ ], '[role="menuitem"], [role="option"], button, [role="button"], li, div, span');
212
+ };
213
+ const pressEnter = (el) => {
214
+ if (!(el instanceof HTMLElement)) return;
215
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
216
+ el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true }));
217
+ };
218
+ const findSearchInput = () => {
219
+ const inputs = asArray(document.querySelectorAll('input, textarea, [role="combobox"], [role="searchbox"], [contenteditable="true"]'));
220
+ const strongPatterns = ['select a conversation', 'search conversation', 'search chats', 'search'];
221
+ const placeholders = [];
222
+ for (const el of inputs) {
223
+ if (!isVisible(el)) continue;
224
+ const placeholder = normalize(el.getAttribute('placeholder') || '');
225
+ const ariaLabel = normalize(el.getAttribute('aria-label') || '');
226
+ const text = normalize(getLabelText(el));
227
+ const combined = [placeholder, ariaLabel, text].filter(Boolean).join(' ');
228
+ placeholders.push({ el, combined });
229
+ }
230
+ for (const p of strongPatterns) {
231
+ const found = placeholders.find((x) => x.combined.includes(p));
232
+ if (found) return found.el;
233
+ }
234
+ return placeholders[0]?.el || null;
235
+ };
236
+
237
+ return (async () => {
238
+ let opened = clickByPatterns([
239
+ 'past conversations',
240
+ 'past conversation',
241
+ 'conversation history',
242
+ 'past chats',
243
+ '過去の会話',
244
+ 'chat history',
245
+ ]);
246
+ if (!opened) {
247
+ opened = clickIconHistoryButton();
248
+ }
249
+ if (!opened) {
250
+ opened = await openMenuThenClickPast();
251
+ }
252
+ if (!opened) {
253
+ return { ok: false, error: 'Past Conversations button not found' };
254
+ }
255
+
256
+ await wait(320);
257
+
258
+ // In some UI states "Select a conversation" itself is a trigger.
259
+ clickByPatterns(['select a conversation', 'select conversation', 'conversation'], '[role="button"], button, [aria-haspopup], [data-testid*="conversation"]');
260
+ await wait(220);
261
+
262
+ const input = findSearchInput();
263
+ if (input) {
264
+ setInputValue(input, wantedRaw);
265
+ await wait(260);
266
+ }
267
+
268
+ let selected = clickByPatterns([wanted, wantedLoose], '[role="option"], li, button, [data-testid*="conversation"]');
269
+ if (!selected && input) {
270
+ pressEnter(input);
271
+ await wait(220);
272
+ selected = true;
273
+ }
274
+ if (!selected) {
275
+ return { ok: false, error: 'Conversation not found in Past Conversations' };
276
+ }
277
+ return { ok: true };
278
+ })();
279
+ })()`;
280
+ }
281
+ /**
282
+ * Service for managing chat sessions on Antigravity via CDP.
283
+ *
284
+ * CDP dependencies are received as method arguments (connection pool compatible).
285
+ */
286
+ class ChatSessionService {
287
+ static ACTIVATE_SESSION_MAX_WAIT_MS = 30000;
288
+ static ACTIVATE_SESSION_RETRY_INTERVAL_MS = 800;
289
+ /**
290
+ * Start a new chat session in the Antigravity UI.
291
+ *
292
+ * Strategy:
293
+ * 1. Check the state of the new chat button
294
+ * 2. cursor: not-allowed -> already an empty chat (do nothing)
295
+ * 3. cursor: pointer -> click via Input.dispatchMouseEvent coordinates
296
+ * 4. Button not found -> error
297
+ *
298
+ * @param cdpService CdpService instance to use
299
+ * @returns { ok: true } on success, { ok: false, error: string } on failure
300
+ */
301
+ async startNewChat(cdpService) {
302
+ try {
303
+ // Contexts may be empty right after Antigravity starts.
304
+ // Wait up to 10 seconds for the cascade-panel to become ready.
305
+ let contexts = cdpService.getContexts();
306
+ if (contexts.length === 0) {
307
+ const ready = await cdpService.waitForCascadePanelReady(10000, 500);
308
+ if (!ready) {
309
+ return { ok: false, error: 'No contexts available (timed out)' };
310
+ }
311
+ contexts = cdpService.getContexts();
312
+ }
313
+ // Get button state (retry waiting for DOM load: up to 5 times, 1 second interval)
314
+ let btnState = await this.getNewChatButtonState(cdpService, contexts);
315
+ if (!btnState.found) {
316
+ const maxRetries = 5;
317
+ for (let i = 0; i < maxRetries && !btnState.found; i++) {
318
+ await new Promise(r => setTimeout(r, 1000));
319
+ contexts = cdpService.getContexts();
320
+ btnState = await this.getNewChatButtonState(cdpService, contexts);
321
+ }
322
+ }
323
+ if (!btnState.found) {
324
+ return { ok: false, error: 'New chat button not found' };
325
+ }
326
+ // cursor: not-allowed -> already an empty chat (no need to create new)
327
+ if (!btnState.enabled) {
328
+ return { ok: true };
329
+ }
330
+ // cursor: pointer -> click via CDP Input API coordinates
331
+ await cdpService.call('Input.dispatchMouseEvent', {
332
+ type: 'mouseMoved', x: btnState.x, y: btnState.y,
333
+ });
334
+ await cdpService.call('Input.dispatchMouseEvent', {
335
+ type: 'mousePressed', x: btnState.x, y: btnState.y,
336
+ button: 'left', clickCount: 1,
337
+ });
338
+ await cdpService.call('Input.dispatchMouseEvent', {
339
+ type: 'mouseReleased', x: btnState.x, y: btnState.y,
340
+ button: 'left', clickCount: 1,
341
+ });
342
+ // Wait for UI to update after click
343
+ await new Promise(r => setTimeout(r, 1500));
344
+ // Check if button changed to not-allowed (evidence that a new chat was opened)
345
+ const afterState = await this.getNewChatButtonState(cdpService, contexts);
346
+ if (afterState.found && !afterState.enabled) {
347
+ return { ok: true };
348
+ }
349
+ // Button still enabled -> click may not have worked
350
+ return { ok: false, error: 'Clicked new chat button but state did not change' };
351
+ }
352
+ catch (error) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ return { ok: false, error: message };
355
+ }
356
+ }
357
+ /**
358
+ * Get the current chat session information.
359
+ * @param cdpService CdpService instance to use
360
+ * @returns Chat session information
361
+ */
362
+ async getCurrentSessionInfo(cdpService) {
363
+ try {
364
+ const contexts = cdpService.getContexts();
365
+ for (const ctx of contexts) {
366
+ try {
367
+ const result = await cdpService.call('Runtime.evaluate', {
368
+ expression: GET_CHAT_TITLE_SCRIPT,
369
+ returnByValue: true,
370
+ contextId: ctx.id,
371
+ });
372
+ const value = result?.result?.value;
373
+ if (value && value.title) {
374
+ return {
375
+ title: value.title,
376
+ hasActiveChat: value.hasActiveChat ?? false,
377
+ };
378
+ }
379
+ }
380
+ catch (_) { /* try next context */ }
381
+ }
382
+ return { title: '(Failed to retrieve)', hasActiveChat: false };
383
+ }
384
+ catch (error) {
385
+ return { title: '(Failed to retrieve)', hasActiveChat: false };
386
+ }
387
+ }
388
+ /**
389
+ * Activate an existing chat by title.
390
+ * Returns ok:false if the target chat cannot be located or verified.
391
+ */
392
+ async activateSessionByTitle(cdpService, title, options) {
393
+ if (!title || title.trim().length === 0) {
394
+ return { ok: false, error: 'Session title is empty' };
395
+ }
396
+ const current = await this.getCurrentSessionInfo(cdpService);
397
+ if (current.title.trim() === title.trim()) {
398
+ return { ok: true };
399
+ }
400
+ const maxWaitMs = options?.maxWaitMs ?? ChatSessionService.ACTIVATE_SESSION_MAX_WAIT_MS;
401
+ const retryIntervalMs = options?.retryIntervalMs ?? ChatSessionService.ACTIVATE_SESSION_RETRY_INTERVAL_MS;
402
+ let usedPastConversations = false;
403
+ let directResult = { ok: false, error: 'not attempted' };
404
+ let pastResult = null;
405
+ let clicked = false;
406
+ const startedAt = Date.now();
407
+ let attempts = 0;
408
+ while (Date.now() - startedAt <= maxWaitMs) {
409
+ attempts += 1;
410
+ directResult = await this.tryActivateByDirectSidePanel(cdpService, title);
411
+ clicked = directResult.ok;
412
+ if (!clicked) {
413
+ pastResult = await this.tryActivateByPastConversations(cdpService, title);
414
+ clicked = pastResult.ok;
415
+ usedPastConversations = pastResult.ok;
416
+ }
417
+ if (clicked) {
418
+ break;
419
+ }
420
+ if (Date.now() - startedAt <= maxWaitMs) {
421
+ await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
422
+ }
423
+ }
424
+ if (!clicked) {
425
+ return {
426
+ ok: false,
427
+ error: `Failed to activate session "${title}" ` +
428
+ `after ${attempts} attempt(s) ` +
429
+ `(direct: ${directResult.error || 'direct search failed'}; ` +
430
+ `past: ${pastResult?.error || 'past conversations search failed'})`,
431
+ };
432
+ }
433
+ // Wait briefly for DOM state transition and verify destination chat.
434
+ await new Promise((resolve) => setTimeout(resolve, 500));
435
+ const after = await this.getCurrentSessionInfo(cdpService);
436
+ if (after.title.trim() === title.trim()) {
437
+ return { ok: true };
438
+ }
439
+ // If direct side-panel activation hit the wrong row, try the explicit Past Conversations flow.
440
+ if (!usedPastConversations) {
441
+ const viaPast = await this.tryActivateByPastConversations(cdpService, title);
442
+ if (viaPast.ok) {
443
+ await new Promise((resolve) => setTimeout(resolve, 500));
444
+ const afterPast = await this.getCurrentSessionInfo(cdpService);
445
+ if (afterPast.title.trim() === title.trim()) {
446
+ return { ok: true };
447
+ }
448
+ return {
449
+ ok: false,
450
+ error: `Past Conversations selected a different chat (expected="${title}", actual="${afterPast.title}")`,
451
+ };
452
+ }
453
+ return {
454
+ ok: false,
455
+ error: `Activated chat did not match target title (expected="${title}", actual="${after.title}") ` +
456
+ `and Past Conversations fallback failed (${viaPast.error || 'unknown'})`,
457
+ };
458
+ }
459
+ return {
460
+ ok: false,
461
+ error: `Activated chat did not match target title (expected="${title}", actual="${after.title}")`,
462
+ };
463
+ }
464
+ async tryActivateByDirectSidePanel(cdpService, title) {
465
+ return this.tryActivateWithScript(cdpService, buildActivateChatByTitleScript(title), false);
466
+ }
467
+ async tryActivateByPastConversations(cdpService, title) {
468
+ return this.tryActivateWithScript(cdpService, buildActivateViaPastConversationsScript(title), true);
469
+ }
470
+ async tryActivateWithScript(cdpService, script, awaitPromise) {
471
+ const contexts = cdpService.getContexts();
472
+ let lastError = 'Activation script returned no match';
473
+ for (const ctx of contexts) {
474
+ try {
475
+ const result = await cdpService.call('Runtime.evaluate', {
476
+ expression: script,
477
+ returnByValue: true,
478
+ awaitPromise,
479
+ contextId: ctx.id,
480
+ });
481
+ const value = result?.result?.value;
482
+ if (value?.ok) {
483
+ return { ok: true };
484
+ }
485
+ if (value?.error && typeof value.error === 'string') {
486
+ lastError = value.error;
487
+ }
488
+ }
489
+ catch (error) {
490
+ lastError = error instanceof Error ? error.message : String(error);
491
+ }
492
+ }
493
+ return { ok: false, error: lastError };
494
+ }
495
+ /**
496
+ * Get the state (enabled/disabled, coordinates) of the new chat button.
497
+ */
498
+ async getNewChatButtonState(cdpService, contexts) {
499
+ for (const ctx of contexts) {
500
+ try {
501
+ const res = await cdpService.call('Runtime.evaluate', {
502
+ expression: GET_NEW_CHAT_BUTTON_SCRIPT,
503
+ returnByValue: true,
504
+ contextId: ctx.id,
505
+ });
506
+ const value = res?.result?.value;
507
+ if (value?.found) {
508
+ return { found: true, enabled: value.enabled, x: value.x, y: value.y };
509
+ }
510
+ }
511
+ catch (_) { /* try next context */ }
512
+ }
513
+ return { found: false, enabled: false, x: 0, y: 0 };
514
+ }
515
+ }
516
+ exports.ChatSessionService = ChatSessionService;