smart-context-mcp 1.16.5 → 1.18.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.
package/README.md CHANGED
@@ -56,7 +56,7 @@ Restart your AI client. Done.
56
56
  # Check installed version
57
57
  npm list -g smart-context-mcp
58
58
 
59
- # Should show: smart-context-mcp@1.16.5 (or later)
59
+ # Should show: smart-context-mcp@1.18.0 (or later)
60
60
 
61
61
  # Update to latest version
62
62
  npm update -g smart-context-mcp
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "smart-context-mcp",
3
3
  "mcpName": "io.github.Arrayo/smart-context-mcp",
4
- "version": "1.16.5",
4
+ "version": "1.18.0",
5
5
  "description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
6
6
  "author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
7
7
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/Arrayo/smart-context-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.16.5",
9
+ "version": "1.18.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "smart-context-mcp",
14
- "version": "1.16.5",
14
+ "version": "1.18.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -52,8 +52,11 @@ export const buildRecommendedPathLines = (
52
52
  lines.push(`${nextToolsLabel}: ${recommendedPath.nextTools.slice(0, 3).join(' -> ')}`);
53
53
  }
54
54
 
55
- if (includePath && recommendedPath.steps?.[0]?.instruction) {
56
- lines.push(`${pathLabel}: ${truncate(recommendedPath.steps[0].instruction, maxLength)}`);
55
+ if (includePath) {
56
+ const pathInstruction = recommendedPath.steps?.[0]?.instruction ?? recommendedPath.next ?? null;
57
+ if (pathInstruction) {
58
+ lines.push(`${pathLabel}: ${truncate(pathInstruction, maxLength)}`);
59
+ }
57
60
  }
58
61
 
59
62
  return lines;
@@ -0,0 +1,234 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { loadIndex, queryIndex, queryRelated } from '../index.js';
5
+ import { projectRoot } from '../utils/paths.js';
6
+ import { getExplainCache, setExplainCache } from '../storage/sqlite.js';
7
+ import { countTokens } from '../tokenCounter.js';
8
+
9
+ const SIDE_EFFECT_PATTERNS = [
10
+ { kind: 'io', re: /\b(fs|fsPromises)\.(read|write|append|unlink|mkdir|rm|stat|exists|copy|rename)/ },
11
+ { kind: 'io', re: /\b(readFileSync|writeFileSync|appendFileSync|unlinkSync|mkdirSync|rmSync|statSync)\b/ },
12
+ { kind: 'network', re: /\b(fetch|axios|http\.request|https\.request|XMLHttpRequest|WebSocket)\b/ },
13
+ { kind: 'process', re: /\b(process\.(env|exit|kill|chdir)|child_process|execSync|spawnSync|execFile|spawn)\b/ },
14
+ { kind: 'logging', re: /\bconsole\.(log|info|warn|error|debug)\b/ },
15
+ { kind: 'mutation', re: /\b(this\.\w+\s*=|let\s+\w+\s*=|\w+\.push\(|\w+\.splice\(|delete\s+\w+\[)/ },
16
+ { kind: 'throws', re: /\bthrow\s+(new\s+)?\w+/ },
17
+ { kind: 'async', re: /\b(await|Promise\.(all|race|any|allSettled)|setTimeout|setInterval|setImmediate)\b/ },
18
+ { kind: 'db', re: /\b(prepare|execute|query|transaction|raw|knex|pgp)\(/ },
19
+ ];
20
+
21
+ const COMMENT_LINE_RE = /^\s*(?:\/\/|#|\*|\/\*\*|\/\*|"""|''')/;
22
+ const STRIPPED_COMMENT_RE = /^\s*(?:\/\/+|#+|\*+\/?|\/\*\*?|"""|''')\s?/;
23
+
24
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
25
+
26
+ const stripCommentMarkers = (line) =>
27
+ line.replace(STRIPPED_COMMENT_RE, '').replace(/\*\/\s*$/, '').trim();
28
+
29
+ export const extractDocstring = (lines, signatureLineIndex) => {
30
+ if (signatureLineIndex <= 0) return '';
31
+ const docLines = [];
32
+ for (let i = signatureLineIndex - 1; i >= 0; i -= 1) {
33
+ const line = lines[i];
34
+ if (line == null) break;
35
+ if (line.trim() === '') {
36
+ if (docLines.length === 0) continue;
37
+ break;
38
+ }
39
+ if (!COMMENT_LINE_RE.test(line)) break;
40
+ docLines.unshift(stripCommentMarkers(line));
41
+ }
42
+ return docLines.join(' ').replace(/\s+/g, ' ').trim().slice(0, 280);
43
+ };
44
+
45
+ export const extractFirstBodyLine = (lines, signatureLineIndex) => {
46
+ for (let i = signatureLineIndex + 1; i < lines.length && i < signatureLineIndex + 12; i += 1) {
47
+ const line = lines[i];
48
+ if (line == null) continue;
49
+ const trimmed = line.trim();
50
+ if (trimmed === '' || trimmed === '{' || COMMENT_LINE_RE.test(trimmed)) continue;
51
+ return trimmed.slice(0, 160);
52
+ }
53
+ return '';
54
+ };
55
+
56
+ export const detectSideEffects = (block) => {
57
+ const found = new Set();
58
+ for (const { kind, re } of SIDE_EFFECT_PATTERNS) {
59
+ if (re.test(block)) found.add(kind);
60
+ }
61
+ return [...found];
62
+ };
63
+
64
+ const extractBlock = (lines, startLine, maxLines = 80) => {
65
+ const startIdx = Math.max(0, (startLine ?? 1) - 1);
66
+ const endIdx = Math.min(lines.length, startIdx + maxLines);
67
+ return {
68
+ block: lines.slice(startIdx, endIdx).join('\n'),
69
+ startIdx,
70
+ endIdx,
71
+ };
72
+ };
73
+
74
+ const countCallers = (index, relPath, symbol) => {
75
+ if (!index) return 0;
76
+ const hits = queryIndex(index, symbol);
77
+ const related = queryRelated(index, relPath);
78
+ const callerFiles = new Set(related.importedBy);
79
+ const externalHits = hits.filter((h) => h.path !== relPath).length;
80
+ return callerFiles.size + externalHits;
81
+ };
82
+
83
+ const lookupSymbolMeta = (index, relPath, symbol) => {
84
+ if (!index) return null;
85
+ const hits = queryIndex(index, symbol);
86
+ const local = hits.find((h) => h.path === relPath);
87
+ return local ?? hits[0] ?? null;
88
+ };
89
+
90
+ export const buildStructuralExplanation = ({
91
+ fullPath,
92
+ content,
93
+ symbol,
94
+ root = projectRoot,
95
+ index = null,
96
+ }) => {
97
+ const lines = content.split('\n');
98
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
99
+ const resolvedIndex = index ?? loadIndex(root);
100
+ const meta = lookupSymbolMeta(resolvedIndex, relPath, symbol);
101
+
102
+ if (!meta) {
103
+ return null;
104
+ }
105
+
106
+ const { block, startIdx } = extractBlock(lines, meta.line, 80);
107
+ const signature = meta.signature ?? lines[startIdx]?.trim() ?? '';
108
+ const docstring = extractDocstring(lines, startIdx);
109
+ const firstBodyLine = extractFirstBodyLine(lines, startIdx);
110
+ const sideEffects = detectSideEffects(block);
111
+ const callers = countCallers(resolvedIndex, relPath, symbol);
112
+ const contentHash = sha256(block);
113
+
114
+ return {
115
+ symbol,
116
+ file: relPath,
117
+ line: meta.line,
118
+ kind: meta.kind ?? null,
119
+ parent: meta.parent ?? null,
120
+ signature: signature.slice(0, 200),
121
+ docstring,
122
+ firstBodyLine,
123
+ sideEffects,
124
+ callers,
125
+ contentHash,
126
+ };
127
+ };
128
+
129
+ const formatExplanationText = (explanation) => {
130
+ const lines = [
131
+ `${explanation.symbol} (${explanation.kind ?? 'symbol'}) — ${explanation.file}:${explanation.line}`,
132
+ `signature: ${explanation.signature || '<unknown>'}`,
133
+ ];
134
+ if (explanation.parent) lines.push(`parent: ${explanation.parent}`);
135
+ if (explanation.docstring) lines.push(`docs: ${explanation.docstring}`);
136
+ if (explanation.firstBodyLine) lines.push(`first body: ${explanation.firstBodyLine}`);
137
+ if (explanation.sideEffects.length > 0) lines.push(`side effects: ${explanation.sideEffects.join(', ')}`);
138
+ lines.push(`callers: ${explanation.callers}`);
139
+ return lines.join('\n');
140
+ };
141
+
142
+ export const explainSymbol = async ({
143
+ fullPath,
144
+ content,
145
+ symbol,
146
+ root = projectRoot,
147
+ index = null,
148
+ useCache = true,
149
+ }) => {
150
+ const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
151
+ const partial = buildStructuralExplanation({ fullPath, content, symbol, root, index });
152
+
153
+ if (!partial) {
154
+ return {
155
+ symbol,
156
+ file: relPath,
157
+ found: false,
158
+ text: `Symbol not found in index or content: ${symbol}`,
159
+ cached: false,
160
+ provider: 'structural',
161
+ };
162
+ }
163
+
164
+ if (useCache) {
165
+ try {
166
+ const cached = await getExplainCache({
167
+ relPath,
168
+ symbol,
169
+ contentHash: partial.contentHash,
170
+ });
171
+ if (cached?.explanation) {
172
+ const text = formatExplanationText(cached.explanation);
173
+ return {
174
+ ...cached.explanation,
175
+ found: true,
176
+ text,
177
+ cached: true,
178
+ provider: cached.provider,
179
+ };
180
+ }
181
+ } catch {
182
+ // cache unavailable — fall through and recompute
183
+ }
184
+ }
185
+
186
+ const text = formatExplanationText(partial);
187
+ const tokens = countTokens(text);
188
+
189
+ if (useCache) {
190
+ try {
191
+ await setExplainCache({
192
+ relPath,
193
+ symbol,
194
+ contentHash: partial.contentHash,
195
+ explanation: partial,
196
+ provider: 'structural',
197
+ tokens,
198
+ });
199
+ } catch {
200
+ // best-effort cache write
201
+ }
202
+ }
203
+
204
+ return {
205
+ ...partial,
206
+ found: true,
207
+ text,
208
+ cached: false,
209
+ provider: 'structural',
210
+ };
211
+ };
212
+
213
+ export const explainSymbols = async ({ fullPath, content, symbols, root = projectRoot, index = null, useCache = true }) => {
214
+ const list = Array.isArray(symbols) ? symbols : [symbols];
215
+ const results = [];
216
+ for (const sym of list) {
217
+ if (!sym) continue;
218
+ results.push(await explainSymbol({ fullPath, content, symbol: sym, root, index, useCache }));
219
+ }
220
+ return results;
221
+ };
222
+
223
+ export const formatExplanationsAsText = (results) =>
224
+ results.map((r) => r.text).join('\n\n');
225
+
226
+ const fileExists = (p) => {
227
+ try {
228
+ return fs.existsSync(p);
229
+ } catch {
230
+ return false;
231
+ }
232
+ };
233
+
234
+ export const __internal = { fileExists, formatExplanationText };
@@ -0,0 +1,174 @@
1
+ import path from 'node:path';
2
+ import { queryIndex, queryRelated } from './index.js';
3
+
4
+ const DEFAULT_MAX_HOPS = 5;
5
+ const NEAREST_LIMIT = 3;
6
+
7
+ const buildAdjacency = (index, { directed = false } = {}) => {
8
+ const adj = new Map();
9
+ const ensure = (key) => {
10
+ if (!adj.has(key)) adj.set(key, new Set());
11
+ return adj.get(key);
12
+ };
13
+ for (const edge of index?.graph?.edges ?? []) {
14
+ if (!edge?.from || !edge?.to) continue;
15
+ if (edge.kind && edge.kind !== 'import' && edge.kind !== 'testOf') continue;
16
+ ensure(edge.from).add(edge.to);
17
+ if (!directed) ensure(edge.to).add(edge.from);
18
+ }
19
+ return adj;
20
+ };
21
+
22
+ export const resolveEntityToFiles = (index, entity) => {
23
+ if (!entity || typeof entity !== 'string') return [];
24
+ const normalized = entity.replace(/\\/g, '/').trim();
25
+
26
+ if (normalized.includes('/') || /\.[a-zA-Z0-9]+$/.test(normalized)) {
27
+ if (index?.files?.[normalized]) return [normalized];
28
+ const filesMap = index?.files ?? {};
29
+ const matches = Object.keys(filesMap).filter((rel) => rel.endsWith(`/${normalized}`) || rel === normalized);
30
+ return matches;
31
+ }
32
+
33
+ const hits = queryIndex(index, normalized);
34
+ return [...new Set(hits.map((h) => h.path))];
35
+ };
36
+
37
+ const reconstructPath = (parents, target) => {
38
+ const path = [target];
39
+ let cursor = target;
40
+ while (parents.has(cursor)) {
41
+ const prev = parents.get(cursor);
42
+ if (prev === null) break;
43
+ path.unshift(prev);
44
+ cursor = prev;
45
+ }
46
+ return path;
47
+ };
48
+
49
+ export const findPath = (index, fromFile, toFile, { maxHops = DEFAULT_MAX_HOPS, directed = false } = {}) => {
50
+ if (!index?.graph?.edges) return null;
51
+ if (!fromFile || !toFile) return null;
52
+ if (fromFile === toFile) return { hops: 0, path: [fromFile] };
53
+
54
+ const adj = buildAdjacency(index, { directed });
55
+ if (!adj.has(fromFile) && !adj.has(toFile)) return null;
56
+
57
+ const visited = new Map();
58
+ visited.set(fromFile, 0);
59
+ const parents = new Map([[fromFile, null]]);
60
+ const queue = [fromFile];
61
+
62
+ while (queue.length > 0) {
63
+ const current = queue.shift();
64
+ const depth = visited.get(current);
65
+ if (depth >= maxHops) continue;
66
+
67
+ const neighbors = adj.get(current) ?? new Set();
68
+ for (const next of neighbors) {
69
+ if (visited.has(next)) continue;
70
+ visited.set(next, depth + 1);
71
+ parents.set(next, current);
72
+ if (next === toFile) {
73
+ return { hops: depth + 1, path: reconstructPath(parents, toFile) };
74
+ }
75
+ queue.push(next);
76
+ }
77
+ }
78
+
79
+ return null;
80
+ };
81
+
82
+ const collectNearestNeighbors = (index, file, limit = NEAREST_LIMIT) => {
83
+ const result = new Map();
84
+ if (!file || !index) return [];
85
+
86
+ const related = queryRelated(index, file);
87
+ for (const rel of [...related.imports, ...related.importedBy, ...related.tests, ...related.neighbors]) {
88
+ if (rel === file) continue;
89
+ if (!result.has(rel)) result.set(rel, result.size);
90
+ if (result.size >= limit) break;
91
+ }
92
+ return [...result.keys()];
93
+ };
94
+
95
+ export const findNearest = (index, fromFile, toFile, limit = NEAREST_LIMIT) => ({
96
+ fromNeighbors: collectNearestNeighbors(index, fromFile, limit),
97
+ toNeighbors: collectNearestNeighbors(index, toFile, limit),
98
+ });
99
+
100
+ const symbolForPathStep = (index, relPath, symbolName) => {
101
+ const filesMap = index?.files ?? {};
102
+ const entry = filesMap[relPath];
103
+ if (!entry?.symbols) return null;
104
+ if (symbolName) {
105
+ return entry.symbols.find((s) => s.name?.toLowerCase() === symbolName.toLowerCase()) ?? null;
106
+ }
107
+ return entry.symbols.find((s) => s.kind === 'function' || s.kind === 'class' || s.kind === 'const')
108
+ ?? entry.symbols[0]
109
+ ?? null;
110
+ };
111
+
112
+ export const describePath = (index, pathFiles, { hintSymbols = {} } = {}) => {
113
+ if (!Array.isArray(pathFiles) || pathFiles.length === 0) return [];
114
+ return pathFiles.map((rel) => {
115
+ const sym = symbolForPathStep(index, rel, hintSymbols[rel]);
116
+ return {
117
+ file: rel,
118
+ symbol: sym?.name ?? null,
119
+ signature: sym?.signature ?? null,
120
+ line: sym?.line ?? null,
121
+ kind: sym?.kind ?? null,
122
+ };
123
+ });
124
+ };
125
+
126
+ export const buildPathsResult = (index, from, to, options = {}) => {
127
+ const fromFiles = resolveEntityToFiles(index, from);
128
+ const toFiles = resolveEntityToFiles(index, to);
129
+
130
+ if (fromFiles.length === 0 || toFiles.length === 0) {
131
+ return {
132
+ from,
133
+ to,
134
+ resolved: { from: fromFiles, to: toFiles },
135
+ found: false,
136
+ reason: fromFiles.length === 0 ? 'from-not-found' : 'to-not-found',
137
+ path: [],
138
+ hops: null,
139
+ fallback: null,
140
+ };
141
+ }
142
+
143
+ for (const f of fromFiles) {
144
+ for (const t of toFiles) {
145
+ const result = findPath(index, f, t, options);
146
+ if (result) {
147
+ return {
148
+ from,
149
+ to,
150
+ resolved: { from: [f], to: [t] },
151
+ found: true,
152
+ hops: result.hops,
153
+ path: describePath(index, result.path),
154
+ fallback: null,
155
+ };
156
+ }
157
+ }
158
+ }
159
+
160
+ const seedFrom = fromFiles[0];
161
+ const seedTo = toFiles[0];
162
+ const fallback = findNearest(index, seedFrom, seedTo, options.nearestLimit ?? NEAREST_LIMIT);
163
+
164
+ return {
165
+ from,
166
+ to,
167
+ resolved: { from: [seedFrom], to: [seedTo] },
168
+ found: false,
169
+ reason: 'no-path',
170
+ path: [],
171
+ hops: null,
172
+ fallback,
173
+ };
174
+ };
@@ -128,3 +128,24 @@ export const getIndexStatus = (root = projectRoot) => {
128
128
  age: meta?.builtAt ? Date.now() - meta.builtAt : null
129
129
  };
130
130
  };
131
+
132
+ let backgroundBuildPromise = null;
133
+
134
+ export const triggerBackgroundIndexBuild = ({ root = projectRoot, timeoutMs = INDEX_BUILD_TIMEOUT_MS } = {}) => {
135
+ if (backgroundBuildPromise) {
136
+ return backgroundBuildPromise;
137
+ }
138
+
139
+ const status = getIndexStatus(root);
140
+ if (status.available && status.fresh) {
141
+ return Promise.resolve({ status: 'fresh', cached: true });
142
+ }
143
+
144
+ backgroundBuildPromise = ensureIndexReady({ root, timeoutMs })
145
+ .catch((error) => ({ status: 'error', error: error?.message ?? String(error) }))
146
+ .finally(() => {
147
+ backgroundBuildPromise = null;
148
+ });
149
+
150
+ return backgroundBuildPromise;
151
+ };
package/src/index.js CHANGED
@@ -5,7 +5,7 @@ import ts from 'typescript';
5
5
  import { isBinaryBuffer } from './utils/fs.js';
6
6
  import { IGNORED_DIRS } from './config/ignored-paths.js';
7
7
 
8
- const INDEX_VERSION = 4;
8
+ const INDEX_VERSION = 6;
9
9
 
10
10
  const MAX_SIGNATURE_LEN = 200;
11
11
  const MAX_SNIPPET_LEN = 280;
@@ -60,8 +60,15 @@ const indexableExtensions = new Set([
60
60
  '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
61
61
  '.py', '.go', '.rs', '.java',
62
62
  '.cs', '.kt', '.php', '.swift',
63
+ '.md', '.markdown',
63
64
  ]);
64
65
 
66
+ const isIndexableMarkdownFile = (fullPath) => {
67
+ const ext = path.extname(fullPath).toLowerCase();
68
+ if (ext !== '.md' && ext !== '.markdown') return false;
69
+ return isAdrPath(fullPath);
70
+ };
71
+
65
72
  const ignoredDirs = new Set(IGNORED_DIRS);
66
73
 
67
74
  const scriptKindByExtension = {
@@ -564,6 +571,105 @@ const extractSwiftImports = (content) => {
564
571
  return { imports, exports: [] };
565
572
  };
566
573
 
574
+ // ---------------------------------------------------------------------------
575
+ // ADR / Spec / Architecture markdown parser
576
+ // ---------------------------------------------------------------------------
577
+
578
+ const ADR_PATH_RE = /(?:^|\/)(?:adrs?|decisions|architecture|design-docs)\//i;
579
+ const ADR_FILENAME_RE = /^(?:adr[-_]?\d{0,4}.*|\d{3,4}[-_].*|SPEC|ARCHITECTURE|DESIGN|RFC[-_]?\d*.*)\.(?:md|markdown)$/i;
580
+ const ADR_STATUS_RE = /^\s*[*_>-]{0,3}\s*status\s*[*_]{0,2}\s*[:=]?\s*[*_]{0,2}\s*([A-Za-z][A-Za-z -]+)/i;
581
+ const MARKDOWN_HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
582
+ const ADR_VALID_STATUS = new Set([
583
+ 'proposed', 'draft', 'rejected', 'accepted', 'deprecated',
584
+ 'superseded', 'amended', 'approved', 'in review', 'in-review',
585
+ ]);
586
+
587
+ export const isAdrPath = (relPath) => {
588
+ if (!relPath) return false;
589
+ const norm = relPath.replace(/\\/g, '/');
590
+ if (ADR_PATH_RE.test(norm)) return true;
591
+ const base = path.basename(norm);
592
+ return ADR_FILENAME_RE.test(base);
593
+ };
594
+
595
+ const slugify = (text) => text.trim()
596
+ .toLowerCase()
597
+ .replace(/[^\w\s-]/g, '')
598
+ .replace(/\s+/g, '-')
599
+ .replace(/-+/g, '-')
600
+ .replace(/^-|-$/g, '')
601
+ .slice(0, 60);
602
+
603
+ const extractAdrStatus = (lines, headingLine) => {
604
+ const start = Math.max(0, headingLine - 1);
605
+ const end = Math.min(lines.length, start + 20);
606
+ for (let i = start; i < end; i += 1) {
607
+ const m = ADR_STATUS_RE.exec(lines[i]);
608
+ if (!m) continue;
609
+ const value = m[1].trim().toLowerCase().replace(/\s+/g, '-');
610
+ if (ADR_VALID_STATUS.has(value) || ADR_VALID_STATUS.has(value.replace('-', ' '))) {
611
+ return value;
612
+ }
613
+ }
614
+ return null;
615
+ };
616
+
617
+ export const extractAdrSymbols = (content, fullPath) => {
618
+ const symbols = [];
619
+ const lines = content.split('\n');
620
+ let titleSet = false;
621
+
622
+ for (let i = 0; i < lines.length; i += 1) {
623
+ const headingMatch = MARKDOWN_HEADING_RE.exec(lines[i]);
624
+ if (!headingMatch) continue;
625
+
626
+ const level = headingMatch[1].length;
627
+ const titleText = headingMatch[2].trim();
628
+ if (!titleText) continue;
629
+
630
+ if (!titleSet && level === 1) {
631
+ const name = slugify(titleText) || `adr-${path.basename(fullPath).toLowerCase()}`;
632
+ const status = extractAdrStatus(lines, i + 1);
633
+ const signature = `# ${titleText}${status ? ` (status: ${status})` : ''}`;
634
+ symbols.push({
635
+ name,
636
+ kind: 'adr',
637
+ line: i + 1,
638
+ signature: trimSignature(signature),
639
+ snippet: trimSnippet(titleText),
640
+ ...(status ? { status } : {}),
641
+ title: titleText,
642
+ });
643
+ titleSet = true;
644
+ continue;
645
+ }
646
+
647
+ if (level === 2 || level === 3) {
648
+ const sectionSlug = slugify(titleText);
649
+ if (!sectionSlug) continue;
650
+ symbols.push({
651
+ name: sectionSlug,
652
+ kind: 'adr-section',
653
+ line: i + 1,
654
+ signature: trimSignature(`${headingMatch[1]} ${titleText}`),
655
+ });
656
+ }
657
+ }
658
+
659
+ if (!titleSet) {
660
+ const base = path.basename(fullPath).replace(/\.[^.]+$/, '');
661
+ symbols.unshift({
662
+ name: slugify(base) || 'adr',
663
+ kind: 'adr',
664
+ line: 1,
665
+ signature: trimSignature(`# ${base}`),
666
+ title: base,
667
+ });
668
+ }
669
+
670
+ return symbols;
671
+ };
672
+
567
673
  // ---------------------------------------------------------------------------
568
674
  // Unified file info extraction
569
675
  // ---------------------------------------------------------------------------
@@ -591,6 +697,7 @@ const extractFileInfo = (fullPath, content) => {
591
697
  else if (ext === '.kt') info = { symbols: extractKotlinSymbols(content), ...extractKotlinImports(content) };
592
698
  else if (ext === '.php') info = { symbols: extractPhpSymbols(content), ...extractPhpImports(content) };
593
699
  else if (ext === '.swift') info = { symbols: extractSwiftSymbols(content), ...extractSwiftImports(content) };
700
+ else if ((ext === '.md' || ext === '.markdown') && isAdrPath(fullPath)) info = { symbols: extractAdrSymbols(content, fullPath), imports: [], exports: [] };
594
701
  else info = { symbols: [], imports: [], exports: [] };
595
702
 
596
703
  return {
@@ -672,7 +779,10 @@ const walkForIndex = (dir, files = []) => {
672
779
 
673
780
  if (entry.isDirectory()) {
674
781
  walkForIndex(fullPath, files);
675
- } else if (indexableExtensions.has(path.extname(entry.name).toLowerCase())) {
782
+ } else {
783
+ const ext = path.extname(entry.name).toLowerCase();
784
+ if (!indexableExtensions.has(ext)) continue;
785
+ if ((ext === '.md' || ext === '.markdown') && !isIndexableMarkdownFile(fullPath)) continue;
676
786
  files.push(fullPath);
677
787
  }
678
788
  }
@@ -722,8 +832,6 @@ export const buildIndex = (root, progress = null) => {
722
832
  if (!invertedIndex[key]) invertedIndex[key] = [];
723
833
  const entry = { path: relPath, line: sym.line, kind: sym.kind };
724
834
  if (sym.parent) entry.parent = sym.parent;
725
- if (sym.signature) entry.signature = sym.signature;
726
- if (sym.snippet) entry.snippet = sym.snippet;
727
835
  invertedIndex[key].push(entry);
728
836
  }
729
837
  } catch { /* unreadable */ }
@@ -783,10 +891,24 @@ export const buildIndex = (root, progress = null) => {
783
891
  // Query helpers
784
892
  // ---------------------------------------------------------------------------
785
893
 
894
+ const findSymbolForHit = (index, key, hit) => {
895
+ const symbols = index.files?.[hit.path]?.symbols;
896
+ if (!Array.isArray(symbols)) return null;
897
+ return symbols.find((sym) => sym.name?.toLowerCase() === key && sym.line === hit.line) ?? null;
898
+ };
899
+
786
900
  export const queryIndex = (index, symbolName) => {
787
901
  if (!index?.invertedIndex) return [];
788
902
  const key = symbolName.toLowerCase();
789
- return index.invertedIndex[key] ?? [];
903
+ const hits = index.invertedIndex[key] ?? [];
904
+ return hits.map((hit) => {
905
+ const sym = findSymbolForHit(index, key, hit);
906
+ if (!sym) return hit;
907
+ const enriched = { ...hit };
908
+ if (sym.signature && !enriched.signature) enriched.signature = sym.signature;
909
+ if (sym.snippet && !enriched.snippet) enriched.snippet = sym.snippet;
910
+ return enriched;
911
+ });
790
912
  };
791
913
 
792
914
  export const queryRelated = (index, relPath) => {
@@ -873,8 +995,6 @@ export const reindexFile = (index, root, relPath) => {
873
995
  if (!index.invertedIndex[key]) index.invertedIndex[key] = [];
874
996
  const invEntry = { path: relPath, line: sym.line, kind: sym.kind };
875
997
  if (sym.parent) invEntry.parent = sym.parent;
876
- if (sym.signature) invEntry.signature = sym.signature;
877
- if (sym.snippet) invEntry.snippet = sym.snippet;
878
998
  index.invertedIndex[key].push(invEntry);
879
999
  }
880
1000