lazy-gravity 0.10.0 → 0.12.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 (33) hide show
  1. package/README.md +7 -0
  2. package/dist/bin/commands/doctor.js +45 -7
  3. package/dist/bot/index.js +526 -24
  4. package/dist/commands/chatCommandHandler.js +11 -41
  5. package/dist/commands/registerSlashCommands.js +62 -0
  6. package/dist/database/scheduleRepository.js +50 -6
  7. package/dist/events/interactionCreateHandler.js +163 -28
  8. package/dist/events/messageCreateHandler.js +24 -28
  9. package/dist/handlers/fileChangeButtonAction.js +12 -24
  10. package/dist/handlers/genericActionButtonAction.js +16 -18
  11. package/dist/handlers/planningButtonAction.js +105 -17
  12. package/dist/handlers/planningModalSubmitAction.js +38 -0
  13. package/dist/handlers/questionSelectAction.js +8 -7
  14. package/dist/handlers/questionSkipAction.js +7 -6
  15. package/dist/platform/discord/wrappers.js +76 -0
  16. package/dist/services/approvalDetector.js +43 -14
  17. package/dist/services/artifactService.js +61 -21
  18. package/dist/services/assistantDomExtractor.js +23 -3
  19. package/dist/services/cdpBridgeManager.js +35 -2
  20. package/dist/services/cdpConnectionPool.js +7 -2
  21. package/dist/services/cdpService.js +113 -8
  22. package/dist/services/chatSessionService.js +15 -8
  23. package/dist/services/heartbeatService.js +261 -0
  24. package/dist/services/notificationSender.js +12 -2
  25. package/dist/services/planningDetector.js +1 -1
  26. package/dist/services/promptDispatcher.js +21 -1
  27. package/dist/services/questionDetector.js +128 -76
  28. package/dist/services/responseMonitor.js +17 -1
  29. package/dist/services/scheduleService.js +101 -4
  30. package/dist/utils/configLoader.js +8 -0
  31. package/dist/utils/fileOpenCache.js +2 -6
  32. package/dist/utils/questionActionUtils.js +22 -0
  33. package/package.json +2 -1
@@ -40,7 +40,7 @@ const GET_NEW_CHAT_BUTTON_SCRIPT = `(() => {
40
40
  * The title element is a div with the text-ellipsis class inside the header.
41
41
  */
42
42
  const GET_CHAT_TITLE_SCRIPT = `(() => {
43
- const panel = document.querySelector('.antigravity-agent-side-panel');
43
+ const panel = document.querySelector('.antigravity-agent-side-panel') || document.body;
44
44
  if (!panel) return { title: '', hasActiveChat: false };
45
45
  const header = panel.querySelector('div[class*="border-b"]');
46
46
  if (!header) return { title: '', hasActiveChat: false };
@@ -58,7 +58,7 @@ const GET_CHAT_TITLE_SCRIPT = `(() => {
58
58
  return { title: title || '(Untitled)', hasActiveChat };
59
59
  })()`;
60
60
  const GET_SESSION_VIEW_STATE_SCRIPT = `(() => {
61
- const panel = document.querySelector('.antigravity-agent-side-panel');
61
+ const panel = document.querySelector('.antigravity-agent-side-panel') || document.body;
62
62
  if (!panel) {
63
63
  return {
64
64
  panelFound: false,
@@ -169,7 +169,8 @@ const SCRAPE_PAST_CONVERSATIONS_SCRIPT = `(() => {
169
169
  // Try the visible QuickInput dialog first, then fall back to the side panel.
170
170
  const quickInputPanels = Array.from(document.querySelectorAll('div[class*="bg-quickinput-background"]'));
171
171
  const panel = quickInputPanels.find((el) => isVisible(el))
172
- || document.querySelector('.antigravity-agent-side-panel');
172
+ || document.querySelector('.antigravity-agent-side-panel')
173
+ || document.body;
173
174
  if (!panel) return null;
174
175
 
175
176
  const items = [];
@@ -276,9 +277,11 @@ function buildActivateChatByTitleScript(title) {
276
277
  for (const node of nodes) {
277
278
  const text = normalize(node.textContent || '');
278
279
  if (!text) continue;
280
+
281
+ const isShort = wanted.length < 5;
279
282
  if (text === wanted) {
280
283
  exact.push({ node, textLength: text.length });
281
- } else if (text.includes(wanted)) {
284
+ } else if (!isShort && text.includes(wanted)) {
282
285
  includes.push({ node, textLength: text.length });
283
286
  } else if (wordsMatch(text, wantedRaw)) {
284
287
  fuzzy.push({ node, textLength: text.length });
@@ -369,10 +372,12 @@ function buildActivateViaPastConversationsScript(title) {
369
372
  if (!pattern) continue;
370
373
  const p = normalize(pattern);
371
374
  const pLoose = normalizeLoose(pattern);
375
+ const isShort = p.length < 5;
376
+
372
377
  if (
373
378
  text === p ||
374
- text.includes(p) ||
375
- (pLoose && (textLoose === pLoose || textLoose.includes(pLoose)))
379
+ (pLoose && textLoose === pLoose) ||
380
+ (!isShort && (text.includes(p) || (pLoose && textLoose.includes(pLoose))))
376
381
  ) {
377
382
  matched.push({ el, score: Math.abs(text.length - pattern.length) });
378
383
  break;
@@ -611,7 +616,8 @@ class ChatSessionService {
611
616
  const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
612
617
  const quickInputPanels = Array.from(document.querySelectorAll('div[class*="bg-quickinput-background"]'));
613
618
  const panel = quickInputPanels.find((el) => isVisible(el))
614
- || document.querySelector('.antigravity-agent-side-panel');
619
+ || document.querySelector('.antigravity-agent-side-panel')
620
+ || document.body;
615
621
  if (!panel) return false;
616
622
  const containers = Array.from(
617
623
  panel.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]')
@@ -1095,12 +1101,13 @@ class ChatSessionService {
1095
1101
  /**
1096
1102
  * Rename the current chat in the Antigravity UI directly by updating the DOM.
1097
1103
  * Note: This is a cosmetic change until Antigravity persists a rename.
1104
+ * IDE syncing for chat renaming is strictly "best-effort".
1098
1105
  */
1099
1106
  async renameCurrentChatInUI(cdpService, newTitle) {
1100
1107
  try {
1101
1108
  const contextId = cdpService.getPrimaryContextId();
1102
1109
  const RENAME_SCRIPT = `(() => {
1103
- const panel = document.querySelector('.antigravity-agent-side-panel');
1110
+ const panel = document.querySelector('.antigravity-agent-side-panel') || document.body;
1104
1111
  if (!panel) return { ok: false, error: 'Panel not found' };
1105
1112
  const header = panel.querySelector('div[class*="border-b"]');
1106
1113
  const titleEl = header?.querySelector('div[class*="text-ellipsis"]');
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HeartbeatService = void 0;
4
+ exports.parseInterval = parseInterval;
5
+ exports.formatDuration = formatDuration;
6
+ exports.formatRelativeTime = formatRelativeTime;
7
+ const discord_js_1 = require("discord.js");
8
+ const logger_1 = require("../utils/logger");
9
+ const configLoader_1 = require("../utils/configLoader");
10
+ class HeartbeatService {
11
+ client = null;
12
+ bridge = null;
13
+ intervalId = null;
14
+ generationToken = 0;
15
+ isSending = false;
16
+ botStartTime = Date.now();
17
+ lastActivityTimestamp = Date.now();
18
+ constructor() { }
19
+ init(client, bridge) {
20
+ this.client = client;
21
+ this.bridge = bridge;
22
+ this.botStartTime = Date.now();
23
+ this.lastActivityTimestamp = Date.now();
24
+ }
25
+ recordActivity() {
26
+ this.lastActivityTimestamp = Date.now();
27
+ }
28
+ start() {
29
+ this.stop();
30
+ const config = configLoader_1.ConfigLoader.load();
31
+ if (!config.heartbeatEnabled) {
32
+ logger_1.logger.info('[HeartbeatService] Periodic heartbeat is disabled.');
33
+ return;
34
+ }
35
+ const channelId = config.heartbeatChannelId;
36
+ if (!channelId) {
37
+ logger_1.logger.warn('[HeartbeatService] Enabled but heartbeatChannelId is not configured.');
38
+ return;
39
+ }
40
+ let interval = config.heartbeatIntervalMs ?? 3600000;
41
+ if (interval < 10000) {
42
+ logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too low. Clamping to 10000ms.`);
43
+ interval = 10000;
44
+ }
45
+ else if (interval > 2147483647) {
46
+ logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too high (max is 2147483647ms). Clamping to 2147483647ms.`);
47
+ interval = 2147483647;
48
+ }
49
+ logger_1.logger.info(`[HeartbeatService] Starting periodic heartbeat every ${interval}ms to channel ${channelId}`);
50
+ // Run immediately on start
51
+ this.sendHeartbeat().catch(err => {
52
+ logger_1.logger.error('[HeartbeatService] Failed to send initial heartbeat:', err);
53
+ });
54
+ this.intervalId = setInterval(() => {
55
+ this.sendHeartbeat().catch(err => {
56
+ logger_1.logger.error('[HeartbeatService] Failed to send periodic heartbeat:', err);
57
+ });
58
+ }, interval);
59
+ }
60
+ stop() {
61
+ this.generationToken++;
62
+ if (this.intervalId) {
63
+ clearInterval(this.intervalId);
64
+ this.intervalId = null;
65
+ }
66
+ }
67
+ async updateConfig(enabled, intervalMs, channelId) {
68
+ this.generationToken++;
69
+ const config = configLoader_1.ConfigLoader.load();
70
+ // If channel changed, clear the last message ID
71
+ if (config.heartbeatChannelId !== channelId) {
72
+ if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
73
+ try {
74
+ const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
75
+ if (oldChannel && oldChannel.isTextBased()) {
76
+ const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
77
+ if (oldMsg) {
78
+ await oldMsg.delete().catch(() => { });
79
+ }
80
+ }
81
+ }
82
+ catch (err) {
83
+ logger_1.logger.debug('[HeartbeatService] Failed to delete old heartbeat message from previous channel:', err);
84
+ }
85
+ }
86
+ configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: undefined });
87
+ }
88
+ // Save to config.json
89
+ configLoader_1.ConfigLoader.save({
90
+ heartbeatEnabled: enabled,
91
+ heartbeatIntervalMs: intervalMs,
92
+ heartbeatChannelId: channelId,
93
+ });
94
+ logger_1.logger.info(`[HeartbeatService] Config updated: enabled=${enabled}, interval=${intervalMs}ms, channel=${channelId}`);
95
+ // Restart loop
96
+ this.start();
97
+ }
98
+ async disable() {
99
+ this.generationToken++;
100
+ const config = configLoader_1.ConfigLoader.load();
101
+ if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
102
+ try {
103
+ const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
104
+ if (oldChannel && oldChannel.isTextBased()) {
105
+ const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
106
+ if (oldMsg) {
107
+ await oldMsg.delete().catch(() => { });
108
+ }
109
+ }
110
+ }
111
+ catch (err) {
112
+ logger_1.logger.debug('[HeartbeatService] Failed to delete heartbeat message upon disabling:', err);
113
+ }
114
+ }
115
+ configLoader_1.ConfigLoader.save({
116
+ heartbeatEnabled: false,
117
+ heartbeatLastMessageId: undefined,
118
+ });
119
+ this.stop();
120
+ logger_1.logger.info('[HeartbeatService] Heartbeat disabled.');
121
+ }
122
+ async sendHeartbeat() {
123
+ if (!this.client || !this.bridge) {
124
+ logger_1.logger.warn('[HeartbeatService] Cannot send heartbeat: client or bridge not initialized.');
125
+ return;
126
+ }
127
+ if (this.isSending) {
128
+ logger_1.logger.debug('[HeartbeatService] Heartbeat send already in flight. Skipping overlapping execution.');
129
+ return;
130
+ }
131
+ const gen = this.generationToken;
132
+ this.isSending = true;
133
+ const config = configLoader_1.ConfigLoader.load();
134
+ const channelId = config.heartbeatChannelId;
135
+ if (!channelId) {
136
+ this.isSending = false;
137
+ return;
138
+ }
139
+ try {
140
+ const channel = await this.client.channels.fetch(channelId);
141
+ if (gen !== this.generationToken) {
142
+ logger_1.logger.debug(`[HeartbeatService] Aborting heartbeat send: stale generation (expected ${gen}, current ${this.generationToken})`);
143
+ return;
144
+ }
145
+ if (!channel || !channel.isTextBased()) {
146
+ logger_1.logger.warn(`[HeartbeatService] Channel ${channelId} not found or is not a text channel.`);
147
+ return;
148
+ }
149
+ const embed = this.buildHeartbeatEmbed();
150
+ const lastMessageId = config.heartbeatLastMessageId;
151
+ if (lastMessageId) {
152
+ try {
153
+ const message = await channel.messages.fetch(lastMessageId);
154
+ if (gen !== this.generationToken) {
155
+ logger_1.logger.debug('[HeartbeatService] Aborting message edit: stale generation');
156
+ return;
157
+ }
158
+ if (message && message.author.id === this.client.user?.id) {
159
+ await message.edit({ embeds: [embed] });
160
+ logger_1.logger.debug(`[HeartbeatService] Updated heartbeat message in-place: ${lastMessageId}`);
161
+ return;
162
+ }
163
+ }
164
+ catch (fetchErr) {
165
+ logger_1.logger.debug(`[HeartbeatService] Failed to fetch or edit message ${lastMessageId}, sending new one.`);
166
+ }
167
+ }
168
+ if (gen !== this.generationToken) {
169
+ logger_1.logger.debug('[HeartbeatService] Aborting message send: stale generation');
170
+ return;
171
+ }
172
+ // Send new message
173
+ const newMsg = await channel.send({ embeds: [embed] });
174
+ if (gen !== this.generationToken) {
175
+ logger_1.logger.debug(`[HeartbeatService] Stale generation after send. Deleting new message ${newMsg.id} to avoid leakage.`);
176
+ await newMsg.delete().catch(() => { });
177
+ return;
178
+ }
179
+ configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: newMsg.id });
180
+ logger_1.logger.info(`[HeartbeatService] Sent new heartbeat message: ${newMsg.id}`);
181
+ }
182
+ catch (error) {
183
+ logger_1.logger.error('[HeartbeatService] Error in sendHeartbeat:', error);
184
+ }
185
+ finally {
186
+ this.isSending = false;
187
+ }
188
+ }
189
+ buildHeartbeatEmbed() {
190
+ const uptimeMs = Date.now() - this.botStartTime;
191
+ const uptimeStr = formatDuration(uptimeMs);
192
+ const activeWorkspaces = this.bridge?.pool.getActiveWorkspaceNames() || [];
193
+ const activeCount = activeWorkspaces.length;
194
+ const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
195
+ const lastActivityStr = formatRelativeTime(this.lastActivityTimestamp);
196
+ return new discord_js_1.EmbedBuilder()
197
+ .setTitle('💓 LazyGravity Heartbeat')
198
+ .setColor(0x00CC88)
199
+ .addFields({ name: 'Status', value: '🟢 Running', inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
200
+ .setFooter({ text: `Last updated: ${new Date().toLocaleString()}` })
201
+ .setTimestamp();
202
+ }
203
+ }
204
+ exports.HeartbeatService = HeartbeatService;
205
+ /**
206
+ * Parse a duration string like "1h", "6h", "30m", or "2d" to milliseconds.
207
+ * Requiring a unit prevents ambiguity with bare numbers.
208
+ */
209
+ function parseInterval(str) {
210
+ const match = str.trim().toLowerCase().match(/^(\d+)(ms|s|m|h|d)$/);
211
+ if (!match)
212
+ return null;
213
+ const value = parseInt(match[1], 10);
214
+ const unit = match[2];
215
+ switch (unit) {
216
+ case 'ms': return value;
217
+ case 's': return value * 1000;
218
+ case 'm': return value * 60 * 1000;
219
+ case 'h': return value * 60 * 60 * 1000;
220
+ case 'd': return value * 24 * 60 * 60 * 1000;
221
+ default: return null;
222
+ }
223
+ }
224
+ /**
225
+ * Format milliseconds to a human-readable duration (e.g. "2h 15m")
226
+ */
227
+ function formatDuration(ms) {
228
+ const seconds = Math.floor((ms / 1000) % 60);
229
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
230
+ const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
231
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24));
232
+ const parts = [];
233
+ if (days > 0)
234
+ parts.push(`${days}d`);
235
+ if (hours > 0)
236
+ parts.push(`${hours}h`);
237
+ if (minutes > 0)
238
+ parts.push(`${minutes}m`);
239
+ if (seconds > 0 || parts.length === 0)
240
+ parts.push(`${seconds}s`);
241
+ return parts.join(' ');
242
+ }
243
+ /**
244
+ * Format a past timestamp to a relative string (e.g. "5m ago")
245
+ */
246
+ function formatRelativeTime(timestamp) {
247
+ const diff = Date.now() - timestamp;
248
+ if (diff < 5000)
249
+ return 'just now';
250
+ const seconds = Math.floor(diff / 1000);
251
+ if (seconds < 60)
252
+ return `${seconds}s ago`;
253
+ const minutes = Math.floor(seconds / 60);
254
+ if (minutes < 60)
255
+ return `${minutes}m ago`;
256
+ const hours = Math.floor(minutes / 60);
257
+ if (hours < 24)
258
+ return `${hours}h ${minutes % 60}m ago`;
259
+ const days = Math.floor(hours / 24);
260
+ return `${days}d ago`;
261
+ }
@@ -82,7 +82,7 @@ function customId(prefix, projectName, channelId) {
82
82
  // ---------------------------------------------------------------------------
83
83
  /** Build the approval notification message. */
84
84
  function buildApprovalNotification(opts) {
85
- const { title, description, projectName, channelId, toolNames, extraFields, hasAlwaysAllow, alwaysAllowText } = opts;
85
+ const { title, description, projectName, channelId, toolNames, extraFields, hasAlwaysAllow, alwaysAllowText, walkthroughCustomId, taskCustomId } = opts;
86
86
  const richContent = (0, richContentBuilder_1.pipe)((0, richContentBuilder_1.createRichContent)(), (rc) => (0, richContentBuilder_1.withTitle)(rc, title), (rc) => (0, richContentBuilder_1.withDescription)(rc, description), (rc) => (0, richContentBuilder_1.withColor)(rc, COLOR_APPROVAL), (rc) => (0, richContentBuilder_1.addField)(rc, 'Project', projectName, true), (rc) => toolNames && toolNames.length > 0
87
87
  ? (0, richContentBuilder_1.addField)(rc, 'Tools', toolNames.join(', '), true)
88
88
  : rc, (rc) => extraFields
@@ -98,6 +98,16 @@ function buildApprovalNotification(opts) {
98
98
  const components = [
99
99
  buttonRow(...buttons),
100
100
  ];
101
+ const artifactButtons = [];
102
+ if (walkthroughCustomId) {
103
+ artifactButtons.push(button(walkthroughCustomId, 'Review walkthrough.md', 'success'));
104
+ }
105
+ if (taskCustomId) {
106
+ artifactButtons.push(button(taskCustomId, 'Review task.md', 'primary'));
107
+ }
108
+ if (artifactButtons.length > 0) {
109
+ components.push(buttonRow(...artifactButtons));
110
+ }
101
111
  return { richContent, components };
102
112
  }
103
113
  /** Build the planning mode notification message. */
@@ -107,8 +117,8 @@ function buildPlanningNotification(opts) {
107
117
  ? extraFields.reduce((acc, f) => (0, richContentBuilder_1.addField)(acc, f.name, f.value, f.inline), rc)
108
118
  : rc, (rc) => (0, richContentBuilder_1.withFooter)(rc, 'Planning mode detected'), (rc) => (0, richContentBuilder_1.withTimestamp)(rc));
109
119
  const buttons = [];
110
- const openLabel = opts.openText || 'Open';
111
120
  if (opts.hasOpenButton !== false) {
121
+ const openLabel = opts.openText || 'Open Plan';
112
122
  buttons.push(button(customId(PLANNING_OPEN_ACTION_PREFIX, projectName, channelId), openLabel, 'primary'));
113
123
  }
114
124
  const buttonLabel = opts.proceedText || 'Proceed';
@@ -247,7 +247,7 @@ class PlanningDetector {
247
247
  * @returns true if click succeeded
248
248
  */
249
249
  async clickRejectButton(buttonText) {
250
- const text = buttonText ?? this.lastDetectedInfo?.rejectText ?? 'Reject All';
250
+ const text = buttonText || this.lastDetectedInfo?.rejectText || 'Reject All';
251
251
  return this.clickButton(text);
252
252
  }
253
253
  /**
@@ -1,12 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PromptDispatcher = void 0;
4
+ const logger_1 = require("../utils/logger");
4
5
  /**
5
6
  * Dispatcher that calls the existing sendPromptToAntigravity.
6
7
  * Unifies dependency injection on the caller side and simplifies event handlers.
7
8
  */
8
9
  class PromptDispatcher {
9
10
  deps;
11
+ activeMonitors = new Map();
10
12
  constructor(deps) {
11
13
  this.deps = deps;
12
14
  }
@@ -17,7 +19,25 @@ class PromptDispatcher {
17
19
  await this._dispatch(req, { ...req.options, resumeOnly: true });
18
20
  }
19
21
  async _dispatch(req, options) {
20
- await this.deps.sendPromptImpl(this.deps.bridge, req.message, req.prompt, req.cdp, this.deps.modeService, this.deps.modelService, req.inboundImages ?? [], options);
22
+ const channelId = req.message.channelId;
23
+ const existing = this.activeMonitors.get(channelId);
24
+ if (existing) {
25
+ logger_1.logger.info(`[PromptDispatcher] Aborting previous active monitor for channel ${channelId}`);
26
+ await existing.stop().catch((err) => logger_1.logger.error('[PromptDispatcher] Error stopping monitor:', err));
27
+ this.activeMonitors.delete(channelId);
28
+ }
29
+ const wrappedOptions = {
30
+ ...(options || {}),
31
+ onMonitorCreated: (monitor) => {
32
+ this.activeMonitors.set(channelId, monitor);
33
+ options?.onMonitorCreated?.(monitor);
34
+ },
35
+ onFullCompletion: () => {
36
+ this.activeMonitors.delete(channelId);
37
+ options?.onFullCompletion?.();
38
+ }
39
+ };
40
+ await this.deps.sendPromptImpl(this.deps.bridge, req.message, req.prompt, req.cdp, this.deps.modeService, this.deps.modelService, req.inboundImages ?? [], wrappedOptions);
21
41
  }
22
42
  }
23
43
  exports.PromptDispatcher = PromptDispatcher;