smart-context-mcp 1.17.0 → 1.18.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 +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/explain/explainer.js +234 -0
- package/src/graph-paths.js +174 -0
- package/src/index.js +112 -2
- package/src/orchestration/adapters/claude-adapter.js +26 -0
- package/src/orchestration/adapters/cursor-adapter.js +26 -0
- package/src/orchestration/policy/soft-prompts.js +97 -0
- package/src/review/heuristics.js +105 -0
- package/src/server.js +50 -10
- package/src/storage/sqlite.js +105 -1
- package/src/tools/smart-context.js +84 -1
- package/src/tools/smart-read.js +23 -3
- package/src/tools/smart-review.js +194 -0
- package/src/tools/smart-search.js +19 -2
- package/src/tools/smart-test.js +261 -0
- package/src/tools/smart-turn.js +7 -2
- package/src/turn/next-actions.js +136 -0
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.
|
|
59
|
+
# Should show: smart-context-mcp@1.18.1 (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.
|
|
4
|
+
"version": "1.18.1",
|
|
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.
|
|
9
|
+
"version": "1.18.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "smart-context-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.18.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -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
|
+
};
|
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 =
|
|
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
|
|
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
|
}
|
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
normalizeWhitespace,
|
|
22
22
|
truncate,
|
|
23
23
|
} from '../policy/event-policy.js';
|
|
24
|
+
import {
|
|
25
|
+
evaluateSoftPrompt,
|
|
26
|
+
isSoftPromptsEnabled,
|
|
27
|
+
markSoftPromptEmitted,
|
|
28
|
+
shouldEmitSoftPrompt,
|
|
29
|
+
} from '../policy/soft-prompts.js';
|
|
24
30
|
|
|
25
31
|
export const HOOK_CLIENT = 'claude';
|
|
26
32
|
export const STOP_MAX_TOKENS = 300;
|
|
@@ -501,6 +507,26 @@ export const createClaudeAdapter = ({
|
|
|
501
507
|
continuityState: existing.continuityState,
|
|
502
508
|
});
|
|
503
509
|
}
|
|
510
|
+
|
|
511
|
+
if (isSoftPromptsEnabled() && shouldEmitSoftPrompt(hookKey)) {
|
|
512
|
+
const softPrompt = evaluateSoftPrompt({
|
|
513
|
+
toolName: input.tool_name,
|
|
514
|
+
toolInput: input.tool_input,
|
|
515
|
+
toolResponse: input.tool_response,
|
|
516
|
+
state: nextState,
|
|
517
|
+
});
|
|
518
|
+
if (softPrompt) {
|
|
519
|
+
markSoftPromptEmitted(hookKey);
|
|
520
|
+
await recordHookMetrics({
|
|
521
|
+
action: 'PostToolUse',
|
|
522
|
+
sessionId: existing.projectSessionId,
|
|
523
|
+
additionalContext: softPrompt.message,
|
|
524
|
+
continuityState: existing.continuityState,
|
|
525
|
+
});
|
|
526
|
+
return buildClaudeHookContextResponse('PostToolUse', softPrompt.message);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
504
530
|
return null;
|
|
505
531
|
};
|
|
506
532
|
|
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
normalizeWhitespace,
|
|
22
22
|
truncate,
|
|
23
23
|
} from '../policy/event-policy.js';
|
|
24
|
+
import {
|
|
25
|
+
evaluateSoftPrompt,
|
|
26
|
+
isSoftPromptsEnabled,
|
|
27
|
+
markSoftPromptEmitted,
|
|
28
|
+
shouldEmitSoftPrompt,
|
|
29
|
+
} from '../policy/soft-prompts.js';
|
|
24
30
|
|
|
25
31
|
export const HOOK_CLIENT = 'cursor';
|
|
26
32
|
export const STOP_MAX_TOKENS = 300;
|
|
@@ -504,6 +510,26 @@ export const createCursorAdapter = ({
|
|
|
504
510
|
continuityState: existing.continuityState,
|
|
505
511
|
});
|
|
506
512
|
}
|
|
513
|
+
|
|
514
|
+
if (isSoftPromptsEnabled() && shouldEmitSoftPrompt(hookKey)) {
|
|
515
|
+
const softPrompt = evaluateSoftPrompt({
|
|
516
|
+
toolName: input.tool_name,
|
|
517
|
+
toolInput: input.tool_input,
|
|
518
|
+
toolResponse: input.tool_response,
|
|
519
|
+
state: nextState,
|
|
520
|
+
});
|
|
521
|
+
if (softPrompt) {
|
|
522
|
+
markSoftPromptEmitted(hookKey);
|
|
523
|
+
await recordHookMetrics({
|
|
524
|
+
action: 'PostToolUse',
|
|
525
|
+
sessionId: existing.projectSessionId,
|
|
526
|
+
additionalContext: softPrompt.message,
|
|
527
|
+
continuityState: existing.continuityState,
|
|
528
|
+
});
|
|
529
|
+
return buildCursorHookContextResponse('PostToolUse', softPrompt.message);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
507
533
|
return null;
|
|
508
534
|
};
|
|
509
535
|
|