lazy-gravity 0.9.2 → 0.10.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.
Files changed (38) hide show
  1. package/dist/bot/index.js +232 -41
  2. package/dist/bot/telegramMessageHandler.js +1 -0
  3. package/dist/commands/chatCommandHandler.js +71 -1
  4. package/dist/commands/registerSlashCommands.js +13 -1
  5. package/dist/events/interactionCreateHandler.js +324 -26
  6. package/dist/events/messageCreateHandler.js +53 -4
  7. package/dist/handlers/__tests__/fileChangeButtonAction.test.js +85 -0
  8. package/dist/handlers/approvalButtonAction.js +2 -1
  9. package/dist/handlers/errorPopupButtonAction.js +2 -1
  10. package/dist/handlers/fileChangeButtonAction.js +69 -0
  11. package/dist/handlers/genericActionButtonAction.js +74 -0
  12. package/dist/handlers/planningButtonAction.js +24 -9
  13. package/dist/handlers/questionSelectAction.js +69 -0
  14. package/dist/handlers/questionSkipAction.js +67 -0
  15. package/dist/handlers/runCommandButtonAction.js +2 -1
  16. package/dist/platform/discord/discordResponseRenderer.js +164 -0
  17. package/dist/services/approvalDetector.js +70 -24
  18. package/dist/services/artifactService.js +57 -31
  19. package/dist/services/assistantDomExtractor.js +174 -3
  20. package/dist/services/cdpBridgeManager.js +114 -6
  21. package/dist/services/cdpConnectionPool.js +15 -0
  22. package/dist/services/cdpService.js +21 -7
  23. package/dist/services/chatSessionService.js +133 -33
  24. package/dist/services/errorPopupDetector.js +23 -11
  25. package/dist/services/notificationSender.js +70 -3
  26. package/dist/services/planningDetector.js +69 -25
  27. package/dist/services/promptDispatcher.js +7 -1
  28. package/dist/services/questionDetector.js +376 -0
  29. package/dist/services/responseMonitor.js +28 -2
  30. package/dist/services/runCommandDetector.js +14 -7
  31. package/dist/ui/artifactsUi.js +4 -3
  32. package/dist/utils/consecutiveEmptyPollGate.js +21 -0
  33. package/dist/utils/fileOpenCache.js +33 -0
  34. package/dist/utils/htmlToDiscordMarkdown.js +5 -2
  35. package/dist/utils/pathUtils.js +12 -0
  36. package/dist/utils/projectResolver.js +10 -0
  37. package/dist/utils/questionActionUtils.js +25 -0
  38. package/package.json +1 -1
@@ -3,26 +3,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ApprovalDetector = void 0;
4
4
  exports.buildClickScript = buildClickScript;
5
5
  const logger_1 = require("../utils/logger");
6
+ const consecutiveEmptyPollGate_1 = require("../utils/consecutiveEmptyPollGate");
6
7
  /**
7
8
  * Approval button detection script for the Antigravity UI
8
9
  *
9
10
  * Detects allow/deny button pairs and extracts descriptions with fallbacks.
10
11
  */
11
12
  const DETECT_APPROVAL_SCRIPT = `(() => {
12
- const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', '今回のみ許可', '1回のみ許可', '一度許可'];
13
+ const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', 'yes, allow this time', '今回のみ許可', '1回のみ許可', '一度許可'];
13
14
  const ALWAYS_ALLOW_PATTERNS = [
14
15
  'allow this conversation',
15
16
  'allow this chat',
16
17
  'always allow',
18
+ 'yes, and always allow',
17
19
  '常に許可',
18
20
  'この会話を許可',
19
21
  ];
20
- const ALLOW_PATTERNS = ['allow', 'permit', '許可', '承認', '確認'];
21
- const DENY_PATTERNS = ['deny', '拒否', 'decline'];
22
+ const ALLOW_PATTERNS = ['allow', 'permit', 'accept', 'approve', '許可', '承認', '確認'];
23
+ const DENY_PATTERNS = ['deny', 'reject', '拒否', 'decline', 'no (tell the agent'];
22
24
 
23
25
  const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
24
26
 
25
- const allButtons = Array.from(document.querySelectorAll('button'))
27
+ const allButtons = Array.from(document.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
26
28
  .filter(btn => btn.offsetParent !== null);
27
29
 
28
30
  let approveBtn = allButtons.find(btn => {
@@ -45,7 +47,7 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
45
47
  || approveBtn.parentElement
46
48
  || document.body;
47
49
 
48
- const containerButtons = Array.from(container.querySelectorAll('button'))
50
+ const containerButtons = Array.from(container.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
49
51
  .filter(btn => btn.offsetParent !== null);
50
52
 
51
53
  const denyBtn = containerButtons.find(btn => {
@@ -78,14 +80,48 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
78
80
 
79
81
  // 2. Parent element text (excluding button text)
80
82
  if (!description) {
81
- const parent = approveBtn.parentElement?.parentElement || approveBtn.parentElement;
82
- if (parent) {
83
- const clone = parent.cloneNode(true);
84
- const buttons = clone.querySelectorAll('button');
85
- buttons.forEach(b => b.remove());
86
- const parentText = (clone.textContent || '').trim();
87
- if (parentText.length > 5 && parentText.length < 500) {
88
- description = parentText;
83
+ let modal = approveBtn.closest('.notify-user-container, [role="dialog"], .modal, .dialog, .approval-container, .permission-dialog');
84
+
85
+ if (!modal) {
86
+ // New Antigravity IDE uses a sticky footer with no identifying modal classes.
87
+ // Traverse up until we find a container that includes the file list popup (.bottom-full)
88
+ let p = approveBtn.parentElement;
89
+ while (p && p.tagName !== 'BODY') {
90
+ if (p.querySelector('.bottom-full')) {
91
+ modal = p;
92
+ break;
93
+ }
94
+ p = p.parentElement;
95
+ }
96
+ if (!modal) {
97
+ modal = approveBtn.parentElement?.parentElement?.parentElement || approveBtn.parentElement?.parentElement;
98
+ }
99
+ }
100
+
101
+ if (modal) {
102
+ const parts = [];
103
+ const walk = (node) => {
104
+ if (node.nodeType === 1) {
105
+ // Skip buttons entirely
106
+ if (node.tagName === 'BUTTON' || node.getAttribute('role') === 'button' || node.classList.contains('cursor-pointer')) return;
107
+
108
+ const display = window.getComputedStyle(node).display;
109
+ if (display === 'none') return;
110
+
111
+ const isBlock = display === 'block' || display === 'flex' || node.tagName === 'DIV' || node.tagName === 'LI';
112
+ if (isBlock && parts.length > 0 && parts[parts.length - 1] !== '\\n') parts.push('\\n');
113
+ for (const child of node.childNodes) walk(child);
114
+ if (isBlock && parts.length > 0 && parts[parts.length - 1] !== '\\n') parts.push('\\n');
115
+ } else if (node.nodeType === 3) {
116
+ const t = node.textContent || '';
117
+ if (t.trim()) parts.push(t.trim());
118
+ }
119
+ };
120
+ walk(modal);
121
+
122
+ const parentText = parts.join(' ').replace(/\\n\\s*/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
123
+ if (parentText.length > 5) {
124
+ description = parentText.length > 2000 ? parentText.substring(0, 2000) + '...\\n(truncated)' : parentText;
89
125
  }
90
126
  }
91
127
  }
@@ -102,11 +138,12 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
102
138
  * Press the toggle on the right side of Allow Once to expand the Always Allow dropdown.
103
139
  */
104
140
  const EXPAND_ALWAYS_ALLOW_MENU_SCRIPT = `(() => {
105
- const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', '今回のみ許可', '1回のみ許可', '一度許可'];
141
+ const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', 'yes, allow this time', '今回のみ許可', '1回のみ許可', '一度許可'];
106
142
  const ALWAYS_ALLOW_PATTERNS = [
107
143
  'allow this conversation',
108
144
  'allow this chat',
109
145
  'always allow',
146
+ 'yes, and always allow',
110
147
  '常に許可',
111
148
  'この会話を許可',
112
149
  ];
@@ -181,17 +218,19 @@ function buildClickScript(buttonText) {
181
218
  const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
182
219
  const text = ${safeText};
183
220
  const wanted = normalize(text);
184
- const allButtons = Array.from(document.querySelectorAll('button'));
221
+ const allButtons = Array.from(document.querySelectorAll('button, [role="button"], a, [class*="btn"], [class*="button"], [class*="action"]')).reverse();
185
222
  const target = allButtons.find(btn => {
186
- if (!btn.offsetParent) return false;
223
+ const style = window.getComputedStyle(btn);
224
+ if (style.display === 'none' || style.visibility === 'hidden' || btn.disabled) return false;
187
225
  const buttonText = normalize(btn.textContent || '');
188
226
  const ariaLabel = normalize(btn.getAttribute('aria-label') || '');
189
227
  return buttonText === wanted ||
190
228
  ariaLabel === wanted ||
191
- buttonText.includes(wanted) ||
192
- ariaLabel.includes(wanted);
229
+ (buttonText.includes(wanted) && buttonText.length < wanted.length + 10) ||
230
+ (ariaLabel.includes(wanted) && ariaLabel.length < wanted.length + 10);
193
231
  });
194
232
  if (!target) return { ok: false, error: 'Button not found: ' + text };
233
+ target.scrollIntoView({ block: 'center' });
195
234
  const rect = target.getBoundingClientRect();
196
235
  const x = rect.left + rect.width / 2;
197
236
  const y = rect.top + rect.height / 2;
@@ -199,6 +238,7 @@ function buildClickScript(buttonText) {
199
238
  for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
200
239
  target.dispatchEvent(new PointerEvent(type, { ...eventInit, pointerId: 1 }));
201
240
  }
241
+ if (typeof target.click === 'function') target.click();
202
242
  return { ok: true };
203
243
  })()`;
204
244
  }
@@ -219,6 +259,8 @@ class ApprovalDetector {
219
259
  lastDetectedKey = null;
220
260
  /** Full ApprovalInfo from the last detection (used for clicking) */
221
261
  lastDetectedInfo = null;
262
+ /** Gate for empty polls before reset */
263
+ emptyPollGate = new consecutiveEmptyPollGate_1.ConsecutiveEmptyPollGate(3);
222
264
  constructor(options) {
223
265
  this.cdpService = options.cdpService;
224
266
  this.pollIntervalMs = options.pollIntervalMs ?? 1500;
@@ -234,6 +276,7 @@ class ApprovalDetector {
234
276
  this.isRunning = true;
235
277
  this.lastDetectedKey = null;
236
278
  this.lastDetectedInfo = null;
279
+ this.emptyPollGate.reset();
237
280
  this.schedulePoll();
238
281
  }
239
282
  /**
@@ -284,6 +327,7 @@ class ApprovalDetector {
284
327
  const result = await this.cdpService.call('Runtime.evaluate', callParams);
285
328
  const info = result?.result?.value ?? null;
286
329
  if (info) {
330
+ this.emptyPollGate.recordDetection();
287
331
  // Duplicate prevention: use approveText + description combination as key
288
332
  const key = `${info.approveText}::${info.description}`;
289
333
  if (key !== this.lastDetectedKey) {
@@ -293,12 +337,14 @@ class ApprovalDetector {
293
337
  }
294
338
  }
295
339
  else {
296
- // Reset when buttons disappear (prepare for next approval detection)
297
- const wasDetected = this.lastDetectedKey !== null;
298
- this.lastDetectedKey = null;
299
- this.lastDetectedInfo = null;
300
- if (wasDetected && this.onResolved) {
301
- this.onResolved();
340
+ if (this.emptyPollGate.recordEmptyPoll()) {
341
+ // Reset when buttons disappear for consecutive polls
342
+ const wasDetected = this.lastDetectedKey !== null;
343
+ this.lastDetectedKey = null;
344
+ this.lastDetectedInfo = null;
345
+ if (wasDetected && this.onResolved) {
346
+ this.onResolved();
347
+ }
302
348
  }
303
349
  }
304
350
  }
@@ -148,11 +148,12 @@ class ArtifactService {
148
148
  }
149
149
  }
150
150
  /**
151
- * Try to find a conversation UUID whose overview.txt contains the given session title.
151
+ * Try to find a conversation UUID whose transcript or overview contains the given session title.
152
152
  * Uses an exact match first, falling back to keyword overlap scoring.
153
+ * If workspaceFilter is provided, restricts matching exclusively to conversations belonging to that workspace.
153
154
  * Returns the UUID or null if not found.
154
155
  */
155
- findConversationByTitle(title) {
156
+ findConversationByTitle(title, workspaceFilter) {
156
157
  if (!title || !title.trim())
157
158
  return null;
158
159
  const needle = title.trim().toLowerCase();
@@ -186,40 +187,58 @@ class ArtifactService {
186
187
  const uniqueNeedleWords = Array.from(new Set(cleanNeedleWords));
187
188
  // Require a stronger minimum score of 2 to prevent weak one-word matches.
188
189
  const minScore = 2;
190
+ const filterStr = workspaceFilter ? workspaceFilter.toLowerCase() : null;
189
191
  for (const id of sortedIds) {
190
- const overviewPath = path.join(this.brainBasePath, id, '.system_generated', 'logs', 'overview.txt');
191
- try {
192
- if (!fs.existsSync(overviewPath))
193
- continue;
194
- // Only read the first 4KB to avoid huge files
195
- let fd = null;
192
+ let score = 0;
193
+ let exactMatch = false;
194
+ let belongsToWorkspace = !filterStr; // True if no filter, or if we prove it belongs
195
+ const scanFile = (filePath, readSize) => {
196
196
  try {
197
- fd = fs.openSync(overviewPath, 'r');
198
- const buf = Buffer.alloc(4096);
199
- const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
200
- const header = buf.slice(0, bytesRead).toString('utf-8').toLowerCase();
201
- if (header.includes(needle)) {
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++;
197
+ if (!fs.existsSync(filePath))
198
+ return;
199
+ let fd = null;
200
+ try {
201
+ fd = fs.openSync(filePath, 'r');
202
+ const buf = Buffer.alloc(readSize);
203
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
204
+ const content = buf.slice(0, bytesRead).toString('utf-8').toLowerCase();
205
+ if (filterStr && content.includes(filterStr)) {
206
+ belongsToWorkspace = true;
207
+ }
208
+ if (belongsToWorkspace) {
209
+ if (content.includes(needle)) {
210
+ exactMatch = true;
211
+ }
212
+ else {
213
+ const contentTokens = new Set(content.replace(/[^\p{L}\p{N}]+/gu, ' ').split(/\s+/));
214
+ let currentScore = 0;
215
+ for (const word of uniqueNeedleWords) {
216
+ if (contentTokens.has(word))
217
+ currentScore++;
218
+ }
219
+ if (currentScore > score)
220
+ score = currentScore;
221
+ }
222
+ }
210
223
  }
211
- if (score > bestScore) {
212
- bestScore = score;
213
- bestId = id;
224
+ finally {
225
+ if (fd !== null)
226
+ fs.closeSync(fd);
214
227
  }
215
228
  }
216
- finally {
217
- if (fd !== null)
218
- fs.closeSync(fd);
219
- }
220
- }
221
- catch {
222
- // Skip unreadable files
229
+ catch { /* ignore */ }
230
+ };
231
+ // 1. Check transcript.jsonl (modern)
232
+ scanFile(path.join(this.brainBasePath, id, '.system_generated', 'logs', 'transcript.jsonl'), 16384);
233
+ // 2. Check overview.txt (legacy)
234
+ scanFile(path.join(this.brainBasePath, id, '.system_generated', 'logs', 'overview.txt'), 4096);
235
+ if (!belongsToWorkspace)
236
+ continue;
237
+ if (exactMatch)
238
+ return id; // Exact match in the right workspace takes precedence immediately
239
+ if (score > bestScore) {
240
+ bestScore = score;
241
+ bestId = id;
223
242
  }
224
243
  }
225
244
  if (bestScore >= minScore) {
@@ -256,6 +275,13 @@ class ArtifactService {
256
275
  // Nothing has artifacts — return most recent anyway (caller handles empty list)
257
276
  return sorted[0]?.id ?? null;
258
277
  }
278
+ /**
279
+ * Build the filesystem path for a specific artifact file.
280
+ */
281
+ getArtifactPath(conversationId, filename) {
282
+ const safe = path.basename(filename);
283
+ return path.join(this.brainBasePath, conversationId, safe);
284
+ }
259
285
  /**
260
286
  * Read the markdown content of a specific artifact file.
261
287
  * Returns null if the file cannot be read.
@@ -27,6 +27,12 @@ function classifyAssistantSegments(payload) {
27
27
  finalOutputText: '',
28
28
  activityLines: [],
29
29
  feedback: [],
30
+ planCards: [],
31
+ actionButtons: [],
32
+ fileChanges: [],
33
+ citations: [],
34
+ fileChangesTexts: [],
35
+ citedFiles: [],
30
36
  diagnostics: {
31
37
  source: 'legacy-fallback',
32
38
  segmentCounts: {},
@@ -41,8 +47,13 @@ function classifyAssistantSegments(payload) {
41
47
  const bodyTexts = [];
42
48
  const activityLines = [];
43
49
  const feedbackTexts = [];
50
+ const planCards = [];
51
+ const actionButtons = [];
52
+ const fileChanges = [];
53
+ const citations = [];
44
54
  const segmentCounts = {};
45
55
  const allFingerprints = [];
56
+ const fileChangesTexts = [];
46
57
  for (const seg of segments) {
47
58
  segmentCounts[seg.kind] = (segmentCounts[seg.kind] ?? 0) + 1;
48
59
  switch (seg.kind) {
@@ -65,16 +76,68 @@ function classifyAssistantSegments(payload) {
65
76
  feedbackTexts.push(seg.text.trim());
66
77
  }
67
78
  break;
79
+ case 'plan-card':
80
+ if (seg.text && seg.text.trim()) {
81
+ planCards.push(seg.text.trim());
82
+ }
83
+ break;
84
+ case 'action-button':
85
+ if (seg.text && seg.text.trim()) {
86
+ actionButtons.push(seg.text.trim());
87
+ }
88
+ break;
89
+ case 'file-change':
90
+ if (seg.text && seg.text.trim()) {
91
+ const parts = seg.text.split('|');
92
+ fileChanges.push({ path: parts[0]?.trim() || '', type: parts[1]?.trim() || '' });
93
+ }
94
+ break;
95
+ case 'citation':
96
+ if (seg.text && seg.text.trim()) {
97
+ citations.push(seg.text.trim());
98
+ }
99
+ break;
100
+ case 'file-changes':
101
+ if (seg.text && seg.text.trim()) {
102
+ fileChangesTexts.push(seg.text.trim());
103
+ }
104
+ break;
68
105
  }
69
106
  }
70
107
  // Concatenate all body segments — the CDP script may yield multiple nodes
71
108
  // (streaming partial + final, multi-block markdown, etc.)
72
109
  const lastBody = bodyTexts.join('\n\n') || '';
73
110
  const finalOutputText = (0, htmlToDiscordMarkdown_1.htmlToDiscordMarkdown)(lastBody);
111
+ // Extract file:/// links from finalOutputText and preserve citations from Pass 4
112
+ const citedFiles = Array.from(new Set([...citations]));
113
+ const fileRegex = /\[.*?\]\((file:\/\/\/[^\)]+)\)/g;
114
+ let match;
115
+ while ((match = fileRegex.exec(finalOutputText)) !== null) {
116
+ const fileUrl = match[1];
117
+ if (!citedFiles.includes(fileUrl)) {
118
+ citedFiles.push(fileUrl);
119
+ }
120
+ }
121
+ // Also extract backticked inline code that looks like a filename with common extensions
122
+ // e.g., `implementation_plan.md`
123
+ const inlineCodeRegex = /`([a-zA-Z0-9_\-\.]+\.(?:md|ts|js|json|html|css|txt|csv|py|java|go|cpp|c))`[^`]?/g;
124
+ let inlineMatch;
125
+ while ((inlineMatch = inlineCodeRegex.exec(finalOutputText)) !== null) {
126
+ const filename = inlineMatch[1];
127
+ if (!citedFiles.includes(filename)) {
128
+ citedFiles.push(filename);
129
+ }
130
+ }
74
131
  return {
75
132
  finalOutputText,
76
133
  activityLines,
77
134
  feedback: feedbackTexts,
135
+ planCards,
136
+ actionButtons,
137
+ fileChanges,
138
+ citations,
139
+ fileChangesTexts,
140
+ citedFiles,
78
141
  diagnostics: {
79
142
  source: 'dom-structured',
80
143
  segmentCounts,
@@ -145,10 +208,17 @@ function extractAssistantSegmentsPayloadScript() {
145
208
  if (node.closest('details')) return true;
146
209
  if (node.closest('[class*="feedback"], footer')) return true;
147
210
  if (node.closest('.notify-user-container')) return true;
148
- if (node.closest('[role="dialog"]')) return true;
149
- if (node.closest('form')) return true;
211
+ // Guard against approval dialogs leaking into the output (they contain plan cards or review buttons)
212
+ var dialogNode = node.closest('dialog, [role="dialog"], form');
213
+ if (dialogNode && dialogNode.querySelector('[data-testid="plan-card"], [class*="plan-summary"], .actions-container, .review-button')) {
214
+ return true;
215
+ }
216
+
217
+ // allow interactive tool forms (ask_question, ask_permission)
150
218
  if (node.closest('[data-message-author-role="user"], [data-message-role="user"]')) return true;
151
- if (node.querySelector('textarea') || node.closest('textarea')) return true;
219
+ // We do not exclude nodes just because they contain a textarea (e.g. ask_question "Other" option).
220
+ // The main chat input is excluded by the "ask anything" check below.
221
+ if (node.tagName && node.tagName.toLowerCase() === 'textarea') return true;
152
222
  var text = (node.innerText || '').toLowerCase();
153
223
  if (text.includes('ask anything, @ to mention')) return true;
154
224
  if (text.includes('0 files with changes')) return true;
@@ -158,6 +228,7 @@ function extractAssistantSegmentsPayloadScript() {
158
228
  var segments = [];
159
229
  var seen = new Set();
160
230
  var bodyFound = false;
231
+ var latestMessageContainer = null;
161
232
 
162
233
  // Pass 1: Find assistant body — last non-excluded content node (recency first)
163
234
  var combinedSelector = selectors.join(', ');
@@ -238,6 +309,7 @@ function extractAssistantSegmentsPayloadScript() {
238
309
  domPath: 'article:nth(' + i + ')'
239
310
  });
240
311
  bodyFound = true;
312
+ latestMessageContainer = node.closest('[data-message-id], .message-row, [class*="message-container"]') || node.parentNode || scope;
241
313
  break; // Only capture the single latest assistant message
242
314
  }
243
315
  }
@@ -362,6 +434,105 @@ function extractAssistantSegmentsPayloadScript() {
362
434
  }
363
435
  }
364
436
 
437
+ // Pass 4: Extract Antigravity 2.0 structured cards and artifacts
438
+ var artifactScope = latestMessageContainer || scope;
439
+ // 4a. Plan Cards
440
+ var planNodes = artifactScope.querySelectorAll('[data-testid="plan-card"], [class*="plan-summary"]');
441
+ for (var pi2 = 0; pi2 < planNodes.length; pi2++) {
442
+ var pnode = planNodes[pi2];
443
+ var ptext = (pnode.innerText || pnode.textContent || '').trim();
444
+ if (ptext) {
445
+ segments.push({
446
+ kind: 'plan-card',
447
+ text: ptext,
448
+ role: 'assistant',
449
+ messageIndex: 0,
450
+ domPath: 'plan-card:nth(' + pi2 + ')'
451
+ });
452
+ }
453
+ }
454
+
455
+ // 4b. Action Buttons (Open, Proceed, Review, etc.) - outside feedback footers
456
+ var actionBtns = artifactScope.querySelectorAll('.actions-container button, [class*="action-btn"], .review-button, [class*="review-btn"]');
457
+ for (var abi = 0; abi < actionBtns.length; abi++) {
458
+ var abtnText = (actionBtns[abi].textContent || '').trim();
459
+ if (!abtnText) continue;
460
+ var lower = abtnText.toLowerCase();
461
+ // Ignore approval buttons since they are handled by approvalDetector
462
+ if (lower.includes('accept all') || lower.includes('reject all')) continue;
463
+
464
+ segments.push({
465
+ kind: 'action-button',
466
+ text: abtnText,
467
+ role: 'assistant',
468
+ messageIndex: 0,
469
+ domPath: 'action-btn.' + abtnText.toLowerCase().replace(/\s+/g, '-')
470
+ });
471
+ }
472
+
473
+ // 4c. File Edits
474
+ var fileEdits = artifactScope.querySelectorAll('[class*="file-edit-item"], .file-edit-item, .bottom-full .cursor-pointer');
475
+ for (var fei = 0; fei < fileEdits.length; fei++) {
476
+ var editNode = fileEdits[fei];
477
+ if (seen.has(editNode)) continue;
478
+ seen.add(editNode);
479
+ var fpath = (editNode.querySelector('.file-path, [class*="path"]') || {}).textContent || '';
480
+ var ftype = (editNode.querySelector('.edit-type, [class*="type"]') || {}).textContent || 'Modified';
481
+
482
+ if (!fpath) {
483
+ var spans = editNode.querySelectorAll('span.whitespace-nowrap.text-sm');
484
+ if (spans.length > 0) {
485
+ fpath = spans[0].textContent || '';
486
+ }
487
+ }
488
+ if (!fpath) {
489
+ fpath = (editNode.textContent || '').trim();
490
+ if (fpath.endsWith(ftype)) {
491
+ fpath = fpath.slice(0, -ftype.length).trim();
492
+ }
493
+ }
494
+ if (fpath) {
495
+ segments.push({
496
+ kind: 'file-change',
497
+ text: fpath.trim() + '|' + ftype.trim(),
498
+ role: 'assistant',
499
+ messageIndex: 0,
500
+ domPath: 'file-edit:nth(' + fei + ')'
501
+ });
502
+ }
503
+ }
504
+
505
+ // 4d. Citations
506
+ var citationNodes = artifactScope.querySelectorAll('[class*="citation"], a[href^="file://"]');
507
+ for (var cti = 0; cti < citationNodes.length; cti++) {
508
+ var cnode = citationNodes[cti];
509
+ var ctext = (cnode.getAttribute('href') || cnode.textContent || '').trim();
510
+ if (ctext) {
511
+ segments.push({
512
+ kind: 'citation',
513
+ text: ctext,
514
+ role: 'assistant',
515
+ messageIndex: 0,
516
+ domPath: 'citation:nth(' + cti + ')'
517
+ });
518
+ }
519
+ }
520
+ // 4e. File Changes Text Blocks
521
+ var fcbNodes = artifactScope.querySelectorAll('.file-changes-block code, [class*="file-changes"] code');
522
+ for (var fci = 0; fci < fcbNodes.length; fci++) {
523
+ var fcNode = fcbNodes[fci];
524
+ var fcText = (fcNode.textContent || '').trim();
525
+ if (fcText) {
526
+ segments.push({
527
+ kind: 'file-changes',
528
+ text: fcText,
529
+ role: 'assistant',
530
+ messageIndex: 0,
531
+ domPath: 'file-changes:nth(' + fci + ')'
532
+ });
533
+ }
534
+ }
535
+
365
536
  if (!bodyFound && segments.length === 0) return null;
366
537
 
367
538
  return {