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
@@ -0,0 +1,94 @@
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
+ }
@@ -92,6 +92,8 @@ function mergeConfig(persisted) {
92
92
  const workspaceBaseDir = expandTilde(rawDir);
93
93
  const guildId = process.env.GUILD_ID ?? persisted.guildId ?? undefined;
94
94
  const autoApproveFileEdits = resolveBoolean(process.env.AUTO_APPROVE_FILE_EDITS, persisted.autoApproveFileEdits, false);
95
+ const logLevel = resolveLogLevel(process.env.LOG_LEVEL, persisted.logLevel);
96
+ const extractionMode = resolveExtractionMode(process.env.EXTRACTION_MODE, persisted.extractionMode);
95
97
  return {
96
98
  discordToken: token,
97
99
  clientId,
@@ -99,6 +101,8 @@ function mergeConfig(persisted) {
99
101
  allowedUserIds,
100
102
  workspaceBaseDir,
101
103
  autoApproveFileEdits,
104
+ logLevel,
105
+ extractionMode,
102
106
  };
103
107
  }
104
108
  function resolveAllowedUserIds(persisted) {
@@ -114,6 +118,20 @@ function resolveAllowedUserIds(persisted) {
114
118
  }
115
119
  return [];
116
120
  }
121
+ const VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'none'];
122
+ function resolveLogLevel(envValue, persistedValue) {
123
+ const raw = envValue?.toLowerCase() ?? persistedValue;
124
+ if (raw && VALID_LOG_LEVELS.includes(raw)) {
125
+ return raw;
126
+ }
127
+ return 'info';
128
+ }
129
+ function resolveExtractionMode(envValue, persistedValue) {
130
+ const raw = envValue ?? persistedValue;
131
+ if (raw === 'legacy')
132
+ return 'legacy';
133
+ return 'structured';
134
+ }
117
135
  function resolveBoolean(envValue, persistedValue, defaultValue) {
118
136
  if (envValue !== undefined)
119
137
  return envValue.toLowerCase() === 'true';
@@ -0,0 +1,33 @@
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 message component rows.
7
+ * Shared utility used by interaction handlers and detector callbacks.
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((button) => button !== null);
27
+ if (disabledButtons.length === 0)
28
+ return null;
29
+ nextRow.addComponents(...disabledButtons);
30
+ return nextRow;
31
+ })
32
+ .filter((row) => row !== null);
33
+ }
@@ -5,6 +5,7 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.formatForDiscord = formatForDiscord;
7
7
  exports.splitOutputAndLogs = splitOutputAndLogs;
8
+ exports.separateOutputForDelivery = separateOutputForDelivery;
8
9
  exports.sanitizeActivityLines = sanitizeActivityLines;
9
10
  /** Known UI chrome literal strings (exact match after trim + lowercase) */
10
11
  const UI_CHROME_LITERALS = new Set([
@@ -54,6 +55,9 @@ const UI_CHROME_REGEXES = [
54
55
  /^[a-z0-9._-]+\s*\/\s*[a-z0-9._-]+$/i, // MCP server/tool format: "jina-mcp-server / search_web"
55
56
  /^full output written to\b/i, // tool result redirect: "Full output written to ..."
56
57
  /^output\.[a-z0-9._-]+(?:#l\d+(?:-\d+)?)?$/i, // output file ref: "output.txt#L1-131"
58
+ /^\s*\{\s*$/, // lone JSON opening brace
59
+ /^\s*\}\s*$/, // lone JSON closing brace
60
+ /^\s*"[^"]*"\s*:\s*/, // JSON key-value line: "query": "..."
57
61
  ];
58
62
  /**
59
63
  * Check if a line is UI chrome (not real assistant output).
@@ -71,17 +75,31 @@ function isUiChromeLine(line) {
71
75
  }
72
76
  return false;
73
77
  }
78
+ /** Regex matching file references like src/bot/index.ts:54 or tests/utils/config.ts.
79
+ * Consumes one trailing space so the replacement `\`ref\`` doesn't leave a double space. */
80
+ const FILE_REF_REGEX = /(?<![`/\\])(\b[a-zA-Z][\w.-]*(?:\/[\w.-]+)+(?::\d+(?:-\d+)?)?)\s?(?!`)/g;
74
81
  /**
75
82
  * Format text for Discord Embed display.
76
83
  * Wraps table lines and tree lines in code blocks.
84
+ * Wraps file references (e.g. src/bot/index.ts:54) in inline code backticks.
77
85
  */
78
86
  function formatForDiscord(text) {
79
87
  const lines = text.split('\n');
80
88
  const result = [];
81
89
  let inSpecialBlock = false;
90
+ let inCodeBlock = false;
82
91
  for (let i = 0; i < lines.length; i++) {
83
92
  const line = lines[i];
84
93
  const trimmed = line.trim();
94
+ if (trimmed.startsWith('```')) {
95
+ inCodeBlock = !inCodeBlock;
96
+ result.push(line);
97
+ continue;
98
+ }
99
+ if (inCodeBlock) {
100
+ result.push(line);
101
+ continue;
102
+ }
85
103
  const isTableLine = (trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.length > 2) ||
86
104
  /^\|[\s\-:]+\|/.test(trimmed);
87
105
  const isTreeLine = /[├└│┌┐┘┤┬┴┼]/.test(line) ||
@@ -99,10 +117,10 @@ function formatForDiscord(text) {
99
117
  else if (!isSpecialLine && inSpecialBlock) {
100
118
  result.push('```');
101
119
  inSpecialBlock = false;
102
- result.push(line);
120
+ result.push(wrapFileReferences(line));
103
121
  }
104
122
  else {
105
- result.push(line);
123
+ result.push(wrapFileReferences(line));
106
124
  }
107
125
  }
108
126
  if (inSpecialBlock) {
@@ -110,47 +128,162 @@ function formatForDiscord(text) {
110
128
  }
111
129
  return result.join('\n');
112
130
  }
131
+ /**
132
+ * Wrap file references (e.g. src/bot/index.ts:54) in inline code backticks.
133
+ */
134
+ function wrapFileReferences(line) {
135
+ return line.replace(FILE_REF_REGEX, '`$1`');
136
+ }
137
+ /** Check if a line is an MCP tool call format (server / tool_name) */
138
+ function isMcpFormatLine(line) {
139
+ return /^[a-z0-9._-]+\s*\/\s*[a-z0-9._-]+$/i.test(line);
140
+ }
141
+ /**
142
+ * Check if a line is a multi-word activity log (e.g. "Analyzing current workspace").
143
+ * Used only by splitOutputAndLogs — NOT by sanitizeActivityLines, which needs
144
+ * to keep these lines in the activity log output.
145
+ */
146
+ function isActivityLogLine(line) {
147
+ const trimmed = (line || '').trim();
148
+ if (!trimmed)
149
+ return false;
150
+ return /^(?:analy[sz]ing|reading|writing|running|searching|planning|thinking|processing|loading|executing|testing|debugging|analyzed|read|wrote|ran)\s+.+/i.test(trimmed)
151
+ && trimmed.length <= 220;
152
+ }
113
153
  /**
114
154
  * Split raw text into output (real content) and logs (UI chrome).
115
155
  * Code blocks are always preserved as output.
156
+ *
157
+ * When tool call blocks are detected (MCP format lines), applies a
158
+ * "last paragraph wins" heuristic: only the final block of non-chrome
159
+ * text is treated as the assistant's answer; everything between tool
160
+ * call regions is treated as thinking text and moved to logs.
116
161
  */
117
162
  function splitOutputAndLogs(rawText) {
118
163
  const normalized = (rawText || '').replace(/\r/g, '');
119
164
  if (!normalized.trim()) {
120
165
  return { output: '', logs: '' };
121
166
  }
122
- const outputLines = [];
123
- const logLines = [];
124
- let inCodeBlock = false;
125
167
  const lines = normalized.split('\n');
168
+ const classes = [];
169
+ let inCodeBlock = false;
126
170
  for (const line of lines) {
127
171
  const trimmed = (line || '').trim();
128
172
  if (trimmed.startsWith('```')) {
129
173
  inCodeBlock = !inCodeBlock;
130
- outputLines.push(line);
174
+ classes.push('code');
131
175
  continue;
132
176
  }
133
177
  if (inCodeBlock) {
134
- outputLines.push(line);
178
+ classes.push('code');
135
179
  continue;
136
180
  }
137
181
  if (!trimmed) {
138
- outputLines.push(line);
182
+ classes.push('blank');
139
183
  continue;
140
184
  }
141
- if (isUiChromeLine(trimmed)) {
142
- logLines.push(trimmed);
185
+ if (isUiChromeLine(trimmed) || isActivityLogLine(trimmed)) {
186
+ classes.push('chrome');
143
187
  }
144
188
  else {
145
- outputLines.push(line);
189
+ classes.push('output');
190
+ }
191
+ }
192
+ // Check if tool calls are present — triggers "last paragraph wins" heuristic
193
+ const hasMcpCalls = lines.some((l, i) => classes[i] === 'chrome' && isMcpFormatLine(l.trim()));
194
+ if (!hasMcpCalls) {
195
+ // Simple path: no tool calls, line-by-line separation
196
+ const outputLines = [];
197
+ const logLines = [];
198
+ for (let i = 0; i < lines.length; i++) {
199
+ if (classes[i] === 'chrome') {
200
+ logLines.push(lines[i].trim());
201
+ }
202
+ else {
203
+ outputLines.push(lines[i]);
204
+ }
205
+ }
206
+ return {
207
+ output: collapseBlankLines(outputLines.join('\n')),
208
+ logs: collapseBlankLines(logLines.join('\n')),
209
+ };
210
+ }
211
+ // Complex path: tool calls present — only the LAST paragraph is output.
212
+ // Scan bottom-up to find the last non-chrome text line.
213
+ let lastOutputEnd = -1;
214
+ for (let i = lines.length - 1; i >= 0; i--) {
215
+ if (classes[i] === 'output' || classes[i] === 'code') {
216
+ lastOutputEnd = i;
217
+ break;
146
218
  }
147
219
  }
148
- const normalizeText = (text) => text
149
- .replace(/\n{3,}/g, '\n\n')
150
- .trim();
220
+ if (lastOutputEnd === -1) {
221
+ // No output lines at all
222
+ const logLines = lines.filter((_, i) => classes[i] === 'chrome').map((l) => l.trim());
223
+ return { output: '', logs: collapseBlankLines(logLines.join('\n')) };
224
+ }
225
+ // Walk upward from lastOutputEnd to find the start of the final paragraph.
226
+ // Stop at any blank line or chrome line — in the legacy tool-call path, only
227
+ // the immediate last paragraph is preserved as output. Multi-paragraph final
228
+ // answers are handled correctly by the structured extraction mode (Phase 1).
229
+ let lastOutputStart = lastOutputEnd;
230
+ for (let i = lastOutputEnd - 1; i >= 0; i--) {
231
+ if (classes[i] === 'blank' || classes[i] === 'chrome') {
232
+ break;
233
+ }
234
+ if (classes[i] === 'output' || classes[i] === 'code') {
235
+ lastOutputStart = i;
236
+ }
237
+ }
238
+ const outputLines = [];
239
+ const logLines = [];
240
+ for (let i = 0; i < lines.length; i++) {
241
+ if (i >= lastOutputStart && i <= lastOutputEnd) {
242
+ // Inside the final answer block — keep non-chrome lines as output
243
+ if (classes[i] !== 'chrome') {
244
+ outputLines.push(lines[i]);
245
+ }
246
+ else {
247
+ logLines.push(lines[i].trim());
248
+ }
249
+ }
250
+ else if (classes[i] === 'chrome' || classes[i] === 'output') {
251
+ // Outside the final block — chrome or thinking text → logs
252
+ logLines.push(lines[i].trim());
253
+ }
254
+ // blank lines outside the final block are dropped
255
+ }
256
+ return {
257
+ output: collapseBlankLines(outputLines.join('\n')),
258
+ logs: collapseBlankLines(logLines.join('\n')),
259
+ };
260
+ }
261
+ /** Collapse 3+ consecutive newlines to 2, and trim */
262
+ function collapseBlankLines(text) {
263
+ return text.replace(/\n{3,}/g, '\n\n').trim();
264
+ }
265
+ /**
266
+ * Separate raw text into output and logs, choosing between DOM-structured
267
+ * extraction and legacy string-based separation.
268
+ *
269
+ * When domSource is 'dom-structured', DOM results are used directly.
270
+ * When domSource is 'legacy-fallback', falls back to splitOutputAndLogs().
271
+ */
272
+ function separateOutputForDelivery(options) {
273
+ const { rawText, domSource, domOutputText, domActivityLines } = options;
274
+ if (domSource === 'dom-structured' && domOutputText !== undefined) {
275
+ return {
276
+ source: 'dom-structured',
277
+ output: domOutputText,
278
+ logs: (domActivityLines ?? []).join('\n'),
279
+ };
280
+ }
281
+ // Fallback to legacy string-based separation
282
+ const separated = splitOutputAndLogs(rawText);
151
283
  return {
152
- output: normalizeText(outputLines.join('\n')),
153
- logs: normalizeText(logLines.join('\n')),
284
+ source: 'legacy-fallback',
285
+ output: separated.output,
286
+ logs: separated.logs,
154
287
  };
155
288
  }
156
289
  /**
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ /**
3
+ * Lightweight HTML-to-Discord-Markdown converter.
4
+ * Runs on Node.js (no browser DOM required).
5
+ * Converts common HTML tags to Discord-compatible Markdown.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.htmlToDiscordMarkdown = htmlToDiscordMarkdown;
9
+ /**
10
+ * Convert an HTML string to Discord-compatible Markdown.
11
+ *
12
+ * Supported conversions:
13
+ * - <h1>-<h3> → # - ###
14
+ * - <strong>/<b> → **...**
15
+ * - <em>/<i> → *...*
16
+ * - <code> → `...`
17
+ * - <pre><code> → ```\n...\n```
18
+ * - <ol><li> → 1. item
19
+ * - <ul><li> → - item
20
+ * - <p> → \n\n
21
+ * - <br> → \n
22
+ * - <span class="context-scope-mention"> → `text`
23
+ * - Elements with file-path title attribute → title + text
24
+ * - <style> → removed entirely
25
+ * - All other tags → stripped, text preserved
26
+ */
27
+ function htmlToDiscordMarkdown(html) {
28
+ if (!html)
29
+ return '';
30
+ let result = html;
31
+ // Remove <style> tags and their content
32
+ result = result.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
33
+ // Handle <br> and <br/>
34
+ result = result.replace(/<br\s*\/?>/gi, '\n');
35
+ // Handle <hr>
36
+ result = result.replace(/<hr\s*\/?>/gi, '\n---\n');
37
+ // Handle headings (h1-h3)
38
+ result = result.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_m, content) => `\n# ${stripTags(content).trim()}\n`);
39
+ result = result.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_m, content) => `\n## ${stripTags(content).trim()}\n`);
40
+ result = result.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_m, content) => `\n### ${stripTags(content).trim()}\n`);
41
+ // Handle <pre><code> blocks (must come before inline <code>)
42
+ // Extract language from class="language-xxx" if present.
43
+ // Do NOT decode entities here — let the final decodeEntities() handle them
44
+ // after stripTags() has run, to avoid decoded < > being stripped as tags.
45
+ result = result.replace(/<pre[^>]*>\s*<code(?:\s+class="language-([^"]*)")?[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (_m, lang, content) => `\n\`\`\`${lang || ''}\n${content}\n\`\`\`\n`);
46
+ // Handle inline <code>
47
+ result = result.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
48
+ // Handle <strong> and <b>
49
+ result = result.replace(/<(?:strong|b)(?:\s[^>]*)?>((?: |\s|[^<]|<(?!\/(?:strong|b)>))*)<\/(?:strong|b)>/gi, '**$1**');
50
+ // Handle <em> and <i>
51
+ result = result.replace(/<(?:em|i)(?:\s[^>]*)?>((?: |\s|[^<]|<(?!\/(?:em|i)>))*)<\/(?:em|i)>/gi, '*$1*');
52
+ // Handle <span class="context-scope-mention"> → `text`
53
+ result = result.replace(/<span[^>]*class="[^"]*context-scope-mention[^"]*"[^>]*>([\s\S]*?)<\/span>/gi, (_m, text) => `\`${stripTags(text).trim()}\``);
54
+ // Handle elements with title attribute containing file paths
55
+ // e.g. <div title="src/bot/index.ts">:54</div> → src/bot/index.ts:54
56
+ result = result.replace(/<(?:div|span|a)[^>]*\btitle="([^"]*)"[^>]*>([\s\S]*?)<\/(?:div|span|a)>/gi, (_m, title, text) => {
57
+ if (looksLikeFilePath(title)) {
58
+ return `${title}${stripTags(text).trim()}`;
59
+ }
60
+ return stripTags(text);
61
+ });
62
+ // Handle <p> and <div> BEFORE list processing so <li> content is clean text
63
+ result = result.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '$1\n\n');
64
+ result = result.replace(/<div[^>]*>([\s\S]*?)<\/div>/gi, '$1\n');
65
+ // Handle lists — process innermost first to support nesting.
66
+ // Loop up to 5 times to peel nested lists from inside out.
67
+ for (let iteration = 0; iteration < 5; iteration++) {
68
+ if (!/<(?:ul|ol)\b/i.test(result))
69
+ break;
70
+ // Process innermost <ul> (no nested <ul>/<ol> inside)
71
+ result = result.replace(/<ul[^>]*>((?:(?!<\/?(?:ul|ol)\b)[\s\S])*?)<\/ul>/gi, (_m, content) => {
72
+ const items = content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_lm, text) => formatListItem('- ', text));
73
+ return `\n${items}`;
74
+ });
75
+ // Process innermost <ol> (no nested <ul>/<ol> inside)
76
+ result = result.replace(/<ol[^>]*>((?:(?!<\/?(?:ul|ol)\b)[\s\S])*?)<\/ol>/gi, (_m, content) => {
77
+ let counter = 0;
78
+ const items = content.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_lm, text) => {
79
+ counter++;
80
+ return formatListItem(`${counter}. `, text);
81
+ });
82
+ return `\n${items}`;
83
+ });
84
+ }
85
+ // Strip remaining HTML tags
86
+ result = stripTags(result);
87
+ // Decode HTML entities
88
+ result = decodeEntities(result);
89
+ // Escape double underscores outside code blocks/inline code to prevent
90
+ // Discord from interpreting __dirname, __proto__ etc. as underline markup.
91
+ result = escapeDoubleUnderscores(result);
92
+ // Clean up excessive whitespace
93
+ result = result.replace(/\n{3,}/g, '\n\n');
94
+ result = result.trim();
95
+ return result;
96
+ }
97
+ /**
98
+ * Format a list item: strip tags, then indent any lines that are
99
+ * already-processed nested list items (starting with - or 1.).
100
+ */
101
+ function formatListItem(prefix, rawContent) {
102
+ const cleaned = stripTags(rawContent).trim();
103
+ if (!cleaned)
104
+ return '';
105
+ const lines = cleaned.split('\n').filter((l) => l.trim());
106
+ if (lines.length === 0)
107
+ return '';
108
+ // First line gets the bullet prefix
109
+ const result = [prefix + lines[0]];
110
+ // Subsequent lines: indent by 2 spaces (nested content)
111
+ for (let i = 1; i < lines.length; i++) {
112
+ const trimmed = lines[i].trimStart();
113
+ // Already a list marker from inner processing — indent it
114
+ if (/^[-•]/.test(trimmed) || /^\d+\.\s/.test(trimmed)) {
115
+ result.push(' ' + trimmed);
116
+ }
117
+ else if (trimmed.startsWith('```')) {
118
+ // Code block fence — indent
119
+ result.push(' ' + trimmed);
120
+ }
121
+ else {
122
+ // Continuation text — indent
123
+ result.push(' ' + trimmed);
124
+ }
125
+ }
126
+ return result.join('\n') + '\n';
127
+ }
128
+ /** Check if a string looks like a file path */
129
+ function looksLikeFilePath(value) {
130
+ if (!value)
131
+ return false;
132
+ // Must contain at least one / and end with an extension-like pattern
133
+ return /^[a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+$/.test(value) && value.includes('/');
134
+ }
135
+ /** Strip all HTML tags from a string */
136
+ function stripTags(html) {
137
+ return html.replace(/<[^>]+>/g, '');
138
+ }
139
+ /** Decode common HTML entities and generic numeric entities */
140
+ function decodeEntities(text) {
141
+ return text
142
+ .replace(/&amp;/g, '&')
143
+ .replace(/&lt;/g, '<')
144
+ .replace(/&gt;/g, '>')
145
+ .replace(/&quot;/g, '"')
146
+ .replace(/&#39;/g, "'")
147
+ .replace(/&#x27;/g, "'")
148
+ .replace(/&nbsp;/g, ' ')
149
+ .replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCodePoint(parseInt(hex, 16)))
150
+ .replace(/&#(\d+);/g, (_m, dec) => String.fromCodePoint(parseInt(dec, 10)));
151
+ }
152
+ /**
153
+ * Escape double underscores outside code blocks and inline code
154
+ * to prevent Discord from interpreting them as underline markup.
155
+ * e.g. __dirname → \_\_dirname
156
+ */
157
+ function escapeDoubleUnderscores(text) {
158
+ const lines = text.split('\n');
159
+ const result = [];
160
+ let inCodeBlock = false;
161
+ for (const line of lines) {
162
+ if (line.trimStart().startsWith('```')) {
163
+ inCodeBlock = !inCodeBlock;
164
+ result.push(line);
165
+ continue;
166
+ }
167
+ if (inCodeBlock) {
168
+ result.push(line);
169
+ continue;
170
+ }
171
+ // Outside code blocks: escape __ that are NOT inside inline backticks
172
+ // Split by backtick-delimited segments, only escape outside backticks
173
+ const parts = line.split(/(`[^`]*`)/g);
174
+ const escaped = parts.map((part, idx) => {
175
+ // Odd indices are inside backticks — leave as-is
176
+ if (idx % 2 === 1)
177
+ return part;
178
+ // Even indices are outside backticks — escape __
179
+ return part.replace(/__/g, '\\_\\_');
180
+ });
181
+ result.push(escaped.join(''));
182
+ }
183
+ return result.join('\n');
184
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.logBuffer = exports.LogBuffer = void 0;
4
+ const MAX_ENTRIES = 200;
5
+ // Strip ANSI escape codes for clean buffer storage
6
+ const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
7
+ function stripAnsi(text) {
8
+ return text.replace(ANSI_REGEX, '');
9
+ }
10
+ class LogBuffer {
11
+ buffer = [];
12
+ head = 0;
13
+ count = 0;
14
+ append(level, message) {
15
+ const entry = {
16
+ timestamp: new Date().toISOString(),
17
+ level,
18
+ message: stripAnsi(message),
19
+ };
20
+ if (this.count < MAX_ENTRIES) {
21
+ this.buffer.push(entry);
22
+ this.count++;
23
+ }
24
+ else {
25
+ this.buffer[this.head] = entry;
26
+ }
27
+ this.head = (this.head + 1) % MAX_ENTRIES;
28
+ }
29
+ getRecent(count, levelFilter) {
30
+ const all = [];
31
+ for (let i = 0; i < this.count; i++) {
32
+ const idx = (this.head - this.count + i + MAX_ENTRIES * 2) % MAX_ENTRIES;
33
+ all.push(this.buffer[idx]);
34
+ }
35
+ const filtered = levelFilter
36
+ ? all.filter((e) => e.level === levelFilter)
37
+ : all;
38
+ return filtered.slice(-count);
39
+ }
40
+ clear() {
41
+ this.buffer.length = 0;
42
+ this.head = 0;
43
+ this.count = 0;
44
+ }
45
+ }
46
+ exports.LogBuffer = LogBuffer;
47
+ exports.logBuffer = new LogBuffer();