lazy-gravity 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -108,6 +108,7 @@ Telegram commands use underscores instead of subcommand syntax (Telegram does no
108
108
 
109
109
  - `/project` — Manage workspace bindings (list, select, create)
110
110
  - `/project_create <name>` — Create a new workspace directory
111
+ - `/new` — Start a new chat session
111
112
  - `/template` — List prompt templates with execute buttons
112
113
  - `/template_add <name> <prompt>` — Add a new prompt template
113
114
  - `/template_delete <name>` — Delete a prompt template
package/dist/bin/cli.js CHANGED
File without changes
@@ -81,7 +81,7 @@ function openMacOS(port) {
81
81
  }
82
82
  function openWindows(port) {
83
83
  return new Promise((resolve, reject) => {
84
- (0, child_process_1.execFile)(`${APP_NAME}.exe`, [`--remote-debugging-port=${port}`], { shell: true }, (err) => {
84
+ (0, child_process_1.execFile)(APP_NAME, [`--remote-debugging-port=${port}`], { shell: true }, (err) => {
85
85
  if (err) {
86
86
  reject(new Error(`Failed to open ${APP_NAME}: ${err.message}`));
87
87
  return;
package/dist/bot/index.js CHANGED
@@ -965,6 +965,7 @@ const startBot = async (cliLogLevel) => {
965
965
  activeMonitors,
966
966
  botToken: config.telegramToken,
967
967
  botApi: telegramBot.api,
968
+ chatSessionService,
968
969
  });
969
970
  // Compose select handlers: project select + mode select
970
971
  const projectSelectHandler = (0, telegramProjectCommand_1.createTelegramSelectHandler)({
@@ -1020,6 +1021,7 @@ const startBot = async (cliLogLevel) => {
1020
1021
  { command: 'template_add', description: 'Add a prompt template' },
1021
1022
  { command: 'template_delete', description: 'Delete a prompt template' },
1022
1023
  { command: 'project_create', description: 'Create a new workspace' },
1024
+ { command: 'new', description: 'Start a new chat session' },
1023
1025
  { command: 'logs', description: 'Show recent log entries' },
1024
1026
  { command: 'stop', description: 'Interrupt active LLM generation' },
1025
1027
  { command: 'help', description: 'Show available commands' },
@@ -15,6 +15,7 @@
15
15
  * /autoaccept — Toggle auto-accept for approval dialogs
16
16
  * /template — List and execute prompt templates
17
17
  * /logs — Show recent log entries
18
+ * /new — Start a new chat session
18
19
  */
19
20
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
21
  if (k2 === undefined) k2 = k;
@@ -68,7 +69,7 @@ const logger_1 = require("../utils/logger");
68
69
  // ---------------------------------------------------------------------------
69
70
  // Known commands (used by both parser and /help output)
70
71
  // ---------------------------------------------------------------------------
71
- const KNOWN_COMMANDS = ['start', 'help', 'status', 'stop', 'ping', 'mode', 'model', 'screenshot', 'autoaccept', 'template', 'template_add', 'template_delete', 'project_create', 'logs'];
72
+ const KNOWN_COMMANDS = ['start', 'help', 'status', 'stop', 'ping', 'mode', 'model', 'screenshot', 'autoaccept', 'template', 'template_add', 'template_delete', 'project_create', 'logs', 'new'];
72
73
  /**
73
74
  * Parse a Telegram command from message text.
74
75
  *
@@ -147,6 +148,9 @@ async function handleTelegramCommand(deps, message, parsed) {
147
148
  case 'logs':
148
149
  await handleLogs(message, parsed.args);
149
150
  break;
151
+ case 'new':
152
+ await handleNew(deps, message);
153
+ break;
150
154
  default:
151
155
  // Should not happen — parser filters unknowns
152
156
  break;
@@ -183,6 +187,7 @@ async function handleHelp(message) {
183
187
  '/template_add — Add a prompt template',
184
188
  '/template_delete — Delete a prompt template',
185
189
  '/project_create — Create a new workspace',
190
+ '/new — Start a new chat session',
186
191
  '/logs — Show recent log entries',
187
192
  '/stop — Interrupt active LLM generation',
188
193
  '/ping — Check bot latency',
@@ -409,6 +414,51 @@ async function handleLogs(message, args) {
409
414
  const truncated = text.length > 4096 ? text.slice(0, 4090) + '\n...' : text;
410
415
  await message.reply({ text: truncated }).catch(logger_1.logger.error);
411
416
  }
417
+ async function handleNew(deps, message) {
418
+ if (!deps.chatSessionService) {
419
+ await message.reply({ text: 'Chat session service not available.' }).catch(logger_1.logger.error);
420
+ return;
421
+ }
422
+ // Resolve workspace binding for this chat
423
+ const chatId = message.channel.id;
424
+ const binding = deps.telegramBindingRepo?.findByChatId(chatId);
425
+ if (!binding) {
426
+ await message.reply({
427
+ text: 'No project is linked to this chat. Use /project to bind a workspace first.',
428
+ }).catch(logger_1.logger.error);
429
+ return;
430
+ }
431
+ // Resolve workspace path and connect to CDP
432
+ let cdp;
433
+ try {
434
+ const workspacePath = deps.workspaceService
435
+ ? deps.workspaceService.getWorkspacePath(binding.workspacePath)
436
+ : binding.workspacePath;
437
+ cdp = await deps.bridge.pool.getOrConnect(workspacePath);
438
+ }
439
+ catch (err) {
440
+ logger_1.logger.error('[TelegramCommand:new] CDP connection failed:', err?.message || err);
441
+ await message.reply({ text: 'Failed to connect to Antigravity.' }).catch(logger_1.logger.error);
442
+ return;
443
+ }
444
+ // Start a new chat session
445
+ try {
446
+ const result = await deps.chatSessionService.startNewChat(cdp);
447
+ if (result.ok) {
448
+ await message.reply({ text: 'New chat session started.' }).catch(logger_1.logger.error);
449
+ }
450
+ else {
451
+ logger_1.logger.warn('[TelegramCommand:new] startNewChat failed:', result.error);
452
+ await message.reply({
453
+ text: `Failed to start new chat: ${(0, telegramFormatter_1.escapeHtml)(result.error || 'unknown error')}`,
454
+ }).catch(logger_1.logger.error);
455
+ }
456
+ }
457
+ catch (err) {
458
+ logger_1.logger.error('[TelegramCommand:new] startNewChat threw:', err?.message || err);
459
+ await message.reply({ text: 'Failed to start new chat.' }).catch(logger_1.logger.error);
460
+ }
461
+ }
412
462
  // ---------------------------------------------------------------------------
413
463
  // Helpers
414
464
  // ---------------------------------------------------------------------------
@@ -17,6 +17,7 @@ const processLogBuffer_1 = require("../utils/processLogBuffer");
17
17
  const discordFormatter_1 = require("../utils/discordFormatter");
18
18
  const telegramProjectCommand_1 = require("./telegramProjectCommand");
19
19
  const telegramCommands_1 = require("./telegramCommands");
20
+ const telegramFormatter_1 = require("../platform/telegram/telegramFormatter");
20
21
  const defaultModelApplicator_1 = require("../services/defaultModelApplicator");
21
22
  const logger_1 = require("../utils/logger");
22
23
  const telegramImageHandler_1 = require("../utils/telegramImageHandler");
@@ -63,6 +64,7 @@ function createTelegramMessageHandler(deps) {
63
64
  workspaceService: deps.workspaceService,
64
65
  fetchQuota: deps.fetchQuota,
65
66
  activeMonitors: deps.activeMonitors,
67
+ chatSessionService: deps.chatSessionService,
66
68
  }, message, cmd);
67
69
  return;
68
70
  }
@@ -211,8 +213,10 @@ function createTelegramMessageHandler(deps) {
211
213
  }
212
214
  if (statusMsg && lastActivityLogText) {
213
215
  const elapsed = Math.round((Date.now() - startTime) / 1000);
216
+ // Escape HTML to prevent Telegram parse_mode errors
217
+ // (activity logs may contain <, >, & from code/paths)
214
218
  statusMsg.edit({
215
- text: `${lastActivityLogText}\n\n⏱️ ${elapsed}s`,
219
+ text: `${(0, telegramFormatter_1.escapeHtml)(lastActivityLogText)}\n\n⏱️ ${elapsed}s`,
216
220
  }).catch(() => { });
217
221
  }
218
222
  },
@@ -235,7 +239,7 @@ function createTelegramMessageHandler(deps) {
235
239
  // Update status message with final activity log
236
240
  if (statusMsg && finalLogText && finalLogText.trim().length > 0) {
237
241
  await statusMsg.edit({
238
- text: `${finalLogText}\n\n✅ Done in ${elapsed}s`,
242
+ text: `${(0, telegramFormatter_1.escapeHtml)(finalLogText)}\n\n✅ Done in ${elapsed}s`,
239
243
  }).catch(() => { });
240
244
  }
241
245
  else if (statusMsg) {
@@ -35,6 +35,10 @@ function componentRowsToInlineKeyboard(rows) {
35
35
  let buttons = [];
36
36
  for (const comp of row.components) {
37
37
  if (comp.type === 'button') {
38
+ // Telegram inline keyboards do not support disabled buttons;
39
+ // skip them so resolved overlays don't re-show clickable buttons.
40
+ if (comp.disabled)
41
+ continue;
38
42
  buttons = [...buttons, buttonDefToInline(comp)];
39
43
  }
40
44
  else if (comp.type === 'selectMenu') {
@@ -225,6 +225,26 @@ class CdpService extends events_1.EventEmitter {
225
225
  this.ws.send(JSON.stringify({ id, method, params }));
226
226
  });
227
227
  }
228
+ /**
229
+ * Try call(), and on WebSocket connection error,
230
+ * attempt a single on-demand reconnect then retry once.
231
+ * Non-connection errors (timeout, protocol) are NOT retried.
232
+ */
233
+ async callWithRetry(method, params = {}, timeoutMs = 10000) {
234
+ try {
235
+ return await this.call(method, params);
236
+ }
237
+ catch (error) {
238
+ const message = error instanceof Error ? error.message : String(error);
239
+ const isConnectionError = message === 'WebSocket is not connected' ||
240
+ message === 'WebSocket disconnected';
241
+ if (!isConnectionError) {
242
+ throw error;
243
+ }
244
+ await this.reconnectOnDemand(timeoutMs);
245
+ return await this.call(method, params);
246
+ }
247
+ }
228
248
  async disconnect() {
229
249
  // Stop reconnection attempts
230
250
  this.maxReconnectAttempts = 0;
@@ -644,6 +664,72 @@ class CdpService extends events_1.EventEmitter {
644
664
  logger_1.logger.error('[CdpService]', finalError.message);
645
665
  this.emit('reconnectFailed', finalError);
646
666
  }
667
+ /**
668
+ * Wait for an in-progress reconnection to complete.
669
+ * Resolves when 'reconnected' fires, rejects on 'reconnectFailed' or timeout.
670
+ */
671
+ waitForReconnection(timeoutMs = 15000) {
672
+ return new Promise((resolve, reject) => {
673
+ const timer = setTimeout(() => {
674
+ cleanup();
675
+ reject(new Error('WebSocket is not connected'));
676
+ }, timeoutMs);
677
+ const onReconnected = () => {
678
+ cleanup();
679
+ resolve();
680
+ };
681
+ const onFailed = (_err) => {
682
+ cleanup();
683
+ reject(new Error('WebSocket is not connected'));
684
+ };
685
+ const cleanup = () => {
686
+ clearTimeout(timer);
687
+ this.removeListener('reconnected', onReconnected);
688
+ this.removeListener('reconnectFailed', onFailed);
689
+ };
690
+ this.on('reconnected', onReconnected);
691
+ this.on('reconnectFailed', onFailed);
692
+ });
693
+ }
694
+ /** Shared promise to coalesce concurrent reconnectOnDemand() calls */
695
+ reconnectOnDemandPromise = null;
696
+ /**
697
+ * On-demand reconnect: if already reconnecting, wait; otherwise attempt once.
698
+ * Throws 'WebSocket is not connected' when no workspace path or reconnect fails.
699
+ */
700
+ async reconnectOnDemand(timeoutMs = 15000) {
701
+ if (this.isReconnecting) {
702
+ return this.waitForReconnection(timeoutMs);
703
+ }
704
+ if (!this.currentWorkspacePath) {
705
+ throw new Error('WebSocket is not connected');
706
+ }
707
+ // Coalesce concurrent calls
708
+ if (!this.reconnectOnDemandPromise) {
709
+ this.reconnectOnDemandPromise = (async () => {
710
+ try {
711
+ await this.discoverAndConnectForWorkspace(this.currentWorkspacePath);
712
+ }
713
+ catch {
714
+ throw new Error('WebSocket is not connected');
715
+ }
716
+ finally {
717
+ this.reconnectOnDemandPromise = null;
718
+ }
719
+ })();
720
+ }
721
+ let timer;
722
+ const timeoutPromise = new Promise((_, reject) => {
723
+ timer = setTimeout(() => reject(new Error('WebSocket is not connected')), timeoutMs);
724
+ });
725
+ try {
726
+ await Promise.race([this.reconnectOnDemandPromise, timeoutPromise]);
727
+ }
728
+ finally {
729
+ if (timer)
730
+ clearTimeout(timer);
731
+ }
732
+ }
647
733
  isConnected() {
648
734
  return this.isConnectedFlag;
649
735
  }
@@ -1163,7 +1249,7 @@ class CdpService extends events_1.EventEmitter {
1163
1249
  */
1164
1250
  async setUiMode(modeName) {
1165
1251
  if (!this.isConnectedFlag || !this.ws) {
1166
- throw new Error('Not connected to CDP. Call connect() first.');
1252
+ await this.reconnectOnDemand();
1167
1253
  }
1168
1254
  const safeMode = JSON.stringify(modeName);
1169
1255
  // Internal mode name -> Antigravity UI display name mapping
@@ -1330,7 +1416,7 @@ class CdpService extends events_1.EventEmitter {
1330
1416
  */
1331
1417
  async setUiModel(modelName) {
1332
1418
  if (!this.isConnectedFlag || !this.ws) {
1333
- throw new Error('Not connected to CDP. Call connect() first.');
1419
+ await this.reconnectOnDemand();
1334
1420
  }
1335
1421
  // DOM manipulation script: based on actual Antigravity UI DOM structure
1336
1422
  // Model list uses div.cursor-pointer elements with class 'px-2 py-1 flex items-center justify-between'
@@ -78,12 +78,20 @@ const SCRAPE_PAST_CONVERSATIONS_SCRIPT = `(() => {
78
78
  const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
79
79
  const normalize = (text) => (text || '').trim();
80
80
 
81
+ // Past Conversations opens as a floating QuickInput dialog, not inside the side panel.
82
+ // Try the visible QuickInput dialog first, then fall back to the side panel.
83
+ const quickInputPanels = Array.from(document.querySelectorAll('div[class*="bg-quickinput-background"]'));
84
+ const panel = quickInputPanels.find((el) => isVisible(el))
85
+ || document.querySelector('.antigravity-agent-side-panel');
86
+ if (!panel) return null;
87
+
81
88
  const items = [];
82
89
  const seen = new Set();
83
90
 
84
- // Find the scrollable conversation list container
85
- const containers = Array.from(document.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]'));
86
- const container = containers.find((c) => isVisible(c) && c.querySelectorAll('div[class*="cursor-pointer"]').length > 0) || document;
91
+ // Find the scrollable conversation list container within the side panel
92
+ const containers = Array.from(panel.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]'));
93
+ const container = containers.find((c) => isVisible(c) && c.querySelectorAll('div[class*="cursor-pointer"]').length > 0);
94
+ if (!container) return null;
87
95
 
88
96
  // Detect the "Other Conversations" section boundary.
89
97
  // Sessions below this header belong to other projects and must be excluded.
@@ -131,7 +139,11 @@ const SCRAPE_PAST_CONVERSATIONS_SCRIPT = `(() => {
131
139
  */
132
140
  const FIND_SHOW_MORE_BUTTON_SCRIPT = `(() => {
133
141
  const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
134
- const els = Array.from(document.querySelectorAll('div, span'));
142
+ const quickInputPanels = Array.from(document.querySelectorAll('div[class*="bg-quickinput-background"]'));
143
+ const root = quickInputPanels.find((el) => isVisible(el))
144
+ || document.querySelector('.antigravity-agent-side-panel')
145
+ || document;
146
+ const els = Array.from(root.querySelectorAll('div, span'));
135
147
  for (const el of els) {
136
148
  if (!isVisible(el)) continue;
137
149
  const text = (el.textContent || '').trim();
@@ -436,6 +448,7 @@ class ChatSessionService {
436
448
  * @returns Array of session list items (empty array on failure)
437
449
  */
438
450
  async listAllSessions(cdpService) {
451
+ let panelOpened = false;
439
452
  try {
440
453
  // Step 1: Find Past Conversations button
441
454
  const btnState = await this.evaluateOnAnyContext(cdpService, FIND_PAST_CONVERSATIONS_BUTTON_SCRIPT, false);
@@ -444,8 +457,32 @@ class ChatSessionService {
444
457
  }
445
458
  // Step 2: Click via CDP mouse events (reliable in Electron)
446
459
  await this.cdpMouseClick(cdpService, btnState.x, btnState.y);
447
- // Step 3: Wait for panel to render
448
- await new Promise((r) => setTimeout(r, 500));
460
+ panelOpened = true;
461
+ // Step 3: Wait for panel to render (poll for content, up to 3s)
462
+ const PANEL_READY_CHECK = `(() => {
463
+ const isVisible = (el) => !!el && el instanceof HTMLElement && el.offsetParent !== null;
464
+ const quickInputPanels = Array.from(document.querySelectorAll('div[class*="bg-quickinput-background"]'));
465
+ const panel = quickInputPanels.find((el) => isVisible(el))
466
+ || document.querySelector('.antigravity-agent-side-panel');
467
+ if (!panel) return false;
468
+ const containers = Array.from(
469
+ panel.querySelectorAll('div[class*="overflow-auto"], div[class*="overflow-y-scroll"]')
470
+ );
471
+ return containers.some((c) =>
472
+ isVisible(c) && c.querySelector('div[class*="cursor-pointer"]')
473
+ );
474
+ })()`;
475
+ let panelReady = false;
476
+ const deadline = Date.now() + 3000;
477
+ while (Date.now() < deadline) {
478
+ panelReady = Boolean(await this.evaluateOnAnyContext(cdpService, PANEL_READY_CHECK, false));
479
+ if (panelReady)
480
+ break;
481
+ await new Promise((r) => setTimeout(r, 200));
482
+ }
483
+ if (!panelReady) {
484
+ return [];
485
+ }
449
486
  // Step 4: Scrape sessions
450
487
  let scrapeResult = await this.evaluateOnAnyContext(cdpService, SCRAPE_PAST_CONVERSATIONS_SCRIPT, false);
451
488
  let sessions = scrapeResult?.sessions ?? [];
@@ -460,7 +497,22 @@ class ChatSessionService {
460
497
  sessions = scrapeResult?.sessions ?? [];
461
498
  }
462
499
  }
463
- // Step 7: Close panel with Escape
500
+ return sessions.slice(0, ChatSessionService.LIST_SESSIONS_TARGET);
501
+ }
502
+ catch (_) {
503
+ return [];
504
+ }
505
+ finally {
506
+ if (panelOpened) {
507
+ await this.closePanelWithEscape(cdpService);
508
+ }
509
+ }
510
+ }
511
+ /**
512
+ * Close the Past Conversations panel by sending Escape key events.
513
+ */
514
+ async closePanelWithEscape(cdpService) {
515
+ try {
464
516
  await cdpService.call('Input.dispatchKeyEvent', {
465
517
  type: 'keyDown', key: 'Escape', code: 'Escape',
466
518
  windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
@@ -469,11 +521,8 @@ class ChatSessionService {
469
521
  type: 'keyUp', key: 'Escape', code: 'Escape',
470
522
  windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27,
471
523
  });
472
- return sessions.slice(0, ChatSessionService.LIST_SESSIONS_TARGET);
473
- }
474
- catch (_) {
475
- return [];
476
524
  }
525
+ catch (_) { /* best-effort cleanup */ }
477
526
  }
478
527
  /**
479
528
  * Evaluate a script on the first context that returns a truthy value.
@@ -33,7 +33,7 @@ class ScreenshotService {
33
33
  if (options.captureBeyondViewport !== undefined) {
34
34
  params.captureBeyondViewport = options.captureBeyondViewport;
35
35
  }
36
- const result = await this.cdpService.call('Page.captureScreenshot', params);
36
+ const result = await this.cdpService.callWithRetry('Page.captureScreenshot', params);
37
37
  const base64Data = result?.data ?? '';
38
38
  if (!base64Data) {
39
39
  return {
@@ -73,7 +73,7 @@ class ScreenshotService {
73
73
  if (options.clip) {
74
74
  params.clip = options.clip;
75
75
  }
76
- const result = await this.cdpService.call('Page.captureScreenshot', params);
76
+ const result = await this.cdpService.callWithRetry('Page.captureScreenshot', params);
77
77
  return result?.data ?? null;
78
78
  }
79
79
  catch (error) {
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.COOLDOWN_MS = exports.UPDATE_CHECK_FILE = void 0;
37
37
  exports.shouldCheckForUpdates = shouldCheckForUpdates;
38
38
  exports.fetchLatestVersion = fetchLatestVersion;
39
+ exports.isGlobalInstall = isGlobalInstall;
39
40
  exports.checkForUpdates = checkForUpdates;
40
41
  const https = __importStar(require("https"));
41
42
  const fs = __importStar(require("fs"));
@@ -127,11 +128,25 @@ function compareSemver(a, b) {
127
128
  }
128
129
  return 0;
129
130
  }
131
+ /**
132
+ * Detect whether the process is running from a global npm install
133
+ * (as opposed to a local dev checkout via `ts-node`, `tsx`, etc.).
134
+ */
135
+ function isGlobalInstall() {
136
+ const execPath = process.argv[1] || '';
137
+ // Global installs run from a path containing node_modules
138
+ // Local dev runs from the source tree (no node_modules/.bin in argv[1])
139
+ const globalIndicators = ['/lib/node_modules/', '\\node_modules\\lazy-gravity\\'];
140
+ return globalIndicators.some((indicator) => execPath.includes(indicator));
141
+ }
130
142
  /**
131
143
  * Non-blocking update check. Call at startup (fire-and-forget).
132
144
  * Respects a 24-hour cooldown via a local cache file.
145
+ * Skipped when running from source (dev/local checkout).
133
146
  */
134
147
  async function checkForUpdates(currentVersion) {
148
+ if (!isGlobalInstall())
149
+ return;
135
150
  if (!shouldCheckForUpdates())
136
151
  return;
137
152
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-gravity",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Control Antigravity from anywhere — a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "start:built": "node dist/bin/cli.js",
28
28
  "dev": "ts-node-dev --respawn src/bin/cli.ts",
29
29
  "docs:diagram": "mmdc -i docs/diagrams/architecture.mmd -o docs/images/architecture.svg -b transparent",
30
- "prepublishOnly": "npm run build && npm run test"
30
+ "prepublishOnly": "npm run build"
31
31
  },
32
32
  "keywords": [
33
33
  "discord",
@@ -67,6 +67,7 @@
67
67
  "jest-environment-jsdom": "^30.2.0",
68
68
  "jsdom": "^28.1.0",
69
69
  "minimatch": "^10.2.1",
70
+ "semantic-release": "^25.0.3",
70
71
  "ts-jest": "^29.4.6",
71
72
  "ts-morph": "^27.0.2",
72
73
  "ts-node": "^10.9.2",
@@ -1,285 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.JoinDetachCommandHandler = void 0;
4
- const i18n_1 = require("../utils/i18n");
5
- const discord_js_1 = require("discord.js");
6
- const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
7
- const responseMonitor_1 = require("../services/responseMonitor");
8
- const sessionPickerUi_1 = require("../ui/sessionPickerUi");
9
- const logger_1 = require("../utils/logger");
10
- /** Maximum embed description length (Discord limit is 4096) */
11
- const MAX_EMBED_DESC = 4000;
12
- /**
13
- * Handler for /join and /mirror commands.
14
- *
15
- * /join — List Antigravity sessions and connect to one via a select menu.
16
- * /mirror — Toggle PC-to-Discord message mirroring ON/OFF.
17
- */
18
- class JoinDetachCommandHandler {
19
- chatSessionService;
20
- chatSessionRepo;
21
- bindingRepo;
22
- channelManager;
23
- pool;
24
- client;
25
- /** Active ResponseMonitors per workspace (for AI response mirroring) */
26
- activeResponseMonitors = new Map();
27
- constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, pool, client) {
28
- this.chatSessionService = chatSessionService;
29
- this.chatSessionRepo = chatSessionRepo;
30
- this.bindingRepo = bindingRepo;
31
- this.channelManager = channelManager;
32
- this.pool = pool;
33
- this.client = client;
34
- }
35
- /**
36
- * /join — Show session picker for the workspace bound to this channel.
37
- */
38
- async handleJoin(interaction, bridge) {
39
- const binding = this.bindingRepo.findByChannelId(interaction.channelId);
40
- const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
41
- const workspaceName = binding?.workspacePath ?? session?.workspacePath;
42
- if (!workspaceName) {
43
- await interaction.editReply({
44
- content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
45
- });
46
- return;
47
- }
48
- let cdp;
49
- try {
50
- cdp = await this.pool.getOrConnect(workspaceName);
51
- }
52
- catch (e) {
53
- await interaction.editReply({
54
- content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
55
- });
56
- return;
57
- }
58
- const sessions = await this.chatSessionService.listAllSessions(cdp);
59
- const { embeds, components } = (0, sessionPickerUi_1.buildSessionPickerUI)(sessions);
60
- await interaction.editReply({ embeds, components });
61
- }
62
- /**
63
- * Handle session selection from the /join picker.
64
- *
65
- * Flow:
66
- * 1. Check if a channel already exists for this session (by displayName)
67
- * 2. If yes → reply with a link to that channel
68
- * 3. If no → create a new channel, bind it, activate session, start mirroring
69
- */
70
- async handleJoinSelect(interaction, bridge) {
71
- const selectedTitle = interaction.values[0];
72
- const guild = interaction.guild;
73
- if (!guild) {
74
- await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
75
- return;
76
- }
77
- const binding = this.bindingRepo.findByChannelId(interaction.channelId);
78
- const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
79
- const workspaceName = binding?.workspacePath ?? session?.workspacePath;
80
- if (!workspaceName) {
81
- await interaction.editReply({ content: (0, i18n_1.t)('⚠️ No project is bound to this channel.') });
82
- return;
83
- }
84
- // Step 1: Check if a channel already exists for this session
85
- const existingSession = this.chatSessionRepo.findByDisplayName(workspaceName, selectedTitle);
86
- if (existingSession) {
87
- const embed = new discord_js_1.EmbedBuilder()
88
- .setTitle((0, i18n_1.t)('🔗 Session Already Connected'))
89
- .setDescription((0, i18n_1.t)(`This session already has a channel:\n→ <#${existingSession.channelId}>`))
90
- .setColor(0x3498DB)
91
- .setTimestamp();
92
- await interaction.editReply({ embeds: [embed], components: [] });
93
- return;
94
- }
95
- // Step 2: Connect to CDP
96
- let cdp;
97
- try {
98
- cdp = await this.pool.getOrConnect(workspaceName);
99
- }
100
- catch (e) {
101
- await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`) });
102
- return;
103
- }
104
- // Step 3: Activate the session in Antigravity
105
- const activateResult = await this.chatSessionService.activateSessionByTitle(cdp, selectedTitle);
106
- if (!activateResult.ok) {
107
- await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to join session: ${activateResult.error}`) });
108
- return;
109
- }
110
- // Step 4: Create a new Discord channel for this session
111
- const categoryResult = await this.channelManager.ensureCategory(guild, workspaceName);
112
- const categoryId = categoryResult.categoryId;
113
- const sessionNumber = this.chatSessionRepo.getNextSessionNumber(categoryId);
114
- const channelName = this.channelManager.sanitizeChannelName(`${sessionNumber}-${selectedTitle}`);
115
- const channelResult = await this.channelManager.createSessionChannel(guild, categoryId, channelName);
116
- const newChannelId = channelResult.channelId;
117
- // Step 5: Register binding and session
118
- this.bindingRepo.upsert({
119
- channelId: newChannelId,
120
- workspacePath: workspaceName,
121
- guildId: guild.id,
122
- });
123
- this.chatSessionRepo.create({
124
- channelId: newChannelId,
125
- categoryId,
126
- workspacePath: workspaceName,
127
- sessionNumber,
128
- guildId: guild.id,
129
- });
130
- this.chatSessionRepo.updateDisplayName(newChannelId, selectedTitle);
131
- // Step 6: Start mirroring (routes dynamically to all bound session channels)
132
- this.startMirroring(bridge, cdp, workspaceName);
133
- const embed = new discord_js_1.EmbedBuilder()
134
- .setTitle((0, i18n_1.t)('🔗 Joined Session'))
135
- .setDescription((0, i18n_1.t)(`Connected to: **${selectedTitle}**\n→ <#${newChannelId}>\n\n` +
136
- `📡 Mirroring is **ON** — PC messages will appear in the new channel.\n` +
137
- `Use \`/mirror\` to toggle.`))
138
- .setColor(0x2ECC71)
139
- .setTimestamp();
140
- await interaction.editReply({ embeds: [embed], components: [] });
141
- }
142
- /**
143
- * /mirror — Toggle mirroring ON/OFF for the current channel's workspace.
144
- */
145
- async handleMirror(interaction, bridge) {
146
- const binding = this.bindingRepo.findByChannelId(interaction.channelId);
147
- const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
148
- const workspaceName = binding?.workspacePath ?? session?.workspacePath;
149
- const dirName = workspaceName ? this.pool.extractDirName(workspaceName) : null;
150
- if (!dirName || !workspaceName) {
151
- await interaction.editReply({
152
- content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
153
- });
154
- return;
155
- }
156
- const detector = this.pool.getUserMessageDetector(dirName);
157
- if (detector?.isActive()) {
158
- // Turn OFF — stop user message detector and any active response monitor
159
- detector.stop();
160
- const responseMonitor = this.activeResponseMonitors.get(dirName);
161
- if (responseMonitor?.isActive()) {
162
- await responseMonitor.stop();
163
- this.activeResponseMonitors.delete(dirName);
164
- }
165
- const embed = new discord_js_1.EmbedBuilder()
166
- .setTitle((0, i18n_1.t)('📡 Mirroring OFF'))
167
- .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring has been stopped.'))
168
- .setColor(0x95A5A6)
169
- .setTimestamp();
170
- await interaction.editReply({ embeds: [embed] });
171
- }
172
- else {
173
- // Turn ON
174
- let cdp;
175
- try {
176
- cdp = await this.pool.getOrConnect(workspaceName);
177
- }
178
- catch (e) {
179
- await interaction.editReply({
180
- content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
181
- });
182
- return;
183
- }
184
- this.startMirroring(bridge, cdp, workspaceName);
185
- const embed = new discord_js_1.EmbedBuilder()
186
- .setTitle((0, i18n_1.t)('📡 Mirroring ON'))
187
- .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring is now active.\n' +
188
- 'Messages typed in Antigravity will appear in the corresponding session channel.'))
189
- .setColor(0x2ECC71)
190
- .setTimestamp();
191
- await interaction.editReply({ embeds: [embed] });
192
- }
193
- }
194
- /**
195
- * Start user message mirroring for a workspace.
196
- *
197
- * When a PC message is detected, the callback resolves the correct Discord
198
- * channel via chatSessionRepo.findByDisplayName. Only explicitly joined
199
- * sessions (with a displayName binding) receive mirrored messages.
200
- */
201
- startMirroring(bridge, cdp, workspaceName) {
202
- const dirName = this.pool.extractDirName(workspaceName);
203
- (0, cdpBridgeManager_1.ensureUserMessageDetector)(bridge, cdp, dirName, (info) => {
204
- this.routeMirroredMessage(cdp, dirName, workspaceName, info)
205
- .catch((err) => {
206
- logger_1.logger.error('[Mirror] Error routing mirrored message:', err);
207
- });
208
- });
209
- }
210
- /**
211
- * Route a mirrored PC message to the correct Discord channel and
212
- * start a passive ResponseMonitor to capture the AI response.
213
- *
214
- * Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
215
- * Sessions without an explicit channel binding are silently skipped.
216
- */
217
- async routeMirroredMessage(cdp, dirName, workspaceName, info) {
218
- const chatTitle = await (0, cdpBridgeManager_1.getCurrentChatTitle)(cdp);
219
- if (!chatTitle) {
220
- logger_1.logger.debug('[Mirror] No chat title detected, skipping');
221
- return;
222
- }
223
- const session = this.chatSessionRepo.findByDisplayName(workspaceName, chatTitle);
224
- if (!session) {
225
- logger_1.logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
226
- return;
227
- }
228
- const channel = this.client.channels.cache.get(session.channelId);
229
- if (!channel || !('send' in channel))
230
- return;
231
- const sendable = channel;
232
- // Mirror the user message
233
- const userEmbed = new discord_js_1.EmbedBuilder()
234
- .setDescription(`🖥️ ${info.text}`)
235
- .setColor(0x95A5A6)
236
- .setFooter({ text: `Typed in Antigravity · ${chatTitle}` })
237
- .setTimestamp();
238
- await sendable.send({ embeds: [userEmbed] }).catch((err) => {
239
- logger_1.logger.error('[Mirror] Failed to send user message:', err);
240
- });
241
- // Start passive ResponseMonitor to capture the AI response
242
- this.startResponseMirror(cdp, dirName, sendable, chatTitle);
243
- }
244
- /**
245
- * Start a passive ResponseMonitor that sends the AI response to Discord
246
- * when generation completes.
247
- */
248
- startResponseMirror(cdp, dirName, channel, chatTitle) {
249
- // Stop previous monitor if still running
250
- const prev = this.activeResponseMonitors.get(dirName);
251
- if (prev?.isActive()) {
252
- prev.stop().catch(() => { });
253
- }
254
- const monitor = new responseMonitor_1.ResponseMonitor({
255
- cdpService: cdp,
256
- pollIntervalMs: 2000,
257
- maxDurationMs: 300000,
258
- onComplete: (finalText) => {
259
- this.activeResponseMonitors.delete(dirName);
260
- if (!finalText || finalText.trim().length === 0)
261
- return;
262
- const text = finalText.length > MAX_EMBED_DESC
263
- ? finalText.slice(0, MAX_EMBED_DESC) + '\n…(truncated)'
264
- : finalText;
265
- const embed = new discord_js_1.EmbedBuilder()
266
- .setDescription(text)
267
- .setColor(0x5865F2)
268
- .setFooter({ text: `Antigravity response · ${chatTitle}` })
269
- .setTimestamp();
270
- channel.send({ embeds: [embed] }).catch((err) => {
271
- logger_1.logger.error('[Mirror] Failed to send AI response:', err);
272
- });
273
- },
274
- onTimeout: () => {
275
- this.activeResponseMonitors.delete(dirName);
276
- },
277
- });
278
- this.activeResponseMonitors.set(dirName, monitor);
279
- monitor.startPassive().catch((err) => {
280
- logger_1.logger.error('[Mirror] Failed to start response monitor:', err);
281
- this.activeResponseMonitors.delete(dirName);
282
- });
283
- }
284
- }
285
- exports.JoinDetachCommandHandler = JoinDetachCommandHandler;
@@ -1,46 +0,0 @@
1
- "use strict";
2
- // =============================================================================
3
- // Retry store — keeps retry info for the Retry button on errors
4
- // Extracted to avoid circular dependency between bot/index.ts and
5
- // interactionCreateHandler.ts.
6
- // =============================================================================
7
- Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.RETRY_BTN_PREFIX = void 0;
9
- exports.storeRetry = storeRetry;
10
- exports.getRetryInfo = getRetryInfo;
11
- exports.deleteRetryInfo = deleteRetryInfo;
12
- exports.RETRY_BTN_PREFIX = 'retry_prompt_';
13
- const MAX_RETRY_STORE_SIZE = 100;
14
- /** TTL for retry entries — matches Discord interaction token lifetime (15 min) */
15
- const RETRY_TTL_MS = 15 * 60 * 1000;
16
- const retryStore = new Map();
17
- /** Prune entries older than RETRY_TTL_MS */
18
- function pruneExpired() {
19
- const now = Date.now();
20
- for (const [k, v] of retryStore) {
21
- if (now - v.createdAt > RETRY_TTL_MS)
22
- retryStore.delete(k);
23
- }
24
- }
25
- function storeRetry(key, info) {
26
- pruneExpired();
27
- if (retryStore.size >= MAX_RETRY_STORE_SIZE) {
28
- const firstKey = retryStore.keys().next().value;
29
- if (firstKey !== undefined)
30
- retryStore.delete(firstKey);
31
- }
32
- retryStore.set(key, { ...info, createdAt: Date.now() });
33
- }
34
- function getRetryInfo(key) {
35
- const entry = retryStore.get(key);
36
- if (!entry)
37
- return undefined;
38
- if (Date.now() - entry.createdAt > RETRY_TTL_MS) {
39
- retryStore.delete(key);
40
- return undefined;
41
- }
42
- return entry;
43
- }
44
- function deleteRetryInfo(key) {
45
- retryStore.delete(key);
46
- }
@@ -1,33 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.disableAllButtons = disableAllButtons;
4
- const discord_js_1 = require("discord.js");
5
- /**
6
- * Disable all buttons in the given message component rows.
7
- * Returns new ActionRows with every button set to disabled.
8
- */
9
- function disableAllButtons(components) {
10
- return components
11
- .map((row) => {
12
- const rowAny = row;
13
- if (!Array.isArray(rowAny.components))
14
- return null;
15
- const nextRow = new discord_js_1.ActionRowBuilder();
16
- const disabledButtons = rowAny.components
17
- .map((component) => {
18
- const componentType = component?.type ?? component?.data?.type;
19
- if (componentType !== 2)
20
- return null;
21
- const payload = typeof component?.toJSON === 'function'
22
- ? component.toJSON()
23
- : component;
24
- return discord_js_1.ButtonBuilder.from(payload).setDisabled(true);
25
- })
26
- .filter((btn) => btn !== null);
27
- if (disabledButtons.length === 0)
28
- return null;
29
- nextRow.addComponents(...disabledButtons);
30
- return nextRow;
31
- })
32
- .filter((row) => row !== null);
33
- }
@@ -1,94 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.getAntigravityCliPath = getAntigravityCliPath;
37
- exports.getAntigravityFallback = getAntigravityFallback;
38
- exports.getAntigravityCdpHint = getAntigravityCdpHint;
39
- const os = __importStar(require("os"));
40
- const path = __importStar(require("path"));
41
- const APP_NAME = 'Antigravity';
42
- /**
43
- * Get the Antigravity CLI binary path for the current platform.
44
- *
45
- * - macOS: /Applications/Antigravity.app/Contents/Resources/app/bin/antigravity
46
- * - Windows: %LOCALAPPDATA%\Programs\Antigravity\Antigravity.exe
47
- * - Linux: antigravity (assumed in PATH)
48
- */
49
- function getAntigravityCliPath() {
50
- switch (process.platform) {
51
- case 'darwin':
52
- return '/Applications/Antigravity.app/Contents/Resources/app/bin/antigravity';
53
- case 'win32': {
54
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
55
- return path.join(localAppData, 'Programs', APP_NAME, `${APP_NAME}.exe`);
56
- }
57
- default:
58
- return APP_NAME.toLowerCase();
59
- }
60
- }
61
- /**
62
- * Get fallback launch command and args for opening a workspace.
63
- *
64
- * - macOS: open -a Antigravity <path>
65
- * - Windows: use full exe path with shell (handles spaces in paths)
66
- * - Linux: antigravity <path>
67
- */
68
- function getAntigravityFallback(workspacePath) {
69
- switch (process.platform) {
70
- case 'darwin':
71
- return { command: 'open', args: ['-a', APP_NAME, workspacePath] };
72
- case 'win32': {
73
- const exePath = getAntigravityCliPath();
74
- return { command: exePath, args: [workspacePath], options: { shell: true } };
75
- }
76
- default:
77
- return { command: APP_NAME.toLowerCase(), args: [workspacePath] };
78
- }
79
- }
80
- /**
81
- * Get a platform-appropriate hint for starting Antigravity with CDP.
82
- *
83
- * Used in user-facing messages (Discord embeds, CLI doctor, logs).
84
- */
85
- function getAntigravityCdpHint(port = 9222) {
86
- switch (process.platform) {
87
- case 'darwin':
88
- return `open -a ${APP_NAME} --args --remote-debugging-port=${port}`;
89
- case 'win32':
90
- return `${APP_NAME}.exe --remote-debugging-port=${port}`;
91
- default:
92
- return `${APP_NAME.toLowerCase()} --remote-debugging-port=${port}`;
93
- }
94
- }
@@ -1,147 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.LogFileTransportImpl = void 0;
37
- const fs = __importStar(require("fs"));
38
- const path = __importStar(require("path"));
39
- const os = __importStar(require("os"));
40
- const DEFAULT_LOG_DIR = path.join(os.homedir(), '.lazy-gravity', 'logs');
41
- const LOG_FILE_PREFIX = 'lazy-gravity-';
42
- const LOG_FILE_EXT = '.log';
43
- /** Maximum number of log files to keep (default 14 days). */
44
- const DEFAULT_MAX_FILES = 14;
45
- /** Maximum size in bytes for a single log file (default 10 MB). */
46
- const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;
47
- function formatDate(date) {
48
- const y = date.getFullYear();
49
- const m = String(date.getMonth() + 1).padStart(2, '0');
50
- const d = String(date.getDate()).padStart(2, '0');
51
- return `${y}-${m}-${d}`;
52
- }
53
- function buildFileName(dateStr) {
54
- return `${LOG_FILE_PREFIX}${dateStr}${LOG_FILE_EXT}`;
55
- }
56
- class LogFileTransportImpl {
57
- logDir;
58
- currentDate;
59
- currentFilePath;
60
- constructor(logDir = DEFAULT_LOG_DIR) {
61
- this.logDir = logDir;
62
- this.currentDate = formatDate(new Date());
63
- this.currentFilePath = path.join(this.logDir, buildFileName(this.currentDate));
64
- this.ensureDir();
65
- this.scheduleCleanup();
66
- }
67
- write(level, timestamp, message) {
68
- this.rollIfNeeded();
69
- const line = `${timestamp} [${level}] ${message}\n`;
70
- try {
71
- fs.appendFileSync(this.currentFilePath, line, 'utf-8');
72
- }
73
- catch {
74
- // Silently ignore write errors to avoid crashing the bot
75
- }
76
- }
77
- /**
78
- * Remove old log files that exceed maxFiles count or maxSizeBytes per file.
79
- * Runs asynchronously to avoid blocking startup.
80
- */
81
- cleanup(maxFiles = DEFAULT_MAX_FILES, maxSizeBytes = DEFAULT_MAX_SIZE_BYTES) {
82
- setImmediate(() => {
83
- try {
84
- this.cleanupSync(maxFiles, maxSizeBytes);
85
- }
86
- catch {
87
- // Silently ignore cleanup errors
88
- }
89
- });
90
- }
91
- /** Synchronous cleanup for testability. */
92
- cleanupSync(maxFiles = DEFAULT_MAX_FILES, maxSizeBytes = DEFAULT_MAX_SIZE_BYTES) {
93
- if (!fs.existsSync(this.logDir))
94
- return;
95
- const entries = fs
96
- .readdirSync(this.logDir)
97
- .filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith(LOG_FILE_EXT))
98
- .sort(); // chronological order (YYYY-MM-DD sorts naturally)
99
- // Remove files exceeding size limit
100
- for (const entry of entries) {
101
- const filePath = path.join(this.logDir, entry);
102
- try {
103
- const stat = fs.statSync(filePath);
104
- if (stat.size > maxSizeBytes) {
105
- fs.unlinkSync(filePath);
106
- }
107
- }
108
- catch {
109
- // Ignore stat/unlink errors
110
- }
111
- }
112
- // Re-read after size-based cleanup
113
- const remaining = fs
114
- .readdirSync(this.logDir)
115
- .filter((f) => f.startsWith(LOG_FILE_PREFIX) && f.endsWith(LOG_FILE_EXT))
116
- .sort();
117
- // Remove oldest files if count exceeds limit
118
- const excess = remaining.length - maxFiles;
119
- if (excess > 0) {
120
- for (let i = 0; i < excess; i++) {
121
- const filePath = path.join(this.logDir, remaining[i]);
122
- try {
123
- fs.unlinkSync(filePath);
124
- }
125
- catch {
126
- // Ignore unlink errors
127
- }
128
- }
129
- }
130
- }
131
- ensureDir() {
132
- if (!fs.existsSync(this.logDir)) {
133
- fs.mkdirSync(this.logDir, { recursive: true });
134
- }
135
- }
136
- rollIfNeeded() {
137
- const today = formatDate(new Date());
138
- if (today !== this.currentDate) {
139
- this.currentDate = today;
140
- this.currentFilePath = path.join(this.logDir, buildFileName(today));
141
- }
142
- }
143
- scheduleCleanup() {
144
- this.cleanup();
145
- }
146
- }
147
- exports.LogFileTransportImpl = LogFileTransportImpl;