lazy-gravity 0.9.2 → 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.
Files changed (41) hide show
  1. package/dist/bin/commands/doctor.js +45 -7
  2. package/dist/bot/index.js +401 -41
  3. package/dist/bot/telegramMessageHandler.js +1 -0
  4. package/dist/commands/chatCommandHandler.js +43 -3
  5. package/dist/commands/registerSlashCommands.js +13 -1
  6. package/dist/events/interactionCreateHandler.js +456 -27
  7. package/dist/events/messageCreateHandler.js +66 -7
  8. package/dist/handlers/__tests__/fileChangeButtonAction.test.js +85 -0
  9. package/dist/handlers/approvalButtonAction.js +2 -1
  10. package/dist/handlers/errorPopupButtonAction.js +2 -1
  11. package/dist/handlers/fileChangeButtonAction.js +57 -0
  12. package/dist/handlers/genericActionButtonAction.js +72 -0
  13. package/dist/handlers/planningButtonAction.js +126 -23
  14. package/dist/handlers/planningModalSubmitAction.js +38 -0
  15. package/dist/handlers/questionSelectAction.js +70 -0
  16. package/dist/handlers/questionSkipAction.js +68 -0
  17. package/dist/handlers/runCommandButtonAction.js +2 -1
  18. package/dist/platform/discord/discordResponseRenderer.js +164 -0
  19. package/dist/platform/discord/wrappers.js +76 -0
  20. package/dist/services/approvalDetector.js +110 -35
  21. package/dist/services/artifactService.js +103 -37
  22. package/dist/services/assistantDomExtractor.js +194 -3
  23. package/dist/services/cdpBridgeManager.js +149 -8
  24. package/dist/services/cdpConnectionPool.js +22 -2
  25. package/dist/services/cdpService.js +134 -15
  26. package/dist/services/chatSessionService.js +147 -40
  27. package/dist/services/errorPopupDetector.js +23 -11
  28. package/dist/services/notificationSender.js +80 -3
  29. package/dist/services/planningDetector.js +69 -25
  30. package/dist/services/promptDispatcher.js +27 -1
  31. package/dist/services/questionDetector.js +428 -0
  32. package/dist/services/responseMonitor.js +45 -3
  33. package/dist/services/runCommandDetector.js +14 -7
  34. package/dist/ui/artifactsUi.js +4 -3
  35. package/dist/utils/consecutiveEmptyPollGate.js +21 -0
  36. package/dist/utils/fileOpenCache.js +29 -0
  37. package/dist/utils/htmlToDiscordMarkdown.js +5 -2
  38. package/dist/utils/pathUtils.js +12 -0
  39. package/dist/utils/projectResolver.js +10 -0
  40. package/dist/utils/questionActionUtils.js +47 -0
  41. package/package.json +1 -1
@@ -1,29 +1,45 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ApprovalDetector = void 0;
3
+ exports.ApprovalDetector = exports.DETECT_APPROVAL_SCRIPT = 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
- const DETECT_APPROVAL_SCRIPT = `(() => {
12
- const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', '今回のみ許可', '1回のみ許可', '一度許可'];
12
+ exports.DETECT_APPROVAL_SCRIPT = `(() => {
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'))
26
- .filter(btn => btn.offsetParent !== null);
27
+ const allButtons = Array.from(document.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
28
+ .filter(btn => btn.offsetParent !== null)
29
+ .reverse();
30
+
31
+ const STOP_PATTERNS_GEN = [/^stop$/, /^stop generating$/, /^stop response$/, /^停止$/, /^生成を停止$/, /^応答を停止$/];
32
+ const isGenerating = allButtons.some(btn => {
33
+ const tooltipId = btn.getAttribute('data-tooltip-id');
34
+ if (tooltipId === 'input-send-button-cancel-tooltip') return true;
35
+ const labels = [btn.textContent || '', btn.getAttribute('aria-label') || '', btn.getAttribute('title') || ''];
36
+ return labels.some(val => {
37
+ const normalized = (val || '').toLowerCase().replace(/\\s+/g, ' ').trim();
38
+ return normalized && STOP_PATTERNS_GEN.some(re => re.test(normalized));
39
+ });
40
+ });
41
+
42
+ if (isGenerating) return null; // Wait for generation to finish!
27
43
 
28
44
  let approveBtn = allButtons.find(btn => {
29
45
  const t = normalize(btn.textContent || '');
@@ -45,8 +61,9 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
45
61
  || approveBtn.parentElement
46
62
  || document.body;
47
63
 
48
- const containerButtons = Array.from(container.querySelectorAll('button'))
49
- .filter(btn => btn.offsetParent !== null);
64
+ const containerButtons = Array.from(container.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
65
+ .filter(btn => btn.offsetParent !== null)
66
+ .reverse();
50
67
 
51
68
  const denyBtn = containerButtons.find(btn => {
52
69
  const t = normalize(btn.textContent || '');
@@ -60,9 +77,9 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
60
77
  return ALWAYS_ALLOW_PATTERNS.some(p => t.includes(p));
61
78
  }) || null;
62
79
 
63
- const approveText = (approveBtn.textContent || '').trim();
64
- const alwaysAllowText = alwaysAllowBtn ? (alwaysAllowBtn.textContent || '').trim() : '';
65
- const denyText = (denyBtn.textContent || '').trim();
80
+ const approveText = (approveBtn.innerText || approveBtn.textContent || '').trim();
81
+ const alwaysAllowText = alwaysAllowBtn ? (alwaysAllowBtn.innerText || alwaysAllowBtn.textContent || '').trim() : '';
82
+ const denyText = (denyBtn.innerText || denyBtn.textContent || '').trim();
66
83
 
67
84
  // Description extraction (multiple fallbacks)
68
85
  let description = '';
@@ -78,14 +95,56 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
78
95
 
79
96
  // 2. Parent element text (excluding button text)
80
97
  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;
98
+ let modal = approveBtn.closest('.notify-user-container, [role="dialog"], .modal, .dialog, .approval-container, .permission-dialog');
99
+
100
+ if (!modal) {
101
+ // New Antigravity IDE uses a sticky footer with no identifying modal classes.
102
+ // Traverse up until we find a container that includes the file list popup (.bottom-full)
103
+ let p = approveBtn.parentElement;
104
+ while (p && p.tagName !== 'BODY') {
105
+ if (p.querySelector('.bottom-full')) {
106
+ modal = p;
107
+ break;
108
+ }
109
+ p = p.parentElement;
110
+ }
111
+ if (!modal) {
112
+ modal = approveBtn.parentElement?.parentElement?.parentElement || approveBtn.parentElement?.parentElement;
113
+ if (modal === document.body || modal?.id === 'root') modal = null;
114
+ }
115
+ }
116
+
117
+ if (modal) {
118
+ const parts = [];
119
+ const walk = (node) => {
120
+ if (node.nodeType === 1) {
121
+ // Skip buttons entirely
122
+ if (node.tagName === 'BUTTON' || node.getAttribute('role') === 'button') return;
123
+
124
+ // Skip menu bars and sidebars
125
+ if (node.tagName === 'NAV' || node.getAttribute('role') === 'menubar' || node.closest('nav') || node.closest('.monaco-menu') || node.closest('.sidebar') || node.classList.contains('sidebar')) return;
126
+
127
+ const display = window.getComputedStyle(node).display;
128
+ if (display === 'none') return;
129
+
130
+ const isBlock = display === 'block' || display === 'flex' || node.tagName === 'DIV' || node.tagName === 'LI';
131
+ if (isBlock && parts.length > 0 && parts[parts.length - 1] !== '\\n') parts.push('\\n');
132
+ for (const child of node.childNodes) walk(child);
133
+ if (isBlock && parts.length > 0 && parts[parts.length - 1] !== '\\n') parts.push('\\n');
134
+ } else if (node.nodeType === 3) {
135
+ const t = node.textContent || '';
136
+ if (t.trim()) parts.push(t.trim());
137
+ }
138
+ };
139
+ walk(modal);
140
+
141
+ const parentText = parts.join(' ').replace(/\\n\\s*/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
142
+ if (parentText.length > 5) {
143
+ if (parentText.length > 800 || parentText.includes('F ile\\nE dit\\nS election')) {
144
+ description = 'Code changes require your approval.';
145
+ } else {
146
+ description = parentText;
147
+ }
89
148
  }
90
149
  }
91
150
  }
@@ -102,17 +161,18 @@ const DETECT_APPROVAL_SCRIPT = `(() => {
102
161
  * Press the toggle on the right side of Allow Once to expand the Always Allow dropdown.
103
162
  */
104
163
  const EXPAND_ALWAYS_ALLOW_MENU_SCRIPT = `(() => {
105
- const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', '今回のみ許可', '1回のみ許可', '一度許可'];
164
+ const ALLOW_ONCE_PATTERNS = ['allow once', 'allow one time', 'yes, allow this time', '今回のみ許可', '1回のみ許可', '一度許可'];
106
165
  const ALWAYS_ALLOW_PATTERNS = [
107
166
  'allow this conversation',
108
167
  'allow this chat',
109
168
  'always allow',
169
+ 'yes, and always allow',
110
170
  '常に許可',
111
171
  'この会話を許可',
112
172
  ];
113
173
 
114
174
  const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
115
- const visibleButtons = Array.from(document.querySelectorAll('button'))
175
+ const visibleButtons = Array.from(document.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
116
176
  .filter(btn => btn.offsetParent !== null);
117
177
 
118
178
  const directAlways = visibleButtons.find(btn => {
@@ -132,7 +192,7 @@ const EXPAND_ALWAYS_ALLOW_MENU_SCRIPT = `(() => {
132
192
  || allowOnceBtn.parentElement
133
193
  || document.body;
134
194
 
135
- const containerButtons = Array.from(container.querySelectorAll('button'))
195
+ const containerButtons = Array.from(container.querySelectorAll('button, [role="button"], span.cursor-pointer, div.cursor-pointer'))
136
196
  .filter(btn => btn.offsetParent !== null);
137
197
 
138
198
  const toggleBtn = containerButtons.find(btn => {
@@ -181,17 +241,25 @@ function buildClickScript(buttonText) {
181
241
  const normalize = (text) => (text || '').toLowerCase().replace(/\\s+/g, ' ').trim();
182
242
  const text = ${safeText};
183
243
  const wanted = normalize(text);
184
- const allButtons = Array.from(document.querySelectorAll('button'));
244
+ const allButtons = Array.from(document.querySelectorAll('button, [role="button"], a.action-btn, a[class*="btn"], span.cursor-pointer, div.cursor-pointer')).reverse();
185
245
  const target = allButtons.find(btn => {
186
- if (!btn.offsetParent) return false;
187
- const buttonText = normalize(btn.textContent || '');
246
+ const style = window.getComputedStyle(btn);
247
+ if (style.display === 'none' || style.visibility === 'hidden' || btn.disabled) return false;
248
+ const buttonText = normalize(btn.innerText || btn.textContent || '');
188
249
  const ariaLabel = normalize(btn.getAttribute('aria-label') || '');
250
+
251
+ const isShort = wanted.length < 5;
252
+ if (isShort) {
253
+ return buttonText === wanted || ariaLabel === wanted;
254
+ }
255
+
189
256
  return buttonText === wanted ||
190
257
  ariaLabel === wanted ||
191
- buttonText.includes(wanted) ||
192
- ariaLabel.includes(wanted);
258
+ (buttonText.includes(wanted) && buttonText.length < wanted.length + 10) ||
259
+ (ariaLabel.includes(wanted) && ariaLabel.length < wanted.length + 10);
193
260
  });
194
261
  if (!target) return { ok: false, error: 'Button not found: ' + text };
262
+ target.scrollIntoView({ block: 'center' });
195
263
  const rect = target.getBoundingClientRect();
196
264
  const x = rect.left + rect.width / 2;
197
265
  const y = rect.top + rect.height / 2;
@@ -199,6 +267,7 @@ function buildClickScript(buttonText) {
199
267
  for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
200
268
  target.dispatchEvent(new PointerEvent(type, { ...eventInit, pointerId: 1 }));
201
269
  }
270
+ if (typeof target.click === 'function') target.click();
202
271
  return { ok: true };
203
272
  })()`;
204
273
  }
@@ -219,6 +288,8 @@ class ApprovalDetector {
219
288
  lastDetectedKey = null;
220
289
  /** Full ApprovalInfo from the last detection (used for clicking) */
221
290
  lastDetectedInfo = null;
291
+ /** Gate for empty polls before reset */
292
+ emptyPollGate = new consecutiveEmptyPollGate_1.ConsecutiveEmptyPollGate(3);
222
293
  constructor(options) {
223
294
  this.cdpService = options.cdpService;
224
295
  this.pollIntervalMs = options.pollIntervalMs ?? 1500;
@@ -234,6 +305,7 @@ class ApprovalDetector {
234
305
  this.isRunning = true;
235
306
  this.lastDetectedKey = null;
236
307
  this.lastDetectedInfo = null;
308
+ this.emptyPollGate.reset();
237
309
  this.schedulePoll();
238
310
  }
239
311
  /**
@@ -274,7 +346,7 @@ class ApprovalDetector {
274
346
  try {
275
347
  const contextId = this.cdpService.getPrimaryContextId();
276
348
  const callParams = {
277
- expression: DETECT_APPROVAL_SCRIPT,
349
+ expression: exports.DETECT_APPROVAL_SCRIPT,
278
350
  returnByValue: true,
279
351
  awaitPromise: false,
280
352
  };
@@ -284,6 +356,7 @@ class ApprovalDetector {
284
356
  const result = await this.cdpService.call('Runtime.evaluate', callParams);
285
357
  const info = result?.result?.value ?? null;
286
358
  if (info) {
359
+ this.emptyPollGate.recordDetection();
287
360
  // Duplicate prevention: use approveText + description combination as key
288
361
  const key = `${info.approveText}::${info.description}`;
289
362
  if (key !== this.lastDetectedKey) {
@@ -293,12 +366,14 @@ class ApprovalDetector {
293
366
  }
294
367
  }
295
368
  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();
369
+ if (this.emptyPollGate.recordEmptyPoll()) {
370
+ // Reset when buttons disappear for consecutive polls
371
+ const wasDetected = this.lastDetectedKey !== null;
372
+ this.lastDetectedKey = null;
373
+ this.lastDetectedInfo = null;
374
+ if (wasDetected && this.onResolved) {
375
+ this.onResolved();
376
+ }
302
377
  }
303
378
  }
304
379
  }
@@ -69,9 +69,17 @@ const COMMON_WORDS = new Set(['the', 'and', 'for', 'with', 'from', 'this', 'that
69
69
  class ArtifactService {
70
70
  brainBasePath;
71
71
  constructor(brainBasePath) {
72
- this.brainBasePath =
73
- brainBasePath ??
74
- path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
72
+ if (brainBasePath) {
73
+ this.brainBasePath = brainBasePath;
74
+ }
75
+ else {
76
+ const idePath = path.join(os.homedir(), '.gemini', 'antigravity-ide', 'brain');
77
+ const defaultPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
78
+ this.brainBasePath = fs.existsSync(idePath) ? idePath : defaultPath;
79
+ }
80
+ }
81
+ getBrainBasePath() {
82
+ return this.brainBasePath;
75
83
  }
76
84
  /**
77
85
  * List all conversations in the brain directory (UUIDs).
@@ -148,11 +156,12 @@ class ArtifactService {
148
156
  }
149
157
  }
150
158
  /**
151
- * Try to find a conversation UUID whose overview.txt contains the given session title.
159
+ * Try to find a conversation UUID whose transcript or overview contains the given session title.
152
160
  * Uses an exact match first, falling back to keyword overlap scoring.
161
+ * If workspaceFilter is provided, restricts matching exclusively to conversations belonging to that workspace.
153
162
  * Returns the UUID or null if not found.
154
163
  */
155
- findConversationByTitle(title) {
164
+ findConversationByTitle(title, workspaceFilter) {
156
165
  if (!title || !title.trim())
157
166
  return null;
158
167
  const needle = title.trim().toLowerCase();
@@ -186,40 +195,60 @@ class ArtifactService {
186
195
  const uniqueNeedleWords = Array.from(new Set(cleanNeedleWords));
187
196
  // Require a stronger minimum score of 2 to prevent weak one-word matches.
188
197
  const minScore = 2;
198
+ const filterStr = workspaceFilter ? workspaceFilter.toLowerCase() : null;
189
199
  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;
200
+ let score = 0;
201
+ let exactMatch = false;
202
+ let belongsToWorkspace = !filterStr; // True if no filter, or if we prove it belongs
203
+ const scanFile = (filePath, readSize) => {
196
204
  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
205
+ if (!fs.existsSync(filePath))
206
+ return;
207
+ let fd = null;
208
+ try {
209
+ fd = fs.openSync(filePath, 'r');
210
+ const buf = Buffer.alloc(readSize);
211
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
212
+ const content = buf.slice(0, bytesRead).toString('utf-8').toLowerCase();
213
+ if (filterStr) {
214
+ const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
215
+ const regex = new RegExp(`(?:file:\\/\\/\\/[^"\\)]*?\\/|[a-zA-Z]:[\\\\/](?!.*(?:\\.gemini|brain|antigravity))[^"\\)]*?[\\\\/])${escapedFilter}\\b`, 'i');
216
+ if (regex.test(content)) {
217
+ belongsToWorkspace = true;
218
+ }
219
+ }
220
+ if (content.includes(needle)) {
221
+ exactMatch = true;
222
+ }
223
+ else {
224
+ const contentTokens = new Set(content.replace(/[^\p{L}\p{N}]+/gu, ' ').split(/\s+/));
225
+ let currentScore = 0;
226
+ for (const word of uniqueNeedleWords) {
227
+ if (contentTokens.has(word))
228
+ currentScore++;
229
+ }
230
+ if (currentScore > score)
231
+ score = currentScore;
232
+ }
203
233
  }
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++;
234
+ finally {
235
+ if (fd !== null)
236
+ fs.closeSync(fd);
210
237
  }
211
- if (score > bestScore) {
212
- bestScore = score;
213
- bestId = id;
214
- }
215
- }
216
- finally {
217
- if (fd !== null)
218
- fs.closeSync(fd);
219
238
  }
220
- }
221
- catch {
222
- // Skip unreadable files
239
+ catch { /* ignore */ }
240
+ };
241
+ // 1. Check transcript.jsonl (modern)
242
+ scanFile(path.join(this.brainBasePath, id, '.system_generated', 'logs', 'transcript.jsonl'), 16384);
243
+ // 2. Check overview.txt (legacy)
244
+ scanFile(path.join(this.brainBasePath, id, '.system_generated', 'logs', 'overview.txt'), 4096);
245
+ if (!belongsToWorkspace)
246
+ continue;
247
+ if (exactMatch)
248
+ return id; // Exact match in the right workspace takes precedence immediately
249
+ if (score > bestScore) {
250
+ bestScore = score;
251
+ bestId = id;
223
252
  }
224
253
  }
225
254
  if (bestScore >= minScore) {
@@ -232,10 +261,11 @@ class ArtifactService {
232
261
  * that has at least one artifact. Falls back to the most recent conversation
233
262
  * overall if none have artifacts.
234
263
  */
235
- getLatestConversationWithArtifacts() {
264
+ getLatestConversationWithArtifacts(workspaceFilter) {
236
265
  const ids = this.listConversationIds();
237
266
  if (ids.length === 0)
238
267
  return null;
268
+ const filterStr = workspaceFilter ? workspaceFilter.toLowerCase() : null;
239
269
  // Sort by directory mtime descending
240
270
  const sorted = ids
241
271
  .map((id) => {
@@ -248,14 +278,50 @@ class ArtifactService {
248
278
  }
249
279
  })
250
280
  .sort((a, b) => b.mtime - a.mtime);
251
- // Find the first one that has artifacts
281
+ if (filterStr) {
282
+ const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
283
+ const workspaceIds = sorted.filter(({ id }) => {
284
+ let belongs = false;
285
+ const regex = new RegExp(`(?:file:\\/\\/\\/[^"\\)]*?\\/|[a-zA-Z]:[\\\\/](?!.*(?:\\.gemini|brain|antigravity))[^"\\)]*?[\\\\/])${escapedFilter}\\b`, 'i');
286
+ const checkFile = (filePath) => {
287
+ try {
288
+ if (fs.existsSync(filePath)) {
289
+ const content = fs.readFileSync(filePath, 'utf-8').toLowerCase();
290
+ if (regex.test(content)) {
291
+ belongs = true;
292
+ }
293
+ }
294
+ }
295
+ catch { /* ignore */ }
296
+ };
297
+ checkFile(path.join(this.brainBasePath, id, '.system_generated', 'logs', 'transcript.jsonl'));
298
+ if (!belongs) {
299
+ checkFile(path.join(this.brainBasePath, id, 'implementation_plan.md'));
300
+ }
301
+ return belongs;
302
+ });
303
+ if (workspaceIds.length === 0)
304
+ return null;
305
+ const latestId = workspaceIds[0].id;
306
+ if (this.listArtifacts(latestId).length > 0) {
307
+ return latestId;
308
+ }
309
+ return null;
310
+ }
311
+ // Original fallback behavior for no filter
252
312
  for (const { id } of sorted) {
253
313
  if (this.listArtifacts(id).length > 0)
254
314
  return id;
255
315
  }
256
- // Nothing has artifacts — return most recent anyway (caller handles empty list)
257
316
  return sorted[0]?.id ?? null;
258
317
  }
318
+ /**
319
+ * Build the filesystem path for a specific artifact file.
320
+ */
321
+ getArtifactPath(conversationId, filename) {
322
+ const safe = path.basename(filename);
323
+ return path.join(this.brainBasePath, conversationId, safe);
324
+ }
259
325
  /**
260
326
  * Read the markdown content of a specific artifact file.
261
327
  * Returns null if the file cannot be read.