smart-context-mcp 1.3.1 → 1.4.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 +89 -2
- package/package.json +1 -1
- package/src/config/ignored-paths.js +21 -0
- package/src/cross-project.js +2 -62
- package/src/decision-explainer.js +2 -58
- package/src/index.js +2 -4
- package/src/missed-opportunities.js +4 -66
- package/src/server.js +49 -1
- package/src/storage/sqlite.js +1 -1
- package/src/tools/smart-context.js +33 -527
- package/src/tools/smart-edit.js +105 -0
- package/src/tools/smart-read.js +63 -10
- package/src/tools/smart-search.js +4 -2
- package/src/tools/smart-status.js +201 -0
- package/src/tools/smart-summary.js +29 -0
- package/src/usage-feedback.js +4 -43
- package/src/utils/context-scoring.js +400 -0
- package/src/utils/fs.js +13 -7
- package/src/utils/query-extraction.js +180 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { extractSymbolCandidates } from './query-extraction.js';
|
|
3
|
+
|
|
4
|
+
const ROLE_PRIORITY = ['primary', 'test', 'dependency', 'dependent'];
|
|
5
|
+
const ROLE_RANK = Object.fromEntries(ROLE_PRIORITY.map((role, idx) => [role, idx]));
|
|
6
|
+
const EVIDENCE_PRIORITY = {
|
|
7
|
+
entryFile: 0,
|
|
8
|
+
diffHit: 1,
|
|
9
|
+
searchHit: 2,
|
|
10
|
+
symbolMatch: 3,
|
|
11
|
+
symbolDetail: 4,
|
|
12
|
+
testOf: 5,
|
|
13
|
+
dependencyOf: 6,
|
|
14
|
+
dependentOf: 7,
|
|
15
|
+
};
|
|
16
|
+
const ROLE_BASE_SCORE = { primary: 130, test: 85, dependency: 60, dependent: 50 };
|
|
17
|
+
const EVIDENCE_BASE_SCORE = {
|
|
18
|
+
entryFile: 120,
|
|
19
|
+
diffHit: 100,
|
|
20
|
+
searchHit: 70,
|
|
21
|
+
symbolMatch: 90,
|
|
22
|
+
symbolDetail: 95,
|
|
23
|
+
testOf: 40,
|
|
24
|
+
dependencyOf: 25,
|
|
25
|
+
dependentOf: 22,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PRIMARY_PATH_HINT_MAP = [
|
|
29
|
+
{ test: /\b(api|endpoint|endpoints|route|routes)\b/, hints: ['api', 'routes'] },
|
|
30
|
+
{ test: /\b(auth|token|jwt|login|session)\b/, hints: ['auth'] },
|
|
31
|
+
{ test: /\b(config|env|secret|yaml|json)\b/, hints: ['config'] },
|
|
32
|
+
{ test: /\b(test|tests|spec|coverage)\b/, hints: ['test', 'tests'] },
|
|
33
|
+
{ test: /\b(model|models|schema|schemas|entity|entities)\b/, hints: ['model', 'models'] },
|
|
34
|
+
{ test: /\b(container|docker|image|deploy|deployment)\b/, hints: ['dockerfile', 'docker'] },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const TEST_FILE_RE = /(^|\/)(tests?|__tests__)\//;
|
|
38
|
+
const QUERY_TOKEN_RE = /[a-zA-Z0-9_]+/g;
|
|
39
|
+
|
|
40
|
+
const STOP_WORDS = new Set([
|
|
41
|
+
'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'is', 'are',
|
|
42
|
+
'was', 'were', 'be', 'been', 'and', 'or', 'but', 'not', 'this', 'that', 'it',
|
|
43
|
+
'how', 'what', 'where', 'when', 'why', 'which', 'who', 'do', 'does', 'did',
|
|
44
|
+
'has', 'have', 'had', 'from', 'by', 'about', 'into', 'my', 'our', 'your',
|
|
45
|
+
'can', 'could', 'will', 'would', 'should', 'may', 'might', 'i', 'we', 'you',
|
|
46
|
+
'all', 'each', 'every', 'me', 'us', 'them', 'its',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const LOW_SIGNAL_QUERY_WORDS = new Set([
|
|
50
|
+
'find', 'show', 'list', 'get', 'search', 'locate', 'lookup', 'look', 'check',
|
|
51
|
+
'inspect', 'review', 'analyze', 'analyse', 'understand', 'explore', 'read',
|
|
52
|
+
'open', 'walk', 'help', 'need', 'want', 'please', 'context', 'preview',
|
|
53
|
+
'recall', 'stuff', 'thing', 'things', 'happen', 'happens', 'handle', 'handles',
|
|
54
|
+
'handling', 'wired', 'declare', 'declared', 'defined', 'owns', 'owner', 'existing',
|
|
55
|
+
'exercise', 'exercises', 'before', 'main', 'shared', 'related', 'across', 'split',
|
|
56
|
+
'live', 'lives', 'surface', 'public', 'entry', 'point', 'path', 'logic', 'covers',
|
|
57
|
+
'api', 'apis', 'flow', 'flows', 'file', 'files', 'onboarding', 'app', 'application', 'load', 'loads', 'loaded',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const uniqueList = (items = []) => [...new Set(items.filter(Boolean))];
|
|
61
|
+
|
|
62
|
+
const evidenceKey = (evidence) => JSON.stringify([
|
|
63
|
+
evidence.type,
|
|
64
|
+
evidence.via ?? null,
|
|
65
|
+
evidence.ref ?? null,
|
|
66
|
+
evidence.rank ?? null,
|
|
67
|
+
evidence.query ?? null,
|
|
68
|
+
Array.isArray(evidence.symbols) ? evidence.symbols.join('|') : null,
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export const dedupeEvidence = (items = []) => {
|
|
72
|
+
const map = new Map();
|
|
73
|
+
for (const item of items) {
|
|
74
|
+
if (!item?.type) continue;
|
|
75
|
+
const normalized = { ...item };
|
|
76
|
+
if (Array.isArray(normalized.symbols)) {
|
|
77
|
+
normalized.symbols = uniqueList(normalized.symbols).slice(0, 3);
|
|
78
|
+
if (normalized.symbols.length === 0) delete normalized.symbols;
|
|
79
|
+
}
|
|
80
|
+
const key = evidenceKey(normalized);
|
|
81
|
+
if (!map.has(key)) map.set(key, normalized);
|
|
82
|
+
}
|
|
83
|
+
return [...map.values()].sort((a, b) => {
|
|
84
|
+
const priorityDiff = (EVIDENCE_PRIORITY[a.type] ?? 99) - (EVIDENCE_PRIORITY[b.type] ?? 99);
|
|
85
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
86
|
+
return (a.rank ?? 999) - (b.rank ?? 999);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const formatReasonIncluded = (evidence = []) => {
|
|
91
|
+
const primary = evidence[0];
|
|
92
|
+
if (!primary) return 'selected';
|
|
93
|
+
|
|
94
|
+
switch (primary.type) {
|
|
95
|
+
case 'entryFile':
|
|
96
|
+
return 'entry';
|
|
97
|
+
case 'diffHit':
|
|
98
|
+
return primary.ref ? `diff: ${primary.ref}` : 'diff';
|
|
99
|
+
case 'searchHit':
|
|
100
|
+
return primary.query ? `search: ${primary.query}` : 'search';
|
|
101
|
+
case 'symbolMatch':
|
|
102
|
+
return `symbol: ${(primary.symbols ?? []).slice(0, 2).join(', ')}`;
|
|
103
|
+
case 'symbolDetail':
|
|
104
|
+
return `detail: ${(primary.symbols ?? []).slice(0, 2).join(', ')}`;
|
|
105
|
+
case 'testOf':
|
|
106
|
+
return primary.via ? `test: ${primary.via}` : 'test';
|
|
107
|
+
case 'dependencyOf':
|
|
108
|
+
return primary.via ? `imported-by: ${primary.via}` : 'imported-by';
|
|
109
|
+
case 'dependentOf':
|
|
110
|
+
return primary.via ? `imports: ${primary.via}` : 'imports';
|
|
111
|
+
default:
|
|
112
|
+
return 'selected';
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const HIGH_SIGNAL_PREVIEW_KINDS = new Set([
|
|
117
|
+
'actor', 'class', 'enum', 'function', 'interface', 'method',
|
|
118
|
+
'protocol', 'struct', 'trait', 'type',
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const getPreviewKindPriority = (kind) => {
|
|
122
|
+
switch (kind) {
|
|
123
|
+
case 'class':
|
|
124
|
+
case 'function':
|
|
125
|
+
case 'method':
|
|
126
|
+
return 4;
|
|
127
|
+
case 'interface':
|
|
128
|
+
case 'type':
|
|
129
|
+
case 'protocol':
|
|
130
|
+
case 'trait':
|
|
131
|
+
case 'struct':
|
|
132
|
+
case 'enum':
|
|
133
|
+
case 'actor':
|
|
134
|
+
return 3;
|
|
135
|
+
default:
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const compactSymbolPreview = (entry) => ({
|
|
141
|
+
name: entry.name,
|
|
142
|
+
kind: entry.kind,
|
|
143
|
+
...(entry.signature ? { signature: entry.signature } : entry.snippet ? { snippet: entry.snippet } : {}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export const buildSymbolPreviews = (entries = [], matchedSymbols = [], { includeFallback = false, maxItems = 3 } = {}) => {
|
|
147
|
+
if (maxItems <= 0) return [];
|
|
148
|
+
|
|
149
|
+
const matchedSet = new Set(matchedSymbols.map((symbol) => symbol.toLowerCase()));
|
|
150
|
+
const candidates = entries
|
|
151
|
+
.filter((entry) => includeFallback || matchedSet.has(entry.name.toLowerCase()))
|
|
152
|
+
.sort((a, b) => {
|
|
153
|
+
const aMatched = matchedSet.has(a.name.toLowerCase()) ? 1 : 0;
|
|
154
|
+
const bMatched = matchedSet.has(b.name.toLowerCase()) ? 1 : 0;
|
|
155
|
+
if (aMatched !== bMatched) return bMatched - aMatched;
|
|
156
|
+
const aKind = getPreviewKindPriority(a.kind);
|
|
157
|
+
const bKind = getPreviewKindPriority(b.kind);
|
|
158
|
+
if (aKind !== bKind) return bKind - aKind;
|
|
159
|
+
const aRich = Number(Boolean(a.signature)) + Number(Boolean(a.snippet));
|
|
160
|
+
const bRich = Number(Boolean(b.signature)) + Number(Boolean(b.snippet));
|
|
161
|
+
if (aRich !== bRich) return bRich - aRich;
|
|
162
|
+
return a.line - b.line;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const prioritized = [];
|
|
166
|
+
const secondary = [];
|
|
167
|
+
|
|
168
|
+
for (const candidate of candidates) {
|
|
169
|
+
const isMatched = matchedSet.has(candidate.name.toLowerCase());
|
|
170
|
+
if (isMatched || HIGH_SIGNAL_PREVIEW_KINDS.has(candidate.kind)) prioritized.push(candidate);
|
|
171
|
+
else secondary.push(candidate);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return [...prioritized, ...secondary].slice(0, maxItems).map(compactSymbolPreview);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const attachSymbolEvidence = (files, index, symbolCandidates) => {
|
|
178
|
+
if (!index || symbolCandidates.length === 0) return;
|
|
179
|
+
|
|
180
|
+
const candidateMap = new Map(symbolCandidates.map((symbol) => [symbol.toLowerCase(), symbol]));
|
|
181
|
+
|
|
182
|
+
for (const [rel, info] of files) {
|
|
183
|
+
const fileSymbols = index.files?.[rel]?.symbols ?? [];
|
|
184
|
+
const matchedSymbols = [];
|
|
185
|
+
|
|
186
|
+
for (const symbol of fileSymbols) {
|
|
187
|
+
const matched = candidateMap.get(symbol.name.toLowerCase());
|
|
188
|
+
if (matched && !matchedSymbols.includes(matched)) matchedSymbols.push(matched);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (matchedSymbols.length === 0) continue;
|
|
192
|
+
|
|
193
|
+
const evidence = dedupeEvidence([
|
|
194
|
+
...(info.evidence ?? []),
|
|
195
|
+
{ type: 'symbolMatch', symbols: matchedSymbols.slice(0, 3) },
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
files.set(rel, {
|
|
199
|
+
...info,
|
|
200
|
+
evidence,
|
|
201
|
+
matchedSymbols: uniqueList([...(info.matchedSymbols ?? []), ...matchedSymbols]).slice(0, 3),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const computeStaticUtility = (candidate, intent) => {
|
|
207
|
+
let score = ROLE_BASE_SCORE[candidate.role] ?? 40;
|
|
208
|
+
if (candidate.role === 'test' && intent === 'tests') score += 20;
|
|
209
|
+
|
|
210
|
+
for (const evidence of candidate.evidence ?? []) {
|
|
211
|
+
score += EVIDENCE_BASE_SCORE[evidence.type] ?? 0;
|
|
212
|
+
if (evidence.type === 'searchHit') score += Math.max(0, 24 - ((evidence.rank ?? 1) - 1) * 6);
|
|
213
|
+
if (evidence.type === 'symbolMatch') score += (evidence.symbols?.length ?? 0) * 12;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
score += (candidate.matchedSymbols?.length ?? 0) * 10;
|
|
217
|
+
return score;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const inferRelatedRole = (candidate) => {
|
|
221
|
+
const evidenceTypes = new Set((candidate.evidence ?? []).map((item) => item.type));
|
|
222
|
+
if (evidenceTypes.has('testOf')) return 'test';
|
|
223
|
+
if (evidenceTypes.has('dependencyOf')) return 'dependency';
|
|
224
|
+
if (evidenceTypes.has('dependentOf')) return 'dependent';
|
|
225
|
+
return 'dependent';
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const computePrimarySignal = (candidate, intent) => {
|
|
229
|
+
const relLower = (candidate.rel ?? '').toLowerCase();
|
|
230
|
+
let score = 0;
|
|
231
|
+
|
|
232
|
+
for (const evidence of candidate.evidence ?? []) {
|
|
233
|
+
if (evidence.type === 'entryFile') score += 120;
|
|
234
|
+
if (evidence.type === 'diffHit') score += 110;
|
|
235
|
+
if (evidence.type === 'searchHit') score += Math.max(0, 28 - ((evidence.rank ?? 1) - 1) * 6);
|
|
236
|
+
if (evidence.type === 'symbolMatch') score += (evidence.symbols?.length ?? 0) * 10;
|
|
237
|
+
if (evidence.type === 'symbolDetail') score += (evidence.symbols?.length ?? 0) * 12;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
score += (candidate.matchedSymbols?.length ?? 0) * 12;
|
|
241
|
+
|
|
242
|
+
if (TEST_FILE_RE.test(relLower)) {
|
|
243
|
+
score += intent === 'tests' ? 10 : -60;
|
|
244
|
+
} else if (relLower.startsWith('src/')) {
|
|
245
|
+
score += 10;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return score;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const tokenizePath = (rel) =>
|
|
252
|
+
uniqueList((rel.toLowerCase().match(/[a-z0-9]+/g) || []).filter((token) => token.length > 1));
|
|
253
|
+
|
|
254
|
+
const extractPrimaryPathHints = (task) => {
|
|
255
|
+
const lowerTask = task.toLowerCase();
|
|
256
|
+
const hints = new Set(
|
|
257
|
+
(lowerTask.match(QUERY_TOKEN_RE) || [])
|
|
258
|
+
.map((token) => token.toLowerCase())
|
|
259
|
+
.filter((token) => token.length > 2 && !STOP_WORDS.has(token) && !LOW_SIGNAL_QUERY_WORDS.has(token))
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
for (const entry of PRIMARY_PATH_HINT_MAP) {
|
|
263
|
+
if (entry.test.test(lowerTask)) {
|
|
264
|
+
for (const hint of entry.hints) hints.add(hint);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return [...hints];
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const scorePrimarySeed = (seed, task, intent) => {
|
|
272
|
+
const rel = seed.rel ?? '';
|
|
273
|
+
const relLower = rel.toLowerCase();
|
|
274
|
+
const basename = path.basename(relLower, path.extname(relLower));
|
|
275
|
+
const pathTokens = new Set(tokenizePath(relLower));
|
|
276
|
+
const pathHints = extractPrimaryPathHints(task);
|
|
277
|
+
let score = 0;
|
|
278
|
+
|
|
279
|
+
for (const evidence of seed.evidence ?? []) {
|
|
280
|
+
if (evidence.type !== 'searchHit') continue;
|
|
281
|
+
score += Math.max(0, 40 - ((evidence.rank ?? 1) - 1) * 8);
|
|
282
|
+
if (!evidence.query) continue;
|
|
283
|
+
|
|
284
|
+
const query = evidence.query.toLowerCase();
|
|
285
|
+
if (basename === query) score += 28;
|
|
286
|
+
else if (relLower.includes(query)) score += 18;
|
|
287
|
+
else if (pathTokens.has(query)) score += 14;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let hintHits = 0;
|
|
291
|
+
for (const hint of pathHints) {
|
|
292
|
+
if (basename === hint) {
|
|
293
|
+
score += 28;
|
|
294
|
+
hintHits++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (pathTokens.has(hint) || relLower.includes(hint)) {
|
|
298
|
+
score += 18;
|
|
299
|
+
hintHits++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const targetsApiSurface = pathHints.includes('api') || pathHints.includes('routes');
|
|
304
|
+
if (targetsApiSurface) {
|
|
305
|
+
if (/(^|\/)(api|routes)(\/|$)/.test(relLower)) score += 28;
|
|
306
|
+
if (/(^|\/)(models?|schemas?)(\/|$)/.test(relLower)) score -= 12;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (TEST_FILE_RE.test(relLower)) {
|
|
310
|
+
score += intent === 'tests' ? 24 : -40;
|
|
311
|
+
} else if (intent === 'tests') {
|
|
312
|
+
score -= 10;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (intent === 'implementation' && relLower.startsWith('src/')) score += 10;
|
|
316
|
+
if ((intent === 'debug' || intent === 'review') && relLower.startsWith('src/')) score += 8;
|
|
317
|
+
if (hintHits > 0 && relLower.startsWith('src/')) score += 6;
|
|
318
|
+
|
|
319
|
+
return score;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const rerankPrimarySeeds = (primarySeeds, task, intent) =>
|
|
323
|
+
[...primarySeeds].sort((a, b) =>
|
|
324
|
+
scorePrimarySeed(b, task, intent) - scorePrimarySeed(a, task, intent)
|
|
325
|
+
|| a.rel.localeCompare(b.rel)
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
export const computePrimaryPromotionScore = (candidate, task, intent) => {
|
|
329
|
+
let score = scorePrimarySeed(candidate, task, intent);
|
|
330
|
+
score += computePrimarySignal(candidate, intent);
|
|
331
|
+
if (candidate.role === 'primary') score += 6;
|
|
332
|
+
return score;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export const normalizePrimaryCandidate = (files, task, intent) => {
|
|
336
|
+
const candidates = [...files.entries()].map(([rel, info]) => ({ rel, ...info }));
|
|
337
|
+
if (candidates.length === 0) return;
|
|
338
|
+
|
|
339
|
+
const currentPrimary = candidates.find((candidate) => candidate.role === 'primary');
|
|
340
|
+
const best = [...candidates].sort((a, b) =>
|
|
341
|
+
computePrimaryPromotionScore(b, task, intent) - computePrimaryPromotionScore(a, task, intent)
|
|
342
|
+
|| a.rel.localeCompare(b.rel)
|
|
343
|
+
)[0];
|
|
344
|
+
|
|
345
|
+
if (!best) return;
|
|
346
|
+
|
|
347
|
+
const currentScore = currentPrimary
|
|
348
|
+
? computePrimaryPromotionScore(currentPrimary, task, intent)
|
|
349
|
+
: Number.NEGATIVE_INFINITY;
|
|
350
|
+
const bestScore = computePrimaryPromotionScore(best, task, intent);
|
|
351
|
+
const chosenPrimary = currentPrimary && currentScore > bestScore + 10 ? currentPrimary : best;
|
|
352
|
+
|
|
353
|
+
for (const candidate of candidates) {
|
|
354
|
+
if (candidate.rel === chosenPrimary.rel) {
|
|
355
|
+
files.set(candidate.rel, { ...files.get(candidate.rel), role: 'primary' });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (candidate.role !== 'primary') continue;
|
|
360
|
+
files.set(candidate.rel, { ...files.get(candidate.rel), role: inferRelatedRole(candidate) });
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const collectViaRefs = (candidate) => uniqueList((candidate.evidence ?? []).map((item) => item.via));
|
|
365
|
+
|
|
366
|
+
export const computeMarginalPenalty = (candidate, selected) => {
|
|
367
|
+
if (selected.length === 0) return 0;
|
|
368
|
+
|
|
369
|
+
const dir = path.dirname(candidate.rel);
|
|
370
|
+
const candidateVia = new Set(collectViaRefs(candidate));
|
|
371
|
+
const candidateSymbols = new Set((candidate.matchedSymbols ?? []).map((symbol) => symbol.toLowerCase()));
|
|
372
|
+
|
|
373
|
+
let penalty = 0;
|
|
374
|
+
let sameDirCount = 0;
|
|
375
|
+
let sameRoleCount = 0;
|
|
376
|
+
let sameViaCount = 0;
|
|
377
|
+
let overlappingSymbolCount = 0;
|
|
378
|
+
|
|
379
|
+
for (const item of selected) {
|
|
380
|
+
if (path.dirname(item.rel) === dir) sameDirCount++;
|
|
381
|
+
if (item.role === candidate.role) sameRoleCount++;
|
|
382
|
+
|
|
383
|
+
for (const via of collectViaRefs(item)) {
|
|
384
|
+
if (candidateVia.has(via)) sameViaCount++;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const symbol of item.matchedSymbols ?? []) {
|
|
388
|
+
if (candidateSymbols.has(symbol.toLowerCase())) overlappingSymbolCount++;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
penalty += sameDirCount * (candidate.role === 'primary' ? 3 : 8);
|
|
393
|
+
penalty += sameRoleCount * (candidate.role === 'primary' ? 2 : 5);
|
|
394
|
+
penalty += sameViaCount * 12;
|
|
395
|
+
penalty += overlappingSymbolCount * 18;
|
|
396
|
+
|
|
397
|
+
return penalty;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export { ROLE_RANK, ROLE_BASE_SCORE, EVIDENCE_BASE_SCORE };
|
package/src/utils/fs.js
CHANGED
|
@@ -2,17 +2,17 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { projectRoot } from './paths.js';
|
|
4
4
|
|
|
5
|
-
const assertInsideProject = (fullPath) => {
|
|
6
|
-
const relative = path.relative(
|
|
5
|
+
const assertInsideProject = (fullPath, root = projectRoot) => {
|
|
6
|
+
const relative = path.relative(root, fullPath);
|
|
7
7
|
|
|
8
8
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
9
9
|
throw new Error(`Path escapes project root: ${fullPath}`);
|
|
10
10
|
}
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
export const resolveSafePath = (inputPath = '.') => {
|
|
14
|
-
const fullPath = path.resolve(
|
|
15
|
-
assertInsideProject(fullPath);
|
|
13
|
+
export const resolveSafePath = (inputPath = '.', root = projectRoot) => {
|
|
14
|
+
const fullPath = path.resolve(root, inputPath);
|
|
15
|
+
assertInsideProject(fullPath, root);
|
|
16
16
|
return fullPath;
|
|
17
17
|
};
|
|
18
18
|
|
|
@@ -35,8 +35,8 @@ export const isDockerfile = (filePath) => {
|
|
|
35
35
|
return baseName === 'dockerfile' || baseName.startsWith('dockerfile.');
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
export const readTextFile = (inputPath) => {
|
|
39
|
-
const fullPath = resolveSafePath(inputPath);
|
|
38
|
+
export const readTextFile = (inputPath, root = projectRoot) => {
|
|
39
|
+
const fullPath = resolveSafePath(inputPath, root);
|
|
40
40
|
const raw = fs.readFileSync(fullPath);
|
|
41
41
|
|
|
42
42
|
if (isBinaryBuffer(raw)) {
|
|
@@ -45,3 +45,9 @@ export const readTextFile = (inputPath) => {
|
|
|
45
45
|
|
|
46
46
|
return { fullPath, content: raw.toString('utf8') };
|
|
47
47
|
};
|
|
48
|
+
|
|
49
|
+
export const writeTextFile = (inputPath, content, root = projectRoot) => {
|
|
50
|
+
const fullPath = resolveSafePath(inputPath, root);
|
|
51
|
+
fs.writeFileSync(fullPath, content, 'utf8');
|
|
52
|
+
return fullPath;
|
|
53
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const INTENT_KEYWORDS = {
|
|
2
|
+
debug: ['debug', 'fix', 'error', 'bug', 'crash', 'fail', 'broken', 'issue', 'trace'],
|
|
3
|
+
tests: ['test', 'spec', 'coverage', 'assert', 'mock', 'jest', 'vitest'],
|
|
4
|
+
config: ['config', 'env', 'setup', 'deploy', 'docker', 'ci', 'terraform', 'yaml', 'secret', 'secrets', 'settings', 'database'],
|
|
5
|
+
docs: ['doc', 'readme', 'explain', 'document', 'guide'],
|
|
6
|
+
implementation: ['implement', 'add', 'create', 'build', 'feature', 'refactor', 'update', 'modify'],
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const STOP_WORDS = new Set([
|
|
10
|
+
'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'is', 'are',
|
|
11
|
+
'was', 'were', 'be', 'been', 'and', 'or', 'but', 'not', 'this', 'that', 'it',
|
|
12
|
+
'how', 'what', 'where', 'when', 'why', 'which', 'who', 'do', 'does', 'did',
|
|
13
|
+
'has', 'have', 'had', 'from', 'by', 'about', 'into', 'my', 'our', 'your',
|
|
14
|
+
'can', 'could', 'will', 'would', 'should', 'may', 'might', 'i', 'we', 'you',
|
|
15
|
+
'all', 'each', 'every', 'me', 'us', 'them', 'its',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const LOW_SIGNAL_QUERY_WORDS = new Set([
|
|
19
|
+
'find', 'show', 'list', 'get', 'search', 'locate', 'lookup', 'look', 'check',
|
|
20
|
+
'inspect', 'review', 'analyze', 'analyse', 'understand', 'explore', 'read',
|
|
21
|
+
'open', 'walk', 'help', 'need', 'want', 'please', 'context', 'preview',
|
|
22
|
+
'recall', 'stuff', 'thing', 'things', 'happen', 'happens', 'handle', 'handles',
|
|
23
|
+
'handling', 'wired', 'declare', 'declared', 'defined', 'owns', 'owner', 'existing',
|
|
24
|
+
'exercise', 'exercises', 'before', 'main', 'shared', 'related', 'across', 'split',
|
|
25
|
+
'live', 'lives', 'surface', 'public', 'entry', 'point', 'path', 'logic', 'covers',
|
|
26
|
+
'api', 'apis', 'flow', 'flows', 'file', 'files', 'onboarding', 'app', 'application', 'load', 'loads', 'loaded',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const IDENTIFIER_RE = /\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b|\b[A-Z][a-zA-Z0-9]{2,}\b|\b[a-z]{2,}_[a-z_]+\b/g;
|
|
30
|
+
const QUERY_TOKEN_RE = /[a-zA-Z0-9_]+/g;
|
|
31
|
+
|
|
32
|
+
const uniqueList = (items = []) => [...new Set(items.filter(Boolean))];
|
|
33
|
+
|
|
34
|
+
export const inferIntent = (task) => {
|
|
35
|
+
const lower = task.toLowerCase();
|
|
36
|
+
let best = 'explore';
|
|
37
|
+
let bestScore = 0;
|
|
38
|
+
|
|
39
|
+
for (const [intent, keywords] of Object.entries(INTENT_KEYWORDS)) {
|
|
40
|
+
const score = keywords.filter((kw) => lower.includes(kw)).length;
|
|
41
|
+
if (score > bestScore) { bestScore = score; best = intent; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return best;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const extractCompoundQueries = (task) => {
|
|
48
|
+
const lowerTask = task.toLowerCase();
|
|
49
|
+
const queries = [];
|
|
50
|
+
|
|
51
|
+
if (/\b(create[-\s]+user|user[-\s]+creation)\b/.test(lowerTask)) {
|
|
52
|
+
queries.push('createUser');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (/\bjwt[-\s]+secret\b/.test(lowerTask)) {
|
|
56
|
+
queries.push('jwtSecret');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return queries;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const filterRedundantPromptQueries = (queries, compoundQueries) => {
|
|
63
|
+
const lowerCompoundQueries = new Set(compoundQueries.map((query) => query.toLowerCase()));
|
|
64
|
+
return queries.filter((query) => {
|
|
65
|
+
const lowerQuery = query.toLowerCase();
|
|
66
|
+
if (lowerCompoundQueries.has('jwtsecret') && lowerQuery === 'jwt') return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const extractSymbolCandidates = (task) => {
|
|
72
|
+
const compoundQueries = extractCompoundQueries(task);
|
|
73
|
+
return uniqueList([
|
|
74
|
+
...compoundQueries,
|
|
75
|
+
...filterRedundantPromptQueries(task.match(IDENTIFIER_RE) || [], compoundQueries),
|
|
76
|
+
]);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const isLikelyCodeSymbol = (token) =>
|
|
80
|
+
token.includes('_')
|
|
81
|
+
|| /\d/.test(token)
|
|
82
|
+
|| /[a-z][A-Z]/.test(token)
|
|
83
|
+
|| /[A-Z]{2,}/.test(token);
|
|
84
|
+
|
|
85
|
+
const scoreKeywordQuery = (token, lowerTask) => {
|
|
86
|
+
let score = Math.min(token.length, 8);
|
|
87
|
+
const position = lowerTask.indexOf(token);
|
|
88
|
+
if (position >= 0) score += Math.max(0, 16 - position);
|
|
89
|
+
if (token.length >= 12) score += 1;
|
|
90
|
+
return score;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const extractKeywordQueries = (task, { allowIntentKeywords = false } = {}) => {
|
|
94
|
+
const intentKws = new Set(Object.values(INTENT_KEYWORDS).flat());
|
|
95
|
+
const lowerTask = task.toLowerCase();
|
|
96
|
+
const compoundQueries = extractCompoundQueries(task);
|
|
97
|
+
|
|
98
|
+
return filterRedundantPromptQueries(
|
|
99
|
+
[...new Set((task.match(QUERY_TOKEN_RE) || [])
|
|
100
|
+
.map((token) => token.toLowerCase())
|
|
101
|
+
.filter((token) => {
|
|
102
|
+
if (token.length <= 2) return false;
|
|
103
|
+
if (/^\d+$/.test(token)) return false;
|
|
104
|
+
if (STOP_WORDS.has(token)) return false;
|
|
105
|
+
if (LOW_SIGNAL_QUERY_WORDS.has(token)) return false;
|
|
106
|
+
if (!allowIntentKeywords && intentKws.has(token)) return false;
|
|
107
|
+
return true;
|
|
108
|
+
})
|
|
109
|
+
.sort((a, b) => scoreKeywordQuery(b, lowerTask) - scoreKeywordQuery(a, lowerTask)
|
|
110
|
+
|| lowerTask.indexOf(a) - lowerTask.indexOf(b)
|
|
111
|
+
|| b.length - a.length
|
|
112
|
+
|| a.localeCompare(b)))],
|
|
113
|
+
compoundQueries,
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const extractExpandedQueries = (task) => {
|
|
118
|
+
const lowerTask = task.toLowerCase();
|
|
119
|
+
const queries = [...extractCompoundQueries(task)];
|
|
120
|
+
|
|
121
|
+
if (/\b(container|docker|image|deploy|deployment)\b/.test(lowerTask)) {
|
|
122
|
+
queries.push('FROM');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return queries;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const extractFallbackSearchQuery = (task) => {
|
|
129
|
+
const symbolFallback = extractSymbolCandidates(task).find(isLikelyCodeSymbol);
|
|
130
|
+
if (symbolFallback) return symbolFallback;
|
|
131
|
+
|
|
132
|
+
const keywordFallback = extractKeywordQueries(task, { allowIntentKeywords: true })[0];
|
|
133
|
+
if (keywordFallback) return keywordFallback;
|
|
134
|
+
|
|
135
|
+
return task.trim();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const extractSearchQueries = (task) => {
|
|
139
|
+
const symbolQueries = extractSymbolCandidates(task)
|
|
140
|
+
.filter(isLikelyCodeSymbol)
|
|
141
|
+
.filter((candidate) => !LOW_SIGNAL_QUERY_WORDS.has(candidate.toLowerCase()) && !STOP_WORDS.has(candidate.toLowerCase()));
|
|
142
|
+
const keywordQueries = extractKeywordQueries(task);
|
|
143
|
+
const queries = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
|
|
146
|
+
for (const candidate of [...symbolQueries, ...keywordQueries]) {
|
|
147
|
+
const key = candidate.toLowerCase();
|
|
148
|
+
if (seen.has(key)) continue;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
queries.push(candidate);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return queries.slice(0, 3);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const LITERAL_PATTERN_MARKERS = [
|
|
157
|
+
{ pattern: /\/\*\*/, literal: '/**' },
|
|
158
|
+
{ pattern: /\/\*/, literal: '/*' },
|
|
159
|
+
{ pattern: /\/\/\s*TODO/, literal: 'TODO' },
|
|
160
|
+
{ pattern: /\/\/\s*FIXME/, literal: 'FIXME' },
|
|
161
|
+
{ pattern: /\/\/\s*XXX/, literal: 'XXX' },
|
|
162
|
+
{ pattern: /\/\/\s*HACK/, literal: 'HACK' },
|
|
163
|
+
{ pattern: /console\.log/, literal: 'console.log' },
|
|
164
|
+
{ pattern: /console\.error/, literal: 'console.error' },
|
|
165
|
+
{ pattern: /debugger/, literal: 'debugger' },
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
export const extractLiteralPatterns = (task) => {
|
|
169
|
+
const patterns = [];
|
|
170
|
+
|
|
171
|
+
for (const marker of LITERAL_PATTERN_MARKERS) {
|
|
172
|
+
if (marker.pattern.test(task)) {
|
|
173
|
+
patterns.push(marker.literal);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return patterns;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export { extractExpandedQueries, extractFallbackSearchQuery, extractKeywordQueries };
|