lazy-gravity 0.8.0 → 0.8.2

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
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <img src="https://img.shields.io/badge/version-0.3.0-blue?style=flat-square" alt="Version" />
6
+ <img src="https://img.shields.io/npm/v/lazy-gravity?style=flat-square&color=blue" alt="Version" />
7
7
  <img src="https://img.shields.io/badge/Antigravity-1.19.5-ff6b35?style=flat-square" alt="Antigravity" />
8
8
  <img src="https://img.shields.io/badge/node-18.x+-brightgreen?style=flat-square&logo=node.js" alt="Node.js" />
9
9
  <img src="https://img.shields.io/badge/discord.js-14.x-5865F2?style=flat-square&logo=discord&logoColor=white" alt="discord.js" />
@@ -64,6 +64,8 @@ function artifactTypeLabel(type) {
64
64
  // ---------------------------------------------------------------------------
65
65
  // ArtifactService
66
66
  // ---------------------------------------------------------------------------
67
+ /** Common words to ignore when scoring fuzzy title matches */
68
+ const COMMON_WORDS = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that']);
67
69
  class ArtifactService {
68
70
  brainBasePath;
69
71
  constructor(brainBasePath) {
@@ -147,6 +149,7 @@ class ArtifactService {
147
149
  }
148
150
  /**
149
151
  * Try to find a conversation UUID whose overview.txt contains the given session title.
152
+ * Uses an exact match first, falling back to keyword overlap scoring.
150
153
  * Returns the UUID or null if not found.
151
154
  */
152
155
  findConversationByTitle(title) {
@@ -167,6 +170,22 @@ class ArtifactService {
167
170
  })
168
171
  .sort((a, b) => b.mtime - a.mtime)
169
172
  .map(x => x.id);
173
+ let bestId = null;
174
+ let bestScore = 0;
175
+ // Unicode-aware split to support CJK. Preserve CJK characters (len 1) but filter short Latin words.
176
+ const cleanNeedleWords = needle
177
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
178
+ .split(/\s+/)
179
+ .filter(w => {
180
+ if (COMMON_WORDS.has(w))
181
+ return false;
182
+ // Allow CJK characters (Scripts: Han, Hiragana, Katakana, Hangul) even if length 1.
183
+ const isCJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(w);
184
+ return isCJK || w.length > 2;
185
+ });
186
+ const uniqueNeedleWords = Array.from(new Set(cleanNeedleWords));
187
+ // Require a stronger minimum score of 2 to prevent weak one-word matches.
188
+ const minScore = 2;
170
189
  for (const id of sortedIds) {
171
190
  const overviewPath = path.join(this.brainBasePath, id, '.system_generated', 'logs', 'overview.txt');
172
191
  try {
@@ -180,7 +199,18 @@ class ArtifactService {
180
199
  const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
181
200
  const header = buf.slice(0, bytesRead).toString('utf-8').toLowerCase();
182
201
  if (header.includes(needle)) {
183
- return id;
202
+ return id; // Exact match takes precedence
203
+ }
204
+ // Use same Unicode-aware split for header tokens
205
+ const headerTokens = new Set(header.replace(/[^\p{L}\p{N}]+/gu, ' ').split(/\s+/));
206
+ let score = 0;
207
+ for (const word of uniqueNeedleWords) {
208
+ if (headerTokens.has(word))
209
+ score++;
210
+ }
211
+ if (score > bestScore) {
212
+ bestScore = score;
213
+ bestId = id;
184
214
  }
185
215
  }
186
216
  finally {
@@ -192,6 +222,9 @@ class ArtifactService {
192
222
  // Skip unreadable files
193
223
  }
194
224
  }
225
+ if (bestScore >= minScore) {
226
+ return bestId;
227
+ }
195
228
  return null;
196
229
  }
197
230
  /**
@@ -30,6 +30,8 @@ function classifyAssistantSegments(payload) {
30
30
  diagnostics: {
31
31
  source: 'legacy-fallback',
32
32
  segmentCounts: {},
33
+ allFingerprints: [],
34
+ totalSegments: 0,
33
35
  fallbackReason: 'invalid-payload',
34
36
  },
35
37
  };
@@ -40,12 +42,15 @@ function classifyAssistantSegments(payload) {
40
42
  const activityLines = [];
41
43
  const feedbackTexts = [];
42
44
  const segmentCounts = {};
45
+ const allFingerprints = [];
43
46
  for (const seg of segments) {
44
47
  segmentCounts[seg.kind] = (segmentCounts[seg.kind] ?? 0) + 1;
45
48
  switch (seg.kind) {
46
49
  case 'assistant-body':
47
50
  if (seg.text && seg.text.trim()) {
48
51
  bodyTexts.push(seg.text);
52
+ const fp = seg.text.length + ':' + seg.text.slice(0, 50) + ':' + seg.text.slice(-50);
53
+ allFingerprints.push(fp);
49
54
  }
50
55
  break;
51
56
  case 'thinking':
@@ -62,9 +67,10 @@ function classifyAssistantSegments(payload) {
62
67
  break;
63
68
  }
64
69
  }
65
- // Join body segments and apply HTML-to-Markdown conversion
66
- const rawBody = bodyTexts.join('\n\n');
67
- const finalOutputText = (0, htmlToDiscordMarkdown_1.htmlToDiscordMarkdown)(rawBody);
70
+ // Concatenate all body segments the CDP script may yield multiple nodes
71
+ // (streaming partial + final, multi-block markdown, etc.)
72
+ const lastBody = bodyTexts.join('\n\n') || '';
73
+ const finalOutputText = (0, htmlToDiscordMarkdown_1.htmlToDiscordMarkdown)(lastBody);
68
74
  return {
69
75
  finalOutputText,
70
76
  activityLines,
@@ -72,6 +78,8 @@ function classifyAssistantSegments(payload) {
72
78
  diagnostics: {
73
79
  source: 'dom-structured',
74
80
  segmentCounts,
81
+ allFingerprints,
82
+ totalSegments: segments.length,
75
83
  },
76
84
  };
77
85
  }
@@ -100,9 +108,9 @@ function extractAssistantSegmentsPayloadScript() {
100
108
 
101
109
  // Same selectors as RESPONSE_TEXT — ordered by specificity
102
110
  var selectors = [
111
+ '.text-ide-message-block-bot-color',
103
112
  '.rendered-markdown',
104
113
  '.leading-relaxed.select-text',
105
- '.flex.flex-col.gap-y-3',
106
114
  '[data-message-author-role="assistant"]',
107
115
  '[data-message-role="assistant"]',
108
116
  '[class*="assistant-message"]',
@@ -138,6 +146,12 @@ function extractAssistantSegmentsPayloadScript() {
138
146
  if (node.closest('[class*="feedback"], footer')) return true;
139
147
  if (node.closest('.notify-user-container')) return true;
140
148
  if (node.closest('[role="dialog"]')) return true;
149
+ if (node.closest('form')) return true;
150
+ if (node.closest('[data-message-author-role="user"], [data-message-role="user"]')) return true;
151
+ if (node.querySelector('textarea') || node.closest('textarea')) return true;
152
+ var text = (node.innerText || '').toLowerCase();
153
+ if (text.includes('ask anything, @ to mention')) return true;
154
+ if (text.includes('0 files with changes')) return true;
141
155
  return false;
142
156
  };
143
157
 
@@ -221,10 +235,43 @@ function extractAssistantSegmentsPayloadScript() {
221
235
  text: bodyHtml,
222
236
  role: 'assistant',
223
237
  messageIndex: 0,
224
- domPath: 'multi-selector'
238
+ domPath: 'article:nth(' + i + ')'
225
239
  });
226
240
  bodyFound = true;
227
- break; // Only take the last (most recent) output node
241
+ break; // Only capture the single latest assistant message
242
+ }
243
+ }
244
+
245
+ // Pass 3: Thinking logs and tool calls
246
+ var thinkingNodes = scope.querySelectorAll('[class*="thinking"], [class*="thought"]');
247
+ for (var j = 0; j < thinkingNodes.length; j++) {
248
+ var tnode = thinkingNodes[j];
249
+ if (isInsideExcludedContainer(tnode)) continue;
250
+ var ttext = (tnode.innerText || tnode.textContent || '').trim();
251
+ if (ttext) {
252
+ segments.push({
253
+ kind: 'thinking',
254
+ text: ttext,
255
+ role: 'assistant',
256
+ messageIndex: 0,
257
+ domPath: 'thinking:nth(' + j + ')'
258
+ });
259
+ }
260
+ }
261
+
262
+ var toolNodes = scope.querySelectorAll('[class*="tool-interaction"], [class*="call-summary"]');
263
+ for (var k = 0; k < toolNodes.length; k++) {
264
+ var knode = toolNodes[k];
265
+ if (isInsideExcludedContainer(knode)) continue;
266
+ var ktext = (knode.innerText || knode.textContent || '').trim();
267
+ if (ktext) {
268
+ segments.push({
269
+ kind: 'tool-call',
270
+ text: ktext,
271
+ role: 'assistant',
272
+ messageIndex: 0,
273
+ domPath: 'tool:nth(' + k + ')'
274
+ });
228
275
  }
229
276
  }
230
277
 
@@ -229,7 +229,7 @@ function parseRunCommandCustomId(customId) {
229
229
  return null;
230
230
  }
231
231
  /** Initialize the CDP bridge (lazy connection: pool creation only) */
232
- function initCdpBridge(autoApproveDefault, accountPorts = {}, accountUserDataDirs = {}) {
232
+ function initCdpBridge(autoApproveDefault, accountPorts = {}, accountUserDataDirs = {}, cdpHost = '127.0.0.1') {
233
233
  const pool = new cdpConnectionPool_1.CdpConnectionPool({
234
234
  accountPorts,
235
235
  accountUserDataDirs,
@@ -238,6 +238,7 @@ function initCdpBridge(autoApproveDefault, accountPorts = {}, accountUserDataDir
238
238
  // Reconnection is triggered when the next chat/template message is sent.
239
239
  maxReconnectAttempts: 0,
240
240
  reconnectDelayMs: 3000,
241
+ cdpHost,
241
242
  });
242
243
  const quota = new quotaService_1.QuotaService();
243
244
  const autoAccept = new autoAcceptService_1.AutoAcceptService(autoApproveDefault);
@@ -250,6 +251,7 @@ function initCdpBridge(autoApproveDefault, accountPorts = {}, accountUserDataDir
250
251
  approvalChannelByWorkspace: new Map(),
251
252
  approvalChannelBySession: new Map(),
252
253
  selectedAccountByChannel: new Map(),
254
+ cdpHost,
253
255
  };
254
256
  }
255
257
  /**
@@ -526,6 +528,14 @@ function ensureRunCommandDetector(bridge, cdp, projectName, accountName = 'defau
526
528
  function ensureUserMessageDetector(bridge, cdp, projectName, onUserMessage, accountName = 'default') {
527
529
  const existing = bridge.pool.getUserMessageDetector(projectName, accountName);
528
530
  if (existing && existing.isActive()) {
531
+ // Remove only the listeners we may have previously attached via this
532
+ // code path. Using removeAllListeners('message') would wipe any
533
+ // subscriber added elsewhere, so we track and swap explicitly.
534
+ const prevListener = existing.__userMsgHandler;
535
+ if (prevListener) {
536
+ existing.off('message', prevListener);
537
+ }
538
+ existing.__userMsgHandler = onUserMessage;
529
539
  existing.on('message', onUserMessage);
530
540
  return;
531
541
  }
@@ -533,6 +543,7 @@ function ensureUserMessageDetector(bridge, cdp, projectName, onUserMessage, acco
533
543
  cdpService: cdp,
534
544
  pollIntervalMs: 2000,
535
545
  });
546
+ detector.__userMsgHandler = onUserMessage;
536
547
  detector.on('message', onUserMessage);
537
548
  detector.start();
538
549
  bridge.pool.registerUserMessageDetector(projectName, detector, accountName);
@@ -47,7 +47,10 @@ const ws_1 = __importDefault(require("ws"));
47
47
  /** Antigravity UI DOM selector constants */
48
48
  const SELECTORS = {
49
49
  /** Chat input box: textbox excluding xterm */
50
- CHAT_INPUT: 'div[role="textbox"]:not(.xterm-helper-textarea)',
50
+ CHAT_INPUT: [
51
+ 'div[role="textbox"]:not(.xterm-helper-textarea)',
52
+ 'div[role="combobox"][contenteditable="true"][aria-label="Message input"]',
53
+ ].join(', '),
51
54
  /** Submit button search target tag */
52
55
  SUBMIT_BUTTON_CONTAINER: 'button',
53
56
  /** Submit icon SVG class candidates */
@@ -61,11 +64,15 @@ const WORKSPACE_STATE_SCRIPT = `(() => {
61
64
 
62
65
  let isGenerating = false;
63
66
  for (const scope of scopes) {
64
- const stopEl = scope.querySelector('[data-tooltip-id="input-send-button-cancel-tooltip"]');
65
- if (stopEl) {
66
- isGenerating = true;
67
- break;
67
+ const els = scope.querySelectorAll('[data-tooltip-id="input-send-button-cancel-tooltip"]');
68
+ for (let i = 0; i < els.length; i++) {
69
+ const style = window.getComputedStyle(els[i]);
70
+ if (style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0) {
71
+ isGenerating = true;
72
+ break;
73
+ }
68
74
  }
75
+ if (isGenerating) break;
69
76
  }
70
77
 
71
78
  if (!isGenerating) {
@@ -142,6 +149,7 @@ class CdpService extends events_1.EventEmitter {
142
149
  /** Workspace switching flag (suppresses disconnected event) */
143
150
  isSwitchingWorkspace = false;
144
151
  accountName;
152
+ cdpHost;
145
153
  accountPorts;
146
154
  accountUserDataDirs;
147
155
  constructor(options = {}) {
@@ -154,6 +162,7 @@ class CdpService extends events_1.EventEmitter {
154
162
  this.cdpCallTimeout = options.cdpCallTimeout;
155
163
  this.maxReconnectAttempts = options.maxReconnectAttempts ?? 3;
156
164
  this.reconnectDelayMs = options.reconnectDelayMs ?? 2000;
165
+ this.cdpHost = options.cdpHost ?? '127.0.0.1';
157
166
  }
158
167
  resolveAccountPorts(accountName) {
159
168
  const explicitPort = this.accountPorts[accountName];
@@ -189,7 +198,7 @@ class CdpService extends events_1.EventEmitter {
189
198
  let allPages = [];
190
199
  for (const port of this.ports) {
191
200
  try {
192
- const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
201
+ const list = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
193
202
  allPages.push(...list);
194
203
  }
195
204
  catch (e) {
@@ -466,7 +475,7 @@ class CdpService extends events_1.EventEmitter {
466
475
  let respondingPort = null;
467
476
  for (const port of this.ports) {
468
477
  try {
469
- const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
478
+ const list = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
470
479
  pages.push(...list);
471
480
  // Prioritize recording ports that contain workbench pages
472
481
  const hasWorkbench = list.some((t) => t.url?.includes('workbench'));
@@ -557,8 +566,8 @@ class CdpService extends events_1.EventEmitter {
557
566
  logger_1.logger.debug(`[CdpService] Probe success: detected "${projectName}"`);
558
567
  return true;
559
568
  }
560
- // If title is "Untitled (Workspace)", verify by folder path
561
- if (normalizedLiveTitle.includes('untitled') && workspacePath) {
569
+ // If title match failed, or if it's "Untitled", verify by folder path
570
+ if (workspacePath) {
562
571
  const folderMatch = await this.probeWorkspaceFolderPath(projectName, workspacePath);
563
572
  if (folderMatch) {
564
573
  return true;
@@ -708,7 +717,7 @@ class CdpService extends events_1.EventEmitter {
708
717
  let knownPageIds = new Set();
709
718
  for (const port of this.ports) {
710
719
  try {
711
- const preLaunchPages = await this.getJson(`http://127.0.0.1:${port}/json/list`);
720
+ const preLaunchPages = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
712
721
  preLaunchPages.forEach((p) => {
713
722
  if (p.id)
714
723
  knownPageIds.add(p.id);
@@ -723,7 +732,7 @@ class CdpService extends events_1.EventEmitter {
723
732
  let pages = [];
724
733
  for (const port of this.ports) {
725
734
  try {
726
- const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
735
+ const list = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
727
736
  pages.push(...list);
728
737
  }
729
738
  catch {
@@ -767,7 +776,7 @@ class CdpService extends events_1.EventEmitter {
767
776
  let allPages = [];
768
777
  for (const port of this.ports) {
769
778
  try {
770
- const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
779
+ const list = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
771
780
  allPages.push(...list);
772
781
  }
773
782
  catch {
@@ -993,7 +1002,7 @@ class CdpService extends events_1.EventEmitter {
993
1002
  let stillOpen = false;
994
1003
  for (const port of this.ports) {
995
1004
  try {
996
- const list = await this.getJson(`http://127.0.0.1:${port}/json/list`);
1005
+ const list = await this.getJson(`http://${this.cdpHost}:${port}/json/list`);
997
1006
  if (list.some((page) => page?.id === closingTargetId)) {
998
1007
  stillOpen = true;
999
1008
  break;
@@ -1290,6 +1299,12 @@ class CdpService extends events_1.EventEmitter {
1290
1299
  }
1291
1300
  async retryInjectOnce(text, firstError, imageFilePaths) {
1292
1301
  logger_1.logger.warn(`[CdpService] Initial message injection failed: ${firstError}. Retrying once after readiness check...`);
1302
+ if (this.isTransientInjectError(firstError)) {
1303
+ const target = await this.findWorkbenchTarget();
1304
+ if (target?.webSocketDebuggerUrl) {
1305
+ await this.openChatPanelViaKeyboard(target.webSocketDebuggerUrl);
1306
+ }
1307
+ }
1293
1308
  try {
1294
1309
  await this.reconnectOnDemand(INJECT_RETRY_READY_TIMEOUT_MS);
1295
1310
  }
@@ -15,9 +15,9 @@ exports.RESPONSE_SELECTORS = {
15
15
  const scopes = [panel, document].filter(Boolean);
16
16
 
17
17
  const selectors = [
18
+ { sel: '.text-ide-message-block-bot-color', score: 11 },
18
19
  { sel: '.rendered-markdown', score: 10 },
19
20
  { sel: '.leading-relaxed.select-text', score: 9 },
20
- { sel: '.flex.flex-col.gap-y-3', score: 8 },
21
21
  { sel: '[data-message-author-role="assistant"]', score: 7 },
22
22
  { sel: '[data-message-role="assistant"]', score: 6 },
23
23
  { sel: '[class*="assistant-message"]', score: 5 },
@@ -47,6 +47,12 @@ exports.RESPONSE_SELECTORS = {
47
47
  if (node.closest('[class*="feedback"], footer')) return true;
48
48
  if (node.closest('.notify-user-container')) return true;
49
49
  if (node.closest('[role="dialog"]')) return true;
50
+ if (node.closest('form')) return true;
51
+ if (node.closest('[data-message-author-role="user"], [data-message-role="user"]')) return true;
52
+ if (node.querySelector('textarea') || node.closest('textarea')) return true;
53
+ var text = (node.innerText || '').toLowerCase();
54
+ if (text.includes('ask anything, @ to mention')) return true;
55
+ if (text.includes('0 files with changes')) return true;
50
56
  return false;
51
57
  };
52
58
 
@@ -99,8 +105,16 @@ exports.RESPONSE_SELECTORS = {
99
105
  const scopes = [panel, document].filter(Boolean);
100
106
 
101
107
  for (const scope of scopes) {
102
- const el = scope.querySelector('[data-tooltip-id="input-send-button-cancel-tooltip"]');
103
- if (el) return { isGenerating: true };
108
+ const els = scope.querySelectorAll('[data-tooltip-id="input-send-button-cancel-tooltip"]');
109
+ for (let i = 0; i < els.length; i++) {
110
+ const el = els[i];
111
+ if (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0) {
112
+ const style = window.getComputedStyle(el);
113
+ if (style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0) {
114
+ return { isGenerating: true };
115
+ }
116
+ }
117
+ }
104
118
  }
105
119
 
106
120
  const normalize = (value) => (value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
@@ -140,10 +154,16 @@ exports.RESPONSE_SELECTORS = {
140
154
  const scopes = [panel, document].filter(Boolean);
141
155
 
142
156
  for (const scope of scopes) {
143
- const el = scope.querySelector('[data-tooltip-id="input-send-button-cancel-tooltip"]');
144
- if (el && typeof el.click === 'function') {
145
- el.click();
146
- return { ok: true, method: 'tooltip-id' };
157
+ const els = scope.querySelectorAll('[data-tooltip-id="input-send-button-cancel-tooltip"]');
158
+ for (let i = 0; i < els.length; i++) {
159
+ const el = els[i];
160
+ if (el && typeof el.click === 'function') {
161
+ const style = window.getComputedStyle(el);
162
+ if (style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity) > 0) {
163
+ el.click();
164
+ return { ok: true, method: 'tooltip-id' };
165
+ }
166
+ }
147
167
  }
148
168
  }
149
169
 
@@ -185,9 +205,9 @@ exports.RESPONSE_SELECTORS = {
185
205
  const scopes = [panel, document].filter(Boolean);
186
206
 
187
207
  const selectors = [
208
+ { sel: '.text-ide-message-block-bot-color', score: 11 },
188
209
  { sel: '.rendered-markdown', score: 10 },
189
210
  { sel: '.leading-relaxed.select-text', score: 9 },
190
- { sel: '.flex.flex-col.gap-y-3', score: 8 },
191
211
  { sel: '[data-message-author-role="assistant"]', score: 7 },
192
212
  { sel: '[data-message-role="assistant"]', score: 6 },
193
213
  { sel: '[class*="assistant-message"]', score: 5 },
@@ -215,6 +235,12 @@ exports.RESPONSE_SELECTORS = {
215
235
  if (node.closest('[class*="feedback"], footer')) return true;
216
236
  if (node.closest('.notify-user-container')) return true;
217
237
  if (node.closest('[role="dialog"]')) return true;
238
+ if (node.closest('form')) return true;
239
+ if (node.closest('[data-message-author-role="user"], [data-message-role="user"]')) return true;
240
+ if (node.querySelector('textarea') || node.closest('textarea')) return true;
241
+ var text = (node.innerText || '').toLowerCase();
242
+ if (text.includes('ask anything, @ to mention')) return true;
243
+ if (text.includes('0 files with changes')) return true;
218
244
  return false;
219
245
  };
220
246
  const looksLikeToolOutput = (text) => {
@@ -271,9 +297,9 @@ exports.RESPONSE_SELECTORS = {
271
297
  const scopes = [panel, document].filter(Boolean);
272
298
 
273
299
  const selectors = [
300
+ { sel: '.text-ide-message-block-bot-color', score: 11 },
274
301
  { sel: '.rendered-markdown', score: 10 },
275
302
  { sel: '.leading-relaxed.select-text', score: 9 },
276
- { sel: '.flex.flex-col.gap-y-3', score: 8 },
277
303
  { sel: '[data-message-author-role="assistant"]', score: 7 },
278
304
  { sel: '[data-message-role="assistant"]', score: 6 },
279
305
  { sel: '[class*="assistant-message"]', score: 5 },
@@ -308,6 +334,12 @@ exports.RESPONSE_SELECTORS = {
308
334
  if (node.closest('[class*="feedback"], footer')) return true;
309
335
  if (node.closest('.notify-user-container')) return true;
310
336
  if (node.closest('[role="dialog"]')) return true;
337
+ if (node.closest('form')) return true;
338
+ if (node.closest('[data-message-author-role="user"], [data-message-role="user"]')) return true;
339
+ if (node.querySelector('textarea') || node.closest('textarea')) return true;
340
+ var text = (node.innerText || '').toLowerCase();
341
+ if (text.includes('ask anything, @ to mention')) return true;
342
+ if (text.includes('0 files with changes')) return true;
311
343
  return false;
312
344
  };
313
345
 
@@ -25,38 +25,31 @@ const DETECT_USER_MESSAGE_SCRIPT = `(() => {
25
25
  const panel = document.querySelector('.antigravity-agent-side-panel');
26
26
  const scope = panel || document;
27
27
 
28
- // Strategy A (primary): Query .whitespace-pre-wrap elements directly inside
29
- // user bubble containers. This avoids the parent-container problem where
30
- // querySelectorAll matches a wrapper that contains multiple bubbles.
31
- const textEls = scope.querySelectorAll(
32
- '[class*="bg-gray-500/15"][class*="select-text"] .whitespace-pre-wrap'
33
- );
28
+ const bubbles = Array.from(scope.querySelectorAll('.bg-input.p-2'));
29
+ const userBubbles = bubbles.filter(el => {
30
+ if (el.closest('.text-ide-message-block-bot-color')) return false;
31
+ if (el.closest('.rendered-markdown, .prose')) return false;
32
+ if (el.closest('[data-message-author-role="assistant"], [data-message-role="assistant"]')) return false;
33
+ return !!el.querySelector('.whitespace-pre-wrap');
34
+ });
34
35
 
35
- if (textEls.length > 0) {
36
- const lastTextEl = textEls[textEls.length - 1];
37
- const text = (lastTextEl.textContent || '').trim();
38
- if (text.length > 0) return { text };
36
+ if (userBubbles.length > 0) {
37
+ const lastBubble = userBubbles[userBubbles.length - 1];
38
+ // Surgical extraction: only the content of the message div
39
+ const textEl = lastBubble.querySelector('.whitespace-pre-wrap');
40
+ if (textEl) {
41
+ // Clone to strip buttons without affecting the real UI
42
+ const clone = textEl.cloneNode(true);
43
+ const buttons = clone.querySelectorAll('button, [role="button"]');
44
+ buttons.forEach(b => b.remove());
45
+
46
+ let text = (clone.textContent || '').trim();
47
+ // Final safety strip
48
+ text = text.replace(/\\s*(?:Undo|撤銷|撤销|元に戻す)\\s*$/i, '').trim();
49
+ if (text.length > 0) return { text };
50
+ }
39
51
  }
40
-
41
- // Strategy B (fallback): Find individual bubble containers, filtering out
42
- // any element that itself contains nested bubble elements (i.e., a parent wrapper).
43
- const userBubbles = Array.from(scope.querySelectorAll(
44
- '[class*="bg-gray-500/15"][class*="rounded-lg"][class*="select-text"]'
45
- )).filter(el => !el.querySelector('[class*="bg-gray-500/15"][class*="select-text"]'));
46
-
47
- if (userBubbles.length === 0) return null;
48
-
49
- const lastBubble = userBubbles[userBubbles.length - 1];
50
- const textEl = lastBubble.querySelector('.whitespace-pre-wrap')
51
- || lastBubble.querySelector('[style*="word-break"]');
52
-
53
- const text = textEl
54
- ? (textEl.textContent || '').trim()
55
- : (lastBubble.textContent || '').trim();
56
-
57
- if (!text || text.length < 1) return null;
58
-
59
- return { text };
52
+ return null;
60
53
  })()`;
61
54
  /**
62
55
  * Normalize text for echo hash comparison.
@@ -87,6 +80,8 @@ class UserMessageDetector extends events_1.EventEmitter {
87
80
  /** Set of all previously detected message hashes (defense-in-depth dedup) */
88
81
  seenHashes = new Set();
89
82
  static MAX_SEEN_HASHES = 50;
83
+ /** The actual text of the last emitted message (extra safety dedup) */
84
+ lastSentText = null;
90
85
  /** True during the first poll — seeds existing DOM state without firing callback */
91
86
  isPriming = false;
92
87
  constructor(options) {
@@ -106,12 +101,12 @@ class UserMessageDetector extends events_1.EventEmitter {
106
101
  this.echoHashes.delete(hash);
107
102
  }, 60000);
108
103
  }
109
- /** Start monitoring. The first poll seeds the current DOM state without firing the callback. */
110
104
  start() {
111
105
  if (this.isRunning)
112
106
  return;
113
107
  this.isRunning = true;
114
108
  this.lastDetectedHash = null;
109
+ this.lastSentText = null;
115
110
  this.seenHashes.clear();
116
111
  this.isPriming = true;
117
112
  // echoHashes are intentionally NOT cleared — they have their own 60s TTL
@@ -204,7 +199,13 @@ class UserMessageDetector extends events_1.EventEmitter {
204
199
  this.addToSeenHashes(hash);
205
200
  return;
206
201
  }
202
+ if (info.text === this.lastSentText) {
203
+ logger_1.logger.debug(`[UserMessageDetector] lastSentText match, skipping: "${preview}..."`);
204
+ this.lastDetectedHash = hash;
205
+ return;
206
+ }
207
207
  this.lastDetectedHash = hash;
208
+ this.lastSentText = info.text;
208
209
  this.addToSeenHashes(hash);
209
210
  logger_1.logger.debug(`[UserMessageDetector] New message detected: "${preview}..."`);
210
211
  this.emit('message', info);
@@ -108,6 +108,8 @@ function mergeConfig(persisted) {
108
108
  // Telegram credentials — only required when Telegram is an active platform
109
109
  const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? persisted.telegramToken ?? undefined;
110
110
  const telegramAllowedUserIds = resolveTelegramAllowedUserIds(persisted);
111
+ const rawCdpHost = (process.env.CDP_HOST ?? persisted.cdpHost ?? '').trim();
112
+ const cdpHost = rawCdpHost || '127.0.0.1';
111
113
  if (platforms.includes('telegram') && !telegramToken) {
112
114
  throw new Error('TELEGRAM_BOT_TOKEN is required when platforms include "telegram"');
113
115
  }
@@ -125,6 +127,7 @@ function mergeConfig(persisted) {
125
127
  telegramToken,
126
128
  telegramAllowedUserIds,
127
129
  platforms,
130
+ cdpHost,
128
131
  };
129
132
  }
130
133
  function resolveAllowedUserIds(persisted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-gravity",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Control Antigravity from anywhere — a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -58,6 +58,8 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@mermaid-js/mermaid-cli": "^11.12.0",
61
+ "@semantic-release/changelog": "^6.0.3",
62
+ "@semantic-release/git": "^10.0.1",
61
63
  "@types/better-sqlite3": "^7.6.13",
62
64
  "@types/jest": "^30.0.0",
63
65
  "@types/node": "^25.3.0",