lazy-gravity 0.0.4 → 0.2.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 (44) hide show
  1. package/README.md +22 -7
  2. package/dist/bin/cli.js +18 -18
  3. package/dist/bin/commands/doctor.js +25 -19
  4. package/dist/bin/commands/start.js +25 -2
  5. package/dist/bot/index.js +445 -126
  6. package/dist/commands/joinCommandHandler.js +302 -0
  7. package/dist/commands/joinDetachCommandHandler.js +285 -0
  8. package/dist/commands/registerSlashCommands.js +40 -0
  9. package/dist/commands/workspaceCommandHandler.js +17 -28
  10. package/dist/database/chatSessionRepository.js +10 -0
  11. package/dist/database/userPreferenceRepository.js +72 -0
  12. package/dist/events/interactionCreateHandler.js +338 -30
  13. package/dist/events/messageCreateHandler.js +161 -47
  14. package/dist/services/antigravityLauncher.js +4 -3
  15. package/dist/services/approvalDetector.js +7 -0
  16. package/dist/services/assistantDomExtractor.js +339 -0
  17. package/dist/services/cdpBridgeManager.js +323 -39
  18. package/dist/services/cdpConnectionPool.js +117 -33
  19. package/dist/services/cdpService.js +149 -53
  20. package/dist/services/chatSessionService.js +229 -8
  21. package/dist/services/errorPopupDetector.js +271 -0
  22. package/dist/services/planningDetector.js +318 -0
  23. package/dist/services/responseMonitor.js +308 -70
  24. package/dist/services/retryStore.js +46 -0
  25. package/dist/services/updateCheckService.js +147 -0
  26. package/dist/services/userMessageDetector.js +221 -0
  27. package/dist/ui/buttonUtils.js +33 -0
  28. package/dist/ui/modeUi.js +11 -1
  29. package/dist/ui/modelsUi.js +24 -13
  30. package/dist/ui/outputUi.js +30 -0
  31. package/dist/ui/projectListUi.js +83 -0
  32. package/dist/ui/sessionPickerUi.js +48 -0
  33. package/dist/utils/antigravityPaths.js +94 -0
  34. package/dist/utils/configLoader.js +18 -0
  35. package/dist/utils/discordButtonUtils.js +33 -0
  36. package/dist/utils/discordFormatter.js +149 -16
  37. package/dist/utils/htmlToDiscordMarkdown.js +184 -0
  38. package/dist/utils/logBuffer.js +47 -0
  39. package/dist/utils/logFileTransport.js +147 -0
  40. package/dist/utils/logger.js +86 -21
  41. package/dist/utils/pathUtils.js +57 -0
  42. package/dist/utils/plainTextFormatter.js +70 -0
  43. package/dist/utils/processLogBuffer.js +4 -0
  44. package/package.json +4 -4
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ResponseMonitor = exports.RESPONSE_SELECTORS = void 0;
4
4
  const logger_1 = require("../utils/logger");
5
+ const assistantDomExtractor_1 = require("./assistantDomExtractor");
5
6
  /** Lean DOM selectors for response extraction */
6
7
  exports.RESPONSE_SELECTORS = {
7
8
  /** Scored selector approach for extracting response text.
@@ -27,7 +28,7 @@ exports.RESPONSE_SELECTORS = {
27
28
  const looksLikeActivityLog = (text) => {
28
29
  const normalized = (text || '').trim().toLowerCase();
29
30
  if (!normalized) return false;
30
- const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|analyzed|read|wrote|ran)/i;
31
+ const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|fetching|connecting|creating|updating|deleting|installing|building|compiling|deploying|checking|scanning|parsing|resolving|downloading|uploading|analyzed|read|wrote|ran|created|updated|deleted|fetched|built|compiled|installed|resolved|downloaded|connected)\\b/i;
31
32
  if (activityPattern.test(normalized) && normalized.length <= 220) return true;
32
33
  if (/^initiating\\s/i.test(normalized) && normalized.length <= 500) return true;
33
34
  if (/^thought for\\s/i.test(normalized) && normalized.length <= 500) return true;
@@ -43,6 +44,8 @@ exports.RESPONSE_SELECTORS = {
43
44
  const isInsideExcludedContainer = (node) => {
44
45
  if (node.closest('details')) return true;
45
46
  if (node.closest('[class*="feedback"], footer')) return true;
47
+ if (node.closest('.notify-user-container')) return true;
48
+ if (node.closest('[role="dialog"]')) return true;
46
49
  return false;
47
50
  };
48
51
 
@@ -57,6 +60,15 @@ exports.RESPONSE_SELECTORS = {
57
60
  return false;
58
61
  };
59
62
 
63
+ const looksLikeQuotaPopup = (text) => {
64
+ var lower = (text || '').trim().toLowerCase();
65
+ // Inline error: "Error You have exhausted your quota on this model."
66
+ if (lower.includes('exhausted your quota') || lower.includes('exhausted quota')) return true;
67
+ // Popup: quota keyword + dismiss/upgrade button text
68
+ if (!lower.includes('model quota reached') && !lower.includes('quota exceeded') && !lower.includes('rate limit')) return false;
69
+ return lower.includes('dismiss') || lower.includes('upgrade');
70
+ };
71
+
60
72
  const combinedSelector = selectors.map((s) => s.sel).join(', ');
61
73
  const seen = new Set();
62
74
 
@@ -72,6 +84,7 @@ exports.RESPONSE_SELECTORS = {
72
84
  if (looksLikeActivityLog(text)) continue;
73
85
  if (looksLikeFeedbackFooter(text)) continue;
74
86
  if (looksLikeToolOutput(text)) continue;
87
+ if (looksLikeQuotaPopup(text)) continue;
75
88
  // Prefer recency first: return the newest acceptable node.
76
89
  return text;
77
90
  }
@@ -185,7 +198,7 @@ exports.RESPONSE_SELECTORS = {
185
198
  const looksLikeActivityLog = (text) => {
186
199
  const normalized = (text || '').trim().toLowerCase();
187
200
  if (!normalized) return false;
188
- const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|analyzed|read|wrote|ran)/i;
201
+ const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|fetching|connecting|creating|updating|deleting|installing|building|compiling|deploying|checking|scanning|parsing|resolving|downloading|uploading|analyzed|read|wrote|ran|created|updated|deleted|fetched|built|compiled|installed|resolved|downloaded|connected)\\b/i;
189
202
  if (activityPattern.test(normalized) && normalized.length <= 220) return true;
190
203
  if (/^initiating\\s/i.test(normalized) && normalized.length <= 500) return true;
191
204
  if (/^thought for\\s/i.test(normalized) && normalized.length <= 500) return true;
@@ -199,6 +212,8 @@ exports.RESPONSE_SELECTORS = {
199
212
  const isInsideExcludedContainer = (node) => {
200
213
  if (node.closest('details')) return true;
201
214
  if (node.closest('[class*="feedback"], footer')) return true;
215
+ if (node.closest('.notify-user-container')) return true;
216
+ if (node.closest('[role="dialog"]')) return true;
202
217
  return false;
203
218
  };
204
219
  const looksLikeToolOutput = (text) => {
@@ -229,6 +244,12 @@ exports.RESPONSE_SELECTORS = {
229
244
  else if (looksLikeActivityLog(text)) skip = 'activity-log';
230
245
  else if (looksLikeFeedbackFooter(text)) skip = 'feedback-footer';
231
246
  else if (looksLikeToolOutput(text)) skip = 'tool-output';
247
+ else {
248
+ var qlower = (text || '').trim().toLowerCase();
249
+ if (qlower.includes('exhausted your quota') || qlower.includes('exhausted quota')) skip = 'quota-popup';
250
+ else if ((qlower.includes('model quota reached') || qlower.includes('quota exceeded') || qlower.includes('rate limit'))
251
+ && (qlower.includes('dismiss') || qlower.includes('upgrade'))) skip = 'quota-popup';
252
+ }
232
253
  const classes = (node.className || '').toString().slice(0, 80);
233
254
  results.push({
234
255
  sel,
@@ -263,7 +284,7 @@ exports.RESPONSE_SELECTORS = {
263
284
  const looksLikeActivityLog = (text) => {
264
285
  const normalized = (text || '').trim().toLowerCase();
265
286
  if (!normalized) return false;
266
- const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|analyzed|read|wrote|ran)/i;
287
+ const activityPattern = /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|fetching|connecting|creating|updating|deleting|installing|building|compiling|deploying|checking|scanning|parsing|resolving|downloading|uploading|analyzed|read|wrote|ran|created|updated|deleted|fetched|built|compiled|installed|resolved|downloaded|connected)\\b/i;
267
288
  if (activityPattern.test(normalized) && normalized.length <= 220) return true;
268
289
  if (/^initiating\\s/i.test(normalized) && normalized.length <= 500) return true;
269
290
  if (/^thought for\\s/i.test(normalized) && normalized.length <= 500) return true;
@@ -284,6 +305,8 @@ exports.RESPONSE_SELECTORS = {
284
305
  const isInsideExcludedContainer = (node) => {
285
306
  if (node.closest('details')) return true;
286
307
  if (node.closest('[class*="feedback"], footer')) return true;
308
+ if (node.closest('.notify-user-container')) return true;
309
+ if (node.closest('[role="dialog"]')) return true;
287
310
  return false;
288
311
  };
289
312
 
@@ -309,11 +332,32 @@ exports.RESPONSE_SELECTORS = {
309
332
 
310
333
  return results;
311
334
  })()`,
312
- /** Quota error detection */
335
+ /** Quota error detection — text-based h3 span match first, class-based fallback second */
313
336
  QUOTA_ERROR: `(() => {
314
337
  const panel = document.querySelector('.antigravity-agent-side-panel');
315
338
  const scope = panel || document;
339
+ const QUOTA_KEYWORDS = ['model quota reached', 'rate limit', 'quota exceeded', 'exhausted your quota', 'exhausted quota'];
340
+ const isInsideResponse = (node) =>
341
+ node.closest('.rendered-markdown, .prose, pre, code, [data-message-author-role="assistant"], [data-message-role="assistant"], [class*="message-content"]');
316
342
 
343
+ // Primary: text-based detection via h3 span (Tailwind-only popup)
344
+ const headings = scope.querySelectorAll('h3 span, h3');
345
+ for (const el of headings) {
346
+ if (isInsideResponse(el)) continue;
347
+ const text = (el.textContent || '').trim().toLowerCase();
348
+ if (QUOTA_KEYWORDS.some(kw => text.includes(kw))) return true;
349
+ }
350
+
351
+ // Inline error: "Error You have exhausted your quota on this model."
352
+ // Appears in process log area as a span inside flex containers
353
+ const inlineSpans = scope.querySelectorAll('span');
354
+ for (const el of inlineSpans) {
355
+ if (isInsideResponse(el)) continue;
356
+ const text = (el.textContent || '').trim().toLowerCase();
357
+ if (text.includes('exhausted your quota') || text.includes('exhausted quota')) return true;
358
+ }
359
+
360
+ // Fallback: semantic class-based detection
317
361
  const errorSelectors = [
318
362
  '[role="alert"]',
319
363
  '[class*="error"]',
@@ -327,16 +371,67 @@ exports.RESPONSE_SELECTORS = {
327
371
  ];
328
372
  const errorElements = scope.querySelectorAll(errorSelectors.join(', '));
329
373
  for (const el of errorElements) {
330
- if (el.closest('.rendered-markdown, .prose, pre, code, [data-message-author-role="assistant"], [data-message-role="assistant"], [class*="message-content"]')) {
331
- continue;
332
- }
374
+ if (isInsideResponse(el)) continue;
333
375
  const text = (el.textContent || '').trim().toLowerCase();
334
- if (text.includes('model quota reached') || text.includes('rate limit') || text.includes('quota exceeded')) {
335
- return true;
336
- }
376
+ if (QUOTA_KEYWORDS.some(kw => text.includes(kw))) return true;
337
377
  }
338
378
  return false;
339
379
  })()`,
380
+ /** Structured DOM extraction — walks DOM to produce typed segment array */
381
+ RESPONSE_STRUCTURED: (0, assistantDomExtractor_1.extractAssistantSegmentsPayloadScript)(),
382
+ /** One-shot DOM diagnostic — dumps DOM structure around activity areas */
383
+ DOM_DIAGNOSTIC: `(() => {
384
+ var panel = document.querySelector('.antigravity-agent-side-panel');
385
+ var scope = panel || document;
386
+ var diag = { detailsCount: 0, detailsDump: [], activityNodes: [], allTextNodes: [] };
387
+
388
+ // 1. Dump all <details> elements
389
+ var details = scope.querySelectorAll('details');
390
+ diag.detailsCount = details.length;
391
+ for (var i = 0; i < Math.min(details.length, 5); i++) {
392
+ diag.detailsDump.push({
393
+ outerHTML: details[i].outerHTML.slice(0, 500),
394
+ summaryText: (details[i].querySelector('summary') || {}).textContent || '(no summary)',
395
+ childCount: details[i].children.length
396
+ });
397
+ }
398
+
399
+ // 2. Find all text nodes that look like activity
400
+ var selectors = '.rendered-markdown, .leading-relaxed.select-text, .flex.flex-col.gap-y-3, [data-message-author-role="assistant"], [data-message-role="assistant"], [class*="assistant-message"], [class*="message-content"], [class*="markdown-body"], .prose';
401
+ var nodes = scope.querySelectorAll(selectors);
402
+ for (var j = 0; j < nodes.length; j++) {
403
+ var text = (nodes[j].innerText || nodes[j].textContent || '').trim();
404
+ if (!text || text.length < 2) continue;
405
+ diag.allTextNodes.push({
406
+ tag: nodes[j].tagName,
407
+ className: (nodes[j].className || '').toString().slice(0, 100),
408
+ text: text.slice(0, 200),
409
+ insideDetails: !!nodes[j].closest('details'),
410
+ length: text.length
411
+ });
412
+ }
413
+
414
+ // 3. Broader scan: any element with activity-like text
415
+ var allEls = scope.querySelectorAll('*');
416
+ for (var k = 0; k < allEls.length; k++) {
417
+ var el = allEls[k];
418
+ if (el.children.length > 2) continue; // only leaf-ish nodes
419
+ var t = (el.textContent || '').trim();
420
+ if (!t || t.length < 5 || t.length > 300) continue;
421
+ var lower = t.toLowerCase();
422
+ if (/^(?:analy[sz]|read|writ|run|search|think|process|execut|debug|test)/i.test(lower) || /\\//.test(t)) {
423
+ diag.activityNodes.push({
424
+ tag: el.tagName,
425
+ className: (el.className || '').toString().slice(0, 100),
426
+ text: t.slice(0, 200),
427
+ parentTag: el.parentElement ? el.parentElement.tagName : null,
428
+ parentClass: el.parentElement ? (el.parentElement.className || '').toString().slice(0, 100) : null,
429
+ insideDetails: !!el.closest('details')
430
+ });
431
+ }
432
+ }
433
+ return diag;
434
+ })()`,
340
435
  };
341
436
  /**
342
437
  * Lean AI response monitor.
@@ -351,13 +446,13 @@ class ResponseMonitor {
351
446
  pollIntervalMs;
352
447
  maxDurationMs;
353
448
  stopGoneConfirmCount;
449
+ extractionMode;
354
450
  onProgress;
355
451
  onComplete;
356
452
  onTimeout;
357
453
  onPhaseChange;
358
454
  onProcessLog;
359
455
  pollTimer = null;
360
- timeoutTimer = null;
361
456
  isRunning = false;
362
457
  lastText = null;
363
458
  baselineText = null;
@@ -366,11 +461,21 @@ class ResponseMonitor {
366
461
  stopGoneCount = 0;
367
462
  quotaDetected = false;
368
463
  seenProcessLogKeys = new Set();
464
+ structuredDiagLogged = false;
465
+ // CDP disconnect handling (#48)
466
+ isPaused = false;
467
+ onCdpDisconnected = null;
468
+ onCdpReconnected = null;
469
+ onCdpReconnectFailed = null;
470
+ // Activity-based timeout (#49)
471
+ lastActivityTime = 0;
369
472
  constructor(options) {
370
473
  this.cdpService = options.cdpService;
371
474
  this.pollIntervalMs = options.pollIntervalMs ?? 2000;
372
475
  this.maxDurationMs = options.maxDurationMs ?? 300000;
373
476
  this.stopGoneConfirmCount = options.stopGoneConfirmCount ?? 3;
477
+ this.extractionMode = options.extractionMode
478
+ ?? (process.env.EXTRACTION_MODE === 'legacy' ? 'legacy' : 'structured');
374
479
  this.onProgress = options.onProgress;
375
480
  this.onComplete = options.onComplete;
376
481
  this.onTimeout = options.onTimeout;
@@ -379,18 +484,31 @@ class ResponseMonitor {
379
484
  }
380
485
  /** Start monitoring */
381
486
  async start() {
487
+ return this.initMonitoring(false);
488
+ }
489
+ /**
490
+ * Start monitoring in passive mode.
491
+ * Same as start() but with generationStarted=true, so text changes
492
+ * are detected immediately without waiting for the stop button to appear.
493
+ * Used when joining an existing session that may already be generating.
494
+ */
495
+ async startPassive() {
496
+ return this.initMonitoring(true);
497
+ }
498
+ /** Internal initialization shared between start() and startPassive() */
499
+ async initMonitoring(passive) {
382
500
  if (this.isRunning)
383
501
  return;
384
502
  this.isRunning = true;
503
+ this.isPaused = false;
385
504
  this.lastText = null;
386
505
  this.baselineText = null;
387
- this.generationStarted = false;
388
- this.currentPhase = 'waiting';
506
+ this.generationStarted = passive;
507
+ this.currentPhase = passive ? 'generating' : 'waiting';
389
508
  this.stopGoneCount = 0;
390
509
  this.quotaDetected = false;
391
510
  this.seenProcessLogKeys = new Set();
392
- // Always fire callback on start, even though phase is already 'waiting'
393
- this.onPhaseChange?.('waiting', null);
511
+ this.onPhaseChange?.(this.currentPhase, null);
394
512
  // Capture baseline text
395
513
  try {
396
514
  const baseResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_TEXT));
@@ -414,35 +532,44 @@ class ResponseMonitor {
414
532
  catch {
415
533
  // baseline capture only
416
534
  }
417
- // Set timeout timer
418
- if (this.maxDurationMs > 0) {
419
- this.timeoutTimer = setTimeout(async () => {
420
- const lastText = this.lastText ?? '';
421
- this.setPhase('timeout', lastText);
422
- await this.stop();
423
- try {
424
- await Promise.resolve(this.onTimeout?.(lastText));
425
- }
426
- catch (error) {
427
- logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
535
+ // In structured mode, also capture activity lines from the structured
536
+ // extraction to align the baseline with polling logic. The PROCESS_LOGS
537
+ // script skips <details> content, but structured extraction (Pass 2)
538
+ // explicitly walks <details> elements — without this, tool-call/thinking
539
+ // entries from previous turns leak into the process log as "new" entries.
540
+ if (this.extractionMode === 'structured') {
541
+ try {
542
+ const structuredBaseline = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_STRUCTURED));
543
+ const baselineClassified = (0, assistantDomExtractor_1.classifyAssistantSegments)(structuredBaseline?.result?.value);
544
+ if (baselineClassified.diagnostics.source === 'dom-structured') {
545
+ for (const line of baselineClassified.activityLines) {
546
+ const key = (line || '').replace(/\r/g, '').trim().slice(0, 200);
547
+ if (key)
548
+ this.seenProcessLogKeys.add(key);
549
+ }
428
550
  }
429
- }, this.maxDurationMs);
551
+ }
552
+ catch {
553
+ // structured baseline is best-effort
554
+ }
430
555
  }
431
- logger_1.logger.info(`── Monitoring started | poll=${this.pollIntervalMs}ms timeout=${this.maxDurationMs / 1000}s baseline=${this.baselineText?.length ?? 0}ch`);
432
- // Start polling
556
+ // Activity-based timeout: track last activity time instead of fixed timer (#49)
557
+ this.lastActivityTime = Date.now();
558
+ // Register CDP connection event listeners (#48)
559
+ this.registerCdpConnectionListeners();
560
+ const mode = passive ? 'Passive monitoring' : 'Monitoring';
561
+ logger_1.logger.debug(`── ${mode} started | poll=${this.pollIntervalMs}ms inactivityTimeout=${this.maxDurationMs / 1000}s baseline=${this.baselineText?.length ?? 0}ch`);
433
562
  this.schedulePoll();
434
563
  }
435
564
  /** Stop monitoring */
436
565
  async stop() {
437
566
  this.isRunning = false;
567
+ this.isPaused = false;
568
+ this.unregisterCdpConnectionListeners();
438
569
  if (this.pollTimer) {
439
570
  clearTimeout(this.pollTimer);
440
571
  this.pollTimer = null;
441
572
  }
442
- if (this.timeoutTimer) {
443
- clearTimeout(this.timeoutTimer);
444
- this.timeoutTimer = null;
445
- }
446
573
  }
447
574
  /** Get current phase */
448
575
  getPhase() {
@@ -494,14 +621,71 @@ class ResponseMonitor {
494
621
  case 'quotaReached':
495
622
  logger_1.logger.warn('Quota Reached');
496
623
  break;
624
+ case 'disconnected':
625
+ logger_1.logger.warn(`CDP Disconnected — paused (${len} chars captured)`);
626
+ break;
497
627
  default:
498
628
  logger_1.logger.phase(`${phase}`);
499
629
  }
500
630
  this.onPhaseChange?.(phase, text);
501
631
  }
502
632
  }
633
+ registerCdpConnectionListeners() {
634
+ this.onCdpDisconnected = () => {
635
+ if (!this.isRunning)
636
+ return;
637
+ logger_1.logger.warn('[ResponseMonitor] CDP disconnected — pausing poll');
638
+ this.isPaused = true;
639
+ if (this.pollTimer) {
640
+ clearTimeout(this.pollTimer);
641
+ this.pollTimer = null;
642
+ }
643
+ this.setPhase('disconnected', this.lastText);
644
+ };
645
+ this.onCdpReconnected = () => {
646
+ if (!this.isRunning)
647
+ return;
648
+ logger_1.logger.warn('[ResponseMonitor] CDP reconnected — resuming poll');
649
+ this.isPaused = false;
650
+ this.lastActivityTime = Date.now();
651
+ const resumePhase = this.generationStarted ? 'generating' : 'waiting';
652
+ this.setPhase(resumePhase, this.lastText);
653
+ this.schedulePoll();
654
+ };
655
+ this.onCdpReconnectFailed = async (err) => {
656
+ if (!this.isRunning)
657
+ return;
658
+ logger_1.logger.error('[ResponseMonitor] CDP reconnection failed — stopping monitor:', err.message);
659
+ const lastText = this.lastText ?? '';
660
+ this.setPhase('disconnected', lastText);
661
+ await this.stop();
662
+ try {
663
+ await Promise.resolve(this.onTimeout?.(lastText));
664
+ }
665
+ catch (error) {
666
+ logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
667
+ }
668
+ };
669
+ this.cdpService.on('disconnected', this.onCdpDisconnected);
670
+ this.cdpService.on('reconnected', this.onCdpReconnected);
671
+ this.cdpService.on('reconnectFailed', this.onCdpReconnectFailed);
672
+ }
673
+ unregisterCdpConnectionListeners() {
674
+ if (this.onCdpDisconnected) {
675
+ this.cdpService.removeListener('disconnected', this.onCdpDisconnected);
676
+ this.onCdpDisconnected = null;
677
+ }
678
+ if (this.onCdpReconnected) {
679
+ this.cdpService.removeListener('reconnected', this.onCdpReconnected);
680
+ this.onCdpReconnected = null;
681
+ }
682
+ if (this.onCdpReconnectFailed) {
683
+ this.cdpService.removeListener('reconnectFailed', this.onCdpReconnectFailed);
684
+ this.onCdpReconnectFailed = null;
685
+ }
686
+ }
503
687
  schedulePoll() {
504
- if (!this.isRunning)
688
+ if (!this.isRunning || this.isPaused)
505
689
  return;
506
690
  this.pollTimer = setTimeout(async () => {
507
691
  await this.poll();
@@ -523,11 +707,34 @@ class ResponseMonitor {
523
707
  return params;
524
708
  }
525
709
  /**
526
- * Single poll: exactly 4 CDP calls.
527
- * 1. Stop button check
528
- * 2. Quota error check
529
- * 3. Text extraction
530
- * 4. Process log extraction
710
+ * Emit new process log entries, deduplicating against previously seen keys.
711
+ */
712
+ emitNewProcessLogs(entries) {
713
+ const newEntries = [];
714
+ for (const line of entries) {
715
+ const normalized = (line || '').replace(/\r/g, '').trim();
716
+ if (!normalized)
717
+ continue;
718
+ const key = normalized.slice(0, 200);
719
+ if (this.seenProcessLogKeys.has(key))
720
+ continue;
721
+ this.seenProcessLogKeys.add(key);
722
+ newEntries.push(normalized.slice(0, 300));
723
+ }
724
+ if (newEntries.length > 0) {
725
+ this.lastActivityTime = Date.now();
726
+ try {
727
+ this.onProcessLog?.(newEntries.join('\n\n'));
728
+ }
729
+ catch {
730
+ // callback error
731
+ }
732
+ }
733
+ }
734
+ /**
735
+ * Single poll cycle.
736
+ * - Legacy mode: 4 CDP calls (stop, quota, text, process logs).
737
+ * - Structured mode: 3-4 CDP calls (stop, quota, structured; legacy text on fallback).
531
738
  */
532
739
  async poll() {
533
740
  try {
@@ -538,45 +745,62 @@ class ResponseMonitor {
538
745
  // 2. Quota error check
539
746
  const quotaResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.QUOTA_ERROR));
540
747
  const quotaDetected = quotaResult?.result?.value === true;
541
- // 3. Text extraction
542
- const textResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_TEXT));
543
- const rawText = textResult?.result?.value;
544
- const exceptionDetail = textResult?.result?.exceptionDetails ?? textResult?.exceptionDetails;
545
- if (exceptionDetail) {
546
- logger_1.logger.warn('[ResponseMonitor:poll] RESPONSE_TEXT threw:', exceptionDetail.text ?? JSON.stringify(exceptionDetail).slice(0, 200));
547
- }
548
- const currentText = typeof rawText === 'string' ? rawText.trim() || null : null;
549
- // 4. Process log extraction
550
- try {
551
- const logResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.PROCESS_LOGS));
552
- const logEntries = logResult?.result?.value;
553
- if (Array.isArray(logEntries)) {
554
- const newEntries = [];
555
- for (const raw of logEntries) {
556
- const normalized = (raw || '').replace(/\r/g, '').trim();
557
- if (!normalized)
558
- continue;
559
- const key = normalized.slice(0, 200);
560
- if (this.seenProcessLogKeys.has(key))
561
- continue;
562
- this.seenProcessLogKeys.add(key);
563
- newEntries.push(normalized.slice(0, 300));
564
- }
565
- if (newEntries.length > 0) {
566
- try {
567
- this.onProcessLog?.(newEntries.join('\n\n'));
748
+ // 3. Text extraction (structured or legacy)
749
+ let currentText = null;
750
+ let structuredHandledLogs = false;
751
+ if (this.extractionMode === 'structured') {
752
+ // Structured: use DOM segment extraction with HTML-to-Markdown
753
+ try {
754
+ const structuredResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_STRUCTURED));
755
+ const payload = structuredResult?.result?.value;
756
+ const classified = (0, assistantDomExtractor_1.classifyAssistantSegments)(payload);
757
+ if (classified.diagnostics.source === 'dom-structured') {
758
+ currentText = classified.finalOutputText.trim() || null;
759
+ structuredHandledLogs = true;
760
+ if (!this.structuredDiagLogged) {
761
+ this.structuredDiagLogged = true;
762
+ logger_1.logger.debug('[ResponseMonitor] Structured extraction OK — segments:', classified.diagnostics.segmentCounts);
568
763
  }
569
- catch {
570
- // callback error
764
+ // Emit structured activity lines as process logs
765
+ if (classified.activityLines.length > 0) {
766
+ this.emitNewProcessLogs(classified.activityLines);
571
767
  }
572
768
  }
769
+ else if (!this.structuredDiagLogged) {
770
+ this.structuredDiagLogged = true;
771
+ logger_1.logger.warn('[ResponseMonitor:poll] Structured extraction failed — reason:', classified.diagnostics.fallbackReason ?? 'unknown', '| payload type:', typeof payload, '| payload:', payload === null ? 'null' : payload === undefined ? 'undefined' : 'object');
772
+ }
773
+ }
774
+ catch (error) {
775
+ logger_1.logger.warn('[ResponseMonitor:poll] RESPONSE_STRUCTURED failed, falling back to legacy:', error);
573
776
  }
574
777
  }
575
- catch {
576
- // process log extraction is best-effort
778
+ // Legacy path (or fallback from structured)
779
+ if (currentText === null) {
780
+ const textResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.RESPONSE_TEXT));
781
+ const rawText = textResult?.result?.value;
782
+ const exceptionDetail = textResult?.result?.exceptionDetails ?? textResult?.exceptionDetails;
783
+ if (exceptionDetail) {
784
+ logger_1.logger.warn('[ResponseMonitor:poll] RESPONSE_TEXT threw:', exceptionDetail.text ?? JSON.stringify(exceptionDetail).slice(0, 200));
785
+ }
786
+ currentText = typeof rawText === 'string' ? rawText.trim() || null : null;
787
+ }
788
+ // 4. Process log extraction — always when structured didn't handle it
789
+ if (!structuredHandledLogs) {
790
+ try {
791
+ const logResult = await this.cdpService.call('Runtime.evaluate', this.buildEvaluateParams(exports.RESPONSE_SELECTORS.PROCESS_LOGS));
792
+ const logEntries = logResult?.result?.value;
793
+ if (Array.isArray(logEntries)) {
794
+ this.emitNewProcessLogs(logEntries);
795
+ }
796
+ }
797
+ catch {
798
+ // process log extraction is best-effort
799
+ }
577
800
  }
578
801
  // Handle stop button appearing
579
802
  if (isGenerating) {
803
+ this.lastActivityTime = Date.now();
580
804
  if (!this.generationStarted) {
581
805
  this.generationStarted = true;
582
806
  this.setPhase('thinking', null);
@@ -611,6 +835,7 @@ class ResponseMonitor {
611
835
  // Text change handling
612
836
  const textChanged = effectiveText !== null && effectiveText !== this.lastText;
613
837
  if (textChanged) {
838
+ this.lastActivityTime = Date.now();
614
839
  this.lastText = effectiveText;
615
840
  if (this.currentPhase === 'waiting' || this.currentPhase === 'thinking') {
616
841
  this.setPhase('generating', effectiveText);
@@ -636,6 +861,19 @@ class ResponseMonitor {
636
861
  return;
637
862
  }
638
863
  }
864
+ // Activity-based inactivity timeout (#49)
865
+ if (this.maxDurationMs > 0 && Date.now() - this.lastActivityTime >= this.maxDurationMs) {
866
+ const lastText = this.lastText ?? '';
867
+ this.setPhase('timeout', lastText);
868
+ await this.stop();
869
+ try {
870
+ await Promise.resolve(this.onTimeout?.(lastText));
871
+ }
872
+ catch (error) {
873
+ logger_1.logger.error('[ResponseMonitor] timeout callback failed:', error);
874
+ }
875
+ return;
876
+ }
639
877
  }
640
878
  catch (error) {
641
879
  logger_1.logger.error('[ResponseMonitor] poll error:', error);
@@ -0,0 +1,46 @@
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
+ }