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
package/dist/bot/index.js CHANGED
@@ -62,6 +62,10 @@ const workspaceBindingRepository_1 = require("../database/workspaceBindingReposi
62
62
  const channelPreferenceRepository_1 = require("../database/channelPreferenceRepository");
63
63
  const chatSessionRepository_1 = require("../database/chatSessionRepository");
64
64
  const artifactThreadRepository_1 = require("../database/artifactThreadRepository");
65
+ const scheduleRepository_1 = require("../database/scheduleRepository");
66
+ const scheduleService_1 = require("../services/scheduleService");
67
+ const cron_parser_1 = __importDefault(require("cron-parser"));
68
+ const workspaceQueue_1 = require("./workspaceQueue");
65
69
  const workspaceService_1 = require("../services/workspaceService");
66
70
  const workspaceCommandHandler_1 = require("../commands/workspaceCommandHandler");
67
71
  const chatCommandHandler_1 = require("../commands/chatCommandHandler");
@@ -108,6 +112,7 @@ const buttonHandler_1 = require("../handlers/buttonHandler");
108
112
  const selectHandler_1 = require("../handlers/selectHandler");
109
113
  const approvalButtonAction_1 = require("../handlers/approvalButtonAction");
110
114
  const planningButtonAction_1 = require("../handlers/planningButtonAction");
115
+ const heartbeatService_1 = require("../services/heartbeatService");
111
116
  const errorPopupButtonAction_1 = require("../handlers/errorPopupButtonAction");
112
117
  const runCommandButtonAction_1 = require("../handlers/runCommandButtonAction");
113
118
  const modelButtonAction_1 = require("../handlers/modelButtonAction");
@@ -191,11 +196,15 @@ function createSerialTaskQueueForTest(queueName, traceId) {
191
196
  async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService, modelService, inboundImages = [], options) {
192
197
  // Completion signal — called exactly once when the entire prompt lifecycle ends
193
198
  let completionSignaled = false;
199
+ let onApprovalRef = null;
194
200
  const signalCompletion = (exitPath) => {
195
201
  if (completionSignaled)
196
202
  return;
197
203
  completionSignaled = true;
198
204
  logger_1.logger.debug(`[sendPrompt:${message.channelId}] signalCompletion via ${exitPath}`);
205
+ if (onApprovalRef) {
206
+ cdp.off('approval_required', onApprovalRef);
207
+ }
199
208
  options?.onFullCompletion?.();
200
209
  };
201
210
  // Resolve output format once at the start (no mid-response switches)
@@ -587,6 +596,8 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
587
596
  return;
588
597
  }
589
598
  const startTime = Date.now();
599
+ let earlyConvIdResolved = false;
600
+ let earlyConvIdInFlight = false;
590
601
  await upsertLiveActivityEmbeds(`${PHASE_ICONS.thinking} Process Log`, '', PHASE_COLORS.thinking, (0, i18n_1.t)('⏱️ Elapsed: 0s | Process log'), { source: 'initial' });
591
602
  const monitor = new responseMonitor_1.ResponseMonitor({
592
603
  cdpService: cdp,
@@ -613,6 +624,30 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
613
624
  expectedVersion: activityVersion,
614
625
  skipWhenFinalized: true,
615
626
  }).catch(() => { });
627
+ // Try to resolve conversation_id early if not already bound
628
+ const { artifactService, chatSessionRepo, chatSessionService } = options || {};
629
+ if (!earlyConvIdResolved && !earlyConvIdInFlight && artifactService && chatSessionRepo && chatSessionService) {
630
+ earlyConvIdInFlight = true;
631
+ chatSessionService.getCurrentSessionInfo(cdp)
632
+ .then((sessionInfo) => {
633
+ if (sessionInfo && sessionInfo.title && sessionInfo.title !== (0, i18n_1.t)('(Untitled)')) {
634
+ const session = chatSessionRepo.findByChannelId(message.channelId);
635
+ const workspaceDirName = (session
636
+ ? bridge.pool.extractProjectName(session.workspacePath)
637
+ : cdp.getCurrentWorkspaceName()) ?? undefined;
638
+ const convId = artifactService.findConversationByTitle(sessionInfo.title, workspaceDirName);
639
+ if (convId) {
640
+ chatSessionRepo.setConversationId(message.channelId, convId);
641
+ earlyConvIdResolved = true;
642
+ logger_1.logger.debug(`[EarlyBinding] Saved conversation_id: "${convId}" for channel: ${message.channelId}`);
643
+ }
644
+ }
645
+ })
646
+ .catch(() => { })
647
+ .finally(() => {
648
+ earlyConvIdInFlight = false;
649
+ });
650
+ }
616
651
  },
617
652
  onProgress: (text) => {
618
653
  if (isFinalized)
@@ -706,6 +741,12 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
706
741
  const components = [];
707
742
  if (!citedFiles)
708
743
  citedFiles = [];
744
+ // Only pause when newly created conversation folders and files need to flush to disk
745
+ const existingConvId = options?.chatSessionRepo?.findByChannelId(message.channelId)?.conversationId;
746
+ if (!existingConvId) {
747
+ await new Promise((resolve) => setTimeout(resolve, 1000));
748
+ }
749
+ let activeConversationId = undefined;
709
750
  if (options && message.guild) {
710
751
  try {
711
752
  const sessionInfo = await options.chatSessionService.getCurrentSessionInfo(cdp);
@@ -733,6 +774,7 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
733
774
  const convId = options.artifactService.findConversationByTitle(sessionInfo.title, workspaceDirName);
734
775
  if (convId) {
735
776
  options.chatSessionRepo.setConversationId(message.channelId, convId);
777
+ activeConversationId = convId;
736
778
  }
737
779
  }
738
780
  }
@@ -741,6 +783,51 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
741
783
  logger_1.logger.error('[Rename] Failed to get title from Antigravity and rename:', e);
742
784
  }
743
785
  }
786
+ const classifiedForFiles = monitor.getLastClassified();
787
+ if (classifiedForFiles?.fileChanges && classifiedForFiles.fileChanges.length > 0) {
788
+ for (const fc of classifiedForFiles.fileChanges) {
789
+ if (!citedFiles.includes(fc.path)) {
790
+ citedFiles.push(fc.path);
791
+ }
792
+ }
793
+ }
794
+ if (!activeConversationId && options?.chatSessionRepo) {
795
+ activeConversationId = options.chatSessionRepo.findByChannelId(message.channelId)?.conversationId ?? undefined;
796
+ }
797
+ if (activeConversationId && options?.artifactService) {
798
+ const artifacts = options.artifactService.listArtifacts(activeConversationId);
799
+ const planArt = artifacts.find(art => art.artifactType === 'ARTIFACT_TYPE_IMPLEMENTATION_PLAN' ||
800
+ art.filename.toLowerCase() === 'implementation_plan.md');
801
+ let planTime = 0;
802
+ if (planArt) {
803
+ try {
804
+ planTime = fs_1.default.statSync(planArt.absolutePath).mtimeMs;
805
+ }
806
+ catch { /* ignore */ }
807
+ }
808
+ for (const art of artifacts) {
809
+ const nameWithExt = art.filename;
810
+ const nameWithoutExt = art.filename.replace(/\.[^/.]+$/, "");
811
+ const regex = new RegExp(`\\b${nameWithoutExt.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i');
812
+ const mentionsArtifact = finalOutputText.includes(nameWithExt) || finalOutputText.includes(`\`${nameWithoutExt}\``) || regex.test(finalOutputText);
813
+ const isTask = art.artifactType === 'ARTIFACT_TYPE_TASK' || nameWithExt.toLowerCase() === 'task.md';
814
+ const isWalkthrough = art.artifactType === 'ARTIFACT_TYPE_WALKTHROUGH' || nameWithExt.toLowerCase() === 'walkthrough.md';
815
+ let isSpecialArtifact = false;
816
+ if (isTask || isWalkthrough) {
817
+ let artTime = 0;
818
+ try {
819
+ artTime = fs_1.default.statSync(art.absolutePath).mtimeMs;
820
+ }
821
+ catch { /* ignore */ }
822
+ if (artTime > planTime) {
823
+ isSpecialArtifact = true;
824
+ }
825
+ }
826
+ if ((mentionsArtifact || isSpecialArtifact) && !citedFiles.includes(nameWithExt)) {
827
+ citedFiles.push(nameWithExt);
828
+ }
829
+ }
830
+ }
744
831
  if (citedFiles && citedFiles.length > 0) {
745
832
  // Strip trailing punctuation and deduplicate
746
833
  let uniqueFiles = Array.from(new Set(citedFiles.map(f => {
@@ -756,38 +843,82 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
756
843
  // Max 5 buttons per row in Discord
757
844
  for (let i = 0; i < Math.min(uniqueFiles.length, 5); i++) {
758
845
  let fileUrl = uniqueFiles[i];
846
+ let isArtifactMatch = false;
847
+ let artifactDisplayName = fileUrl;
848
+ if (activeConversationId && options?.artifactService) {
849
+ const artifacts = options.artifactService.listArtifacts(activeConversationId);
850
+ const rawName = fileUrl.replace(/^file:\/\/\//, '');
851
+ const matched = artifacts.find(a => a.filename.toLowerCase() === fileUrl.toLowerCase() ||
852
+ a.filename.toLowerCase() === rawName.toLowerCase());
853
+ if (matched) {
854
+ isArtifactMatch = true;
855
+ fileUrl = `file:///${matched.absolutePath.replace(/\\/g, '/')}`;
856
+ artifactDisplayName = matched.filename;
857
+ }
858
+ }
759
859
  const wsPath = cdp.getCurrentWorkspacePath();
760
860
  if (!wsPath) {
761
861
  continue;
762
862
  }
763
- if (!fileUrl.startsWith('file:///') && !path_1.default.isAbsolute(fileUrl)) {
764
- fileUrl = `file:///${path_1.default.resolve(wsPath, fileUrl).replace(/\\/g, '/')}`;
765
- }
766
- let fsPath = fileUrl.replace(/^file:\/\/\//, '');
767
- if (path_1.default.sep === '/' && !fsPath.startsWith('/')) {
768
- fsPath = '/' + fsPath;
769
- }
770
- else if (process.platform === 'win32' && fsPath.startsWith('/')) {
771
- fsPath = fsPath.substring(1);
772
- }
773
- const relative = path_1.default.relative(wsPath, fsPath);
774
- if (relative.startsWith('..') || path_1.default.isAbsolute(relative)) {
775
- continue;
776
- }
777
- if (!fs_1.default.existsSync(fsPath)) {
778
- continue;
863
+ if (!isArtifactMatch) {
864
+ if (!fileUrl.startsWith('file:///') && !path_1.default.isAbsolute(fileUrl)) {
865
+ fileUrl = `file:///${path_1.default.resolve(wsPath, fileUrl).replace(/\\/g, '/')}`;
866
+ }
867
+ let fsPath = fileUrl.replace(/^file:\/\/\//, '');
868
+ if (path_1.default.sep === '/' && !fsPath.startsWith('/')) {
869
+ fsPath = '/' + fsPath;
870
+ }
871
+ else if (process.platform === 'win32' && fsPath.startsWith('/')) {
872
+ fsPath = fsPath.substring(1);
873
+ }
874
+ const relative = path_1.default.relative(wsPath, fsPath);
875
+ const isArtifact = fsPath.includes('.gemini') && fsPath.includes('antigravity') && fsPath.includes('brain');
876
+ if (!isArtifact && (relative.startsWith('..') || path_1.default.isAbsolute(relative))) {
877
+ continue;
878
+ }
879
+ if (!fs_1.default.existsSync(fsPath)) {
880
+ continue;
881
+ }
779
882
  }
780
883
  let displayName = fileUrl;
781
- if (displayName.startsWith('file:///')) {
884
+ if (isArtifactMatch) {
885
+ displayName = artifactDisplayName;
886
+ }
887
+ else if (displayName.startsWith('file:///')) {
782
888
  const parts = displayName.split('/');
783
889
  displayName = parts[parts.length - 1];
784
890
  }
785
891
  const hashId = Math.random().toString(36).substring(2, 10);
786
892
  fileOpenCache_1.fileOpenCache.set(hashId, fileUrl);
893
+ let customId = `file_open:cache:${hashId}`;
894
+ if (isArtifactMatch && activeConversationId && displayName) {
895
+ const artId = `file_open:art:${activeConversationId}:${displayName}`;
896
+ if (artId.length < 100) {
897
+ customId = artId;
898
+ }
899
+ }
900
+ else if (wsPath) {
901
+ let fsPath = fileUrl.replace(/^file:\/\/\//, '');
902
+ if (path_1.default.sep === '/' && !fsPath.startsWith('/')) {
903
+ fsPath = '/' + fsPath;
904
+ }
905
+ else if (process.platform === 'win32' && fsPath.startsWith('/')) {
906
+ fsPath = fsPath.substring(1);
907
+ }
908
+ const resolvedFsPath = path_1.default.resolve(fsPath);
909
+ const relative = path_1.default.relative(wsPath, resolvedFsPath);
910
+ if (!relative.startsWith('..') && !path_1.default.isAbsolute(relative)) {
911
+ const relSlash = relative.replace(/\\/g, '/');
912
+ const relId = `file_open:rel:${relSlash}`;
913
+ if (relId.length < 100) {
914
+ customId = relId;
915
+ }
916
+ }
917
+ }
787
918
  row.addComponents(new discord_js_1.ButtonBuilder()
788
- .setCustomId(`file_open:${hashId}`)
789
- .setLabel(`Open ${displayName.substring(0, 20)}`)
790
- .setStyle(discord_js_1.ButtonStyle.Primary));
919
+ .setCustomId(customId)
920
+ .setLabel(isArtifactMatch ? `Review ${displayName.substring(0, 15)}` : `Open ${displayName.substring(0, 20)}`)
921
+ .setStyle(isArtifactMatch ? discord_js_1.ButtonStyle.Success : discord_js_1.ButtonStyle.Primary));
791
922
  }
792
923
  if (row.components.length > 0) {
793
924
  components.push(row);
@@ -895,6 +1026,32 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
895
1026
  }
896
1027
  },
897
1028
  });
1029
+ onApprovalRef = async () => {
1030
+ if (isFinalized)
1031
+ return;
1032
+ const textToUse = lastProgressText || monitor.getLastText() || '';
1033
+ const separated = (0, discordFormatter_1.splitOutputAndLogs)(textToUse);
1034
+ const outputText = separated.output || textToUse;
1035
+ if (outputText && outputText.trim().length > 0) {
1036
+ liveResponseUpdateVersion += 1;
1037
+ const responseVersion = liveResponseUpdateVersion;
1038
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
1039
+ await upsertLiveResponseEmbeds((0, i18n_1.t)('Response'), outputText, PHASE_COLORS.thinking, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Awaiting Approval`), {
1040
+ source: 'approval-pause',
1041
+ expectedVersion: responseVersion,
1042
+ skipWhenFinalized: true,
1043
+ }).catch(() => { });
1044
+ }
1045
+ };
1046
+ cdp.on('approval_required', onApprovalRef);
1047
+ if (options?.onMonitorCreated) {
1048
+ options.onMonitorCreated({
1049
+ stop: async () => {
1050
+ isFinalized = true;
1051
+ await monitor.stop();
1052
+ }
1053
+ });
1054
+ }
898
1055
  await monitor.start();
899
1056
  // 1-second elapsed timer — updates footer independently of process log events
900
1057
  const elapsedTimer = setInterval(() => {
@@ -948,9 +1105,13 @@ const startBot = async (cliLogLevel) => {
948
1105
  const workspaceBindingRepo = new workspaceBindingRepository_1.WorkspaceBindingRepository(db);
949
1106
  const chatSessionRepo = new chatSessionRepository_1.ChatSessionRepository(db);
950
1107
  const artifactThreadRepo = new artifactThreadRepository_1.ArtifactThreadRepository(db);
1108
+ const scheduleRepo = new scheduleRepository_1.ScheduleRepository(db);
1109
+ const scheduleService = new scheduleService_1.ScheduleService(scheduleRepo);
1110
+ const workspaceQueue = new workspaceQueue_1.WorkspaceQueue();
951
1111
  const artifactService = new artifactService_1.ArtifactService();
952
1112
  const workspaceService = new workspaceService_1.WorkspaceService(config.workspaceBaseDir);
953
1113
  const channelManager = new channelManager_1.ChannelManager();
1114
+ const heartbeatService = new heartbeatService_1.HeartbeatService();
954
1115
  // Auto-launch Antigravity with CDP port if not already running
955
1116
  await (0, antigravityLauncher_1.ensureAntigravityRunning)();
956
1117
  // Initialize CDP bridge (lazy connection: pool creation only)
@@ -959,6 +1120,8 @@ const startBot = async (cliLogLevel) => {
959
1120
  .filter((account) => typeof account.userDataDir === 'string' && account.userDataDir.trim().length > 0)
960
1121
  .map((account) => [account.name, account.userDataDir.trim()]));
961
1122
  const bridge = (0, cdpBridgeManager_1.initCdpBridge)(config.autoApproveFileEdits, accountPorts, accountUserDataDirs);
1123
+ bridge.chatSessionRepo = chatSessionRepo;
1124
+ bridge.artifactService = artifactService;
962
1125
  // Initialize CDP-dependent services (constructor CDP dependency removed)
963
1126
  const chatSessionService = new chatSessionService_1.ChatSessionService();
964
1127
  const titleGenerator = new titleGeneratorService_1.TitleGeneratorService();
@@ -1009,7 +1172,20 @@ const startBot = async (cliLogLevel) => {
1009
1172
  accountPrefRepo,
1010
1173
  accounts: config.antigravityAccounts,
1011
1174
  });
1012
- await (0, antigravityLauncher_1.startAntigravity)(accountPorts[accountName] ?? 9222);
1175
+ const status = await (0, antigravityLauncher_1.startAntigravity)(accountPorts[accountName] ?? 9222);
1176
+ if (status === 'started') {
1177
+ const cdp = new cdpService_1.CdpService({ portsToScan: [accountPorts[accountName] ?? 9222] });
1178
+ try {
1179
+ await cdp.connect();
1180
+ await cdp.openChatPanel();
1181
+ }
1182
+ catch (e) {
1183
+ // ignore
1184
+ }
1185
+ finally {
1186
+ await cdp.disconnect().catch(() => { });
1187
+ }
1188
+ }
1013
1189
  });
1014
1190
  const chatHandler = new chatCommandHandler_1.ChatCommandHandler(chatSessionService, chatSessionRepo, workspaceBindingRepo, channelManager, workspaceService, bridge.pool, (channelId, userId) => (0, accountUtils_1.resolveScopedAccountName)({
1015
1191
  channelId,
@@ -1050,6 +1226,8 @@ const startBot = async (cliLogLevel) => {
1050
1226
  }));
1051
1227
  client.once(discord_js_1.Events.ClientReady, async (readyClient) => {
1052
1228
  logger_1.logger.info(`Ready! Logged in as ${readyClient.user.tag} | extractionMode=${config.extractionMode}`);
1229
+ heartbeatService.init(readyClient, bridge);
1230
+ heartbeatService.start();
1053
1231
  try {
1054
1232
  await (0, registerSlashCommands_1.registerSlashCommands)(discordToken, discordClientId, config.guildId);
1055
1233
  }
@@ -1090,6 +1268,74 @@ const startBot = async (cliLogLevel) => {
1090
1268
  catch (error) {
1091
1269
  logger_1.logger.warn('Failed to send startup dashboard embed:', error);
1092
1270
  }
1271
+ // Restore scheduled tasks
1272
+ const scheduleJobCallback = async (schedule) => {
1273
+ logger_1.logger.info(`[Schedule] Trigger callback running for task ${schedule.id}. Workspace: ${schedule.workspacePath}, promptLength: ${schedule.prompt.length}`);
1274
+ try {
1275
+ let channelId = schedule.channelId;
1276
+ const isWindows = process.platform === 'win32';
1277
+ const bindings = workspaceBindingRepo.findAll().filter(b => {
1278
+ const absPath = workspaceService.getWorkspacePath(b.workspacePath);
1279
+ return isWindows
1280
+ ? absPath.toLowerCase() === schedule.workspacePath.toLowerCase()
1281
+ : absPath === schedule.workspacePath;
1282
+ });
1283
+ if (!channelId || !bindings.some(b => b.channelId === channelId)) {
1284
+ channelId = bindings[0]?.channelId;
1285
+ }
1286
+ if (!channelId) {
1287
+ logger_1.logger.warn(`[Schedule] No channel bound to workspace ${schedule.workspacePath}. Skipping task ${schedule.id}.`);
1288
+ return;
1289
+ }
1290
+ const channel = await readyClient.channels.fetch(channelId).catch(() => null);
1291
+ if (channel && channel.isTextBased() && 'send' in channel) {
1292
+ logger_1.logger.info(`[Schedule] Sending trigger notification to channel ${channelId} for task ${schedule.id}`);
1293
+ const message = await channel.send({
1294
+ content: '⏰ **Scheduled Task Triggered**',
1295
+ allowedMentions: { parse: [] }
1296
+ });
1297
+ const projectLabel = bridge.pool.extractProjectName(schedule.workspacePath);
1298
+ workspaceQueue.incrementDepth(schedule.workspacePath);
1299
+ await workspaceQueue.enqueue(schedule.workspacePath, async () => {
1300
+ try {
1301
+ logger_1.logger.info(`[Schedule] Dispatching prompt to promptDispatcher for task ${schedule.id}`);
1302
+ const preferredAccount = bridge.pool.getPreferredAccountForWorkspace(schedule.workspacePath)
1303
+ || (config.antigravityAccounts?.[0]?.name ?? 'default');
1304
+ const cdp = await bridge.pool.getOrConnect(schedule.workspacePath, { name: preferredAccount });
1305
+ await promptDispatcher.send({
1306
+ message: message,
1307
+ prompt: schedule.prompt,
1308
+ cdp,
1309
+ inboundImages: [],
1310
+ options: {
1311
+ chatSessionService,
1312
+ chatSessionRepo,
1313
+ channelManager,
1314
+ titleGenerator,
1315
+ userPrefRepo,
1316
+ artifactService,
1317
+ extractionMode: config.extractionMode,
1318
+ },
1319
+ });
1320
+ }
1321
+ finally {
1322
+ const remainingDepth = workspaceQueue.decrementDepth(schedule.workspacePath);
1323
+ if (remainingDepth > 0) {
1324
+ logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
1325
+ }
1326
+ }
1327
+ });
1328
+ }
1329
+ else {
1330
+ logger_1.logger.warn(`[Schedule] Channel ${channelId} not found or not text-based for task ${schedule.id}.`);
1331
+ }
1332
+ }
1333
+ catch (error) {
1334
+ logger_1.logger.error(`[Schedule] Failed to execute task ${schedule.id}:`, error);
1335
+ }
1336
+ };
1337
+ const restoredCount = scheduleService.restoreAll(scheduleJobCallback);
1338
+ logger_1.logger.info(`[Schedule] Restored ${restoredCount} scheduled tasks.`);
1093
1339
  });
1094
1340
  // [Discord Interactions API] Slash command interaction handler
1095
1341
  client.on(discord_js_1.Events.InteractionCreate, (0, interactionCreateHandler_1.createInteractionCreateHandler)({
@@ -1123,7 +1369,9 @@ const startBot = async (cliLogLevel) => {
1123
1369
  channelManager,
1124
1370
  titleGenerator,
1125
1371
  antigravityAccounts: config.antigravityAccounts,
1126
- handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, scheduleServiceArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, artifactService, scheduleServiceArg),
1372
+ heartbeatService,
1373
+ scheduleService,
1374
+ handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, scheduleServiceArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, artifactService, scheduleServiceArg, heartbeatService),
1127
1375
  handleTemplateUse: async (interaction, templateId) => {
1128
1376
  const template = templateRepo.findById(templateId);
1129
1377
  if (!template) {
@@ -1245,6 +1493,8 @@ const startBot = async (cliLogLevel) => {
1245
1493
  accountPrefRepo,
1246
1494
  channelPrefRepo,
1247
1495
  antigravityAccounts: config.antigravityAccounts,
1496
+ heartbeatService,
1497
+ workspaceQueue,
1248
1498
  }));
1249
1499
  await client.login(discordToken);
1250
1500
  } // end: else (credentials present)
@@ -1515,10 +1765,22 @@ async function autoRenameChannel(message, chatSessionRepo, titleGenerator, chann
1515
1765
  logger_1.logger.error('[AutoRename] Rename failed:', err);
1516
1766
  }
1517
1767
  }
1768
+ /**
1769
+ * Utility to parse a cron expression and format the next run time.
1770
+ */
1771
+ function formatNextRunTime(cronExpression) {
1772
+ try {
1773
+ const interval = cron_parser_1.default.parse(cronExpression);
1774
+ return interval.next().toDate().toLocaleString();
1775
+ }
1776
+ catch (err) {
1777
+ return 'Invalid Cron';
1778
+ }
1779
+ }
1518
1780
  /**
1519
1781
  * Handle Discord Interactions API slash commands
1520
1782
  */
1521
- async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService) {
1783
+ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService, heartbeatService) {
1522
1784
  const commandName = interaction.commandName;
1523
1785
  const getAccountPort = (accountName) => {
1524
1786
  const match = antigravityAccounts.find((account) => account.name === accountName);
@@ -2043,7 +2305,9 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
2043
2305
  const candidatePath = path_1.default.isAbsolute(rawPath) ? rawPath : path_1.default.join(wsPath, rawPath);
2044
2306
  const relative = path_1.default.relative(wsPath, candidatePath);
2045
2307
  if (!relative.startsWith('..') && !path_1.default.isAbsolute(relative)) {
2046
- resolvedPath = candidatePath;
2308
+ if (fs_1.default.existsSync(candidatePath)) {
2309
+ resolvedPath = candidatePath;
2310
+ }
2047
2311
  }
2048
2312
  }
2049
2313
  }
@@ -2079,6 +2343,244 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
2079
2343
  });
2080
2344
  break;
2081
2345
  }
2346
+ case 'heartbeat': {
2347
+ const subcommand = interaction.options.getSubcommand();
2348
+ if (!heartbeatService) {
2349
+ await interaction.editReply({ content: 'Heartbeat service not available.' });
2350
+ break;
2351
+ }
2352
+ const envOverrides = [];
2353
+ if (process.env.HEARTBEAT_ENABLED !== undefined)
2354
+ envOverrides.push('HEARTBEAT_ENABLED');
2355
+ if (process.env.HEARTBEAT_INTERVAL_MS !== undefined)
2356
+ envOverrides.push('HEARTBEAT_INTERVAL_MS');
2357
+ if (process.env.HEARTBEAT_CHANNEL_ID !== undefined)
2358
+ envOverrides.push('HEARTBEAT_CHANNEL_ID');
2359
+ let warningPrefix = '';
2360
+ if (envOverrides.length > 0) {
2361
+ warningPrefix = `⚠️ **Warning**: Environment override(s) active: ${envOverrides.join(', ')}. Changes saved to config.json may not take effect until overrides are removed.\n\n`;
2362
+ }
2363
+ if (subcommand === 'on') {
2364
+ const intervalStr = interaction.options.getString('interval') || '1h';
2365
+ const targetChannel = interaction.options.getChannel('channel') || interaction.channel;
2366
+ if (!targetChannel || typeof targetChannel.isTextBased !== 'function' || !targetChannel.isTextBased()) {
2367
+ await interaction.editReply({ content: '⚠️ Please select a valid text channel.' });
2368
+ break;
2369
+ }
2370
+ // Check permissions
2371
+ const botUser = interaction.client.user;
2372
+ const permissions = targetChannel.permissionsFor?.(botUser);
2373
+ if (!permissions || !permissions.has('SendMessages') || !permissions.has('EmbedLinks')) {
2374
+ await interaction.editReply({ content: '⚠️ Bot does not have permission to send messages and embed links in that channel.' });
2375
+ break;
2376
+ }
2377
+ const intervalMs = (0, heartbeatService_1.parseInterval)(intervalStr);
2378
+ if (intervalMs === null || intervalMs <= 0) {
2379
+ await interaction.editReply({ content: '⚠️ Invalid interval format. Use a value with a unit, e.g. "1d", "1h", "30m" (bare numbers are not allowed).' });
2380
+ break;
2381
+ }
2382
+ if (intervalMs < 10000) {
2383
+ await interaction.editReply({ content: '⚠️ Interval must be at least 10 seconds.' });
2384
+ break;
2385
+ }
2386
+ if (intervalMs > 2147483647) {
2387
+ await interaction.editReply({ content: '⚠️ Interval cannot be greater than 24.8 days (2147483647 ms).' });
2388
+ break;
2389
+ }
2390
+ await heartbeatService.updateConfig(true, intervalMs, targetChannel.id);
2391
+ await interaction.editReply({
2392
+ content: `${warningPrefix}💓 Heartbeat enabled! Sending updates every **${intervalStr}** to channel <#${targetChannel.id}>.`
2393
+ });
2394
+ }
2395
+ else if (subcommand === 'off') {
2396
+ await heartbeatService.disable();
2397
+ await interaction.editReply({ content: `${warningPrefix}💓 Heartbeat disabled.` });
2398
+ }
2399
+ else if (subcommand === 'status') {
2400
+ const config = (0, config_1.loadConfig)();
2401
+ const uptimeMs = Date.now() - heartbeatService.botStartTime;
2402
+ const uptimeStr = (0, heartbeatService_1.formatDuration)(uptimeMs);
2403
+ const lastActivityStr = (0, heartbeatService_1.formatRelativeTime)(heartbeatService.lastActivityTimestamp);
2404
+ const activeWorkspaces = bridge.pool.getActiveWorkspaceNames();
2405
+ const activeCount = activeWorkspaces.length;
2406
+ const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
2407
+ const intervalVal = config.heartbeatIntervalMs != null ? (0, heartbeatService_1.formatDuration)(config.heartbeatIntervalMs) : 'N/A';
2408
+ const statusEmbed = new discord_js_1.EmbedBuilder()
2409
+ .setTitle('💓 Heartbeat Status')
2410
+ .setColor(config.heartbeatEnabled ? 0x00CC88 : 0x888888)
2411
+ .addFields({ name: 'Enabled', value: config.heartbeatEnabled ? '🟢 Yes' : '⚪ No', inline: true }, { name: 'Interval', value: config.heartbeatEnabled ? intervalVal : 'N/A', inline: true }, { name: 'Target Channel', value: config.heartbeatChannelId ? `<#${config.heartbeatChannelId}>` : 'N/A', inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
2412
+ .setTimestamp();
2413
+ await interaction.editReply({ embeds: [statusEmbed] });
2414
+ }
2415
+ break;
2416
+ }
2417
+ case 'schedule': {
2418
+ if (!scheduleService) {
2419
+ await interaction.editReply({ content: 'Schedule service not available.' });
2420
+ break;
2421
+ }
2422
+ const subcommand = interaction.options.getSubcommand();
2423
+ if (subcommand === 'list') {
2424
+ const schedules = scheduleService.listSchedules();
2425
+ if (schedules.length === 0) {
2426
+ await interaction.editReply({ content: 'No scheduled tasks found.' });
2427
+ break;
2428
+ }
2429
+ let formatted = '';
2430
+ let truncatedCount = 0;
2431
+ for (const s of schedules) {
2432
+ const nextRunStr = formatNextRunTime(s.cronExpression);
2433
+ const line = `**ID:** ${s.id} | **Cron:** \`${s.cronExpression}\` | **Next:** ${nextRunStr} | **Prompt:** ${s.prompt}\n`;
2434
+ if (formatted.length + line.length > 3900) {
2435
+ truncatedCount = schedules.length - schedules.indexOf(s);
2436
+ break;
2437
+ }
2438
+ formatted += line;
2439
+ }
2440
+ if (truncatedCount > 0) {
2441
+ formatted += `\n*...and ${truncatedCount} more task(s) (truncated due to Discord size limit).*`;
2442
+ }
2443
+ const embed = new discord_js_1.EmbedBuilder()
2444
+ .setTitle('🕒 Scheduled Tasks')
2445
+ .setDescription(formatted)
2446
+ .setColor(0x00CC88);
2447
+ await interaction.editReply({ embeds: [embed] });
2448
+ break;
2449
+ }
2450
+ if (subcommand === 'add') {
2451
+ const cronExpr = interaction.options.getString('cron', true);
2452
+ const promptText = interaction.options.getString('prompt', true);
2453
+ const workspacePath = wsHandler.getWorkspaceForChannel(interaction.channelId);
2454
+ if (!workspacePath) {
2455
+ await interaction.editReply({ content: '⚠️ This channel is not bound to a workspace. Please bind it first.' });
2456
+ break;
2457
+ }
2458
+ try {
2459
+ const jobCb = scheduleService.getJobCallback();
2460
+ if (!jobCb) {
2461
+ await interaction.editReply({ content: '⚠️ Schedule service is still initializing. Please try again in a few seconds.' });
2462
+ break;
2463
+ }
2464
+ const record = scheduleService.addSchedule(cronExpr, promptText, workspacePath, interaction.channelId, jobCb);
2465
+ const nextRun = formatNextRunTime(cronExpr);
2466
+ const nextRunStr = nextRun !== 'Invalid Cron' ? ` (Next run: ${nextRun})` : '';
2467
+ await interaction.editReply({ content: `✅ Scheduled task added! (ID: ${record.id})${nextRunStr}` });
2468
+ }
2469
+ catch (error) {
2470
+ await interaction.editReply({ content: `❌ Failed to add schedule: ${error.message}` });
2471
+ }
2472
+ break;
2473
+ }
2474
+ if (subcommand === 'remove') {
2475
+ const id = interaction.options.getInteger('id', true);
2476
+ const success = scheduleService.removeSchedule(id);
2477
+ if (success) {
2478
+ await interaction.editReply({ content: `✅ Removed scheduled task ID: ${id}` });
2479
+ }
2480
+ else {
2481
+ await interaction.editReply({ content: `⚠️ Scheduled task ID ${id} not found.` });
2482
+ }
2483
+ break;
2484
+ }
2485
+ if (subcommand === 'clear') {
2486
+ const initialSchedules = scheduleService.listSchedules();
2487
+ const count = initialSchedules.length;
2488
+ if (count === 0) {
2489
+ await interaction.editReply({ content: '📅 No scheduled tasks found to clear.' });
2490
+ break;
2491
+ }
2492
+ const initialIdsJson = JSON.stringify(initialSchedules.map(s => s.id).sort((a, b) => a - b));
2493
+ const confirmBtnId = `schedule_clear_confirm_${interaction.id}`;
2494
+ const cancelBtnId = `schedule_clear_cancel_${interaction.id}`;
2495
+ const row = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder()
2496
+ .setCustomId(confirmBtnId)
2497
+ .setLabel(`${(0, i18n_1.t)('Confirm Clear')} (${count})`)
2498
+ .setStyle(discord_js_1.ButtonStyle.Danger), new discord_js_1.ButtonBuilder()
2499
+ .setCustomId(cancelBtnId)
2500
+ .setLabel((0, i18n_1.t)('Cancel'))
2501
+ .setStyle(discord_js_1.ButtonStyle.Secondary));
2502
+ const message = await interaction.editReply({
2503
+ content: `⚠️ **Warning**: This will delete all **${count}** scheduled task(s) and reset the ID counter to 0. Are you sure you want to proceed?`,
2504
+ components: [row]
2505
+ });
2506
+ const collector = message.createMessageComponentCollector({
2507
+ filter: (i) => i.user.id === interaction.user.id && (i.customId === confirmBtnId || i.customId === cancelBtnId),
2508
+ time: 30000,
2509
+ max: 1
2510
+ });
2511
+ collector.on('collect', async (i) => {
2512
+ if (i.customId === confirmBtnId) {
2513
+ const currentSchedules = scheduleService.listSchedules();
2514
+ const currentIdsJson = JSON.stringify(currentSchedules.map(s => s.id).sort((a, b) => a - b));
2515
+ if (initialIdsJson !== currentIdsJson) {
2516
+ await i.update({
2517
+ content: '⚠️ **Action aborted**: The scheduled tasks list changed while waiting for confirmation. No schedules were cleared.',
2518
+ components: []
2519
+ });
2520
+ return;
2521
+ }
2522
+ scheduleService.resetSchedules();
2523
+ await i.update({
2524
+ content: `✅ Successfully removed all **${count}** scheduled task(s) and reset the task ID counter to 0.`,
2525
+ components: []
2526
+ });
2527
+ }
2528
+ else {
2529
+ await i.update({
2530
+ content: '❌ Action cancelled. Scheduled tasks were not cleared.',
2531
+ components: []
2532
+ });
2533
+ }
2534
+ });
2535
+ collector.on('end', async (collected) => {
2536
+ if (collected.size === 0) {
2537
+ await interaction.editReply({
2538
+ content: '⚠️ Action timed out. Scheduled tasks were not cleared.',
2539
+ components: []
2540
+ }).catch(() => { });
2541
+ }
2542
+ });
2543
+ break;
2544
+ }
2545
+ if (subcommand === 'backup') {
2546
+ const json = scheduleService.backupSchedules();
2547
+ const buffer = Buffer.from(json, 'utf-8');
2548
+ await interaction.editReply({
2549
+ content: '📋 **LazyGravity Schedules Backup**',
2550
+ files: [{
2551
+ attachment: buffer,
2552
+ name: 'schedules_backup.json'
2553
+ }]
2554
+ });
2555
+ break;
2556
+ }
2557
+ if (subcommand === 'restore') {
2558
+ const attachment = interaction.options.getAttachment('file', true);
2559
+ if (!attachment.name.endsWith('.json')) {
2560
+ await interaction.editReply({ content: '❌ Attachment must be a `.json` file.' });
2561
+ break;
2562
+ }
2563
+ try {
2564
+ // Download file content using global fetch (available in Node 18+)
2565
+ const response = await fetch(attachment.url);
2566
+ if (!response.ok)
2567
+ throw new Error(`HTTP error! status: ${response.status}`);
2568
+ const jsonText = await response.text();
2569
+ const jobCb = scheduleService.getJobCallback();
2570
+ if (!jobCb) {
2571
+ await interaction.editReply({ content: '⚠️ Schedule service is still initializing. Please try again in a few seconds.' });
2572
+ break;
2573
+ }
2574
+ const restoredCount = scheduleService.restoreSchedules(jsonText, jobCb);
2575
+ await interaction.editReply({ content: `✅ Successfully restored ${restoredCount} scheduled tasks from backup!` });
2576
+ }
2577
+ catch (error) {
2578
+ await interaction.editReply({ content: `❌ Failed to restore schedules: ${error.message}` });
2579
+ }
2580
+ break;
2581
+ }
2582
+ break;
2583
+ }
2082
2584
  default:
2083
2585
  await interaction.editReply({
2084
2586
  content: `Unknown command: /${commandName}`,