minovative-mind-cli 2.13.5 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1395 @@
1
+ /**
2
+ * @fileoverview Context Mentions and Autocomplete Engine for Minovative Mind CLI.
3
+ *
4
+ * This service provides:
5
+ * 1. **Autocomplete Suggestions**: Real-time suggestion generation for `@files`, `@symbols`,
6
+ * `@git` context helpers, `@workspace` aliases, and `@diagnostics` / special mentions.
7
+ * 2. **Mention Parsing**: Robust parsing of `@mention` tokens in user prompts with support for
8
+ * line ranges (`:10-50`), scoped symbol targets (`@symbol:foo`), git modifiers (`@git:diff`),
9
+ * and cross-workspace references (`@alias/path`).
10
+ * 3. **Context Resolution**: Resolves all mentions in a prompt into structured, sanitized,
11
+ * token-budgeted XML injection blocks ready for LLM consumption.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import ignore from 'ignore';
18
+ import { distance } from 'fastest-levenshtein';
19
+ import { workspaceRegistry } from './workspaceRegistry.js';
20
+ import { extractSymbols, extractSymbolIndex, } from '../utils/symbolExtractor.js';
21
+ import { sanitizeForCDATA, estimateTokens, formatContextMentions, CONTEXT_BUDGET_CONFIG, } from '../utils/contextPrompts.js';
22
+ import { resolveAndValidateMultiWorkspacePath, } from '../utils/pathSecurity.js';
23
+ import { localValidate } from '../utils/localSyntaxValidator.js';
24
+ import { debugLog } from '../utils/logger.js';
25
+ const execFileAsync = promisify(execFile);
26
+ // ─── Default Ignored Patterns ────────────────────────────────────────
27
+ const DEFAULT_IGNORED_DIRS = new Set([
28
+ 'node_modules',
29
+ '.git',
30
+ 'dist',
31
+ 'build',
32
+ '.next',
33
+ '.nuxt',
34
+ '__pycache__',
35
+ '.venv',
36
+ 'venv',
37
+ '.cache',
38
+ 'coverage',
39
+ '.turbo',
40
+ '.tmp',
41
+ 'temp',
42
+ 'tmp',
43
+ 'scratch',
44
+ '.minovativemind',
45
+ '.minovative-mind-cli',
46
+ ]);
47
+ const DEFAULT_IGNORED_FILES = new Set([
48
+ 'package-lock.json',
49
+ 'yarn.lock',
50
+ 'pnpm-lock.yaml',
51
+ '.DS_Store',
52
+ 'Thumbs.db',
53
+ '.minovative-scratch.js',
54
+ ]);
55
+ const SOURCE_FILE_EXTENSIONS = new Set([
56
+ '.ts',
57
+ '.tsx',
58
+ '.js',
59
+ '.jsx',
60
+ '.mts',
61
+ '.cts',
62
+ '.mjs',
63
+ '.cjs',
64
+ '.py',
65
+ '.pyi',
66
+ '.go',
67
+ '.rs',
68
+ '.java',
69
+ '.c',
70
+ '.cpp',
71
+ '.h',
72
+ '.hpp',
73
+ '.cs',
74
+ '.php',
75
+ '.rb',
76
+ '.swift',
77
+ '.kt',
78
+ '.json',
79
+ '.yaml',
80
+ '.yml',
81
+ '.toml',
82
+ '.md',
83
+ '.css',
84
+ '.scss',
85
+ '.html',
86
+ '.sql',
87
+ '.sh',
88
+ '.bash',
89
+ ]);
90
+ /**
91
+ * Context Mentions Engine.
92
+ *
93
+ * Core engine responsible for parsing `@` tokens, providing fast autocomplete suggestions,
94
+ * and resolving file, symbol, git, workspace, and diagnostic mentions into structured context.
95
+ */
96
+ export class MentionEngine {
97
+ /** In-memory cache for workspace file listings (TTL: 15 seconds) */
98
+ fileListCache = new Map();
99
+ /** In-memory cache for parsed symbol indexes per file path */
100
+ symbolIndexCache = new Map();
101
+ /** Cache TTL in milliseconds */
102
+ CACHE_TTL_MS = 15_000;
103
+ // ─── Mention Parsing ────────────────────────────────────────────────
104
+ /**
105
+ * Parses all `@mention` tokens from a user prompt text string.
106
+ *
107
+ * Correctly ignores email addresses (e.g. `user@domain.com`) and supports:
108
+ * - `@file:<path>` or `@<path>` (e.g. `@src/index.ts`, `@src/index.ts:10-50`)
109
+ * - `@symbol:<name>` or `@#<name>` or `@symbol:<filePath>:<name>`
110
+ * - `@git:diff`, `@git:staged`, `@git:branch`, `@git:log`, `@git:status`, `@diff`
111
+ * - `@<alias>` or `@<alias>/<path>` for registered external workspaces
112
+ * - `@diagnostics`, `@problems`, `@errors`
113
+ *
114
+ * @param prompt - The input prompt text.
115
+ * @param workspaceRoot - Optional workspace root for alias and file disambiguation.
116
+ * @returns Array of ParsedMention objects.
117
+ */
118
+ parseMentions(prompt, workspaceRoot = process.cwd()) {
119
+ if (!prompt || typeof prompt !== 'string')
120
+ return [];
121
+ const mentions = [];
122
+ // Match @ preceded by whitespace, start of line, or common delimiters (excluding word chars to avoid email matching)
123
+ const mentionRegex = /(?:^|[\s([{"'`=:,])@([A-Za-z0-9_#.:/\-@]+)/g;
124
+ let match;
125
+ while ((match = mentionRegex.exec(prompt)) !== null) {
126
+ const fullMatch = match[0];
127
+ const token = match[1];
128
+ // Determine exact start offset of '@'
129
+ const atIndex = match.index + (fullMatch.indexOf('@'));
130
+ const raw = `@${token}`;
131
+ const range = [atIndex, atIndex + raw.length];
132
+ // Defensive guard against trailing punctuation (e.g. `@src/index.ts.` or `@git:diff,`)
133
+ let cleanToken = token;
134
+ let cleanRaw = raw;
135
+ let cleanEnd = range[1];
136
+ while (cleanToken.length > 0 && /[,.;:!?'")\]}>]$/.test(cleanToken)) {
137
+ cleanToken = cleanToken.slice(0, -1);
138
+ cleanRaw = `@${cleanToken}`;
139
+ cleanEnd--;
140
+ }
141
+ if (!cleanToken)
142
+ continue;
143
+ const parsed = this.classifyMentionToken(cleanRaw, cleanToken, atIndex, cleanEnd, workspaceRoot);
144
+ if (parsed) {
145
+ mentions.push(parsed);
146
+ }
147
+ }
148
+ return mentions;
149
+ }
150
+ /**
151
+ * Classifies a raw mention token into its appropriate MentionType and targets.
152
+ *
153
+ * @private
154
+ */
155
+ classifyMentionToken(raw, token, start, end, workspaceRoot) {
156
+ const range = [start, end];
157
+ // 1. Git Mentions (@git:diff, @diff, @git:staged, @git:status, @git:branch, @git:log)
158
+ if (token === 'git' ||
159
+ token === 'diff' ||
160
+ token.startsWith('git:') ||
161
+ token.startsWith('diff:')) {
162
+ let sub = 'diff';
163
+ if (token.includes(':')) {
164
+ sub = token.split(':')[1].toLowerCase();
165
+ }
166
+ else if (token === 'diff') {
167
+ sub = 'diff';
168
+ }
169
+ return {
170
+ raw,
171
+ type: 'git',
172
+ target: sub || 'diff',
173
+ range,
174
+ valid: true,
175
+ };
176
+ }
177
+ // 2. Diagnostics / Problems / Errors Mentions (@diagnostics, @problems, @errors, @diag, @prob)
178
+ if (token === 'diagnostics' ||
179
+ token === 'problems' ||
180
+ token === 'errors' ||
181
+ token === 'diag' ||
182
+ token === 'prob') {
183
+ return {
184
+ raw,
185
+ type: 'diagnostics',
186
+ target: token,
187
+ range,
188
+ valid: true,
189
+ };
190
+ }
191
+ // 3. Terminal / Console Mentions (@terminal, @console, @term)
192
+ if (token === 'terminal' || token === 'console' || token === 'term') {
193
+ return {
194
+ raw,
195
+ type: 'terminal',
196
+ target: token,
197
+ range,
198
+ valid: true,
199
+ };
200
+ }
201
+ // 4. Explicit Symbol Mentions (@symbol:name, @#name, @symbol:filePath:name)
202
+ if (token.startsWith('symbol:') || token.startsWith('sym:') || token.startsWith('#')) {
203
+ const body = token.startsWith('symbol:')
204
+ ? token.slice(7)
205
+ : token.startsWith('sym:')
206
+ ? token.slice(4)
207
+ : token.slice(1);
208
+ if (!body) {
209
+ return {
210
+ raw,
211
+ type: 'symbol',
212
+ target: '',
213
+ range,
214
+ valid: false,
215
+ error: 'Empty symbol identifier.',
216
+ };
217
+ }
218
+ // Check if file path is prefixed (e.g. `src/utils.ts:extractSymbols`)
219
+ if (body.includes(':')) {
220
+ const parts = body.split(':');
221
+ return {
222
+ raw,
223
+ type: 'symbol',
224
+ target: parts[parts.length - 1],
225
+ subTarget: parts.slice(0, -1).join(':'),
226
+ range,
227
+ valid: true,
228
+ };
229
+ }
230
+ return {
231
+ raw,
232
+ type: 'symbol',
233
+ target: body,
234
+ range,
235
+ valid: true,
236
+ };
237
+ }
238
+ // 5. Explicit File Mentions (@file:path or @file:path:10-50)
239
+ if (token.startsWith('file:')) {
240
+ const filePathWithLine = token.slice(5);
241
+ const { filePath, lineRange } = this.parseLineRange(filePathWithLine);
242
+ const alias = workspaceRegistry.isAliasedPath(filePath)
243
+ ? filePath.slice(1).split('/')[0]
244
+ : null;
245
+ return {
246
+ raw,
247
+ type: 'file',
248
+ target: filePath,
249
+ workspaceAlias: alias,
250
+ lineRange,
251
+ range,
252
+ valid: Boolean(filePath),
253
+ };
254
+ }
255
+ // 6. Registered Workspace Reference (@alias or @alias/path)
256
+ const firstSlash = token.indexOf('/');
257
+ const candidateAlias = firstSlash === -1 ? token : token.slice(0, firstSlash);
258
+ const registeredWorkspaces = workspaceRegistry.list().map((w) => w.alias.toLowerCase());
259
+ if (registeredWorkspaces.includes(candidateAlias.toLowerCase())) {
260
+ if (firstSlash === -1) {
261
+ // Just the workspace alias (@alias)
262
+ return {
263
+ raw,
264
+ type: 'workspace',
265
+ target: candidateAlias,
266
+ workspaceAlias: candidateAlias,
267
+ range,
268
+ valid: true,
269
+ };
270
+ }
271
+ // Workspace file reference (@alias/src/...)
272
+ const { filePath, lineRange } = this.parseLineRange(token);
273
+ return {
274
+ raw,
275
+ type: 'file',
276
+ target: filePath,
277
+ workspaceAlias: candidateAlias,
278
+ lineRange,
279
+ range,
280
+ valid: true,
281
+ };
282
+ }
283
+ // 7. Implicit File Mention (@src/index.ts, @./config.json, @test/foo.test.ts:10-20)
284
+ // Check if token looks like a file path (has slashes, extension, or relative prefix)
285
+ const hasPathIndicators = token.includes('/') ||
286
+ token.startsWith('./') ||
287
+ token.startsWith('../') ||
288
+ /\.[a-zA-Z0-9_-]{1,10}(?::\d+(?:-\d+)?)?$/.test(token);
289
+ if (hasPathIndicators) {
290
+ const { filePath, lineRange } = this.parseLineRange(token);
291
+ const alias = workspaceRegistry.isAliasedPath(filePath)
292
+ ? filePath.slice(1).split('/')[0]
293
+ : null;
294
+ return {
295
+ raw,
296
+ type: 'file',
297
+ target: filePath,
298
+ workspaceAlias: alias,
299
+ lineRange,
300
+ range,
301
+ valid: Boolean(filePath),
302
+ };
303
+ }
304
+ // 8. Implicit Symbol Reference (e.g. `@parseTargetSymbol` or `@UserService.getUser`)
305
+ if (/^[A-Za-z_$][A-Za-z0-9_$.#:-]*$/.test(token)) {
306
+ return {
307
+ raw,
308
+ type: 'symbol',
309
+ target: token,
310
+ range,
311
+ valid: true,
312
+ };
313
+ }
314
+ // Fallback generic mention
315
+ return {
316
+ raw,
317
+ type: 'file',
318
+ target: token,
319
+ range,
320
+ valid: false,
321
+ error: `Unrecognized mention format: ${raw}`,
322
+ };
323
+ }
324
+ /**
325
+ * Extracts line range numbers from a path string (e.g. `src/index.ts:10-50` or `src/index.ts#10-50` or `src/index.ts:25`).
326
+ *
327
+ * @private
328
+ */
329
+ parseLineRange(pathWithLine) {
330
+ // Check :start-end or #start-end or :start
331
+ const match = pathWithLine.match(/[:#](?:L)?(\d+)(?:-(?:L)?(\d+))?$/i);
332
+ if (!match) {
333
+ return { filePath: pathWithLine };
334
+ }
335
+ const filePath = pathWithLine.slice(0, match.index);
336
+ const start = Number.parseInt(match[1], 10);
337
+ const end = match[2] ? Number.parseInt(match[2], 10) : start;
338
+ return {
339
+ filePath,
340
+ lineRange: {
341
+ start: Math.min(start, end),
342
+ end: Math.max(start, end),
343
+ },
344
+ };
345
+ }
346
+ // ─── Autocomplete Query & Suggestions ───────────────────────────────
347
+ /**
348
+ * Determines the active mention query at a specific cursor position in an input string.
349
+ *
350
+ * @param input - The current input text.
351
+ * @param cursorPosition - The 0-based cursor offset.
352
+ * @returns Object describing the active mention query and range.
353
+ */
354
+ getActiveMentionQuery(input, cursorPosition) {
355
+ if (!input || cursorPosition <= 0) {
356
+ return { query: '', startIndex: -1, endIndex: -1, isMention: false };
357
+ }
358
+ const textBeforeCursor = input.slice(0, cursorPosition);
359
+ // Find last '@' that is either at the beginning or preceded by whitespace/delimiters
360
+ const lastAtIndex = textBeforeCursor.lastIndexOf('@');
361
+ if (lastAtIndex === -1) {
362
+ return { query: '', startIndex: -1, endIndex: -1, isMention: false };
363
+ }
364
+ // Ensure '@' is preceded by start of text or whitespace/delimiter (not part of email)
365
+ if (lastAtIndex > 0) {
366
+ const prevChar = textBeforeCursor[lastAtIndex - 1];
367
+ if (/[A-Za-z0-9_]/.test(prevChar)) {
368
+ return { query: '', startIndex: -1, endIndex: -1, isMention: false };
369
+ }
370
+ }
371
+ const queryCandidate = textBeforeCursor.slice(lastAtIndex + 1);
372
+ // If there is whitespace between '@' and cursor, it's no longer an active mention query
373
+ if (/\s/.test(queryCandidate)) {
374
+ return { query: '', startIndex: -1, endIndex: -1, isMention: false };
375
+ }
376
+ return {
377
+ query: queryCandidate,
378
+ startIndex: lastAtIndex,
379
+ endIndex: cursorPosition,
380
+ isMention: true,
381
+ };
382
+ }
383
+ /**
384
+ * Generates ranked autocomplete suggestions based on the current prompt text and cursor position.
385
+ *
386
+ * @param input - Full prompt input text.
387
+ * @param cursorPosition - Cursor index.
388
+ * @param options - Suggestion configuration options.
389
+ * @returns Array of ranked MentionSuggestion objects.
390
+ */
391
+ async getSuggestions(input, cursorPosition, options = {}) {
392
+ const active = this.getActiveMentionQuery(input, cursorPosition);
393
+ if (!active.isMention) {
394
+ return [];
395
+ }
396
+ return this.getSuggestionsForQuery(active.query, {
397
+ ...options,
398
+ cursorPosition,
399
+ });
400
+ }
401
+ /**
402
+ * Generates autocomplete suggestions for a given raw query string (the text after '@').
403
+ *
404
+ * @param query - The query string typed after '@'.
405
+ * @param options - Configuration options.
406
+ * @returns Array of ranked MentionSuggestion objects.
407
+ */
408
+ async getSuggestionsForQuery(query = '', options = {}) {
409
+ const workspaceRoot = options.workspaceRoot || process.cwd();
410
+ const maxSuggestions = options.maxSuggestions ?? 25;
411
+ const includeTypes = options.includeTypes
412
+ ? new Set(options.includeTypes)
413
+ : new Set(['file', 'symbol', 'git', 'workspace', 'diagnostics', 'terminal']);
414
+ const rawClean = query.startsWith('@') ? query.slice(1) : query;
415
+ const normalizedQuery = rawClean.toLowerCase().trim();
416
+ const suggestions = [];
417
+ // 1. Special & Git Suggestions
418
+ if (includeTypes.has('git') || includeTypes.has('diagnostics') || includeTypes.has('terminal')) {
419
+ const specialSuggestions = this.getSpecialSuggestions(normalizedQuery, includeTypes);
420
+ suggestions.push(...specialSuggestions);
421
+ }
422
+ // 2. Workspace Alias Suggestions
423
+ if (includeTypes.has('workspace')) {
424
+ const workspaceSuggestions = this.getWorkspaceSuggestions(normalizedQuery);
425
+ suggestions.push(...workspaceSuggestions);
426
+ }
427
+ // 3. File Suggestions (for current workspace or external workspace)
428
+ if (includeTypes.has('file')) {
429
+ let fileSearchQuery = normalizedQuery;
430
+ let targetWorkspaceRoot = workspaceRoot;
431
+ let prefixAlias = null;
432
+ if (fileSearchQuery.startsWith('file:')) {
433
+ fileSearchQuery = fileSearchQuery.slice(5);
434
+ }
435
+ // Check if querying an external workspace (e.g. `@backend/src` or `backend/src` or `backend/`)
436
+ const cleanFileQuery = fileSearchQuery.startsWith('@') ? fileSearchQuery.slice(1) : fileSearchQuery;
437
+ const slashIdx = cleanFileQuery.indexOf('/');
438
+ if (slashIdx !== -1) {
439
+ const alias = cleanFileQuery.slice(0, slashIdx);
440
+ const ws = workspaceRegistry.get(alias);
441
+ if (ws) {
442
+ targetWorkspaceRoot = ws.absolutePath;
443
+ prefixAlias = ws.alias;
444
+ fileSearchQuery = cleanFileQuery.slice(slashIdx + 1);
445
+ }
446
+ }
447
+ const fileSuggestions = await this.getFileSuggestions(fileSearchQuery, targetWorkspaceRoot, prefixAlias, prefixAlias ? undefined : options.cachedFileList);
448
+ suggestions.push(...fileSuggestions);
449
+ }
450
+ // 4. Symbol Suggestions
451
+ if (includeTypes.has('symbol')) {
452
+ let symbolQuery = normalizedQuery;
453
+ const isHash = normalizedQuery.startsWith('#');
454
+ if (symbolQuery.startsWith('symbol:') || symbolQuery.startsWith('sym:') || symbolQuery.startsWith('#')) {
455
+ symbolQuery = symbolQuery.startsWith('symbol:')
456
+ ? symbolQuery.slice(7)
457
+ : symbolQuery.startsWith('sym:')
458
+ ? symbolQuery.slice(4)
459
+ : symbolQuery.slice(1);
460
+ }
461
+ // Only search symbols if query explicitly triggers symbols or has length >= 2
462
+ const isExplicitSymbol = normalizedQuery.startsWith('symbol:') ||
463
+ normalizedQuery.startsWith('sym:') ||
464
+ normalizedQuery.startsWith('#');
465
+ if (isExplicitSymbol || symbolQuery.length >= 2) {
466
+ const symbolSuggestions = await this.getSymbolSuggestions(symbolQuery, workspaceRoot, options.maxSymbolFiles ?? 250, isHash);
467
+ suggestions.push(...symbolSuggestions);
468
+ }
469
+ }
470
+ // Sort by relevance score descending
471
+ suggestions.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
472
+ return suggestions.slice(0, maxSuggestions);
473
+ }
474
+ /**
475
+ * Generates built-in git, diagnostics, terminal, and symbol template suggestions.
476
+ *
477
+ * @private
478
+ */
479
+ getSpecialSuggestions(query, includeTypes) {
480
+ const special = [];
481
+ if (includeTypes.has('git')) {
482
+ const gitItems = [
483
+ {
484
+ label: '@git:diff',
485
+ value: '@git:diff',
486
+ type: 'git',
487
+ category: 'git',
488
+ description: 'Git diff of uncommitted working tree changes',
489
+ detail: 'Working tree modifications',
490
+ helperLabel: '[git] uncommitted diff',
491
+ icon: '🔀',
492
+ keywords: ['git:diff', 'diff', 'git', 'changes', 'uncommitted'],
493
+ },
494
+ {
495
+ label: '@diff',
496
+ value: '@diff',
497
+ type: 'git',
498
+ category: 'git',
499
+ description: 'Quick git diff of uncommitted working changes',
500
+ detail: 'Alias for @git:diff',
501
+ helperLabel: '[git] working tree diff',
502
+ icon: '🔀',
503
+ keywords: ['diff', 'git:diff', 'git', 'changes', 'uncommitted'],
504
+ },
505
+ {
506
+ label: '@git:staged',
507
+ value: '@git:staged',
508
+ type: 'git',
509
+ category: 'git',
510
+ description: 'Git diff of staged / index changes',
511
+ detail: 'Staged index modifications',
512
+ helperLabel: '[git] staged diff',
513
+ icon: '📦',
514
+ keywords: ['git:staged', 'staged', 'git', 'index'],
515
+ },
516
+ {
517
+ label: '@git:status',
518
+ value: '@git:status',
519
+ type: 'git',
520
+ category: 'git',
521
+ description: 'Git working tree status summary',
522
+ detail: 'Modified & untracked files',
523
+ helperLabel: '[git] status summary',
524
+ icon: '📊',
525
+ keywords: ['git:status', 'status', 'git', 'modified', 'untracked'],
526
+ },
527
+ {
528
+ label: '@git:branch',
529
+ value: '@git:branch',
530
+ type: 'git',
531
+ category: 'git',
532
+ description: 'Current git branch name and tracking status',
533
+ detail: 'Active branch metadata',
534
+ helperLabel: '[git] active branch',
535
+ icon: '🌿',
536
+ keywords: ['git:branch', 'branch', 'git', 'head'],
537
+ },
538
+ {
539
+ label: '@git:log',
540
+ value: '@git:log',
541
+ type: 'git',
542
+ category: 'git',
543
+ description: 'Recent git commit history (last 5 commits)',
544
+ detail: 'Commit log summary',
545
+ helperLabel: '[git] commit log',
546
+ icon: '📜',
547
+ keywords: ['git:log', 'log', 'commits', 'history'],
548
+ },
549
+ ];
550
+ for (const item of gitItems) {
551
+ const score = this.calculateMatchScore(query, item.keywords, item.label);
552
+ if (score > 0) {
553
+ special.push({ ...item, score });
554
+ }
555
+ }
556
+ }
557
+ if (includeTypes.has('diagnostics')) {
558
+ const diagItems = [
559
+ {
560
+ label: '@diagnostics',
561
+ value: '@diagnostics',
562
+ type: 'diagnostics',
563
+ category: 'diag',
564
+ description: 'Current workspace syntax and linter diagnostics',
565
+ detail: 'Validation errors & warnings',
566
+ helperLabel: '[diag] syntax & lint scan',
567
+ icon: '⚠️',
568
+ keywords: ['diagnostics', 'diag', 'problems', 'errors', 'lint', 'syntax'],
569
+ },
570
+ {
571
+ label: '@problems',
572
+ value: '@problems',
573
+ type: 'diagnostics',
574
+ category: 'prob',
575
+ description: 'Workspace diagnostics and syntax problem scan',
576
+ detail: 'Alias for @diagnostics',
577
+ helperLabel: '[prob] problem scan',
578
+ icon: '⚠️',
579
+ keywords: ['problems', 'prob', 'diagnostics', 'errors', 'lint', 'syntax'],
580
+ },
581
+ {
582
+ label: '@errors',
583
+ value: '@errors',
584
+ type: 'diagnostics',
585
+ category: 'diag',
586
+ description: 'Workspace errors and syntax diagnostics',
587
+ detail: 'Alias for @diagnostics',
588
+ helperLabel: '[diag] error scan',
589
+ icon: '❌',
590
+ keywords: ['errors', 'error', 'diagnostics', 'problems', 'syntax', 'diag'],
591
+ },
592
+ ];
593
+ for (const item of diagItems) {
594
+ const score = this.calculateMatchScore(query, item.keywords, item.label);
595
+ if (score > 0) {
596
+ special.push({ ...item, score });
597
+ }
598
+ }
599
+ }
600
+ if (includeTypes.has('terminal')) {
601
+ const termItems = [
602
+ {
603
+ label: '@terminal',
604
+ value: '@terminal',
605
+ type: 'terminal',
606
+ category: 'term',
607
+ description: 'Terminal environment, shell info, and working directory',
608
+ detail: 'Node/platform environment',
609
+ helperLabel: '[term] shell & platform',
610
+ icon: '💻',
611
+ keywords: ['terminal', 'term', 'shell', 'console', 'env'],
612
+ },
613
+ {
614
+ label: '@console',
615
+ value: '@console',
616
+ type: 'terminal',
617
+ category: 'term',
618
+ description: 'Console environment and terminal session details',
619
+ detail: 'Alias for @terminal',
620
+ helperLabel: '[term] console session',
621
+ icon: '🖥️',
622
+ keywords: ['console', 'terminal', 'shell', 'env', 'term'],
623
+ },
624
+ ];
625
+ for (const item of termItems) {
626
+ const score = this.calculateMatchScore(query, item.keywords, item.label);
627
+ if (score > 0) {
628
+ special.push({ ...item, score });
629
+ }
630
+ }
631
+ }
632
+ if (includeTypes.has('symbol') || query.startsWith('sym') || query.startsWith('#') || query.startsWith('symbol')) {
633
+ const symTemplateItem = {
634
+ label: '@symbol:<name>',
635
+ value: '@symbol:',
636
+ type: 'symbol',
637
+ category: 'sym',
638
+ description: 'AST symbol extraction (@symbol:name, @#name, @symbol:path:name)',
639
+ detail: 'Extract function, class, or type declaration',
640
+ helperLabel: '[sym] AST symbol extraction',
641
+ icon: '⚡',
642
+ keywords: ['symbol:', 'symbol', 'sym:', 'sym', '#', 'ast', 'function', 'class', 'method', 'type'],
643
+ };
644
+ const score = this.calculateMatchScore(query, symTemplateItem.keywords, symTemplateItem.label);
645
+ if (score > 0) {
646
+ special.push({ ...symTemplateItem, score: score + 10 });
647
+ }
648
+ }
649
+ return special;
650
+ }
651
+ /**
652
+ * Generates registered workspace alias suggestions.
653
+ *
654
+ * @private
655
+ */
656
+ getWorkspaceSuggestions(query) {
657
+ const workspaces = workspaceRegistry.list();
658
+ const suggestions = [];
659
+ for (const ws of workspaces) {
660
+ const aliasLabel = `@${ws.alias}`;
661
+ const keywords = [ws.alias, `@${ws.alias}`, path.basename(ws.absolutePath)];
662
+ const score = this.calculateMatchScore(query, keywords, aliasLabel);
663
+ if (score > 0) {
664
+ suggestions.push({
665
+ label: aliasLabel,
666
+ value: aliasLabel,
667
+ type: 'workspace',
668
+ category: 'ws',
669
+ description: `External workspace: ${ws.absolutePath}`,
670
+ detail: `Profile: ${ws.profile}`,
671
+ helperLabel: `[ws] @${ws.alias}`,
672
+ icon: '📁',
673
+ workspaceAlias: ws.alias,
674
+ score: score + 15, // boost workspace aliases
675
+ });
676
+ }
677
+ }
678
+ return suggestions;
679
+ }
680
+ /**
681
+ * Generates workspace file suggestions with fast fuzzy/prefix matching.
682
+ *
683
+ * @private
684
+ */
685
+ async getFileSuggestions(query, workspaceRoot, prefixAlias = null, cachedFiles) {
686
+ const files = cachedFiles || (await this.getFileList(workspaceRoot));
687
+ const suggestions = [];
688
+ for (const relPath of files) {
689
+ const basename = path.basename(relPath);
690
+ const ext = path.extname(relPath);
691
+ const fullDisplayPath = prefixAlias ? `${prefixAlias}/${relPath}` : relPath;
692
+ const insertValue = prefixAlias ? `@${prefixAlias}/${relPath}` : `@${relPath}`;
693
+ // Keywords for scoring
694
+ const keywords = [
695
+ relPath.toLowerCase(),
696
+ basename.toLowerCase(),
697
+ fullDisplayPath.toLowerCase(),
698
+ ];
699
+ const score = this.calculateMatchScore(query, keywords, basename.toLowerCase());
700
+ if (score > 0) {
701
+ suggestions.push({
702
+ label: `@${fullDisplayPath}`,
703
+ value: insertValue,
704
+ type: 'file',
705
+ category: prefixAlias ? 'ws' : 'file',
706
+ description: prefixAlias ? `Workspace file: @${prefixAlias}` : `File (${ext || 'binary'})`,
707
+ detail: prefixAlias ? `[${prefixAlias}] ${relPath}` : relPath,
708
+ helperLabel: prefixAlias ? `[ws] @${prefixAlias}/${relPath}` : `[file] ${relPath}`,
709
+ icon: this.getFileIcon(ext),
710
+ filePath: relPath,
711
+ workspaceAlias: prefixAlias,
712
+ score,
713
+ });
714
+ }
715
+ }
716
+ return suggestions;
717
+ }
718
+ /**
719
+ * Generates symbol suggestions across indexed source files.
720
+ *
721
+ * @private
722
+ */
723
+ async getSymbolSuggestions(query, workspaceRoot, maxFiles = 250, isHash = false) {
724
+ const symbols = await this.getSymbolIndex(workspaceRoot, maxFiles);
725
+ const suggestions = [];
726
+ for (const item of symbols) {
727
+ const symName = item.symbol;
728
+ const keywords = [symName.toLowerCase(), path.basename(item.filePath).toLowerCase()];
729
+ const score = this.calculateMatchScore(query, keywords, symName.toLowerCase());
730
+ if (score > 0) {
731
+ const kindLabel = item.kind ? `${item.kind} in ` : '';
732
+ const prefix = isHash ? '@#' : '@symbol:';
733
+ suggestions.push({
734
+ label: `${prefix}${symName}`,
735
+ value: `${prefix}${symName}`,
736
+ type: 'symbol',
737
+ category: 'sym',
738
+ description: `${kindLabel}${item.filePath}`,
739
+ detail: item.signature || symName,
740
+ helperLabel: `[sym] ${symName}`,
741
+ icon: this.getSymbolIcon(item.kind),
742
+ filePath: item.filePath,
743
+ symbolKind: item.kind,
744
+ score: score + 5, // slightly boost exact symbol matches
745
+ });
746
+ }
747
+ }
748
+ return suggestions;
749
+ }
750
+ /**
751
+ * Calculates a match score between a user query and candidate keywords.
752
+ * Higher scores represent stronger matches.
753
+ *
754
+ * @private
755
+ */
756
+ calculateMatchScore(query, keywords, primaryText) {
757
+ if (!query)
758
+ return 50; // Base score for empty query
759
+ let maxScore = 0;
760
+ for (const kw of keywords) {
761
+ if (kw === query) {
762
+ maxScore = Math.max(maxScore, 100); // Exact match
763
+ }
764
+ else if (kw.startsWith(query)) {
765
+ maxScore = Math.max(maxScore, 85 + (query.length / kw.length) * 10); // Prefix match
766
+ }
767
+ else if (kw.includes(query)) {
768
+ maxScore = Math.max(maxScore, 70 + (query.length / kw.length) * 10); // Substring match
769
+ }
770
+ else if (query.length >= 3) {
771
+ // Levenshtein fuzzy distance for typos
772
+ const lev = distance(query, kw.slice(0, query.length));
773
+ if (lev <= 1) {
774
+ maxScore = Math.max(maxScore, 60);
775
+ }
776
+ }
777
+ }
778
+ // Boost if primary text (e.g. basename or symbol name) starts with query
779
+ if (primaryText.startsWith(query)) {
780
+ maxScore += 10;
781
+ }
782
+ return maxScore;
783
+ }
784
+ // ─── Workspace File & Symbol Indexing ────────────────────────────────
785
+ /**
786
+ * Returns a cached recursive list of all relative file paths in the workspace.
787
+ *
788
+ * @param workspaceRoot - Root directory path.
789
+ * @param maxFiles - Safety ceiling on maximum files returned (defaults to 3000).
790
+ * @returns Array of relative file paths.
791
+ */
792
+ async getFileList(workspaceRoot, maxFiles = 3000) {
793
+ const normalizedRoot = path.resolve(workspaceRoot);
794
+ const cached = this.fileListCache.get(normalizedRoot);
795
+ const now = Date.now();
796
+ if (cached && now - cached.timestamp < this.CACHE_TTL_MS) {
797
+ return cached.files;
798
+ }
799
+ const files = [];
800
+ const ig = ignore();
801
+ ig.add(Array.from(DEFAULT_IGNORED_DIRS).map((d) => `${d}/`));
802
+ ig.add(Array.from(DEFAULT_IGNORED_FILES));
803
+ // Read project .gitignore if present
804
+ const gitignorePath = path.join(normalizedRoot, '.gitignore');
805
+ if (fs.existsSync(gitignorePath)) {
806
+ try {
807
+ const content = await fs.promises.readFile(gitignorePath, 'utf-8');
808
+ ig.add(content);
809
+ }
810
+ catch {
811
+ // Continue with defaults
812
+ }
813
+ }
814
+ const scanDirectory = async (currentDir, relativeBase) => {
815
+ if (files.length >= maxFiles)
816
+ return;
817
+ let entries;
818
+ try {
819
+ entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
820
+ }
821
+ catch {
822
+ return;
823
+ }
824
+ for (const entry of entries) {
825
+ if (files.length >= maxFiles)
826
+ break;
827
+ const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
828
+ if (entry.isDirectory()) {
829
+ if (DEFAULT_IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.'))
830
+ continue;
831
+ if (ig.ignores(`${relPath}/`))
832
+ continue;
833
+ await scanDirectory(path.join(currentDir, entry.name), relPath);
834
+ }
835
+ else if (entry.isFile()) {
836
+ if (DEFAULT_IGNORED_FILES.has(entry.name))
837
+ continue;
838
+ if (ig.ignores(relPath))
839
+ continue;
840
+ files.push(relPath);
841
+ }
842
+ }
843
+ };
844
+ await scanDirectory(normalizedRoot, '');
845
+ this.fileListCache.set(normalizedRoot, { files, timestamp: now, root: normalizedRoot });
846
+ return files;
847
+ }
848
+ /**
849
+ * Extracts and returns all declared symbols across workspace source files.
850
+ *
851
+ * @param workspaceRoot - Workspace root path.
852
+ * @param maxFiles - Maximum source files to scan.
853
+ * @returns Array of symbol entries.
854
+ */
855
+ async getSymbolIndex(workspaceRoot, maxFiles = 250) {
856
+ const fileList = await this.getFileList(workspaceRoot);
857
+ const sourceFiles = fileList
858
+ .filter((f) => SOURCE_FILE_EXTENSIONS.has(path.extname(f).toLowerCase()))
859
+ .sort((a, b) => {
860
+ const aIsSrc = a.startsWith('src/') || a.startsWith('lib/') || a.startsWith('app/') ? 0 : 1;
861
+ const bIsSrc = b.startsWith('src/') || b.startsWith('lib/') || b.startsWith('app/') ? 0 : 1;
862
+ if (aIsSrc !== bIsSrc)
863
+ return aIsSrc - bIsSrc;
864
+ return a.localeCompare(b);
865
+ })
866
+ .slice(0, maxFiles);
867
+ const allSymbols = [];
868
+ for (const relPath of sourceFiles) {
869
+ const fullPath = path.resolve(workspaceRoot, relPath);
870
+ try {
871
+ const stat = await fs.promises.stat(fullPath);
872
+ const cached = this.symbolIndexCache.get(fullPath);
873
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
874
+ allSymbols.push(...cached.symbols);
875
+ continue;
876
+ }
877
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
878
+ const rawSymbols = extractSymbolIndex(content, fullPath);
879
+ const fileSymbols = rawSymbols.map((s) => ({
880
+ symbol: s.symbol,
881
+ kind: s.kind,
882
+ filePath: relPath,
883
+ signature: s.signature,
884
+ startLine: s.startLine,
885
+ endLine: s.endLine,
886
+ }));
887
+ this.symbolIndexCache.set(fullPath, {
888
+ symbols: fileSymbols,
889
+ mtimeMs: stat.mtimeMs,
890
+ });
891
+ allSymbols.push(...fileSymbols);
892
+ }
893
+ catch {
894
+ // Skip unreadable files
895
+ }
896
+ }
897
+ return allSymbols;
898
+ }
899
+ // ─── Mention Context Resolution ─────────────────────────────────────
900
+ /**
901
+ * Resolves all mentions in a prompt string into structured context injection blocks.
902
+ *
903
+ * @param prompt - The user prompt containing `@mentions`.
904
+ * @param workspaceRoot - The primary workspace root path.
905
+ * @param options - Resolution and token budgeting options.
906
+ * @returns Comprehensive ResolvedMentionsResult containing structured XML blocks.
907
+ */
908
+ async resolveMentions(prompt, workspaceRoot = process.cwd(), options = {}) {
909
+ const parsedMentions = this.parseMentions(prompt, workspaceRoot);
910
+ if (parsedMentions.length === 0) {
911
+ return {
912
+ originalPrompt: prompt,
913
+ cleanedPrompt: prompt,
914
+ mentions: [],
915
+ formattedContext: '',
916
+ totalTokens: 0,
917
+ hasMentions: false,
918
+ };
919
+ }
920
+ const resolvedMentions = [];
921
+ const seenMentionKeys = new Set();
922
+ for (const mention of parsedMentions) {
923
+ // Deduplicate identical mentions in the same prompt
924
+ const dedupeKey = `${mention.type}:${mention.target}:${mention.subTarget || ''}:${JSON.stringify(mention.lineRange || {})}`;
925
+ if (seenMentionKeys.has(dedupeKey))
926
+ continue;
927
+ seenMentionKeys.add(dedupeKey);
928
+ try {
929
+ const resolved = await this.resolveSingleMention(mention, workspaceRoot, options);
930
+ resolvedMentions.push(resolved);
931
+ }
932
+ catch (err) {
933
+ debugLog(`[MentionEngine] Failed to resolve mention ${mention.raw}: ${err.message}`);
934
+ resolvedMentions.push({
935
+ mention,
936
+ resolved: false,
937
+ content: `<!-- Failed to resolve mention ${mention.raw}: ${err.message} -->`,
938
+ tokenEstimate: 0,
939
+ error: err.message,
940
+ });
941
+ }
942
+ }
943
+ const formattedContext = this.formatMentionsContext(resolvedMentions, options.maxChars);
944
+ const totalTokens = estimateTokens(formattedContext);
945
+ return {
946
+ originalPrompt: prompt,
947
+ cleanedPrompt: prompt,
948
+ mentions: resolvedMentions,
949
+ formattedContext,
950
+ totalTokens,
951
+ hasMentions: resolvedMentions.length > 0,
952
+ };
953
+ }
954
+ /**
955
+ * Resolves a single parsed mention into its structured XML context block.
956
+ *
957
+ * @private
958
+ */
959
+ async resolveSingleMention(mention, workspaceRoot, options) {
960
+ switch (mention.type) {
961
+ case 'file':
962
+ return this.resolveFileMention(mention, workspaceRoot, options);
963
+ case 'symbol':
964
+ return this.resolveSymbolMention(mention, workspaceRoot, options);
965
+ case 'git':
966
+ return this.resolveGitMention(mention, workspaceRoot, options);
967
+ case 'workspace':
968
+ return this.resolveWorkspaceMention(mention, workspaceRoot, options);
969
+ case 'diagnostics':
970
+ return this.resolveDiagnosticsMention(mention, workspaceRoot, options);
971
+ case 'terminal':
972
+ return this.resolveTerminalMention(mention, workspaceRoot, options);
973
+ default:
974
+ throw new Error(`Unsupported mention type: ${mention.type}`);
975
+ }
976
+ }
977
+ /**
978
+ * Resolves a file mention (`@file:src/index.ts` or `@src/index.ts:10-50`).
979
+ *
980
+ * @private
981
+ */
982
+ async resolveFileMention(mention, workspaceRoot, options) {
983
+ const targetPath = mention.workspaceAlias && !mention.target.startsWith('@')
984
+ ? `@${mention.target}`
985
+ : mention.target;
986
+ const resolvedPath = resolveAndValidateMultiWorkspacePath(workspaceRoot, targetPath);
987
+ if (!fs.existsSync(resolvedPath.absolutePath)) {
988
+ throw new Error(`File does not exist: "${mention.target}"`);
989
+ }
990
+ const stats = await fs.promises.stat(resolvedPath.absolutePath);
991
+ if (!stats.isFile()) {
992
+ throw new Error(`Target is not a file: "${mention.target}"`);
993
+ }
994
+ const fullContent = await fs.promises.readFile(resolvedPath.absolutePath, 'utf-8');
995
+ const allLines = fullContent.split('\n');
996
+ const totalLines = allLines.length;
997
+ let fileContent = fullContent;
998
+ let lineRangeAttr = '';
999
+ const isSliced = Boolean(mention.lineRange);
1000
+ if (mention.lineRange) {
1001
+ const startIdx = Math.max(0, mention.lineRange.start - 1);
1002
+ const endIdx = Math.min(allLines.length, mention.lineRange.end);
1003
+ fileContent = allLines.slice(startIdx, endIdx).join('\n');
1004
+ lineRangeAttr = ` lines="${mention.lineRange.start}-${mention.lineRange.end}"`;
1005
+ }
1006
+ const slicedLines = fileContent.split('\n');
1007
+ const linesCount = slicedLines.length;
1008
+ const charCount = fileContent.length;
1009
+ const category = isSliced ? 'lines' : 'file';
1010
+ const statusLabel = isSliced
1011
+ ? `lines ${mention.lineRange.start}-${mention.lineRange.end}, ${charCount} chars`
1012
+ : `file (${stats.size} bytes, ${totalLines} lines)`;
1013
+ const helperLabel = isSliced
1014
+ ? `[lines] ${mention.target} (${mention.lineRange.start}-${mention.lineRange.end})`
1015
+ : `[file] ${mention.target} (${totalLines} lines)`;
1016
+ const workspaceAttr = resolvedPath.alias ? ` workspace="${resolvedPath.alias}"` : '';
1017
+ const formattedContent = `<workspace_file path="${mention.target}"${workspaceAttr}${lineRangeAttr}>
1018
+ <content_data><![CDATA[
1019
+ ${sanitizeForCDATA(fileContent)}
1020
+ ]]\\u200B></content_data>
1021
+ </workspace_file>`;
1022
+ return {
1023
+ mention,
1024
+ resolved: true,
1025
+ content: formattedContent,
1026
+ tokenEstimate: estimateTokens(formattedContent),
1027
+ statusLabel,
1028
+ helperLabel,
1029
+ metadata: {
1030
+ absolutePath: resolvedPath.absolutePath,
1031
+ relativePath: mention.target,
1032
+ sizeBytes: stats.size,
1033
+ charCount,
1034
+ linesCount,
1035
+ totalLines,
1036
+ lineRange: mention.lineRange,
1037
+ isSliced,
1038
+ workspaceAlias: resolvedPath.alias || null,
1039
+ category,
1040
+ statusLabel,
1041
+ helperLabel,
1042
+ },
1043
+ };
1044
+ }
1045
+ /**
1046
+ * Resolves a symbol mention (`@symbol:extractSymbols` or `@#extractSymbols`).
1047
+ *
1048
+ * @private
1049
+ */
1050
+ async resolveSymbolMention(mention, workspaceRoot, options) {
1051
+ const symbolName = mention.target;
1052
+ let targetFilePath = mention.subTarget;
1053
+ let matchedSymbol;
1054
+ // If file path not specified, search symbol index to find declaration file
1055
+ if (!targetFilePath) {
1056
+ const symbols = await this.getSymbolIndex(workspaceRoot);
1057
+ const found = symbols.find((s) => s.symbol.toLowerCase() === symbolName.toLowerCase());
1058
+ if (found) {
1059
+ targetFilePath = found.filePath;
1060
+ matchedSymbol = found;
1061
+ }
1062
+ }
1063
+ if (!targetFilePath) {
1064
+ throw new Error(`Could not find declaration for symbol "${symbolName}" across workspace files.`);
1065
+ }
1066
+ const resolved = resolveAndValidateMultiWorkspacePath(workspaceRoot, targetFilePath);
1067
+ if (!fs.existsSync(resolved.absolutePath)) {
1068
+ throw new Error(`File containing symbol not found: "${targetFilePath}"`);
1069
+ }
1070
+ const rawContent = await fs.promises.readFile(resolved.absolutePath, 'utf-8');
1071
+ const extractedBlock = extractSymbols(rawContent, resolved.absolutePath, [symbolName], {
1072
+ maxLinesPerSymbol: options.symbolMaxLines ?? 300,
1073
+ });
1074
+ const linesCount = extractedBlock.split('\n').length;
1075
+ const charCount = extractedBlock.length;
1076
+ const statusLabel = `${targetFilePath}, ${linesCount} lines`;
1077
+ const helperLabel = `[sym] ${symbolName} in ${targetFilePath} (${linesCount} lines)`;
1078
+ const formattedContent = `<symbol_definition symbol="${symbolName}" file="${targetFilePath}">
1079
+ <content_data><![CDATA[
1080
+ ${sanitizeForCDATA(extractedBlock)}
1081
+ ]]\\u200B></content_data>
1082
+ </symbol_definition>`;
1083
+ return {
1084
+ mention,
1085
+ resolved: true,
1086
+ content: formattedContent,
1087
+ tokenEstimate: estimateTokens(formattedContent),
1088
+ statusLabel,
1089
+ helperLabel,
1090
+ metadata: {
1091
+ symbol: symbolName,
1092
+ filePath: targetFilePath,
1093
+ kind: matchedSymbol?.kind || 'symbol',
1094
+ signature: matchedSymbol?.signature || symbolName,
1095
+ startLine: matchedSymbol?.startLine,
1096
+ endLine: matchedSymbol?.endLine,
1097
+ linesCount,
1098
+ charCount,
1099
+ category: 'symbol',
1100
+ statusLabel,
1101
+ helperLabel,
1102
+ },
1103
+ };
1104
+ }
1105
+ /**
1106
+ * Resolves a git mention (`@git:diff`, `@git:staged`, `@git:branch`, `@git:log`, `@git:status`, `@diff`).
1107
+ *
1108
+ * @private
1109
+ */
1110
+ async resolveGitMention(mention, workspaceRoot, options) {
1111
+ const gitSubCommand = mention.target.toLowerCase() || 'diff';
1112
+ const timeout = options.gitTimeoutMs ?? 5000;
1113
+ let gitArgs = [];
1114
+ let contextType = gitSubCommand;
1115
+ switch (gitSubCommand) {
1116
+ case 'staged':
1117
+ case 'cached':
1118
+ gitArgs = ['diff', '--cached'];
1119
+ contextType = 'staged_diff';
1120
+ break;
1121
+ case 'branch':
1122
+ case 'head':
1123
+ gitArgs = ['branch', '-vv'];
1124
+ contextType = 'branch';
1125
+ break;
1126
+ case 'log':
1127
+ case 'history':
1128
+ gitArgs = ['log', '-n', '5', '--oneline', '--decorate'];
1129
+ contextType = 'log';
1130
+ break;
1131
+ case 'status':
1132
+ gitArgs = ['status', '--short'];
1133
+ contextType = 'status';
1134
+ break;
1135
+ case 'diff':
1136
+ default:
1137
+ gitArgs = ['diff'];
1138
+ contextType = 'diff';
1139
+ break;
1140
+ }
1141
+ let output = '';
1142
+ let isClean = false;
1143
+ try {
1144
+ const { stdout } = await execFileAsync('git', gitArgs, {
1145
+ cwd: workspaceRoot,
1146
+ timeout,
1147
+ maxBuffer: 5 * 1024 * 1024,
1148
+ encoding: 'utf-8',
1149
+ env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0' },
1150
+ });
1151
+ output = stdout.trim();
1152
+ isClean = !output;
1153
+ }
1154
+ catch (err) {
1155
+ output = `[Git command "git ${gitArgs.join(' ')}" returned error: ${err.message}]`;
1156
+ }
1157
+ if (!output) {
1158
+ output = `[No ${contextType} output / clean working tree]`;
1159
+ isClean = true;
1160
+ }
1161
+ const outputLines = output.split('\n').length;
1162
+ const statusLabel = gitSubCommand === 'status'
1163
+ ? `short status, ${isClean ? 'clean' : 'modified'}`
1164
+ : `${contextType}, ${outputLines} lines${isClean ? ' (clean)' : ''}`;
1165
+ const helperLabel = `[git] ${gitSubCommand} (${outputLines} lines)`;
1166
+ const formattedContent = `<git_context type="${contextType}">
1167
+ <content_data><![CDATA[
1168
+ ${sanitizeForCDATA(output)}
1169
+ ]]\\u200B></content_data>
1170
+ </git_context>`;
1171
+ return {
1172
+ mention,
1173
+ resolved: true,
1174
+ content: formattedContent,
1175
+ tokenEstimate: estimateTokens(formattedContent),
1176
+ statusLabel,
1177
+ helperLabel,
1178
+ metadata: {
1179
+ gitSubCommand,
1180
+ contextType,
1181
+ outputLines,
1182
+ charCount: output.length,
1183
+ isClean,
1184
+ category: 'git',
1185
+ statusLabel,
1186
+ helperLabel,
1187
+ },
1188
+ };
1189
+ }
1190
+ /**
1191
+ * Resolves a registered workspace alias mention (`@effortlist-ai`).
1192
+ *
1193
+ * @private
1194
+ */
1195
+ async resolveWorkspaceMention(mention, workspaceRoot, options) {
1196
+ const alias = mention.target;
1197
+ const ws = workspaceRegistry.get(alias);
1198
+ if (!ws) {
1199
+ throw new Error(`Workspace alias "@${alias}" is not registered. Use /workspaces to register it.`);
1200
+ }
1201
+ const files = await this.getFileList(ws.absolutePath, 50);
1202
+ const summary = `Profile: ${ws.profile}\nRoot: ${ws.absolutePath}\nRegistered: ${new Date(ws.registeredAt).toISOString()}\n\nSample Files (${files.length} found):\n${files.slice(0, 25).map((f) => `- ${f}`).join('\n')}`;
1203
+ const statusLabel = `external workspace (${files.length} files scanned)`;
1204
+ const helperLabel = `[ws] @${alias} (${ws.profile})`;
1205
+ const formattedContent = `<workspace_context alias="${alias}" path="${ws.absolutePath}">
1206
+ <content_data><![CDATA[
1207
+ ${sanitizeForCDATA(summary)}
1208
+ ]]\\u200B></content_data>
1209
+ </workspace_context>`;
1210
+ return {
1211
+ mention,
1212
+ resolved: true,
1213
+ content: formattedContent,
1214
+ tokenEstimate: estimateTokens(formattedContent),
1215
+ statusLabel,
1216
+ helperLabel,
1217
+ metadata: {
1218
+ alias,
1219
+ path: ws.absolutePath,
1220
+ profile: ws.profile,
1221
+ filesCount: files.length,
1222
+ category: 'workspace',
1223
+ statusLabel,
1224
+ helperLabel,
1225
+ },
1226
+ };
1227
+ }
1228
+ /**
1229
+ * Resolves diagnostics / problems mention (`@diagnostics`, `@problems`, `@errors`).
1230
+ *
1231
+ * @private
1232
+ */
1233
+ async resolveDiagnosticsMention(mention, workspaceRoot, options) {
1234
+ const files = await this.getFileList(workspaceRoot, 100);
1235
+ const sourceFiles = files
1236
+ .filter((f) => ['.ts', '.js', '.tsx', '.jsx'].includes(path.extname(f).toLowerCase()))
1237
+ .slice(0, 20);
1238
+ const issues = [];
1239
+ for (const relPath of sourceFiles) {
1240
+ const fullPath = path.resolve(workspaceRoot, relPath);
1241
+ try {
1242
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
1243
+ const validation = localValidate(fullPath, content);
1244
+ if (!validation.isValid && validation.error) {
1245
+ issues.push(`${relPath}: ${validation.error}`);
1246
+ }
1247
+ }
1248
+ catch {
1249
+ // Skip unreadable files
1250
+ }
1251
+ }
1252
+ const summary = issues.length > 0
1253
+ ? issues.join('\n')
1254
+ : '[No immediate syntax or structural validation errors detected across checked files]';
1255
+ const statusLabel = issues.length > 0
1256
+ ? `${issues.length} issue(s) detected`
1257
+ : `syntax scan clean (${sourceFiles.length} files checked)`;
1258
+ const helperLabel = `[diag] ${issues.length > 0 ? `${issues.length} errors found` : 'clean syntax scan'}`;
1259
+ const formattedContent = `<diagnostics_context>
1260
+ <content_data><![CDATA[
1261
+ ${sanitizeForCDATA(summary)}
1262
+ ]]\\u200B></content_data>
1263
+ </diagnostics_context>`;
1264
+ return {
1265
+ mention,
1266
+ resolved: true,
1267
+ content: formattedContent,
1268
+ tokenEstimate: estimateTokens(formattedContent),
1269
+ statusLabel,
1270
+ helperLabel,
1271
+ metadata: {
1272
+ filesChecked: sourceFiles.length,
1273
+ issuesCount: issues.length,
1274
+ issues,
1275
+ category: 'diagnostics',
1276
+ statusLabel,
1277
+ helperLabel,
1278
+ },
1279
+ };
1280
+ }
1281
+ /**
1282
+ * Resolves terminal / console mention (`@terminal`, `@console`).
1283
+ *
1284
+ * @private
1285
+ */
1286
+ async resolveTerminalMention(mention, workspaceRoot, options) {
1287
+ const summary = `Active Working Directory: ${workspaceRoot}\nNode Version: ${process.version}\nPlatform: ${process.platform} (${process.arch})`;
1288
+ const statusLabel = `shell environment (${process.platform}-${process.arch}, Node ${process.version})`;
1289
+ const helperLabel = `[term] ${process.platform} (${process.version})`;
1290
+ const formattedContent = `<terminal_context>
1291
+ <content_data><![CDATA[
1292
+ ${sanitizeForCDATA(summary)}
1293
+ ]]\\u200B></content_data>
1294
+ </terminal_context>`;
1295
+ return {
1296
+ mention,
1297
+ resolved: true,
1298
+ content: formattedContent,
1299
+ tokenEstimate: estimateTokens(formattedContent),
1300
+ statusLabel,
1301
+ helperLabel,
1302
+ metadata: {
1303
+ platform: process.platform,
1304
+ arch: process.arch,
1305
+ nodeVersion: process.version,
1306
+ cwd: workspaceRoot,
1307
+ category: 'terminal',
1308
+ statusLabel,
1309
+ helperLabel,
1310
+ },
1311
+ };
1312
+ }
1313
+ /**
1314
+ * Formats resolved mention blocks into a unified XML context injection string,
1315
+ * respecting character budget limits.
1316
+ *
1317
+ * @param resolvedMentions - Array of resolved mention blocks.
1318
+ * @param maxChars - Optional maximum character budget.
1319
+ * @returns Unified XML context block.
1320
+ */
1321
+ formatMentionsContext(resolvedMentions, maxChars = CONTEXT_BUDGET_CONFIG.DEFAULT_CONTEXT_MAX_CHARS) {
1322
+ return formatContextMentions(resolvedMentions, maxChars);
1323
+ }
1324
+ /**
1325
+ * Helper to get an icon glyph corresponding to a file extension.
1326
+ *
1327
+ * @private
1328
+ */
1329
+ getFileIcon(ext) {
1330
+ switch (ext.toLowerCase()) {
1331
+ case '.ts':
1332
+ case '.tsx':
1333
+ case '.mts':
1334
+ return '🔷';
1335
+ case '.js':
1336
+ case '.jsx':
1337
+ case '.mjs':
1338
+ return '🟨';
1339
+ case '.py':
1340
+ case '.pyi':
1341
+ return '🐍';
1342
+ case '.go':
1343
+ return '🐹';
1344
+ case '.rs':
1345
+ return '🦀';
1346
+ case '.json':
1347
+ case '.yaml':
1348
+ case '.yml':
1349
+ case '.toml':
1350
+ return '⚙️';
1351
+ case '.md':
1352
+ return '📝';
1353
+ default:
1354
+ return '📄';
1355
+ }
1356
+ }
1357
+ /**
1358
+ * Helper to get an icon glyph corresponding to a symbol kind.
1359
+ *
1360
+ * @private
1361
+ */
1362
+ getSymbolIcon(kind) {
1363
+ switch (kind) {
1364
+ case 'function':
1365
+ return '⚡';
1366
+ case 'class':
1367
+ return '🏛️';
1368
+ case 'method':
1369
+ return '🔹';
1370
+ case 'interface':
1371
+ return '📋';
1372
+ case 'type':
1373
+ return '🏷️';
1374
+ case 'enum':
1375
+ return '🔢';
1376
+ case 'struct':
1377
+ return '📦';
1378
+ case 'trait':
1379
+ return '🧬';
1380
+ default:
1381
+ return '✨';
1382
+ }
1383
+ }
1384
+ /**
1385
+ * Clears in-memory caches for file listings and symbols.
1386
+ */
1387
+ clearCaches() {
1388
+ this.fileListCache.clear();
1389
+ this.symbolIndexCache.clear();
1390
+ }
1391
+ }
1392
+ /**
1393
+ * Singleton instance of the MentionEngine.
1394
+ */
1395
+ export const mentionEngine = new MentionEngine();