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.
- package/README.md +104 -39
- package/dist/services/agent/slashCommands.js +57 -8
- package/dist/services/agent/syntaxAgent.js +13 -0
- package/dist/services/agent-tools.d.ts +1 -1
- package/dist/services/agent-tools.js +2 -2
- package/dist/services/agent.js +119 -7
- package/dist/services/ai.d.ts +19 -0
- package/dist/services/ai.js +97 -5
- package/dist/services/contextAgent.js +23 -0
- package/dist/services/investigationComplexity.js +1 -1
- package/dist/services/mentionEngine.d.ts +385 -0
- package/dist/services/mentionEngine.js +1395 -0
- package/dist/services/orchestration/investigationAgent.js +6 -2
- package/dist/services/orchestration/messageBus.d.ts +26 -3
- package/dist/services/orchestration/messageBus.js +204 -14
- package/dist/services/orchestration/orchestrator.js +4 -0
- package/dist/services/orchestration/scopedTools.js +16 -2
- package/dist/services/orchestration/subAgent.js +14 -2
- package/dist/services/proxyClient.d.ts +38 -2
- package/dist/services/proxyClient.js +42 -24
- package/dist/utils/config.d.ts +75 -0
- package/dist/utils/config.js +93 -0
- package/dist/utils/contextPrompts.d.ts +28 -4
- package/dist/utils/contextPrompts.js +70 -1
- package/dist/utils/historyPrompt.d.ts +166 -7
- package/dist/utils/historyPrompt.js +775 -30
- package/dist/utils/symbolExtractor.d.ts +111 -8
- package/dist/utils/symbolExtractor.js +616 -64
- package/dist/utils/systemPrompts.d.ts +3 -3
- package/dist/utils/systemPrompts.js +5 -3
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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();
|