smart-context-mcp 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,459 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFile as execFileCb } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { rgPath } from '@vscode/ripgrep';
6
+ import { buildMetrics, persistMetrics } from '../metrics.js';
7
+ import { loadIndex, queryIndex, queryRelated, getGraphCoverage } from '../index.js';
8
+ import { isDockerfile, readTextFile } from '../utils/fs.js';
9
+ import { projectRoot } from '../utils/paths.js';
10
+ import { truncate } from '../utils/text.js';
11
+ import { countTokens } from '../tokenCounter.js';
12
+
13
+ const execFile = promisify(execFileCb);
14
+ import { summarizeGo, summarizeRust, summarizeJava, summarizeShell, summarizeTerraform, summarizeDockerfile, summarizeSql, extractGoSymbol, extractRustSymbol, extractJavaSymbol, summarizeCsharp, extractCsharpSymbol, summarizeKotlin, extractKotlinSymbol, summarizePhp, extractPhpSymbol, summarizeSwift, extractSwiftSymbol } from './smart-read/additional-languages.js';
15
+ import { summarizeCode, extractCodeSymbol } from './smart-read/code.js';
16
+ import { summarizeFallback } from './smart-read/fallback.js';
17
+ import { summarizePython, extractPythonSymbol } from './smart-read/python.js';
18
+ import { summarizeJson } from './smart-read/shared.js';
19
+ import { summarizeToml, summarizeYaml } from './smart-read/structured.js';
20
+
21
+ const codeExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
22
+ const pythonExtensions = new Set(['.py']);
23
+ const tomlExtensions = new Set(['.toml']);
24
+ const yamlExtensions = new Set(['.yaml', '.yml']);
25
+ const goExtensions = new Set(['.go']);
26
+ const rustExtensions = new Set(['.rs']);
27
+ const javaExtensions = new Set(['.java']);
28
+ const shellExtensions = new Set(['.sh', '.bash', '.zsh']);
29
+ const terraformExtensions = new Set(['.tf', '.tfvars', '.hcl']);
30
+ const sqlExtensions = new Set(['.sql']);
31
+ const csharpExtensions = new Set(['.cs']);
32
+ const kotlinExtensions = new Set(['.kt']);
33
+ const phpExtensions = new Set(['.php']);
34
+ const swiftExtensions = new Set(['.swift']);
35
+
36
+ const readCache = new Map();
37
+ const MAX_CACHE_ENTRIES = 200;
38
+
39
+ const buildCacheKey = (fullPath, mode, extra) =>
40
+ extra ? `${fullPath}::${mode}::${extra}` : `${fullPath}::${mode}`;
41
+
42
+ const getFileMtime = (fullPath) => Math.floor(fs.statSync(fullPath).mtimeMs);
43
+
44
+ const getCached = (key, mtime) => {
45
+ const entry = readCache.get(key);
46
+ if (!entry || entry.mtime !== mtime) return null;
47
+ readCache.delete(key);
48
+ readCache.set(key, entry);
49
+ return entry.content;
50
+ };
51
+
52
+ const setCache = (key, mtime, content) => {
53
+ if (readCache.size >= MAX_CACHE_ENTRIES) {
54
+ readCache.delete(readCache.keys().next().value);
55
+ }
56
+ readCache.set(key, { mtime, content });
57
+ };
58
+
59
+ export const clearReadCache = () => readCache.clear();
60
+
61
+ const extractRange = (content, startLine, endLine) => {
62
+ const lines = content.split('\n');
63
+ const start = Math.max(0, (startLine ?? 1) - 1);
64
+ const end = endLine ?? lines.length;
65
+ const slice = lines.slice(start, end);
66
+ const numbered = slice.map((line, i) => `${start + i + 1}|${line}`);
67
+ return truncate(numbered.join('\n'), 12000);
68
+ };
69
+
70
+ const lookupIndexLine = (fullPath, symbolName) => {
71
+ try {
72
+ const index = loadIndex(projectRoot);
73
+ if (!index) return { line: undefined, used: false };
74
+ const relPath = path.relative(projectRoot, fullPath).replace(/\\/g, '/');
75
+ const hits = queryIndex(index, symbolName);
76
+ const match = hits.find((h) => h.path === relPath);
77
+ return { line: match?.line, used: !!match };
78
+ } catch {
79
+ return { line: undefined, used: false };
80
+ }
81
+ };
82
+
83
+ const extractSymbolFromContent = (fullPath, content, symbol) => {
84
+ const extension = path.extname(fullPath).toLowerCase();
85
+
86
+ if (codeExtensions.has(extension)) {
87
+ return extractCodeSymbol(fullPath, content, symbol);
88
+ }
89
+
90
+ if (pythonExtensions.has(extension)) {
91
+ return extractPythonSymbol(content, symbol);
92
+ }
93
+
94
+ if (goExtensions.has(extension)) {
95
+ return extractGoSymbol(content, symbol);
96
+ }
97
+
98
+ if (rustExtensions.has(extension)) {
99
+ return extractRustSymbol(content, symbol);
100
+ }
101
+
102
+ if (javaExtensions.has(extension)) {
103
+ return extractJavaSymbol(content, symbol);
104
+ }
105
+
106
+ if (csharpExtensions.has(extension)) {
107
+ return extractCsharpSymbol(content, symbol);
108
+ }
109
+
110
+ if (kotlinExtensions.has(extension)) {
111
+ return extractKotlinSymbol(content, symbol);
112
+ }
113
+
114
+ if (phpExtensions.has(extension)) {
115
+ return extractPhpSymbol(content, symbol);
116
+ }
117
+
118
+ if (swiftExtensions.has(extension)) {
119
+ return extractSwiftSymbol(content, symbol);
120
+ }
121
+
122
+ const { line: indexLine } = lookupIndexLine(fullPath, symbol);
123
+ return extractSymbolFallback(content, symbol, indexLine);
124
+ };
125
+
126
+ const extractSymbolFallback = (content, symbol, indexLine) => {
127
+ const lines = content.split('\n');
128
+ let idx = indexLine ? indexLine - 1 : -1;
129
+ if (idx < 0 || idx >= lines.length) {
130
+ idx = lines.findIndex((line) => line.includes(symbol));
131
+ }
132
+ if (idx === -1) return `Symbol not found: ${symbol}`;
133
+ const start = Math.max(0, idx - 2);
134
+ const end = Math.min(lines.length, idx + 30);
135
+ const slice = lines.slice(start, end);
136
+ return slice.map((line, i) => `${start + i + 1}|${line}`).join('\n');
137
+ };
138
+
139
+ const resolveParserType = (extension, fullPath) => {
140
+ if (codeExtensions.has(extension)) return 'ast';
141
+ if (pythonExtensions.has(extension) || goExtensions.has(extension) ||
142
+ rustExtensions.has(extension) || javaExtensions.has(extension) ||
143
+ csharpExtensions.has(extension) || kotlinExtensions.has(extension) ||
144
+ phpExtensions.has(extension) || swiftExtensions.has(extension) ||
145
+ shellExtensions.has(extension) || terraformExtensions.has(extension) ||
146
+ sqlExtensions.has(extension) || tomlExtensions.has(extension) ||
147
+ yamlExtensions.has(extension) || extension === '.json' ||
148
+ isDockerfile(fullPath)) return 'heuristic';
149
+ return 'fallback';
150
+ };
151
+
152
+ const MODE_CASCADE = ['full', 'outline', 'signatures'];
153
+
154
+ const generateContent = (fullPath, extension, content, mode) => {
155
+ if (mode === 'full') return truncate(content, 12000);
156
+
157
+ if (isDockerfile(fullPath)) return summarizeDockerfile(content, mode);
158
+ if (extension === '.json') return summarizeJson(content, mode);
159
+ if (codeExtensions.has(extension)) return summarizeCode(fullPath, content, mode);
160
+ if (pythonExtensions.has(extension)) return summarizePython(content, mode);
161
+ if (goExtensions.has(extension)) return summarizeGo(content, mode);
162
+ if (rustExtensions.has(extension)) return summarizeRust(content, mode);
163
+ if (javaExtensions.has(extension)) return summarizeJava(content, mode);
164
+ if (csharpExtensions.has(extension)) return summarizeCsharp(content, mode);
165
+ if (kotlinExtensions.has(extension)) return summarizeKotlin(content, mode);
166
+ if (phpExtensions.has(extension)) return summarizePhp(content, mode);
167
+ if (swiftExtensions.has(extension)) return summarizeSwift(content, mode);
168
+ if (shellExtensions.has(extension)) return summarizeShell(content, mode);
169
+ if (terraformExtensions.has(extension)) return summarizeTerraform(content, mode);
170
+ if (sqlExtensions.has(extension)) return summarizeSql(content, mode);
171
+ if (tomlExtensions.has(extension)) return summarizeToml(content, mode);
172
+ if (yamlExtensions.has(extension)) return summarizeYaml(content, mode);
173
+ return summarizeFallback(content, mode);
174
+ };
175
+
176
+ const generateSymbolContent = (fullPath, content, symbol) => {
177
+ if (!symbol) return { text: 'Error: symbol parameter is required for symbol mode', indexHint: false };
178
+ const symbols = Array.isArray(symbol) ? symbol : [symbol];
179
+ let anyIndexHint = false;
180
+ const results = symbols.map((s) => {
181
+ const { used } = lookupIndexLine(fullPath, s);
182
+ if (used) anyIndexHint = true;
183
+ const extracted = extractSymbolFromContent(fullPath, content, s);
184
+ return symbols.length > 1 ? `--- ${s} ---\n${extracted}` : extracted;
185
+ });
186
+ return { text: truncate(results.join('\n\n'), 12000), indexHint: anyIndexHint };
187
+ };
188
+
189
+ const truncateByTokens = (text, maxTokens) => {
190
+ const marker = `\n[truncated to fit ${maxTokens} token budget]`;
191
+ const markerTokens = countTokens(marker);
192
+ const budget = Math.max(1, maxTokens - markerTokens);
193
+
194
+ const lines = text.split('\n');
195
+ const kept = [];
196
+ let tokens = 0;
197
+
198
+ for (const line of lines) {
199
+ const lineTokens = countTokens(line);
200
+ if (tokens + lineTokens > budget) break;
201
+ kept.push(line);
202
+ tokens += lineTokens;
203
+ }
204
+
205
+ kept.push(marker);
206
+ return kept.join('\n');
207
+ };
208
+
209
+ const cachedGenerate = (fullPath, extension, content, mode, mtime) => {
210
+ const key = buildCacheKey(fullPath, mode);
211
+ const hit = getCached(key, mtime);
212
+ if (hit !== null) return { text: hit, cached: true };
213
+ const text = generateContent(fullPath, extension, content, mode);
214
+ setCache(key, mtime, text);
215
+ return { text, cached: false };
216
+ };
217
+
218
+ const cachedSymbol = (fullPath, content, symbol, mtime) => {
219
+ const symbols = Array.isArray(symbol) ? symbol : [symbol];
220
+ const extra = symbols.join(',');
221
+ const key = buildCacheKey(fullPath, 'symbol', extra);
222
+ const hit = getCached(key, mtime);
223
+ if (hit !== null) return { text: hit.text, indexHint: hit.indexHint, cached: true };
224
+ const result = generateSymbolContent(fullPath, content, symbol);
225
+ setCache(key, mtime, { text: result.text, indexHint: result.indexHint });
226
+ return { ...result, cached: false };
227
+ };
228
+
229
+ const cachedRange = (content, startLine, endLine, fullPath, mtime) => {
230
+ const extra = `${startLine ?? ''}-${endLine ?? ''}`;
231
+ const key = buildCacheKey(fullPath, 'range', extra);
232
+ const hit = getCached(key, mtime);
233
+ if (hit !== null) return { text: hit, cached: true };
234
+ const text = extractRange(content, startLine, endLine);
235
+ setCache(key, mtime, text);
236
+ return { text, cached: false };
237
+ };
238
+
239
+ export const grepSymbolInFile = async (absPath, symbol) => {
240
+ try {
241
+ const { stdout } = await execFile(rgPath, [
242
+ '--line-number', '--no-heading', '--fixed-strings', '--max-count', '5',
243
+ symbol, absPath,
244
+ ], { timeout: 3000 });
245
+ return stdout.split('\n').filter(Boolean).map((line) => {
246
+ const sep = line.indexOf(':');
247
+ if (sep === -1) return line;
248
+ return `${line.substring(0, sep)}|${line.substring(sep + 1)}`;
249
+ });
250
+ } catch {
251
+ return [];
252
+ }
253
+ };
254
+
255
+ const TYPE_REF_RE = /:\s*([A-Z][A-Za-z0-9_]+)|<([A-Z][A-Za-z0-9_]+)>|(?:extends|implements)\s+([A-Z][A-Za-z0-9_]+)/g;
256
+
257
+ export const extractTypeReferences = (definitionText, index, relPath) => {
258
+ if (!index?.invertedIndex) return [];
259
+ const seen = new Set();
260
+ const results = [];
261
+ for (const match of definitionText.matchAll(TYPE_REF_RE)) {
262
+ const name = match[1] || match[2] || match[3];
263
+ if (!name || seen.has(name)) continue;
264
+ seen.add(name);
265
+ const hits = queryIndex(index, name);
266
+ const localHit = hits.find((h) => h.path === relPath);
267
+ if (localHit) {
268
+ results.push({ name, file: relPath, line: localHit.line });
269
+ } else if (hits.length > 0) {
270
+ results.push({ name, file: hits[0].path, line: hits[0].line });
271
+ }
272
+ }
273
+ return results;
274
+ };
275
+
276
+ export const buildSymbolContext = async (fullPath, symbolNames, root) => {
277
+ const index = loadIndex(root);
278
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
279
+ const sections = { callers: [], tests: [], types: [] };
280
+ const hints = [];
281
+
282
+ if (!index) {
283
+ hints.push('No symbol index — run build_index for cross-file context');
284
+ return { sections, hints };
285
+ }
286
+
287
+ const related = queryRelated(index, relPath);
288
+
289
+ for (const caller of related.importedBy.slice(0, 5)) {
290
+ const callerAbs = path.join(root, caller);
291
+ if (!fs.existsSync(callerAbs)) continue;
292
+ for (const sym of symbolNames) {
293
+ const matches = await grepSymbolInFile(callerAbs, sym);
294
+ if (matches.length > 0) {
295
+ sections.callers.push({ file: caller, symbol: sym, lines: matches.slice(0, 3) });
296
+ }
297
+ }
298
+ }
299
+
300
+ for (const testFile of related.tests.slice(0, 3)) {
301
+ const testAbs = path.join(root, testFile);
302
+ if (!fs.existsSync(testAbs)) continue;
303
+ for (const sym of symbolNames) {
304
+ const matches = await grepSymbolInFile(testAbs, sym);
305
+ if (matches.length > 0) {
306
+ sections.tests.push({ file: testFile, symbol: sym, lines: matches.slice(0, 3) });
307
+ }
308
+ }
309
+ }
310
+
311
+ const symbolDef = symbolNames.map((s) => {
312
+ const hits = queryIndex(index, s);
313
+ const hit = hits.find((h) => h.path === relPath);
314
+ return hit ? { name: s, line: hit.line } : null;
315
+ }).filter(Boolean);
316
+
317
+ if (symbolDef.length > 0) {
318
+ try {
319
+ const fileContent = fs.readFileSync(fullPath, 'utf8');
320
+ const defLines = fileContent.split('\n');
321
+ for (const def of symbolDef) {
322
+ const startIdx = Math.max(0, (def.line ?? 1) - 1);
323
+ const endIdx = Math.min(defLines.length, startIdx + 30);
324
+ const snippet = defLines.slice(startIdx, endIdx).join('\n');
325
+ const typeRefs = extractTypeReferences(snippet, index, relPath);
326
+ for (const t of typeRefs) {
327
+ if (!sections.types.some((e) => e.name === t.name)) sections.types.push(t);
328
+ }
329
+ }
330
+ } catch { /* unreadable — skip types */ }
331
+ }
332
+
333
+ return { sections, hints };
334
+ };
335
+
336
+ const formatContextSections = (sections) => {
337
+ const parts = [];
338
+
339
+ if (sections.callers.length > 0) {
340
+ parts.push('\n--- callers ---');
341
+ for (const c of sections.callers) {
342
+ parts.push(`// ${c.file}`);
343
+ parts.push(...c.lines);
344
+ }
345
+ }
346
+
347
+ if (sections.tests.length > 0) {
348
+ parts.push('\n--- tests ---');
349
+ for (const t of sections.tests) {
350
+ parts.push(`// ${t.file}`);
351
+ parts.push(...t.lines);
352
+ }
353
+ }
354
+
355
+ if (sections.types.length > 0) {
356
+ parts.push('\n--- types ---');
357
+ for (const t of sections.types) {
358
+ parts.push(`// ${t.file} → ${t.name} (line ${t.line})`);
359
+ }
360
+ }
361
+
362
+ return parts.length > 0 ? '\n' + parts.join('\n') : '';
363
+ };
364
+
365
+ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext }) => {
366
+ const { fullPath, content } = readTextFile(filePath);
367
+ const extension = path.extname(fullPath).toLowerCase();
368
+ const mtime = getFileMtime(fullPath);
369
+
370
+ const validBudget = Number.isFinite(maxTokens) && maxTokens >= 1 ? maxTokens : null;
371
+ let effectiveMode = mode;
372
+ let indexHintUsed = false;
373
+ let compressedText;
374
+ let cacheHit = false;
375
+
376
+ if (mode === 'range') {
377
+ const r = cachedRange(content, startLine, endLine, fullPath, mtime);
378
+ compressedText = r.text;
379
+ cacheHit = r.cached;
380
+ } else if (mode === 'symbol') {
381
+ const sym = cachedSymbol(fullPath, content, symbol, mtime);
382
+ compressedText = sym.text;
383
+ indexHintUsed = sym.indexHint;
384
+ cacheHit = sym.cached;
385
+ } else if (validBudget) {
386
+ const cascadeFrom = MODE_CASCADE.indexOf(effectiveMode);
387
+ const cascade = cascadeFrom >= 0 ? MODE_CASCADE.slice(cascadeFrom) : [effectiveMode];
388
+
389
+ for (const candidate of cascade) {
390
+ const g = cachedGenerate(fullPath, extension, content, candidate, mtime);
391
+ compressedText = g.text;
392
+ if (g.cached) cacheHit = true;
393
+ effectiveMode = candidate;
394
+ if (countTokens(compressedText) <= validBudget) break;
395
+ }
396
+
397
+ if (countTokens(compressedText) > validBudget) {
398
+ compressedText = truncateByTokens(compressedText, validBudget);
399
+ }
400
+ } else {
401
+ const g = cachedGenerate(fullPath, extension, content, mode, mtime);
402
+ compressedText = g.text;
403
+ cacheHit = g.cached;
404
+ }
405
+
406
+ let contextResult = null;
407
+
408
+ if (mode === 'symbol' && includeContext && symbol) {
409
+ const symbolNames = Array.isArray(symbol) ? symbol : [symbol];
410
+ const { sections, hints } = await buildSymbolContext(fullPath, symbolNames, projectRoot);
411
+ const contextText = formatContextSections(sections);
412
+ if (contextText) compressedText += contextText;
413
+ contextResult = {
414
+ context: { callers: sections.callers.length, tests: sections.tests.length, types: sections.types.length },
415
+ graphCoverage: getGraphCoverage(extension),
416
+ ...(hints.length > 0 ? { contextHints: hints } : {}),
417
+ };
418
+ }
419
+
420
+ if (validBudget && (mode === 'range' || mode === 'symbol') && countTokens(compressedText) > validBudget) {
421
+ compressedText = truncateByTokens(compressedText, validBudget);
422
+ }
423
+
424
+ const rawMode = effectiveMode === 'full' || effectiveMode === 'range';
425
+ const parser = rawMode ? 'raw' : resolveParserType(extension, fullPath);
426
+ const truncated = compressedText.includes('[truncated ');
427
+
428
+ const metrics = buildMetrics({
429
+ tool: 'smart_read',
430
+ target: fullPath,
431
+ rawText: content,
432
+ compressedText,
433
+ });
434
+
435
+ await persistMetrics(metrics);
436
+
437
+ const confidence = { parser, truncated, cached: cacheHit && !contextResult };
438
+ if (contextResult) confidence.graphCoverage = contextResult.graphCoverage;
439
+
440
+ const result = {
441
+ filePath: fullPath,
442
+ mode,
443
+ parser,
444
+ truncated,
445
+ content: compressedText,
446
+ confidence,
447
+ metrics,
448
+ };
449
+
450
+ if (cacheHit && !contextResult) result.cached = true;
451
+ if (mode === 'symbol') result.indexHint = indexHintUsed;
452
+ if (validBudget && effectiveMode !== mode) {
453
+ result.chosenMode = effectiveMode;
454
+ result.budgetApplied = true;
455
+ }
456
+ if (contextResult) Object.assign(result, contextResult);
457
+
458
+ return result;
459
+ };