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.
- package/dist/bin/commands/doctor.js +45 -7
- package/dist/bot/index.js +401 -41
- package/dist/bot/telegramMessageHandler.js +1 -0
- package/dist/commands/chatCommandHandler.js +43 -3
- package/dist/commands/registerSlashCommands.js +13 -1
- package/dist/events/interactionCreateHandler.js +456 -27
- package/dist/events/messageCreateHandler.js +66 -7
- package/dist/handlers/__tests__/fileChangeButtonAction.test.js +85 -0
- package/dist/handlers/approvalButtonAction.js +2 -1
- package/dist/handlers/errorPopupButtonAction.js +2 -1
- package/dist/handlers/fileChangeButtonAction.js +57 -0
- package/dist/handlers/genericActionButtonAction.js +72 -0
- package/dist/handlers/planningButtonAction.js +126 -23
- package/dist/handlers/planningModalSubmitAction.js +38 -0
- package/dist/handlers/questionSelectAction.js +70 -0
- package/dist/handlers/questionSkipAction.js +68 -0
- package/dist/handlers/runCommandButtonAction.js +2 -1
- package/dist/platform/discord/discordResponseRenderer.js +164 -0
- package/dist/platform/discord/wrappers.js +76 -0
- package/dist/services/approvalDetector.js +110 -35
- package/dist/services/artifactService.js +103 -37
- package/dist/services/assistantDomExtractor.js +194 -3
- package/dist/services/cdpBridgeManager.js +149 -8
- package/dist/services/cdpConnectionPool.js +22 -2
- package/dist/services/cdpService.js +134 -15
- package/dist/services/chatSessionService.js +147 -40
- package/dist/services/errorPopupDetector.js +23 -11
- package/dist/services/notificationSender.js +80 -3
- package/dist/services/planningDetector.js +69 -25
- package/dist/services/promptDispatcher.js +27 -1
- package/dist/services/questionDetector.js +428 -0
- package/dist/services/responseMonitor.js +45 -3
- package/dist/services/runCommandDetector.js +14 -7
- package/dist/ui/artifactsUi.js +4 -3
- package/dist/utils/consecutiveEmptyPollGate.js +21 -0
- package/dist/utils/fileOpenCache.js +29 -0
- package/dist/utils/htmlToDiscordMarkdown.js +5 -2
- package/dist/utils/pathUtils.js +12 -0
- package/dist/utils/projectResolver.js +10 -0
- package/dist/utils/questionActionUtils.js +47 -0
- 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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
73
|
-
brainBasePath
|
|
74
|
-
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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.
|