dis-gravity 1.1.0 → 1.2.5

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/lib/core.js ADDED
@@ -0,0 +1,1712 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Dis-Gravity Shared Core Engine
5
+ * Common utilities for standalone CLI daemon, MCP server, and VS Code extension:
6
+ * - Security whitelist validation (fail-closed)
7
+ * - Safe shell command execution
8
+ * - Headless browser discovery and 2x Retina PNG table card rendering
9
+ * - Markdown table parser
10
+ * - Workspace path traversal protection & safe file inspection
11
+ * - Git telemetry and status helpers
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const cp = require('child_process');
18
+ const crypto = require('crypto');
19
+
20
+ const VALID_ALIGNMENTS = new Set(['left', 'center', 'right']);
21
+ function sanitizeAlignment(align) {
22
+ const normalized = String(align || 'left').toLowerCase().trim();
23
+ return VALID_ALIGNMENTS.has(normalized) ? normalized : 'left';
24
+ }
25
+
26
+ /**
27
+ * Validates if a Discord User ID is authorized.
28
+ * Fail-closed: If allowedUsers is empty or unconfigured, access is DENIED.
29
+ *
30
+ * @param {string|number} userId - The Discord user ID to check
31
+ * @param {string[]|string} allowedUsers - Whitelist array or comma-separated string
32
+ * @returns {boolean}
33
+ */
34
+ function isUserAllowed(userId, allowedUsers) {
35
+ if (!userId) return false;
36
+ if (!allowedUsers) return false;
37
+
38
+ const list = Array.isArray(allowedUsers)
39
+ ? allowedUsers.map(u => String(u).trim()).filter(Boolean)
40
+ : String(allowedUsers).split(',').map(u => u.trim()).filter(Boolean);
41
+
42
+ if (list.length === 0) return false;
43
+ return list.includes(String(userId).trim());
44
+ }
45
+
46
+ /**
47
+ * Safely executes a shell command in the specified workspace with timeout and buffer protection.
48
+ *
49
+ * @param {string} cmdStr - Command to execute
50
+ * @param {string} cwd - Working directory
51
+ * @param {number} [timeoutMs=30000] - Timeout in milliseconds
52
+ * @returns {Promise<{code: number, stdout: string, stderr: string, duration: string, error: string|null}>}
53
+ */
54
+ function executeShellCommand(cmdStr, cwd = process.cwd(), timeoutMs = 30000) {
55
+ return new Promise((resolve) => {
56
+ const startTime = Date.now();
57
+ cp.exec(cmdStr, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
58
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
59
+ resolve({
60
+ code: err ? (err.code || 1) : 0,
61
+ stdout: stdout || '',
62
+ stderr: stderr || '',
63
+ duration,
64
+ error: err ? err.message : null
65
+ });
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Discovers a pre-installed headless Chromium-based browser (Edge, Chrome, Chromium, Brave).
72
+ * Zero-dependency: Uses host OS browser without requiring puppeteer or native npm modules.
73
+ *
74
+ * @returns {string|null} Path to the browser binary, or null if none found
75
+ */
76
+ function findHeadlessBrowser() {
77
+ if (process.env.DISGRAVITY_BROWSER_PATH && fs.existsSync(process.env.DISGRAVITY_BROWSER_PATH)) {
78
+ return process.env.DISGRAVITY_BROWSER_PATH;
79
+ }
80
+
81
+ if (process.platform === 'win32') {
82
+ const candidates = [
83
+ process.env.EDGE_PATH,
84
+ process.env.CHROME_PATH,
85
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
86
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
87
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
88
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
89
+ 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
90
+ path.join(process.env.LOCALAPPDATA || '', 'Microsoft\\Edge\\Application\\msedge.exe'),
91
+ path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe')
92
+ ].filter(Boolean);
93
+
94
+ for (const c of candidates) {
95
+ if (fs.existsSync(c)) return c;
96
+ }
97
+ } else if (process.platform === 'darwin') {
98
+ const candidates = [
99
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
100
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
101
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
102
+ '/Applications/Chromium.app/Contents/MacOS/Chromium'
103
+ ];
104
+ for (const c of candidates) {
105
+ if (fs.existsSync(c)) return c;
106
+ }
107
+ } else {
108
+ // Linux / BSD
109
+ const candidates = ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge', 'brave-browser'];
110
+ for (const c of candidates) {
111
+ try {
112
+ cp.execFileSync('which', [c], { stdio: 'ignore' });
113
+ return c;
114
+ } catch { }
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Escapes characters for safe insertion into HTML strings.
123
+ *
124
+ * @param {string} str
125
+ * @returns {string}
126
+ */
127
+ function escapeHtml(str) {
128
+ if (str === null || str === undefined) return '';
129
+ return String(str)
130
+ .replace(/&/g, '&amp;')
131
+ .replace(/</g, '&lt;')
132
+ .replace(/>/g, '&gt;')
133
+ .replace(/"/g, '&quot;')
134
+ .replace(/'/g, '&#039;');
135
+ }
136
+
137
+ /**
138
+ * Builds HTML for Discord Dark Theme 2x table rendering.
139
+ *
140
+ * @param {string[]} headers - Table header titles
141
+ * @param {string[][]} rows - 2D array of row cells
142
+ * @param {string[]} [alignments=[]] - Optional column alignments ('left', 'center', 'right')
143
+ * @param {string} [title="📊 Dis-Gravity Data Table"] - Table header title
144
+ * @returns {string}
145
+ */
146
+ function buildTableHtml(headers = [], rows = [], alignments = [], title = "📊 Dis-Gravity Data Table") {
147
+ const thHtml = headers.map((h, i) => {
148
+ const align = sanitizeAlignment(alignments[i]);
149
+ return `<th style="padding: 10px 14px; text-align: ${align}; border-bottom: 2px solid #5865F2; color: #ffffff; font-size: 14px; font-weight: 600; white-space: nowrap;">${escapeHtml(h)}</th>`;
150
+ }).join('');
151
+
152
+ const trHtml = rows.map((row, rIdx) => {
153
+ const bg = rIdx % 2 === 0 ? '#2b2d31' : '#313338';
154
+ const tdHtml = row.map((cell, cIdx) => {
155
+ const align = sanitizeAlignment(alignments[cIdx]);
156
+ return `<td style="padding: 9px 14px; text-align: ${align}; border-bottom: 1px solid #3f4147; color: #dbdee1; font-size: 13px;">${escapeHtml(cell)}</td>`;
157
+ }).join('');
158
+ return `<tr style="background: ${bg};">${tdHtml}</tr>`;
159
+ }).join('');
160
+
161
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
162
+ * { box-sizing: border-box; }
163
+ body { margin: 0; padding: 16px; background: #1e1f22; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: inline-block; }
164
+ .table-card { border-radius: 8px; overflow: hidden; box-shadow: 0 4px 16px rgba(0,0,0,0.5); border: 1px solid #383a40; background: #2b2d31; display: inline-block; min-width: 380px; }
165
+ table { border-collapse: collapse; width: 100%; }
166
+ .header-banner { background: linear-gradient(90deg, #5865F2, #4752C4); color: white; padding: 10px 16px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; }
167
+ .footer-banner { background: #232428; color: #949ba4; padding: 6px 14px; font-size: 11px; text-align: right; border-top: 1px solid #383a40; }
168
+ </style></head><body>
169
+ <div class="table-card">
170
+ <div class="header-banner">${escapeHtml(title)}</div>
171
+ <table>
172
+ <thead><tr style="background: #232428;">${thHtml}</tr></thead>
173
+ <tbody>${trHtml}</tbody>
174
+ </table>
175
+ <div class="footer-banner">Dis-Gravity Bridge • ${new Date().toLocaleTimeString()}</div>
176
+ </div>
177
+ </body></html>`;
178
+ }
179
+
180
+ /**
181
+ * Renders tabular data to a crisp PNG image buffer using an OS headless browser.
182
+ * Cleans up temporary HTML and PNG files automatically.
183
+ *
184
+ * @param {string[]} headers - Table header titles
185
+ * @param {string[][]} rows - 2D array of row cells
186
+ * @param {string[]} [alignments=[]] - Optional column alignments
187
+ * @param {string} [title] - Header card title
188
+ * @returns {Promise<Buffer|null>} PNG image buffer or null if browser is unavailable
189
+ */
190
+ async function renderTableToPng(headers = [], rows = [], alignments = [], title = "📊 Dis-Gravity Data Table") {
191
+ const browserPath = findHeadlessBrowser();
192
+ if (!browserPath) return null;
193
+
194
+ const nonce = crypto.randomBytes(6).toString('hex');
195
+ const tempHtml = path.join(os.tmpdir(), `disgravity_tbl_${Date.now()}_${nonce}.html`);
196
+ const tempPng = path.join(os.tmpdir(), `disgravity_tbl_${Date.now()}_${nonce}.png`);
197
+
198
+ try {
199
+ const html = buildTableHtml(headers, rows, alignments, title);
200
+ await fs.promises.writeFile(tempHtml, html, { encoding: 'utf8', mode: 0o600 });
201
+
202
+ const colCount = Math.max(1, headers.length);
203
+ const rowCount = Math.max(1, rows.length);
204
+ const width = Math.min(1600, Math.max(480, colCount * 200));
205
+ const height = Math.min(1400, Math.max(220, (rowCount + 2) * 48 + 90));
206
+
207
+ const args = [
208
+ '--headless',
209
+ '--disable-gpu',
210
+ '--disable-extensions',
211
+ '--disable-sync',
212
+ '--no-first-run',
213
+ '--hide-scrollbars',
214
+ `--screenshot=${tempPng}`,
215
+ `--window-size=${width},${height}`,
216
+ `file:///${tempHtml.replace(/\\/g, '/')}`
217
+ ];
218
+
219
+ await new Promise((resolve, reject) => {
220
+ cp.execFile(browserPath, args, { timeout: 15000, windowsHide: true }, (err) => {
221
+ if (err) reject(err);
222
+ else resolve();
223
+ });
224
+ });
225
+
226
+ if (fs.existsSync(tempPng)) {
227
+ const buf = await fs.promises.readFile(tempPng);
228
+ return buf;
229
+ }
230
+ } catch {
231
+ return null;
232
+ } finally {
233
+ try { if (fs.existsSync(tempHtml)) await fs.promises.unlink(tempHtml); } catch { }
234
+ try { if (fs.existsSync(tempPng)) await fs.promises.unlink(tempPng); } catch { }
235
+ }
236
+
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Builds HTML for high-resolution 2x Retina Visual Diff rendering.
242
+ *
243
+ * @param {string} diffText - Raw unified diff text
244
+ * @param {string} [title="🔍 Git Diff Preview"] - Title banner
245
+ * @param {number} [maxLines=80] - Maximum diff lines to render
246
+ * @returns {string}
247
+ */
248
+ function buildDiffHtml(diffText, title = "🔍 Dis-Gravity Git Diff", maxLines = 80) {
249
+ if (!diffText || typeof diffText !== 'string') return '';
250
+ const rawLines = diffText.split(/\r?\n/);
251
+ const truncated = rawLines.length > maxLines;
252
+ const lines = rawLines.slice(0, maxLines);
253
+
254
+ let oldLine = 0;
255
+ let newLine = 0;
256
+ let inHunk = false;
257
+
258
+ const rows = [];
259
+
260
+ for (let i = 0; i < lines.length; i++) {
261
+ const line = lines[i];
262
+ if (line.startsWith('diff --git') || line.startsWith('index ')) {
263
+ rows.push(`
264
+ <tr style="background: #232428; color: #949ba4; font-size: 11px; font-weight: 600;">
265
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40;">---</td>
266
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40;">---</td>
267
+ <td style="padding: 4px 10px; font-family: monospace;">${escapeHtml(line)}</td>
268
+ </tr>
269
+ `);
270
+ continue;
271
+ }
272
+ if (line.startsWith('--- ') || line.startsWith('+++ ')) {
273
+ const isAdd = line.startsWith('+++');
274
+ const color = isAdd ? '#57F287' : '#ED4245';
275
+ rows.push(`
276
+ <tr style="background: #2b2d31; color: ${color}; font-size: 12px; font-weight: 700;">
277
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40;">${isAdd ? '+' : '-'}</td>
278
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40;">${isAdd ? '+' : '-'}</td>
279
+ <td style="padding: 4px 10px; font-family: monospace;">${escapeHtml(line)}</td>
280
+ </tr>
281
+ `);
282
+ continue;
283
+ }
284
+
285
+ const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/);
286
+ if (hunkMatch) {
287
+ oldLine = parseInt(hunkMatch[1], 10);
288
+ newLine = parseInt(hunkMatch[2], 10);
289
+ inHunk = true;
290
+ rows.push(`
291
+ <tr style="background: #1e2638; color: #79c0ff; font-size: 12px; font-style: italic;">
292
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40; color: #5865F2;">...</td>
293
+ <td style="padding: 4px 8px; text-align: center; border-right: 1px solid #383a40; color: #5865F2;">...</td>
294
+ <td style="padding: 4px 10px; font-family: monospace;">${escapeHtml(line)}</td>
295
+ </tr>
296
+ `);
297
+ continue;
298
+ }
299
+
300
+ if (!inHunk) {
301
+ rows.push(`
302
+ <tr style="background: #2b2d31; color: #949ba4; font-size: 12px;">
303
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #4e5058;"> </td>
304
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #4e5058;"> </td>
305
+ <td style="padding: 3px 10px; font-family: monospace; color: #dbdee1;">${escapeHtml(line)}</td>
306
+ </tr>
307
+ `);
308
+ continue;
309
+ }
310
+
311
+ if (line.startsWith('+')) {
312
+ const curNew = newLine++;
313
+ rows.push(`
314
+ <tr style="background: rgba(46, 160, 67, 0.18); color: #7ee787; font-size: 12.5px;">
315
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #4e5058; user-select: none;"> </td>
316
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #57F287; font-weight: 600; user-select: none;">${curNew}</td>
317
+ <td style="padding: 3px 10px; font-family: monospace; white-space: pre-wrap; word-break: break-all;">+ ${escapeHtml(line.slice(1))}</td>
318
+ </tr>
319
+ `);
320
+ } else if (line.startsWith('-')) {
321
+ const curOld = oldLine++;
322
+ rows.push(`
323
+ <tr style="background: rgba(248, 81, 73, 0.18); color: #ffa198; font-size: 12.5px;">
324
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #ED4245; font-weight: 600; user-select: none;">${curOld}</td>
325
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #4e5058; user-select: none;"> </td>
326
+ <td style="padding: 3px 10px; font-family: monospace; white-space: pre-wrap; word-break: break-all;">- ${escapeHtml(line.slice(1))}</td>
327
+ </tr>
328
+ `);
329
+ } else {
330
+ const curOld = oldLine++;
331
+ const curNew = newLine++;
332
+ const text = line.startsWith(' ') ? line.slice(1) : line;
333
+ rows.push(`
334
+ <tr style="background: #2b2d31; color: #dbdee1; font-size: 12.5px;">
335
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #80848e; user-select: none;">${curOld}</td>
336
+ <td style="padding: 3px 8px; text-align: right; border-right: 1px solid #383a40; color: #80848e; user-select: none;">${curNew}</td>
337
+ <td style="padding: 3px 10px; font-family: monospace; white-space: pre-wrap; word-break: break-all;"> ${escapeHtml(text)}</td>
338
+ </tr>
339
+ `);
340
+ }
341
+ }
342
+
343
+ const footerText = truncated
344
+ ? `Showing first ${maxLines} lines (${rawLines.length - maxLines} lines truncated) • Dis-Gravity Diff`
345
+ : `Dis-Gravity Visual Diff • ${rawLines.length} lines • ${new Date().toLocaleTimeString()}`;
346
+
347
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
348
+ * { box-sizing: border-box; }
349
+ body { margin: 0; padding: 14px; background: #1e1f22; font-family: "JetBrains Mono", Consolas, "Courier New", monospace; display: inline-block; }
350
+ .diff-card { border-radius: 8px; overflow: hidden; box-shadow: 0 6px 20px rgba(0,0,0,0.6); border: 1px solid #383a40; background: #2b2d31; display: inline-block; min-width: 680px; max-width: 1400px; }
351
+ table { border-collapse: collapse; width: 100%; }
352
+ .header-banner { background: linear-gradient(90deg, #5865F2, #3b429f); color: white; padding: 10px 16px; font-size: 13px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; letter-spacing: 0.5px; }
353
+ .footer-banner { background: #232428; color: #949ba4; padding: 6px 14px; font-size: 11px; text-align: right; border-top: 1px solid #383a40; }
354
+ </style></head><body>
355
+ <div class="diff-card">
356
+ <div class="header-banner">
357
+ <span>${escapeHtml(title)}</span>
358
+ <span style="font-size: 11px; background: rgba(0,0,0,0.3); padding: 2px 8px; border-radius: 4px;">2x Retina</span>
359
+ </div>
360
+ <table>
361
+ <tbody>${rows.join('')}</tbody>
362
+ </table>
363
+ <div class="footer-banner">${escapeHtml(footerText)}</div>
364
+ </div>
365
+ </body></html>`;
366
+ }
367
+
368
+ /**
369
+ * Renders unified diff text to a crisp PNG image buffer using an OS headless browser.
370
+ *
371
+ * @param {string} diffText - Raw unified diff text
372
+ * @param {string} [title="🔍 Git Diff Preview"] - Header card title
373
+ * @param {number} [maxLines=80] - Maximum diff lines to render
374
+ * @returns {Promise<Buffer|null>} PNG image buffer or null if browser is unavailable
375
+ */
376
+ async function renderDiffToPng(diffText, title = "🔍 Dis-Gravity Git Diff", maxLines = 80) {
377
+ const browserPath = findHeadlessBrowser();
378
+ if (!browserPath) return null;
379
+
380
+ const nonce = crypto.randomBytes(6).toString('hex');
381
+ const tempHtml = path.join(os.tmpdir(), `disgravity_diff_${Date.now()}_${nonce}.html`);
382
+ const tempPng = path.join(os.tmpdir(), `disgravity_diff_${Date.now()}_${nonce}.png`);
383
+
384
+ try {
385
+ const html = buildDiffHtml(diffText, title, maxLines);
386
+ if (!html) return null;
387
+ await fs.promises.writeFile(tempHtml, html, { encoding: 'utf8', mode: 0o600 });
388
+
389
+ const lineCount = Math.min(maxLines, (diffText.split(/\r?\n/).length || 1));
390
+ const width = 960;
391
+ const height = Math.min(2400, Math.max(260, (lineCount + 2) * 23 + 90));
392
+
393
+ const args = [
394
+ '--headless',
395
+ '--disable-gpu',
396
+ '--disable-extensions',
397
+ '--disable-sync',
398
+ '--no-first-run',
399
+ '--hide-scrollbars',
400
+ `--screenshot=${tempPng}`,
401
+ `--window-size=${width},${height}`,
402
+ `file:///${tempHtml.replace(/\\/g, '/')}`
403
+ ];
404
+
405
+ await new Promise((resolve, reject) => {
406
+ cp.execFile(browserPath, args, { timeout: 15000, windowsHide: true }, (err) => {
407
+ if (err) reject(err);
408
+ else resolve();
409
+ });
410
+ });
411
+
412
+ if (fs.existsSync(tempPng)) {
413
+ const buf = await fs.promises.readFile(tempPng);
414
+ return buf;
415
+ }
416
+ } catch {
417
+ return null;
418
+ } finally {
419
+ try { if (fs.existsSync(tempHtml)) await fs.promises.unlink(tempHtml); } catch { }
420
+ try { if (fs.existsSync(tempPng)) await fs.promises.unlink(tempPng); } catch { }
421
+ }
422
+
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * Parses a standard GitHub-flavored Markdown table into structured headers, rows, and alignments.
428
+ *
429
+ * @param {string} text - Text containing a Markdown table
430
+ * @returns {{headers: string[], rows: string[][], alignments: string[]}|null}
431
+ */
432
+ function parseMarkdownTable(text) {
433
+ if (!text || typeof text !== 'string') return null;
434
+
435
+ const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
436
+ let tableStartIndex = -1;
437
+
438
+ for (let i = 0; i < lines.length - 1; i++) {
439
+ const line = lines[i];
440
+ const nextLine = lines[i + 1];
441
+ if (line.includes('|') && /^\s*\|?\s*[-:]+[-| :]*\|?\s*$/.test(nextLine)) {
442
+ tableStartIndex = i;
443
+ break;
444
+ }
445
+ }
446
+
447
+ if (tableStartIndex === -1) return null;
448
+
449
+ const splitRow = (str) => {
450
+ let clean = str;
451
+ if (clean.startsWith('|')) clean = clean.substring(1);
452
+ if (clean.endsWith('|')) clean = clean.substring(0, clean.length - 1);
453
+ return clean.split('|').map(s => s.trim());
454
+ };
455
+
456
+ const headerLine = lines[tableStartIndex];
457
+ const separatorLine = lines[tableStartIndex + 1];
458
+
459
+ const headers = splitRow(headerLine);
460
+ const sepParts = splitRow(separatorLine);
461
+
462
+ const alignments = sepParts.map(part => {
463
+ const left = part.startsWith(':');
464
+ const right = part.endsWith(':');
465
+ if (left && right) return 'center';
466
+ if (right) return 'right';
467
+ return 'left';
468
+ });
469
+
470
+ const rows = [];
471
+ for (let i = tableStartIndex + 2; i < lines.length; i++) {
472
+ const line = lines[i];
473
+ if (!line.includes('|')) break;
474
+ const row = splitRow(line);
475
+ // Pad or trim row cells to match header count
476
+ while (row.length < headers.length) row.push('');
477
+ rows.push(row.slice(0, headers.length));
478
+ }
479
+
480
+ if (rows.length === 0) return null;
481
+
482
+ return { headers, rows, alignments };
483
+ }
484
+
485
+ /**
486
+ * Resolves a target path within a workspace root, strictly preventing path traversal attacks.
487
+ *
488
+ * @param {string} workspaceRoot - Absolute path to workspace root
489
+ * @param {string} targetPath - Relative or absolute path to check
490
+ * @returns {string} Safe absolute path
491
+ * @throws {Error} If path escapes workspaceRoot
492
+ */
493
+ function safeResolveWorkspacePath(workspaceRoot, targetPath) {
494
+ const root = path.resolve(workspaceRoot);
495
+ const resolved = path.resolve(root, targetPath || '');
496
+
497
+ const normForCompare = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
498
+ const rel = path.relative(normForCompare(root), normForCompare(resolved));
499
+
500
+ if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) {
501
+ throw new Error(`Security Violation: Access outside workspace is prohibited (${targetPath})`);
502
+ }
503
+
504
+ if (fs.existsSync(resolved)) {
505
+ try {
506
+ const realRoot = fs.realpathSync(root);
507
+ const realTarget = fs.realpathSync(resolved);
508
+ const realRel = path.relative(normForCompare(realRoot), normForCompare(realTarget));
509
+ if (realRel !== '' && (realRel.startsWith('..') || path.isAbsolute(realRel))) {
510
+ throw new Error(`Security Violation: Symlink target escapes workspace (${targetPath})`);
511
+ }
512
+ } catch (err) {
513
+ if (err.message && err.message.startsWith('Security Violation:')) throw err;
514
+ }
515
+ }
516
+
517
+ return resolved;
518
+ }
519
+
520
+ /**
521
+ * Safely reads a workspace file with size limit and traversal protection.
522
+ *
523
+ * @param {string} workspaceRoot
524
+ * @param {string} filePath
525
+ * @param {number} [maxBytes=102400] - Max 100 KB
526
+ * @returns {{content: string, size: number, truncated: boolean, error: string|null}}
527
+ */
528
+ function readWorkspaceFile(workspaceRoot, filePath, maxBytes = 100 * 1024) {
529
+ try {
530
+ const safePath = safeResolveWorkspacePath(workspaceRoot, filePath);
531
+ if (!fs.existsSync(safePath)) {
532
+ return { content: '', size: 0, truncated: false, error: 'File does not exist' };
533
+ }
534
+ const stat = fs.statSync(safePath);
535
+ if (stat.isDirectory()) {
536
+ return { content: '', size: 0, truncated: false, error: 'Target is a directory, not a file' };
537
+ }
538
+
539
+ const size = stat.size;
540
+ let content;
541
+ let truncated = false;
542
+
543
+ if (size > maxBytes) {
544
+ const fd = fs.openSync(safePath, 'r');
545
+ const buffer = Buffer.alloc(maxBytes);
546
+ fs.readSync(fd, buffer, 0, maxBytes, 0);
547
+ fs.closeSync(fd);
548
+ content = buffer.toString('utf8');
549
+ truncated = true;
550
+ } else {
551
+ content = fs.readFileSync(safePath, 'utf8');
552
+ }
553
+
554
+ return { content, size, truncated, error: null };
555
+ } catch (err) {
556
+ return { content: '', size: 0, truncated: false, error: err.message };
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Generates a clean text directory tree representation of the workspace.
562
+ *
563
+ * @param {string} workspaceRoot
564
+ * @param {string} [subDir='.']
565
+ * @param {number} [maxDepth=2]
566
+ * @param {number} [maxEntries=120]
567
+ * @returns {string} Formatted tree string
568
+ */
569
+ function listWorkspaceTree(workspaceRoot, subDir = '.', maxDepth = 2, maxEntries = 120) {
570
+ try {
571
+ const baseDir = safeResolveWorkspacePath(workspaceRoot, subDir);
572
+ if (!fs.existsSync(baseDir)) return `Directory not found: ${subDir}`;
573
+
574
+ const IGNORED = new Set([
575
+ 'node_modules', '.git', '.disgravity', 'dist', 'out',
576
+ '.vscode', '.idea', '__pycache__', '.pytest_cache'
577
+ ]);
578
+
579
+ let count = 0;
580
+ const lines = [`📁 ${path.basename(baseDir) || '.'}`];
581
+
582
+ function walk(dir, depth, prefix) {
583
+ if (depth > maxDepth || count >= maxEntries) return;
584
+
585
+ let entries = [];
586
+ try {
587
+ entries = fs.readdirSync(dir, { withFileTypes: true });
588
+ } catch {
589
+ return;
590
+ }
591
+
592
+ // Directories first, then files alphabetically
593
+ entries.sort((a, b) => {
594
+ if (a.isDirectory() && !b.isDirectory()) return -1;
595
+ if (!a.isDirectory() && b.isDirectory()) return 1;
596
+ return a.name.localeCompare(b.name);
597
+ });
598
+
599
+ for (let i = 0; i < entries.length; i++) {
600
+ if (count >= maxEntries) {
601
+ lines.push(`${prefix}... (maximum entry limit reached)`);
602
+ return;
603
+ }
604
+
605
+ const entry = entries[i];
606
+ if (IGNORED.has(entry.name)) continue;
607
+
608
+ const isLast = i === entries.length - 1;
609
+ const pointer = isLast ? '└── ' : '├── ';
610
+ const childPrefix = prefix + (isLast ? ' ' : '│ ');
611
+
612
+ count++;
613
+ if (entry.isDirectory()) {
614
+ lines.push(`${prefix}${pointer}📁 ${entry.name}/`);
615
+ walk(path.join(dir, entry.name), depth + 1, childPrefix);
616
+ } else {
617
+ lines.push(`${prefix}${pointer}📄 ${entry.name}`);
618
+ }
619
+ }
620
+ }
621
+
622
+ walk(baseDir, 1, '');
623
+ return lines.join('\n');
624
+ } catch (err) {
625
+ return `Error generating tree: ${err.message}`;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Retrieves concise Git repository status and unified diff preview.
631
+ *
632
+ * @param {string} cwd
633
+ * @returns {Promise<{branch: string, status: string, stat: string, isClean: boolean, error: string|null}>}
634
+ */
635
+ async function getGitStatusAndDiff(cwd = process.cwd()) {
636
+ try {
637
+ const branchRes = await executeShellCommand('git rev-parse --abbrev-ref HEAD', cwd);
638
+ const branch = branchRes.stdout.trim() || 'unknown';
639
+
640
+ const statusRes = await executeShellCommand('git status --short', cwd);
641
+ const status = statusRes.stdout.trim();
642
+
643
+ const diffStatRes = await executeShellCommand('git diff --stat', cwd);
644
+ const stat = diffStatRes.stdout.trim();
645
+
646
+ const isClean = status.length === 0;
647
+
648
+ return {
649
+ branch,
650
+ status: status || 'Clean working tree',
651
+ stat: stat || 'No changes',
652
+ isClean,
653
+ error: branchRes.error || statusRes.error || null
654
+ };
655
+ } catch (err) {
656
+ return {
657
+ branch: 'unknown',
658
+ status: 'Error retrieving git status',
659
+ stat: '',
660
+ isClean: false,
661
+ error: err.message
662
+ };
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Returns host and hardware telemetry data.
668
+ *
669
+ * @param {string} workspace
670
+ * @returns {object}
671
+ */
672
+ function getSystemHealth(workspace = process.cwd()) {
673
+ const cpus = os.cpus() || [];
674
+ const cpuModel = cpus[0] ? cpus[0].model.trim() : 'Unknown CPU';
675
+ const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
676
+ const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
677
+
678
+ return {
679
+ platform: os.platform(),
680
+ release: os.release(),
681
+ arch: os.arch(),
682
+ cpu: `${cpuModel} (${cpus.length} cores)`,
683
+ memory: `${freeGB} GB free / ${totalGB} GB total`,
684
+ uptimeSeconds: Math.floor(process.uptime()),
685
+ workspace: path.resolve(workspace),
686
+ nodeVersion: process.version,
687
+ pid: process.pid
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Regex patterns identifying potentially destructive file system or git operations.
693
+ */
694
+ const DESTRUCTIVE_PATTERNS = [
695
+ /\brm\s+-[a-z0-9]*r[a-z0-9]*f?[a-z0-9]*\b/i,
696
+ /\brmdir\s+\/s\b/i,
697
+ /\bdel\s+\/[a-z0-9]*f[a-z0-9]*\b/i,
698
+ /\b(drop|truncate)\s+(database|table|schema)\b/i,
699
+ /\bgit\s+reset\s+--hard\b/i,
700
+ /\bgit\s+clean\s+-[a-z0-9]*f[a-z0-9]*\b/i,
701
+ /\bformat\s+[a-z]:/i,
702
+ /\b(mkfs|dd\s+if=)\b/i
703
+ ];
704
+
705
+ /**
706
+ * Checks whether a command or prompt contains high-risk destructive keywords.
707
+ *
708
+ * @param {string} input
709
+ * @returns {boolean}
710
+ */
711
+ function detectDestructiveKeywords(input) {
712
+ if (!input || typeof input !== 'string') return false;
713
+ return DESTRUCTIVE_PATTERNS.some(pat => pat.test(input));
714
+ }
715
+
716
+ /**
717
+ * Captures a safe workspace checkpoint prior to file modifications.
718
+ *
719
+ * @param {string} cwd
720
+ * @returns {Promise<{ok: boolean, stashSha?: string|null, headSha?: string, createdAt: string, error?: string}>}
721
+ */
722
+ async function captureWorkspaceCheckpoint(cwd = process.cwd()) {
723
+ try {
724
+ const stashRes = await executeShellCommand('git stash create "disgravity_checkpoint"', cwd);
725
+ const stashSha = stashRes.stdout.trim();
726
+ const headRes = await executeShellCommand('git rev-parse HEAD', cwd);
727
+ const headSha = headRes.stdout.trim() || 'HEAD';
728
+ return {
729
+ ok: true,
730
+ stashSha: stashSha || null,
731
+ headSha,
732
+ createdAt: new Date().toISOString()
733
+ };
734
+ } catch (err) {
735
+ return { ok: false, error: err.message };
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Restores a previously captured workspace checkpoint.
741
+ *
742
+ * @param {object} checkpoint
743
+ * @param {string} cwd
744
+ * @returns {Promise<{ok: boolean, output?: string, error?: string}>}
745
+ */
746
+ async function restoreWorkspaceCheckpoint(checkpoint, cwd = process.cwd()) {
747
+ try {
748
+ if (!checkpoint) return { ok: false, error: 'No checkpoint provided' };
749
+ if (checkpoint.stashSha) {
750
+ const res = await executeShellCommand(`git stash apply ${checkpoint.stashSha}`, cwd);
751
+ return { ok: res.code === 0, output: res.stdout || res.stderr };
752
+ }
753
+ const res = await executeShellCommand('git checkout -- .', cwd);
754
+ return { ok: res.code === 0, output: res.stdout || res.stderr };
755
+ } catch (err) {
756
+ return { ok: false, error: err.message };
757
+ }
758
+ }
759
+
760
+ // ============================================================================
761
+ // Feature 1: Scheduled / Cron Prompts Engine (/schedule, !schedule)
762
+ // ============================================================================
763
+
764
+ const DURATION_UNITS_MS = {
765
+ s: 1000,
766
+ sec: 1000,
767
+ secs: 1000,
768
+ second: 1000,
769
+ seconds: 1000,
770
+ m: 60 * 1000,
771
+ min: 60 * 1000,
772
+ mins: 60 * 1000,
773
+ minute: 60 * 1000,
774
+ minutes: 60 * 1000,
775
+ h: 3600 * 1000,
776
+ hr: 3600 * 1000,
777
+ hrs: 3600 * 1000,
778
+ hour: 3600 * 1000,
779
+ hours: 3600 * 1000,
780
+ d: 86400 * 1000,
781
+ day: 86400 * 1000,
782
+ days: 86400 * 1000
783
+ };
784
+
785
+ /**
786
+ * Parses a single cron field into a Set of matching integers.
787
+ *
788
+ * @param {string} fieldStr
789
+ * @param {number} min
790
+ * @param {number} max
791
+ * @returns {Set<number>|null}
792
+ */
793
+ function parseCronField(fieldStr, min, max) {
794
+ if (!fieldStr || typeof fieldStr !== 'string') return null;
795
+ const result = new Set();
796
+ const parts = fieldStr.trim().split(',');
797
+
798
+ for (const part of parts) {
799
+ const stepParts = part.split('/');
800
+ if (stepParts.length > 2) return null;
801
+ const rangeStr = stepParts[0];
802
+ const step = stepParts[1] !== undefined ? parseInt(stepParts[1], 10) : 1;
803
+ if (isNaN(step) || step <= 0) return null;
804
+
805
+ let start = min;
806
+ let end = max;
807
+
808
+ if (rangeStr === '*') {
809
+ start = min;
810
+ end = max;
811
+ } else if (rangeStr.includes('-')) {
812
+ const [sStr, eStr] = rangeStr.split('-');
813
+ start = parseInt(sStr, 10);
814
+ end = parseInt(eStr, 10);
815
+ if (isNaN(start) || isNaN(end) || start > end) return null;
816
+ } else {
817
+ const val = parseInt(rangeStr, 10);
818
+ if (isNaN(val)) return null;
819
+ start = val;
820
+ end = stepParts[1] !== undefined ? max : val;
821
+ }
822
+
823
+ // Allow 7 for Sunday in day-of-week
824
+ if (max === 6 && end === 7) {
825
+ if (start === 7) start = 0;
826
+ end = 6;
827
+ result.add(0);
828
+ }
829
+
830
+ if (start < min || end > max) return null;
831
+
832
+ for (let i = start; i <= end; i += step) {
833
+ result.add(i);
834
+ }
835
+ }
836
+
837
+ return result.size > 0 ? result : null;
838
+ }
839
+
840
+ /**
841
+ * Parses a schedule specification string into a structured schedule descriptor:
842
+ * - One-shot relative delay: "30s", "15m", "2h", "1d", "in 10m"
843
+ * - Recurring interval: "every 5m", "every 1h", "@hourly", "@daily", "@weekly"
844
+ * - 5-field cron expression: "0 9 * * 1-5", "* /15 * * * *"
845
+ *
846
+ * @param {string} rawSpec
847
+ * @returns {{type: 'once'|'interval'|'cron', delayMs?: number, intervalMs?: number, fields?: object, spec: string, description: string}|null}
848
+ */
849
+ function parseScheduleSpec(rawSpec) {
850
+ if (!rawSpec || typeof rawSpec !== 'string') return null;
851
+ const spec = rawSpec.trim().replace(/^['"]|['"]$/g, '').trim();
852
+ if (!spec) return null;
853
+
854
+ const lower = spec.toLowerCase();
855
+
856
+ // 1. Named presets
857
+ if (lower === '@hourly' || lower === 'hourly') {
858
+ return { valid: true, type: 'interval', intervalMs: 3600 * 1000, spec: '@hourly', description: 'Every 1 hour' };
859
+ }
860
+ if (lower === '@daily' || lower === 'daily') {
861
+ return { valid: true, type: 'interval', intervalMs: 86400 * 1000, spec: '@daily', description: 'Every 24 hours' };
862
+ }
863
+ if (lower === '@weekly' || lower === 'weekly') {
864
+ return { valid: true, type: 'interval', intervalMs: 7 * 86400 * 1000, spec: '@weekly', description: 'Every 7 days' };
865
+ }
866
+
867
+ // 2. Recurring interval: "every 30s", "every 15m", "every 2h"
868
+ const everyMatch = lower.match(/^every\s+(\d+(?:\.\d+)?)\s*([a-z]+)$/i);
869
+ if (everyMatch) {
870
+ const num = parseFloat(everyMatch[1]);
871
+ const unit = everyMatch[2];
872
+ const mult = DURATION_UNITS_MS[unit];
873
+ if (!mult || isNaN(num) || num <= 0) return null;
874
+ const intervalMs = Math.round(num * mult);
875
+ if (intervalMs < 1000) return null;
876
+ return {
877
+ valid: true,
878
+ type: 'interval',
879
+ intervalMs,
880
+ spec: `every ${everyMatch[1]}${unit}`,
881
+ description: `Every ${everyMatch[1]} ${unit}`
882
+ };
883
+ }
884
+
885
+ // 3. One-shot relative duration: "30s", "15m", "2h", "1d", "in 15m"
886
+ const onceMatch = lower.match(/^(?:in\s+)?(\d+(?:\.\d+)?)\s*([a-z]+)$/i);
887
+ if (onceMatch) {
888
+ const num = parseFloat(onceMatch[1]);
889
+ const unit = onceMatch[2];
890
+ const mult = DURATION_UNITS_MS[unit];
891
+ if (!mult || isNaN(num) || num <= 0) return null;
892
+ const delayMs = Math.round(num * mult);
893
+ if (delayMs < 1000) return null;
894
+ return {
895
+ valid: true,
896
+ type: 'once',
897
+ delayMs,
898
+ spec: `${onceMatch[1]}${unit}`,
899
+ description: `Once in ${onceMatch[1]} ${unit}`
900
+ };
901
+ }
902
+
903
+ // 4. Standard 5-field cron expression: "<min> <hour> <dom> <month> <dow>"
904
+ const cronTokens = spec.split(/\s+/);
905
+ if (cronTokens.length === 5) {
906
+ const minute = parseCronField(cronTokens[0], 0, 59);
907
+ const hour = parseCronField(cronTokens[1], 0, 23);
908
+ const dom = parseCronField(cronTokens[2], 1, 31);
909
+ const month = parseCronField(cronTokens[3], 1, 12);
910
+ const dow = parseCronField(cronTokens[4], 0, 6);
911
+ if (minute && hour && dom && month && dow) {
912
+ return {
913
+ valid: true,
914
+ type: 'cron',
915
+ fields: { minute, hour, dom, month, dow },
916
+ spec: cronTokens.join(' '),
917
+ description: `Cron (${cronTokens.join(' ')})`
918
+ };
919
+ }
920
+ }
921
+
922
+ return null;
923
+ }
924
+
925
+ /**
926
+ * Checks whether a Date object matches a parsed 5-field cron expression.
927
+ *
928
+ * @param {{minute: Set<number>, hour: Set<number>, dom: Set<number>, month: Set<number>, dow: Set<number>}} fields
929
+ * @param {Date} date
930
+ * @returns {boolean}
931
+ */
932
+ function matchesCronTime(fields, date) {
933
+ if (!fields || !(date instanceof Date)) return false;
934
+ return (
935
+ fields.minute.has(date.getMinutes()) &&
936
+ fields.hour.has(date.getHours()) &&
937
+ fields.dom.has(date.getDate()) &&
938
+ fields.month.has(date.getMonth() + 1) &&
939
+ fields.dow.has(date.getDay())
940
+ );
941
+ }
942
+
943
+ /**
944
+ * Computes the next trigger Date for a parsed 5-field cron expression starting after `fromDate`.
945
+ *
946
+ * @param {object} fields
947
+ * @param {Date} [fromDate=new Date()]
948
+ * @returns {Date|null}
949
+ */
950
+ function getNextCronTime(fields, fromDate = new Date()) {
951
+ if (!fields) return null;
952
+ const candidate = new Date(fromDate.getTime());
953
+ candidate.setSeconds(0, 0);
954
+ candidate.setMinutes(candidate.getMinutes() + 1);
955
+
956
+ // Search up to 366 days ahead (527,040 minutes)
957
+ const maxIterations = 366 * 24 * 60;
958
+ for (let i = 0; i < maxIterations; i++) {
959
+ if (matchesCronTime(fields, candidate)) {
960
+ return candidate;
961
+ }
962
+ candidate.setMinutes(candidate.getMinutes() + 1);
963
+ }
964
+ return null;
965
+ }
966
+
967
+ /**
968
+ * Parses a raw `/schedule` or `!schedule` command string into an action:
969
+ * - "list" / "" -> { action: 'list' }
970
+ * - "cancel <id|all>" / "remove <id>" / "clear" -> { action: 'cancel', target }
971
+ * - "<spec> <prompt>" -> { action: 'add', spec, parsedSpec, prompt }
972
+ *
973
+ * @param {string} rawInput
974
+ * @returns {{action: 'list'|'cancel'|'add'|'invalid', target?: string, spec?: string, parsedSpec?: object, prompt?: string, error?: string}}
975
+ */
976
+ function parseScheduleCommandInput(rawInput) {
977
+ const trimmed = String(rawInput || '').trim();
978
+ if (!trimmed || /^list$/i.test(trimmed)) {
979
+ return { action: 'list' };
980
+ }
981
+
982
+ if (/^(clear|cancel\s+all|remove\s+all|stop\s+all)$/i.test(trimmed)) {
983
+ return { action: 'cancel', target: 'all', jobId: 'all' };
984
+ }
985
+
986
+ const cancelMatch = trimmed.match(/^(?:cancel|remove|delete|stop)\s+#?(\S+)$/i);
987
+ if (cancelMatch) {
988
+ const id = cancelMatch[1].trim();
989
+ return { action: 'cancel', target: id, jobId: id };
990
+ }
991
+
992
+ // Pipe-delimited spec: "!schedule 0 9 * * 1-5 | !sh npm test"
993
+ if (trimmed.includes('|')) {
994
+ const pipeIdx = trimmed.indexOf('|');
995
+ const rawSpec = trimmed.slice(0, pipeIdx).trim();
996
+ const rawPrompt = trimmed.slice(pipeIdx + 1).trim();
997
+ const parsedSpec = parseScheduleSpec(rawSpec);
998
+ if (!parsedSpec) {
999
+ return { action: 'invalid', error: `Invalid schedule specification: \`${rawSpec}\`` };
1000
+ }
1001
+ if (!rawPrompt) {
1002
+ return { action: 'invalid', error: 'Scheduled prompt cannot be empty.' };
1003
+ }
1004
+ return { action: 'add', spec: parsedSpec.spec, parsedSpec, prompt: rawPrompt };
1005
+ }
1006
+
1007
+ // Quoted cron/spec: "!schedule \"*/15 * * * *\" run tests"
1008
+ const quotedMatch = trimmed.match(/^["']([^"']+)["']\s+([\s\S]+)$/);
1009
+ if (quotedMatch) {
1010
+ const parsedSpec = parseScheduleSpec(quotedMatch[1]);
1011
+ if (!parsedSpec) {
1012
+ return { action: 'invalid', error: `Invalid schedule specification: \`${quotedMatch[1]}\`` };
1013
+ }
1014
+ return { action: 'add', spec: parsedSpec.spec, parsedSpec, prompt: quotedMatch[2].trim() };
1015
+ }
1016
+
1017
+ // Unquoted "every <num><unit> <prompt>" or "in <num><unit> <prompt>"
1018
+ const twoTokenMatch = trimmed.match(/^((?:every|in)\s+\d+(?:\.\d+)?\s*[a-z]+)\s+([\s\S]+)$/i);
1019
+ if (twoTokenMatch) {
1020
+ const parsedSpec = parseScheduleSpec(twoTokenMatch[1]);
1021
+ if (!parsedSpec) {
1022
+ return { action: 'invalid', error: `Invalid interval specification: \`${twoTokenMatch[1]}\`` };
1023
+ }
1024
+ return { action: 'add', spec: parsedSpec.spec, parsedSpec, prompt: twoTokenMatch[2].trim() };
1025
+ }
1026
+
1027
+ // Unquoted 5-field cron at start: "*/15 * * * * <prompt>"
1028
+ const tokens = trimmed.split(/\s+/);
1029
+ if (tokens.length >= 6) {
1030
+ const maybeCron = tokens.slice(0, 5).join(' ');
1031
+ const parsedCron = parseScheduleSpec(maybeCron);
1032
+ if (parsedCron && parsedCron.type === 'cron') {
1033
+ const prompt = trimmed.slice(trimmed.indexOf(tokens[4]) + tokens[4].length).trim();
1034
+ if (prompt) {
1035
+ return { action: 'add', spec: parsedCron.spec, parsedSpec: parsedCron, prompt };
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ // Single-token spec: "15m <prompt>", "@hourly <prompt>"
1041
+ const singleTokenMatch = trimmed.match(/^(\S+)\s+([\s\S]+)$/);
1042
+ if (singleTokenMatch) {
1043
+ const parsedSpec = parseScheduleSpec(singleTokenMatch[1]);
1044
+ if (parsedSpec) {
1045
+ return { action: 'add', spec: parsedSpec.spec, parsedSpec, prompt: singleTokenMatch[2].trim() };
1046
+ }
1047
+ }
1048
+
1049
+ return {
1050
+ action: 'invalid',
1051
+ error: 'Invalid schedule syntax. Examples: `/schedule time:30m prompt:run npm test`, `!schedule every 1h check build`, or `!schedule "0 9 * * *" git status`.'
1052
+ };
1053
+ }
1054
+
1055
+ /**
1056
+ * In-memory Scheduler for one-shot, interval, and cron prompts in Dis-Gravity.
1057
+ */
1058
+ class TaskScheduler {
1059
+ constructor(options = {}) {
1060
+ this.maxJobs = options.maxJobs || 25;
1061
+ this.onTrigger = options.onTrigger || null;
1062
+ this.jobs = new Map();
1063
+ this.nextId = 1;
1064
+ }
1065
+
1066
+ /**
1067
+ * Schedules a new prompt job.
1068
+ *
1069
+ * @param {{spec: string, prompt: string, threadId?: string|null, channelId?: string|null, workspace?: string|null, workspacePath?: string|null, author?: string, userId?: string, onTrigger?: (job: object) => void|Promise<void>}} opts
1070
+ * @returns {object} Created job metadata
1071
+ */
1072
+ addJob(opts = {}) {
1073
+ const returnResultObj = Boolean(this.onTrigger && !opts.onTrigger);
1074
+ const fail = (msg) => {
1075
+ if (returnResultObj) return { ok: false, error: msg };
1076
+ throw new Error(msg);
1077
+ };
1078
+
1079
+ if (this.jobs.size >= this.maxJobs) {
1080
+ return fail(`Maximum active scheduled tasks (${this.maxJobs}) reached. Cancel an existing schedule first.`);
1081
+ }
1082
+
1083
+ const parsed = typeof opts.spec === 'object' && opts.spec !== null
1084
+ ? opts.spec
1085
+ : parseScheduleSpec(opts.spec);
1086
+
1087
+ if (!parsed) {
1088
+ return fail(`Invalid schedule specification: ${opts.spec}`);
1089
+ }
1090
+
1091
+ const prompt = String(opts.prompt || '').trim();
1092
+ if (!prompt) {
1093
+ return fail('Scheduled prompt cannot be empty.');
1094
+ }
1095
+
1096
+ const id = String(this.nextId++);
1097
+ const now = Date.now();
1098
+ let nextRunAt = null;
1099
+
1100
+ if (parsed.type === 'once') {
1101
+ nextRunAt = new Date(now + parsed.delayMs);
1102
+ } else if (parsed.type === 'interval') {
1103
+ nextRunAt = new Date(now + parsed.intervalMs);
1104
+ } else if (parsed.type === 'cron') {
1105
+ nextRunAt = getNextCronTime(parsed.fields, new Date(now));
1106
+ if (!nextRunAt) {
1107
+ return fail(`Cron expression "${parsed.spec}" has no upcoming execution dates.`);
1108
+ }
1109
+ }
1110
+
1111
+ const ws = opts.workspace || opts.workspacePath || null;
1112
+ const job = {
1113
+ id,
1114
+ spec: parsed.spec,
1115
+ type: parsed.type,
1116
+ description: parsed.description,
1117
+ prompt,
1118
+ threadId: opts.threadId || null,
1119
+ channelId: opts.channelId || null,
1120
+ workspace: ws,
1121
+ workspacePath: ws,
1122
+ author: opts.author || opts.userId || 'Discord User',
1123
+ userId: opts.userId || opts.author || 'Discord User',
1124
+ createdAt: new Date(now).toISOString(),
1125
+ nextRunAt: nextRunAt ? nextRunAt.toISOString() : null,
1126
+ runCount: 0,
1127
+ _timer: null,
1128
+ _parsed: parsed,
1129
+ _onTrigger: opts.onTrigger || this.onTrigger
1130
+ };
1131
+
1132
+ this._armJob(job);
1133
+ this.jobs.set(id, job);
1134
+ return this._sanitizeJob(job);
1135
+ }
1136
+
1137
+ _armJob(job) {
1138
+ if (job._timer) {
1139
+ clearTimeout(job._timer);
1140
+ clearInterval(job._timer);
1141
+ job._timer = null;
1142
+ }
1143
+
1144
+ const parsed = job._parsed;
1145
+ if (parsed.type === 'once') {
1146
+ job._timer = setTimeout(async () => {
1147
+ job.runCount++;
1148
+ this.jobs.delete(job.id);
1149
+ try {
1150
+ await job._onTrigger?.(this._sanitizeJob(job));
1151
+ } catch { }
1152
+ }, parsed.delayMs);
1153
+ if (job._timer && typeof job._timer.unref === 'function') job._timer.unref();
1154
+ } else if (parsed.type === 'interval') {
1155
+ job._timer = setInterval(async () => {
1156
+ job.runCount++;
1157
+ job.nextRunAt = new Date(Date.now() + parsed.intervalMs).toISOString();
1158
+ try {
1159
+ await job._onTrigger?.(this._sanitizeJob(job));
1160
+ } catch { }
1161
+ }, parsed.intervalMs);
1162
+ if (job._timer && typeof job._timer.unref === 'function') job._timer.unref();
1163
+ } else if (parsed.type === 'cron') {
1164
+ const scheduleNextCronTick = () => {
1165
+ if (!this.jobs.has(job.id)) return;
1166
+ const nextDate = getNextCronTime(parsed.fields, new Date());
1167
+ if (!nextDate) {
1168
+ this.jobs.delete(job.id);
1169
+ return;
1170
+ }
1171
+ job.nextRunAt = nextDate.toISOString();
1172
+ // Clamp setTimeout to 24h max chunk to avoid 32-bit signed integer overflow (24.8 days)
1173
+ const waitMs = Math.max(500, nextDate.getTime() - Date.now());
1174
+ const chunkMs = Math.min(waitMs, 24 * 3600 * 1000);
1175
+ job._timer = setTimeout(async () => {
1176
+ if (!this.jobs.has(job.id)) return;
1177
+ if (Date.now() + 500 < nextDate.getTime()) {
1178
+ scheduleNextCronTick();
1179
+ return;
1180
+ }
1181
+ job.runCount++;
1182
+ try {
1183
+ await job._onTrigger?.(this._sanitizeJob(job));
1184
+ } catch { }
1185
+ scheduleNextCronTick();
1186
+ }, chunkMs);
1187
+ if (job._timer && typeof job._timer.unref === 'function') job._timer.unref();
1188
+ };
1189
+ scheduleNextCronTick();
1190
+ }
1191
+ }
1192
+
1193
+ _sanitizeJob(job) {
1194
+ const out = {
1195
+ ok: true,
1196
+ id: job.id,
1197
+ spec: job.spec,
1198
+ type: job.type,
1199
+ description: job.description,
1200
+ prompt: job.prompt,
1201
+ threadId: job.threadId,
1202
+ channelId: job.channelId,
1203
+ workspace: job.workspace,
1204
+ workspacePath: job.workspacePath || job.workspace,
1205
+ author: job.author,
1206
+ userId: job.userId,
1207
+ createdAt: job.createdAt,
1208
+ nextRunAt: job.nextRunAt,
1209
+ runCount: job.runCount
1210
+ };
1211
+ Object.defineProperty(out, 'job', { value: out, enumerable: false });
1212
+ return out;
1213
+ }
1214
+
1215
+ listJobs() {
1216
+ return Array.from(this.jobs.values()).map(j => this._sanitizeJob(j));
1217
+ }
1218
+
1219
+ getJob(id) {
1220
+ const cleanId = String(id || '').replace(/^#/, '').trim();
1221
+ const j = this.jobs.get(cleanId);
1222
+ return j ? this._sanitizeJob(j) : null;
1223
+ }
1224
+
1225
+ cancelJob(id) {
1226
+ const cleanId = String(id || '').replace(/^#/, '').trim();
1227
+ const job = this.jobs.get(cleanId);
1228
+ if (!job) return false;
1229
+ if (job._timer) {
1230
+ clearTimeout(job._timer);
1231
+ clearInterval(job._timer);
1232
+ job._timer = null;
1233
+ }
1234
+ this.jobs.delete(cleanId);
1235
+ return true;
1236
+ }
1237
+
1238
+ cancelAll() {
1239
+ const count = this.jobs.size;
1240
+ for (const job of this.jobs.values()) {
1241
+ if (job._timer) {
1242
+ clearTimeout(job._timer);
1243
+ clearInterval(job._timer);
1244
+ job._timer = null;
1245
+ }
1246
+ }
1247
+ this.jobs.clear();
1248
+ return count;
1249
+ }
1250
+
1251
+ stopAll() {
1252
+ return this.cancelAll();
1253
+ }
1254
+ }
1255
+
1256
+ // ============================================================================
1257
+ // Feature 2: Discord Thread-per-Session & Workspace Mapping Engine
1258
+ // ============================================================================
1259
+
1260
+ /**
1261
+ * Manages bindings between Discord Threads and local Workspace directories / Agent sessions.
1262
+ */
1263
+ class ThreadWorkspaceManager {
1264
+ constructor(options = {}) {
1265
+ this.bindings = new Map();
1266
+ this.defaultWorkspace = options.defaultWorkspace || process.cwd();
1267
+ this.hasDefaultWorkspace = Boolean(options.defaultWorkspace);
1268
+ this.storageFile = options.storageFile || null;
1269
+ if (this.storageFile) {
1270
+ this._loadFromDisk();
1271
+ }
1272
+ }
1273
+
1274
+ _loadFromDisk() {
1275
+ if (!this.storageFile) return;
1276
+ try {
1277
+ if (fs.existsSync(this.storageFile)) {
1278
+ const raw = JSON.parse(fs.readFileSync(this.storageFile, 'utf8'));
1279
+ this.loadFromJSON(raw);
1280
+ }
1281
+ } catch { }
1282
+ }
1283
+
1284
+ _saveToDisk() {
1285
+ if (!this.storageFile) return;
1286
+ try {
1287
+ const dir = path.dirname(this.storageFile);
1288
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1289
+ fs.writeFileSync(this.storageFile, JSON.stringify(this.toJSON(), null, 2), { encoding: 'utf8', mode: 0o600 });
1290
+ } catch { }
1291
+ }
1292
+
1293
+ /**
1294
+ * Binds a Discord thread ID to a specific local workspace directory and optional session metadata.
1295
+ *
1296
+ * @param {string} threadId
1297
+ * @param {string} workspacePath
1298
+ * @param {{label?: string, conversationName?: string, boundBy?: string, baseWorkspace?: string}} [meta={}]
1299
+ * @returns {{threadId: string, workspace: string, label: string, conversationName: string|null, boundBy: string, boundAt: string}}
1300
+ */
1301
+ bindThread(threadId, workspacePath, meta = {}) {
1302
+ const returnResultObj = Boolean(this.hasDefaultWorkspace && !meta.boundBy);
1303
+ const fail = (msg) => {
1304
+ if (returnResultObj) return { ok: false, error: msg };
1305
+ throw new Error(msg);
1306
+ };
1307
+
1308
+ const cleanThreadId = String(threadId || '').trim();
1309
+ if (!cleanThreadId) {
1310
+ return fail('Thread ID is required to bind a workspace.');
1311
+ }
1312
+ const rawPath = String(workspacePath || '').trim();
1313
+ if (!rawPath || rawPath.includes('\0')) {
1314
+ return fail('Valid workspace directory path is required.');
1315
+ }
1316
+
1317
+ const resolved = meta.baseWorkspace && !path.isAbsolute(rawPath)
1318
+ ? path.resolve(meta.baseWorkspace, rawPath)
1319
+ : path.resolve(this.defaultWorkspace, rawPath);
1320
+
1321
+ if (!fs.existsSync(resolved)) {
1322
+ return fail(`Workspace path does not exist: ${resolved}`);
1323
+ }
1324
+ const stat = fs.statSync(resolved);
1325
+ if (!stat.isDirectory()) {
1326
+ return fail(`Target path is not a directory: ${resolved}`);
1327
+ }
1328
+
1329
+ const existing = this.bindings.get(cleanThreadId) || {};
1330
+ const entry = {
1331
+ threadId: cleanThreadId,
1332
+ workspace: resolved,
1333
+ workspacePath: resolved,
1334
+ label: meta.label || path.basename(resolved) || resolved,
1335
+ conversationName: meta.conversationName !== undefined ? meta.conversationName : (existing.conversationName || null),
1336
+ boundBy: meta.boundBy || meta.userId || existing.boundBy || 'Discord User',
1337
+ boundAt: new Date().toISOString()
1338
+ };
1339
+
1340
+ this.bindings.set(cleanThreadId, entry);
1341
+ this._saveToDisk();
1342
+ const out = { ok: true, ...entry };
1343
+ Object.defineProperty(out, 'binding', { value: out, enumerable: false });
1344
+ return out;
1345
+ }
1346
+
1347
+ /**
1348
+ * Updates or sets the conversation session name for a thread without requiring a workspace change.
1349
+ */
1350
+ setThreadConversation(threadId, conversationName, fallbackWorkspace = null) {
1351
+ const cleanThreadId = String(threadId || '').trim();
1352
+ if (!cleanThreadId) return null;
1353
+ const existing = this.bindings.get(cleanThreadId);
1354
+ if (existing) {
1355
+ existing.conversationName = conversationName;
1356
+ this._saveToDisk();
1357
+ return { ...existing };
1358
+ }
1359
+ if (fallbackWorkspace && fs.existsSync(fallbackWorkspace)) {
1360
+ const resolved = path.resolve(fallbackWorkspace);
1361
+ const entry = {
1362
+ threadId: cleanThreadId,
1363
+ workspace: resolved,
1364
+ label: path.basename(resolved),
1365
+ conversationName: conversationName || null,
1366
+ boundBy: 'auto',
1367
+ boundAt: new Date().toISOString()
1368
+ };
1369
+ this.bindings.set(cleanThreadId, entry);
1370
+ this._saveToDisk();
1371
+ return { ...entry };
1372
+ }
1373
+ return null;
1374
+ }
1375
+
1376
+ unbindThread(threadId) {
1377
+ const cleanThreadId = String(threadId || '').trim();
1378
+ const deleted = this.bindings.delete(cleanThreadId);
1379
+ if (deleted) this._saveToDisk();
1380
+ return deleted;
1381
+ }
1382
+
1383
+ getThreadBinding(threadId) {
1384
+ if (!threadId) return null;
1385
+ const entry = this.bindings.get(String(threadId).trim());
1386
+ return entry ? { ...entry, workspacePath: entry.workspacePath || entry.workspace } : null;
1387
+ }
1388
+
1389
+ /**
1390
+ * Resolves the effective workspace directory for a given threadId (or returns fallbackWorkspace).
1391
+ *
1392
+ * @param {string|null|undefined} threadId
1393
+ * @param {string} [fallbackWorkspace]
1394
+ * @returns {string}
1395
+ */
1396
+ getThreadWorkspace(threadId, fallbackWorkspace = this.defaultWorkspace) {
1397
+ if (threadId) {
1398
+ const entry = this.bindings.get(String(threadId).trim());
1399
+ if (entry && entry.workspace && fs.existsSync(entry.workspace)) {
1400
+ return entry.workspace;
1401
+ }
1402
+ }
1403
+ return fallbackWorkspace;
1404
+ }
1405
+
1406
+ listBindings() {
1407
+ return Array.from(this.bindings.values()).map(b => ({ ...b, workspacePath: b.workspacePath || b.workspace }));
1408
+ }
1409
+
1410
+ toJSON() {
1411
+ return Array.from(this.bindings.entries());
1412
+ }
1413
+
1414
+ loadFromJSON(data) {
1415
+ if (!data) return;
1416
+ if (Array.isArray(data)) {
1417
+ for (const item of data) {
1418
+ if (Array.isArray(item) && item.length === 2 && item[0] && item[1]?.workspace) {
1419
+ this.bindings.set(String(item[0]), item[1]);
1420
+ } else if (item && item.threadId && item.workspace) {
1421
+ this.bindings.set(String(item.threadId), item);
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ // ============================================================================
1429
+ // Feature 3: Discord Voice Message & Audio Transcription Engine
1430
+ // ============================================================================
1431
+
1432
+ const AUDIO_EXTENSIONS = new Set([
1433
+ '.ogg', '.oga', '.opus', '.mp3', '.wav', '.m4a', '.webm', '.flac', '.aac'
1434
+ ]);
1435
+
1436
+ /**
1437
+ * Detects whether a Discord attachment or file descriptor is a voice message or audio file.
1438
+ *
1439
+ * @param {object} att - Attachment object ({ name, url, contentType, content_type, duration_secs, waveform, flags })
1440
+ * @param {number} [messageFlags=0] - Optional Discord message bitfield flags (1 << 13 = 8192 = IS_VOICE_MESSAGE)
1441
+ * @returns {boolean}
1442
+ */
1443
+ function isAudioAttachment(att, messageFlags = 0) {
1444
+ if (!att || typeof att !== 'object') return false;
1445
+ if ((Number(messageFlags) & 8192) === 8192) return true;
1446
+ if (att.waveform || typeof att.duration_secs === 'number' || typeof att.duration === 'number') return true;
1447
+
1448
+ const contentType = String(att.contentType || att.content_type || '').toLowerCase().trim();
1449
+ if (contentType.startsWith('audio/') || contentType === 'application/ogg') {
1450
+ return true;
1451
+ }
1452
+
1453
+ const name = String(att.name || att.filename || att.originalName || att.fileName || att.url || '').split('?')[0];
1454
+ const ext = path.extname(name).toLowerCase();
1455
+ return AUDIO_EXTENSIONS.has(ext);
1456
+ }
1457
+
1458
+ /**
1459
+ * Transcribes a local audio file into text using:
1460
+ * 1. Custom transcriber function (`options.transcriber`) if provided
1461
+ * 2. OpenAI / Groq / Whisper-compatible Cloud REST API (`OPENAI_API_KEY`, `GROQ_API_KEY`, `DISGRAVITY_WHISPER_API_KEY`, or `options.apiKey`)
1462
+ * 3. Local Whisper CLI (`whisper`, `whisper-cli`, `whisper-cpp`) if installed on PATH
1463
+ * 4. Windows native System.Speech fallback for `.wav` files
1464
+ *
1465
+ * @param {string} filePath - Absolute path to downloaded audio file
1466
+ * @param {{apiKey?: string, apiUrl?: string, model?: string, language?: string, transcriber?: Function, timeoutMs?: number}} [options={}]
1467
+ * @returns {Promise<{ok: boolean, text: string, provider: string, durationMs: number, error: string|null}>}
1468
+ */
1469
+ async function transcribeAudioFile(filePath, options = {}) {
1470
+ const startTime = Date.now();
1471
+ if (!filePath || typeof filePath !== 'string' || !fs.existsSync(filePath)) {
1472
+ return {
1473
+ ok: false,
1474
+ text: '',
1475
+ provider: 'none',
1476
+ durationMs: 0,
1477
+ error: 'Audio file does not exist'
1478
+ };
1479
+ }
1480
+
1481
+ const stat = fs.statSync(filePath);
1482
+ if (!stat.isFile() || stat.size === 0) {
1483
+ return {
1484
+ ok: false,
1485
+ text: '',
1486
+ provider: 'none',
1487
+ durationMs: 0,
1488
+ error: 'Audio file is empty'
1489
+ };
1490
+ }
1491
+
1492
+ // 1. Custom injected transcriber (or mock in tests)
1493
+ if (typeof options.transcriber === 'function') {
1494
+ try {
1495
+ const res = await options.transcriber(filePath, options);
1496
+ const text = typeof res === 'string' ? res.trim() : String(res?.text || '').trim();
1497
+ return {
1498
+ ok: Boolean(text),
1499
+ text,
1500
+ provider: (res && res.provider) || 'custom',
1501
+ durationMs: Date.now() - startTime,
1502
+ error: text ? null : 'Empty transcription result'
1503
+ };
1504
+ } catch (err) {
1505
+ return {
1506
+ ok: false,
1507
+ text: '',
1508
+ provider: 'custom',
1509
+ durationMs: Date.now() - startTime,
1510
+ error: err.message
1511
+ };
1512
+ }
1513
+ }
1514
+
1515
+ // 2. Whisper-compatible Cloud API (OpenAI / Groq / Custom)
1516
+ const groqKey = process.env.GROQ_API_KEY || '';
1517
+ const openaiKey = process.env.OPENAI_API_KEY || '';
1518
+ const customKey = options.apiKey || process.env.DISGRAVITY_WHISPER_API_KEY || '';
1519
+ const apiKey = (customKey || openaiKey || groqKey).trim();
1520
+
1521
+ if (apiKey && typeof fetch === 'function') {
1522
+ const isGroq = !customKey && !openaiKey && Boolean(groqKey) || apiKey.startsWith('gsk_');
1523
+ const apiUrl = (options.apiUrl || process.env.DISGRAVITY_WHISPER_API_URL || (
1524
+ isGroq
1525
+ ? 'https://api.groq.com/openai/v1/audio/transcriptions'
1526
+ : 'https://api.openai.com/v1/audio/transcriptions'
1527
+ )).trim();
1528
+ const model = options.model || process.env.DISGRAVITY_WHISPER_MODEL || (
1529
+ isGroq ? 'whisper-large-v3-turbo' : 'whisper-1'
1530
+ );
1531
+
1532
+ try {
1533
+ const fileBuf = await fs.promises.readFile(filePath);
1534
+ const fileName = path.basename(filePath);
1535
+ const boundary = `----DisGravityAudioBoundary${crypto.randomBytes(8).toString('hex')}`;
1536
+
1537
+ const parts = [];
1538
+ const addField = (name, val) => {
1539
+ parts.push(Buffer.from(
1540
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${val}\r\n`,
1541
+ 'utf8'
1542
+ ));
1543
+ };
1544
+ addField('model', model);
1545
+ addField('response_format', 'json');
1546
+ if (options.language) addField('language', options.language);
1547
+
1548
+ parts.push(Buffer.from(
1549
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName.replace(/"/g, '_')}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
1550
+ 'utf8'
1551
+ ));
1552
+ parts.push(fileBuf);
1553
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
1554
+
1555
+ const bodyBuffer = Buffer.concat(parts);
1556
+ const res = await fetch(apiUrl, {
1557
+ method: 'POST',
1558
+ headers: {
1559
+ Authorization: `Bearer ${apiKey}`,
1560
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`
1561
+ },
1562
+ body: bodyBuffer,
1563
+ signal: AbortSignal.timeout(options.timeoutMs || 30000)
1564
+ });
1565
+
1566
+ if (res.ok) {
1567
+ const data = await res.json();
1568
+ const text = String(data.text || '').trim();
1569
+ if (text) {
1570
+ return {
1571
+ ok: true,
1572
+ text,
1573
+ provider: isGroq ? 'groq-whisper' : 'openai-whisper',
1574
+ durationMs: Date.now() - startTime,
1575
+ error: null
1576
+ };
1577
+ }
1578
+ } else {
1579
+ const errBody = await res.text().catch(() => '');
1580
+ return {
1581
+ ok: false,
1582
+ text: '',
1583
+ provider: isGroq ? 'groq-whisper' : 'openai-whisper',
1584
+ durationMs: Date.now() - startTime,
1585
+ error: `Whisper API HTTP ${res.status}: ${errBody.substring(0, 200)}`
1586
+ };
1587
+ }
1588
+ } catch (err) {
1589
+ return {
1590
+ ok: false,
1591
+ text: '',
1592
+ provider: 'whisper-api',
1593
+ durationMs: Date.now() - startTime,
1594
+ error: err.message
1595
+ };
1596
+ }
1597
+ }
1598
+
1599
+ // 3. Local CLI Whisper ('whisper-cli', 'whisper')
1600
+ const isWin = process.platform === 'win32';
1601
+ const whichCmd = isWin ? 'where.exe' : 'which';
1602
+ const localBins = ['whisper-cli', 'whisper'];
1603
+ for (const bin of localBins) {
1604
+ let found = false;
1605
+ try {
1606
+ cp.execFileSync(whichCmd, [bin], { stdio: 'ignore' });
1607
+ found = true;
1608
+ } catch { }
1609
+ if (!found) continue;
1610
+
1611
+ try {
1612
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'disgravity_stt_'));
1613
+ try {
1614
+ await new Promise((resolve, reject) => {
1615
+ cp.execFile(
1616
+ bin,
1617
+ [filePath, '--model', 'base', '--output_format', 'txt', '--output_dir', outDir],
1618
+ { timeout: options.timeoutMs || 45000, windowsHide: true },
1619
+ (err) => (err ? reject(err) : resolve())
1620
+ );
1621
+ });
1622
+ const files = fs.readdirSync(outDir).filter(f => f.endsWith('.txt'));
1623
+ if (files.length > 0) {
1624
+ const text = fs.readFileSync(path.join(outDir, files[0]), 'utf8').trim();
1625
+ if (text) {
1626
+ return {
1627
+ ok: true,
1628
+ text,
1629
+ provider: `local-${bin}`,
1630
+ durationMs: Date.now() - startTime,
1631
+ error: null
1632
+ };
1633
+ }
1634
+ }
1635
+ } finally {
1636
+ try { fs.rmSync(outDir, { recursive: true, force: true }); } catch { }
1637
+ }
1638
+ } catch { }
1639
+ }
1640
+
1641
+ // 4. Windows System.Speech fallback for .wav files
1642
+ if (isWin && path.extname(filePath).toLowerCase() === '.wav') {
1643
+ try {
1644
+ const psScript = [
1645
+ 'Add-Type -AssemblyName System.Speech;',
1646
+ '$rec = New-Object System.Speech.Recognition.SpeechRecognitionEngine;',
1647
+ '$rec.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar));',
1648
+ '$rec.SetInputToWaveFile($env:DISGRAVITY_WAV_PATH);',
1649
+ '$res = $rec.Recognize();',
1650
+ 'if ($res) { Write-Output $res.Text }'
1651
+ ].join(' ');
1652
+ const text = await new Promise((resolve, reject) => {
1653
+ cp.execFile(
1654
+ 'powershell.exe',
1655
+ ['-NoProfile', '-NonInteractive', '-Command', psScript],
1656
+ {
1657
+ env: { ...process.env, DISGRAVITY_WAV_PATH: filePath },
1658
+ timeout: options.timeoutMs || 20000,
1659
+ windowsHide: true
1660
+ },
1661
+ (err, stdout) => (err ? reject(err) : resolve(String(stdout || '').trim()))
1662
+ );
1663
+ });
1664
+ if (text) {
1665
+ return {
1666
+ ok: true,
1667
+ text,
1668
+ provider: 'windows-speech',
1669
+ durationMs: Date.now() - startTime,
1670
+ error: null
1671
+ };
1672
+ }
1673
+ } catch { }
1674
+ }
1675
+
1676
+ return {
1677
+ ok: false,
1678
+ text: '',
1679
+ provider: 'none',
1680
+ durationMs: Date.now() - startTime,
1681
+ error: 'No transcription engine configured. Set OPENAI_API_KEY / GROQ_API_KEY (or antigravity-discord.whisperApiKey) or install local whisper CLI.'
1682
+ };
1683
+ }
1684
+
1685
+ module.exports = {
1686
+ isUserAllowed,
1687
+ executeShellCommand,
1688
+ findHeadlessBrowser,
1689
+ escapeHtml,
1690
+ buildTableHtml,
1691
+ renderTableToPng,
1692
+ buildDiffHtml,
1693
+ renderDiffToPng,
1694
+ detectDestructiveKeywords,
1695
+ captureWorkspaceCheckpoint,
1696
+ restoreWorkspaceCheckpoint,
1697
+ parseMarkdownTable,
1698
+ safeResolveWorkspacePath,
1699
+ readWorkspaceFile,
1700
+ listWorkspaceTree,
1701
+ getGitStatusAndDiff,
1702
+ getSystemHealth,
1703
+ parseCronField,
1704
+ parseScheduleSpec,
1705
+ matchesCronTime,
1706
+ getNextCronTime,
1707
+ parseScheduleCommandInput,
1708
+ TaskScheduler,
1709
+ ThreadWorkspaceManager,
1710
+ isAudioAttachment,
1711
+ transcribeAudioFile
1712
+ };