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
package/src/server.js
CHANGED
|
@@ -9,6 +9,8 @@ import { smartContext } from './tools/smart-context.js';
|
|
|
9
9
|
import { smartReadBatch } from './tools/smart-read-batch.js';
|
|
10
10
|
import { smartShell } from './tools/smart-shell.js';
|
|
11
11
|
import { smartSummary } from './tools/smart-summary.js';
|
|
12
|
+
import { smartStatus } from './tools/smart-status.js';
|
|
13
|
+
import { smartEdit } from './tools/smart-edit.js';
|
|
12
14
|
import { smartMetrics } from './tools/smart-metrics.js';
|
|
13
15
|
import { smartTurn } from './tools/smart-turn.js';
|
|
14
16
|
import { projectRoot, projectRootSource } from './utils/paths.js';
|
|
@@ -402,8 +404,19 @@ This ensures optimal performance and context recovery.`,
|
|
|
402
404
|
keepLatestMetrics: z.number().int().min(0).max(100000).optional(),
|
|
403
405
|
vacuum: z.boolean().optional(),
|
|
404
406
|
apply: z.boolean().optional(),
|
|
407
|
+
goal: z.string().optional(),
|
|
408
|
+
status: z.enum(['planning', 'in_progress', 'blocked', 'completed']).optional(),
|
|
409
|
+
pinnedContext: z.array(z.string()).optional(),
|
|
410
|
+
unresolvedQuestions: z.array(z.string()).optional(),
|
|
411
|
+
currentFocus: z.string().optional(),
|
|
412
|
+
whyBlocked: z.string().optional(),
|
|
413
|
+
completed: z.array(z.string()).optional(),
|
|
414
|
+
decisions: z.array(z.string()).optional(),
|
|
415
|
+
blockers: z.array(z.string()).optional(),
|
|
416
|
+
nextStep: z.string().optional(),
|
|
417
|
+
touchedFiles: z.array(z.string()).optional(),
|
|
405
418
|
},
|
|
406
|
-
async ({ action, sessionId, update, event, force, maxTokens, retentionDays, keepLatestEventsPerSession, keepLatestMetrics, vacuum, apply }) =>
|
|
419
|
+
async ({ action, sessionId, update, event, force, maxTokens, retentionDays, keepLatestEventsPerSession, keepLatestMetrics, vacuum, apply, goal, status, pinnedContext, unresolvedQuestions, currentFocus, whyBlocked, completed, decisions, blockers, nextStep, touchedFiles }) =>
|
|
407
420
|
asTextResult(await smartSummary({
|
|
408
421
|
action,
|
|
409
422
|
sessionId,
|
|
@@ -416,9 +429,44 @@ This ensures optimal performance and context recovery.`,
|
|
|
416
429
|
keepLatestMetrics,
|
|
417
430
|
vacuum,
|
|
418
431
|
apply,
|
|
432
|
+
goal,
|
|
433
|
+
status,
|
|
434
|
+
pinnedContext,
|
|
435
|
+
unresolvedQuestions,
|
|
436
|
+
currentFocus,
|
|
437
|
+
whyBlocked,
|
|
438
|
+
completed,
|
|
439
|
+
decisions,
|
|
440
|
+
blockers,
|
|
441
|
+
nextStep,
|
|
442
|
+
touchedFiles,
|
|
419
443
|
})),
|
|
420
444
|
);
|
|
421
445
|
|
|
446
|
+
server.tool(
|
|
447
|
+
'smart_status',
|
|
448
|
+
'Display the current session context including goal, status, recent decisions, touched files, and progress. Returns a formatted summary of what has been done and what is being tracked in the active session. Use this to understand the current state of work without modifying the session. Supports format=detailed (default, full formatted output) or format=compact (minimal JSON). Optional maxItems limits how many recent items to show (default 10).',
|
|
449
|
+
{
|
|
450
|
+
format: z.enum(['detailed', 'compact']).optional(),
|
|
451
|
+
maxItems: z.number().int().min(1).max(50).optional(),
|
|
452
|
+
},
|
|
453
|
+
async ({ format, maxItems }) => asTextResult(await smartStatus({ format, maxItems })),
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
server.tool(
|
|
457
|
+
'smart_edit',
|
|
458
|
+
'Batch edit multiple files with pattern replacement. Supports literal string replacement or regex patterns. Use for bulk refactoring, removing patterns (comments, console.log, etc.), or renaming across files. Optional dryRun shows preview without modifying files. Returns match count and results per file.',
|
|
459
|
+
{
|
|
460
|
+
pattern: z.string(),
|
|
461
|
+
replacement: z.string(),
|
|
462
|
+
files: z.array(z.string()).min(1).max(50),
|
|
463
|
+
mode: z.enum(['literal', 'regex']).optional(),
|
|
464
|
+
dryRun: z.boolean().optional(),
|
|
465
|
+
},
|
|
466
|
+
async ({ pattern, replacement, files, mode, dryRun }) =>
|
|
467
|
+
asTextResult(await smartEdit({ pattern, replacement, files, mode, dryRun })),
|
|
468
|
+
);
|
|
469
|
+
|
|
422
470
|
server.tool(
|
|
423
471
|
'smart_turn',
|
|
424
472
|
'Orchestrate start/end of a meaningful agent turn so context usage becomes almost mandatory with low token overhead. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary and can optionally include compact metrics. Use this instead of manually chaining `smart_summary(get)` and `smart_summary(checkpoint)` when you want a single context-first turn workflow.',
|
package/src/storage/sqlite.js
CHANGED
|
@@ -179,7 +179,7 @@ const MIGRATIONS = [
|
|
|
179
179
|
let sqliteModulePromise = null;
|
|
180
180
|
|
|
181
181
|
export const getStateDir = () => path.join(projectRoot, '.devctx');
|
|
182
|
-
export const getStateDbPath = () => path.join(getStateDir(), STATE_DB_FILENAME);
|
|
182
|
+
export const getStateDbPath = () => process.env.DEVCTX_STATE_DB_PATH || path.join(getStateDir(), STATE_DB_FILENAME);
|
|
183
183
|
export const getLegacySessionsDir = () => path.join(getStateDir(), 'sessions');
|
|
184
184
|
export const getLegacyMetricsPath = () => path.join(getStateDir(), 'metrics.jsonl');
|
|
185
185
|
export const getLegacyActiveSessionPath = () => path.join(getLegacySessionsDir(), 'active.json');
|
|
@@ -21,537 +21,37 @@ import {
|
|
|
21
21
|
generateDiffSummary as generateDetailedDiffSummary,
|
|
22
22
|
getChangedSymbols,
|
|
23
23
|
} from '../diff-analysis.js';
|
|
24
|
+
import {
|
|
25
|
+
inferIntent,
|
|
26
|
+
extractSymbolCandidates,
|
|
27
|
+
extractSearchQueries,
|
|
28
|
+
extractExpandedQueries,
|
|
29
|
+
extractFallbackSearchQuery,
|
|
30
|
+
extractKeywordQueries,
|
|
31
|
+
extractLiteralPatterns,
|
|
32
|
+
} from '../utils/query-extraction.js';
|
|
33
|
+
import {
|
|
34
|
+
dedupeEvidence,
|
|
35
|
+
formatReasonIncluded,
|
|
36
|
+
buildSymbolPreviews,
|
|
37
|
+
attachSymbolEvidence,
|
|
38
|
+
computeStaticUtility,
|
|
39
|
+
inferRelatedRole,
|
|
40
|
+
computePrimarySignal,
|
|
41
|
+
computePrimaryPromotionScore,
|
|
42
|
+
normalizePrimaryCandidate,
|
|
43
|
+
computeMarginalPenalty,
|
|
44
|
+
scorePrimarySeed,
|
|
45
|
+
rerankPrimarySeeds,
|
|
46
|
+
ROLE_RANK,
|
|
47
|
+
ROLE_BASE_SCORE,
|
|
48
|
+
EVIDENCE_BASE_SCORE,
|
|
49
|
+
} from '../utils/context-scoring.js';
|
|
24
50
|
|
|
25
51
|
const execFile = promisify(execFileCallback);
|
|
26
52
|
|
|
27
|
-
const INTENT_KEYWORDS = {
|
|
28
|
-
debug: ['debug', 'fix', 'error', 'bug', 'crash', 'fail', 'broken', 'issue', 'trace'],
|
|
29
|
-
tests: ['test', 'spec', 'coverage', 'assert', 'mock', 'jest', 'vitest'],
|
|
30
|
-
config: ['config', 'env', 'setup', 'deploy', 'docker', 'ci', 'terraform', 'yaml', 'secret', 'secrets', 'settings', 'database'],
|
|
31
|
-
docs: ['doc', 'readme', 'explain', 'document', 'guide'],
|
|
32
|
-
implementation: ['implement', 'add', 'create', 'build', 'feature', 'refactor', 'update', 'modify'],
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const STOP_WORDS = new Set([
|
|
36
|
-
'the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'is', 'are',
|
|
37
|
-
'was', 'were', 'be', 'been', 'and', 'or', 'but', 'not', 'this', 'that', 'it',
|
|
38
|
-
'how', 'what', 'where', 'when', 'why', 'which', 'who', 'do', 'does', 'did',
|
|
39
|
-
'has', 'have', 'had', 'from', 'by', 'about', 'into', 'my', 'our', 'your',
|
|
40
|
-
'can', 'could', 'will', 'would', 'should', 'may', 'might', 'i', 'we', 'you',
|
|
41
|
-
'all', 'each', 'every', 'me', 'us', 'them', 'its',
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const LOW_SIGNAL_QUERY_WORDS = new Set([
|
|
45
|
-
'find', 'show', 'list', 'get', 'search', 'locate', 'lookup', 'look', 'check',
|
|
46
|
-
'inspect', 'review', 'analyze', 'analyse', 'understand', 'explore', 'read',
|
|
47
|
-
'open', 'walk', 'help', 'need', 'want', 'please', 'context', 'preview',
|
|
48
|
-
'recall', 'stuff', 'thing', 'things', 'happen', 'happens', 'handle', 'handles',
|
|
49
|
-
'handling', 'wired', 'declare', 'declared', 'defined', 'owns', 'owner', 'existing',
|
|
50
|
-
'exercise', 'exercises', 'before', 'main', 'shared', 'related', 'across', 'split',
|
|
51
|
-
'live', 'lives', 'surface', 'public', 'entry', 'point', 'path', 'logic', 'covers',
|
|
52
|
-
'api', 'apis', 'flow', 'flows', 'file', 'files', 'onboarding', 'app', 'application', 'load', 'loads', 'loaded',
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
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;
|
|
56
|
-
const QUERY_TOKEN_RE = /[a-zA-Z0-9_]+/g;
|
|
57
|
-
|
|
58
|
-
const ROLE_PRIORITY = ['primary', 'test', 'dependency', 'dependent'];
|
|
59
|
-
const ROLE_RANK = Object.fromEntries(ROLE_PRIORITY.map((role, idx) => [role, idx]));
|
|
60
|
-
const EVIDENCE_PRIORITY = {
|
|
61
|
-
entryFile: 0,
|
|
62
|
-
diffHit: 1,
|
|
63
|
-
searchHit: 2,
|
|
64
|
-
symbolMatch: 3,
|
|
65
|
-
symbolDetail: 4,
|
|
66
|
-
testOf: 5,
|
|
67
|
-
dependencyOf: 6,
|
|
68
|
-
dependentOf: 7,
|
|
69
|
-
};
|
|
70
|
-
const ROLE_BASE_SCORE = { primary: 130, test: 85, dependency: 60, dependent: 50 };
|
|
71
|
-
const EVIDENCE_BASE_SCORE = {
|
|
72
|
-
entryFile: 120,
|
|
73
|
-
diffHit: 100,
|
|
74
|
-
searchHit: 70,
|
|
75
|
-
symbolMatch: 90,
|
|
76
|
-
symbolDetail: 95,
|
|
77
|
-
testOf: 40,
|
|
78
|
-
dependencyOf: 25,
|
|
79
|
-
dependentOf: 22,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
53
|
const uniqueList = (items = []) => [...new Set(items.filter(Boolean))];
|
|
83
54
|
|
|
84
|
-
const evidenceKey = (evidence) => JSON.stringify([
|
|
85
|
-
evidence.type,
|
|
86
|
-
evidence.via ?? null,
|
|
87
|
-
evidence.ref ?? null,
|
|
88
|
-
evidence.rank ?? null,
|
|
89
|
-
evidence.query ?? null,
|
|
90
|
-
Array.isArray(evidence.symbols) ? evidence.symbols.join('|') : null,
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
const dedupeEvidence = (items = []) => {
|
|
94
|
-
const map = new Map();
|
|
95
|
-
for (const item of items) {
|
|
96
|
-
if (!item?.type) continue;
|
|
97
|
-
const normalized = { ...item };
|
|
98
|
-
if (Array.isArray(normalized.symbols)) {
|
|
99
|
-
normalized.symbols = uniqueList(normalized.symbols).slice(0, 3);
|
|
100
|
-
if (normalized.symbols.length === 0) delete normalized.symbols;
|
|
101
|
-
}
|
|
102
|
-
const key = evidenceKey(normalized);
|
|
103
|
-
if (!map.has(key)) map.set(key, normalized);
|
|
104
|
-
}
|
|
105
|
-
return [...map.values()].sort((a, b) => {
|
|
106
|
-
const priorityDiff = (EVIDENCE_PRIORITY[a.type] ?? 99) - (EVIDENCE_PRIORITY[b.type] ?? 99);
|
|
107
|
-
if (priorityDiff !== 0) return priorityDiff;
|
|
108
|
-
return (a.rank ?? 999) - (b.rank ?? 999);
|
|
109
|
-
});
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const formatReasonIncluded = (evidence = []) => {
|
|
113
|
-
const primary = evidence[0];
|
|
114
|
-
if (!primary) return 'selected';
|
|
115
|
-
|
|
116
|
-
switch (primary.type) {
|
|
117
|
-
case 'entryFile':
|
|
118
|
-
return 'entry';
|
|
119
|
-
case 'diffHit':
|
|
120
|
-
return primary.ref ? `diff: ${primary.ref}` : 'diff';
|
|
121
|
-
case 'searchHit':
|
|
122
|
-
return primary.query ? `search: ${primary.query}` : 'search';
|
|
123
|
-
case 'symbolMatch':
|
|
124
|
-
return `symbol: ${(primary.symbols ?? []).slice(0, 2).join(', ')}`;
|
|
125
|
-
case 'symbolDetail':
|
|
126
|
-
return `detail: ${(primary.symbols ?? []).slice(0, 2).join(', ')}`;
|
|
127
|
-
case 'testOf':
|
|
128
|
-
return primary.via ? `test: ${primary.via}` : 'test';
|
|
129
|
-
case 'dependencyOf':
|
|
130
|
-
return primary.via ? `imported-by: ${primary.via}` : 'imported-by';
|
|
131
|
-
case 'dependentOf':
|
|
132
|
-
return primary.via ? `imports: ${primary.via}` : 'imports';
|
|
133
|
-
default:
|
|
134
|
-
return 'selected';
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const HIGH_SIGNAL_PREVIEW_KINDS = new Set([
|
|
139
|
-
'actor', 'class', 'enum', 'function', 'interface', 'method',
|
|
140
|
-
'protocol', 'struct', 'trait', 'type',
|
|
141
|
-
]);
|
|
142
|
-
|
|
143
|
-
const getPreviewKindPriority = (kind) => {
|
|
144
|
-
switch (kind) {
|
|
145
|
-
case 'class':
|
|
146
|
-
case 'function':
|
|
147
|
-
case 'method':
|
|
148
|
-
return 4;
|
|
149
|
-
case 'interface':
|
|
150
|
-
case 'type':
|
|
151
|
-
case 'protocol':
|
|
152
|
-
case 'trait':
|
|
153
|
-
case 'struct':
|
|
154
|
-
case 'enum':
|
|
155
|
-
case 'actor':
|
|
156
|
-
return 3;
|
|
157
|
-
default:
|
|
158
|
-
return 0;
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const compactSymbolPreview = (entry) => ({
|
|
163
|
-
name: entry.name,
|
|
164
|
-
kind: entry.kind,
|
|
165
|
-
...(entry.signature ? { signature: entry.signature } : entry.snippet ? { snippet: entry.snippet } : {}),
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const buildSymbolPreviews = (entries = [], matchedSymbols = [], { includeFallback = false, maxItems = 3 } = {}) => {
|
|
169
|
-
if (maxItems <= 0) return [];
|
|
170
|
-
|
|
171
|
-
const matchedSet = new Set(matchedSymbols.map((symbol) => symbol.toLowerCase()));
|
|
172
|
-
const candidates = entries
|
|
173
|
-
.filter((entry) => includeFallback || matchedSet.has(entry.name.toLowerCase()))
|
|
174
|
-
.sort((a, b) => {
|
|
175
|
-
const aMatched = matchedSet.has(a.name.toLowerCase()) ? 1 : 0;
|
|
176
|
-
const bMatched = matchedSet.has(b.name.toLowerCase()) ? 1 : 0;
|
|
177
|
-
if (aMatched !== bMatched) return bMatched - aMatched;
|
|
178
|
-
const aKind = getPreviewKindPriority(a.kind);
|
|
179
|
-
const bKind = getPreviewKindPriority(b.kind);
|
|
180
|
-
if (aKind !== bKind) return bKind - aKind;
|
|
181
|
-
const aRich = Number(Boolean(a.signature)) + Number(Boolean(a.snippet));
|
|
182
|
-
const bRich = Number(Boolean(b.signature)) + Number(Boolean(b.snippet));
|
|
183
|
-
if (aRich !== bRich) return bRich - aRich;
|
|
184
|
-
return a.line - b.line;
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const prioritized = [];
|
|
188
|
-
const secondary = [];
|
|
189
|
-
|
|
190
|
-
for (const candidate of candidates) {
|
|
191
|
-
const isMatched = matchedSet.has(candidate.name.toLowerCase());
|
|
192
|
-
if (isMatched || HIGH_SIGNAL_PREVIEW_KINDS.has(candidate.kind)) prioritized.push(candidate);
|
|
193
|
-
else secondary.push(candidate);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return [...prioritized, ...secondary].slice(0, maxItems).map(compactSymbolPreview);
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const attachSymbolEvidence = (files, index, symbolCandidates) => {
|
|
200
|
-
if (!index || symbolCandidates.length === 0) return;
|
|
201
|
-
|
|
202
|
-
const candidateMap = new Map(symbolCandidates.map((symbol) => [symbol.toLowerCase(), symbol]));
|
|
203
|
-
|
|
204
|
-
for (const [rel, info] of files) {
|
|
205
|
-
const fileSymbols = index.files?.[rel]?.symbols ?? [];
|
|
206
|
-
const matchedSymbols = [];
|
|
207
|
-
|
|
208
|
-
for (const symbol of fileSymbols) {
|
|
209
|
-
const matched = candidateMap.get(symbol.name.toLowerCase());
|
|
210
|
-
if (matched && !matchedSymbols.includes(matched)) matchedSymbols.push(matched);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (matchedSymbols.length === 0) continue;
|
|
214
|
-
|
|
215
|
-
const evidence = dedupeEvidence([
|
|
216
|
-
...(info.evidence ?? []),
|
|
217
|
-
{ type: 'symbolMatch', symbols: matchedSymbols.slice(0, 3) },
|
|
218
|
-
]);
|
|
219
|
-
|
|
220
|
-
files.set(rel, {
|
|
221
|
-
...info,
|
|
222
|
-
evidence,
|
|
223
|
-
matchedSymbols: uniqueList([...(info.matchedSymbols ?? []), ...matchedSymbols]).slice(0, 3),
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
const computeStaticUtility = (candidate, intent) => {
|
|
229
|
-
let score = ROLE_BASE_SCORE[candidate.role] ?? 40;
|
|
230
|
-
if (candidate.role === 'test' && intent === 'tests') score += 20;
|
|
231
|
-
|
|
232
|
-
for (const evidence of candidate.evidence ?? []) {
|
|
233
|
-
score += EVIDENCE_BASE_SCORE[evidence.type] ?? 0;
|
|
234
|
-
if (evidence.type === 'searchHit') score += Math.max(0, 24 - ((evidence.rank ?? 1) - 1) * 6);
|
|
235
|
-
if (evidence.type === 'symbolMatch') score += (evidence.symbols?.length ?? 0) * 12;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
score += (candidate.matchedSymbols?.length ?? 0) * 10;
|
|
239
|
-
return score;
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
const inferRelatedRole = (candidate) => {
|
|
243
|
-
const evidenceTypes = new Set((candidate.evidence ?? []).map((item) => item.type));
|
|
244
|
-
if (evidenceTypes.has('testOf')) return 'test';
|
|
245
|
-
if (evidenceTypes.has('dependencyOf')) return 'dependency';
|
|
246
|
-
if (evidenceTypes.has('dependentOf')) return 'dependent';
|
|
247
|
-
return 'dependent';
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
const computePrimarySignal = (candidate, intent) => {
|
|
251
|
-
const relLower = (candidate.rel ?? '').toLowerCase();
|
|
252
|
-
let score = 0;
|
|
253
|
-
|
|
254
|
-
for (const evidence of candidate.evidence ?? []) {
|
|
255
|
-
if (evidence.type === 'entryFile') score += 120;
|
|
256
|
-
if (evidence.type === 'diffHit') score += 110;
|
|
257
|
-
if (evidence.type === 'searchHit') score += Math.max(0, 28 - ((evidence.rank ?? 1) - 1) * 6);
|
|
258
|
-
if (evidence.type === 'symbolMatch') score += (evidence.symbols?.length ?? 0) * 10;
|
|
259
|
-
if (evidence.type === 'symbolDetail') score += (evidence.symbols?.length ?? 0) * 12;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
score += (candidate.matchedSymbols?.length ?? 0) * 12;
|
|
263
|
-
|
|
264
|
-
if (TEST_FILE_RE.test(relLower)) {
|
|
265
|
-
score += intent === 'tests' ? 10 : -60;
|
|
266
|
-
} else if (relLower.startsWith('src/')) {
|
|
267
|
-
score += 10;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return score;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
const computePrimaryPromotionScore = (candidate, task, intent) => {
|
|
274
|
-
let score = scorePrimarySeed(candidate, task, intent);
|
|
275
|
-
score += computePrimarySignal(candidate, intent);
|
|
276
|
-
if (candidate.role === 'primary') score += 6;
|
|
277
|
-
return score;
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
const normalizePrimaryCandidate = (files, task, intent) => {
|
|
281
|
-
const candidates = [...files.entries()].map(([rel, info]) => ({ rel, ...info }));
|
|
282
|
-
if (candidates.length === 0) return;
|
|
283
|
-
|
|
284
|
-
const currentPrimary = candidates.find((candidate) => candidate.role === 'primary');
|
|
285
|
-
const best = [...candidates].sort((a, b) =>
|
|
286
|
-
computePrimaryPromotionScore(b, task, intent) - computePrimaryPromotionScore(a, task, intent)
|
|
287
|
-
|| a.rel.localeCompare(b.rel)
|
|
288
|
-
)[0];
|
|
289
|
-
|
|
290
|
-
if (!best) return;
|
|
291
|
-
|
|
292
|
-
const currentScore = currentPrimary
|
|
293
|
-
? computePrimaryPromotionScore(currentPrimary, task, intent)
|
|
294
|
-
: Number.NEGATIVE_INFINITY;
|
|
295
|
-
const bestScore = computePrimaryPromotionScore(best, task, intent);
|
|
296
|
-
const chosenPrimary = currentPrimary && currentScore > bestScore + 10 ? currentPrimary : best;
|
|
297
|
-
|
|
298
|
-
for (const candidate of candidates) {
|
|
299
|
-
if (candidate.rel === chosenPrimary.rel) {
|
|
300
|
-
files.set(candidate.rel, { ...files.get(candidate.rel), role: 'primary' });
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (candidate.role !== 'primary') continue;
|
|
305
|
-
files.set(candidate.rel, { ...files.get(candidate.rel), role: inferRelatedRole(candidate) });
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const collectViaRefs = (candidate) => uniqueList((candidate.evidence ?? []).map((item) => item.via));
|
|
310
|
-
|
|
311
|
-
const computeMarginalPenalty = (candidate, selected) => {
|
|
312
|
-
if (selected.length === 0) return 0;
|
|
313
|
-
|
|
314
|
-
const dir = path.dirname(candidate.rel);
|
|
315
|
-
const candidateVia = new Set(collectViaRefs(candidate));
|
|
316
|
-
const candidateSymbols = new Set((candidate.matchedSymbols ?? []).map((symbol) => symbol.toLowerCase()));
|
|
317
|
-
|
|
318
|
-
let penalty = 0;
|
|
319
|
-
let sameDirCount = 0;
|
|
320
|
-
let sameRoleCount = 0;
|
|
321
|
-
let sameViaCount = 0;
|
|
322
|
-
let overlappingSymbolCount = 0;
|
|
323
|
-
|
|
324
|
-
for (const item of selected) {
|
|
325
|
-
if (path.dirname(item.rel) === dir) sameDirCount++;
|
|
326
|
-
if (item.role === candidate.role) sameRoleCount++;
|
|
327
|
-
|
|
328
|
-
for (const via of collectViaRefs(item)) {
|
|
329
|
-
if (candidateVia.has(via)) sameViaCount++;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
for (const symbol of item.matchedSymbols ?? []) {
|
|
333
|
-
if (candidateSymbols.has(symbol.toLowerCase())) overlappingSymbolCount++;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
penalty += sameDirCount * (candidate.role === 'primary' ? 3 : 8);
|
|
338
|
-
penalty += sameRoleCount * (candidate.role === 'primary' ? 2 : 5);
|
|
339
|
-
penalty += sameViaCount * 12;
|
|
340
|
-
penalty += overlappingSymbolCount * 18;
|
|
341
|
-
|
|
342
|
-
return penalty;
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
export const inferIntent = (task) => {
|
|
346
|
-
const lower = task.toLowerCase();
|
|
347
|
-
let best = 'explore';
|
|
348
|
-
let bestScore = 0;
|
|
349
|
-
|
|
350
|
-
for (const [intent, keywords] of Object.entries(INTENT_KEYWORDS)) {
|
|
351
|
-
const score = keywords.filter((kw) => lower.includes(kw)).length;
|
|
352
|
-
if (score > bestScore) { bestScore = score; best = intent; }
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return best;
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const extractCompoundQueries = (task) => {
|
|
359
|
-
const lowerTask = task.toLowerCase();
|
|
360
|
-
const queries = [];
|
|
361
|
-
|
|
362
|
-
if (/\b(create[-\s]+user|user[-\s]+creation)\b/.test(lowerTask)) {
|
|
363
|
-
queries.push('createUser');
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (/\bjwt[-\s]+secret\b/.test(lowerTask)) {
|
|
367
|
-
queries.push('jwtSecret');
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return queries;
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
const filterRedundantPromptQueries = (queries, compoundQueries) => {
|
|
374
|
-
const lowerCompoundQueries = new Set(compoundQueries.map((query) => query.toLowerCase()));
|
|
375
|
-
return queries.filter((query) => {
|
|
376
|
-
const lowerQuery = query.toLowerCase();
|
|
377
|
-
if (lowerCompoundQueries.has('jwtsecret') && lowerQuery === 'jwt') return false;
|
|
378
|
-
return true;
|
|
379
|
-
});
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
export const extractSymbolCandidates = (task) => {
|
|
383
|
-
const compoundQueries = extractCompoundQueries(task);
|
|
384
|
-
return uniqueList([
|
|
385
|
-
...compoundQueries,
|
|
386
|
-
...filterRedundantPromptQueries(task.match(IDENTIFIER_RE) || [], compoundQueries),
|
|
387
|
-
]);
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
const isLikelyCodeSymbol = (token) =>
|
|
391
|
-
token.includes('_')
|
|
392
|
-
|| /\d/.test(token)
|
|
393
|
-
|| /[a-z][A-Z]/.test(token)
|
|
394
|
-
|| /[A-Z]{2,}/.test(token);
|
|
395
|
-
|
|
396
|
-
const scoreKeywordQuery = (token, lowerTask) => {
|
|
397
|
-
let score = Math.min(token.length, 8);
|
|
398
|
-
const position = lowerTask.indexOf(token);
|
|
399
|
-
if (position >= 0) score += Math.max(0, 16 - position);
|
|
400
|
-
if (token.length >= 12) score += 1;
|
|
401
|
-
return score;
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const extractKeywordQueries = (task, { allowIntentKeywords = false } = {}) => {
|
|
405
|
-
const intentKws = new Set(Object.values(INTENT_KEYWORDS).flat());
|
|
406
|
-
const lowerTask = task.toLowerCase();
|
|
407
|
-
const compoundQueries = extractCompoundQueries(task);
|
|
408
|
-
|
|
409
|
-
return filterRedundantPromptQueries(
|
|
410
|
-
[...new Set((task.match(QUERY_TOKEN_RE) || [])
|
|
411
|
-
.map((token) => token.toLowerCase())
|
|
412
|
-
.filter((token) => {
|
|
413
|
-
if (token.length <= 2) return false;
|
|
414
|
-
if (/^\d+$/.test(token)) return false;
|
|
415
|
-
if (STOP_WORDS.has(token)) return false;
|
|
416
|
-
if (LOW_SIGNAL_QUERY_WORDS.has(token)) return false;
|
|
417
|
-
if (!allowIntentKeywords && intentKws.has(token)) return false;
|
|
418
|
-
return true;
|
|
419
|
-
})
|
|
420
|
-
.sort((a, b) => scoreKeywordQuery(b, lowerTask) - scoreKeywordQuery(a, lowerTask)
|
|
421
|
-
|| lowerTask.indexOf(a) - lowerTask.indexOf(b)
|
|
422
|
-
|| b.length - a.length
|
|
423
|
-
|| a.localeCompare(b)))],
|
|
424
|
-
compoundQueries,
|
|
425
|
-
);
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
const extractExpandedQueries = (task) => {
|
|
429
|
-
const lowerTask = task.toLowerCase();
|
|
430
|
-
const queries = [...extractCompoundQueries(task)];
|
|
431
|
-
|
|
432
|
-
if (/\b(container|docker|image|deploy|deployment)\b/.test(lowerTask)) {
|
|
433
|
-
queries.push('FROM');
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return queries;
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
const extractFallbackSearchQuery = (task) => {
|
|
440
|
-
const symbolFallback = extractSymbolCandidates(task).find(isLikelyCodeSymbol);
|
|
441
|
-
if (symbolFallback) return symbolFallback;
|
|
442
|
-
|
|
443
|
-
const keywordFallback = extractKeywordQueries(task, { allowIntentKeywords: true })[0];
|
|
444
|
-
if (keywordFallback) return keywordFallback;
|
|
445
|
-
|
|
446
|
-
return task.trim();
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
export const extractSearchQueries = (task) => {
|
|
450
|
-
const symbolQueries = extractSymbolCandidates(task)
|
|
451
|
-
.filter(isLikelyCodeSymbol)
|
|
452
|
-
.filter((candidate) => !LOW_SIGNAL_QUERY_WORDS.has(candidate.toLowerCase()) && !STOP_WORDS.has(candidate.toLowerCase()));
|
|
453
|
-
const keywordQueries = extractKeywordQueries(task);
|
|
454
|
-
const queries = [];
|
|
455
|
-
const seen = new Set();
|
|
456
|
-
|
|
457
|
-
for (const candidate of [...symbolQueries, ...keywordQueries]) {
|
|
458
|
-
const key = candidate.toLowerCase();
|
|
459
|
-
if (seen.has(key)) continue;
|
|
460
|
-
seen.add(key);
|
|
461
|
-
queries.push(candidate);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return queries.slice(0, 3);
|
|
465
|
-
};
|
|
466
|
-
|
|
467
|
-
const PRIMARY_PATH_HINT_MAP = [
|
|
468
|
-
{ test: /\b(api|endpoint|endpoints|route|routes)\b/, hints: ['api', 'routes'] },
|
|
469
|
-
{ test: /\b(auth|token|jwt|login|session)\b/, hints: ['auth'] },
|
|
470
|
-
{ test: /\b(config|env|secret|yaml|json)\b/, hints: ['config'] },
|
|
471
|
-
{ test: /\b(test|tests|spec|coverage)\b/, hints: ['test', 'tests'] },
|
|
472
|
-
{ test: /\b(model|models|schema|schemas|entity|entities)\b/, hints: ['model', 'models'] },
|
|
473
|
-
{ test: /\b(container|docker|image|deploy|deployment)\b/, hints: ['dockerfile', 'docker'] },
|
|
474
|
-
];
|
|
475
|
-
|
|
476
|
-
const TEST_FILE_RE = /(^|\/)(tests?|__tests__)\//;
|
|
477
|
-
|
|
478
|
-
const tokenizePath = (rel) =>
|
|
479
|
-
uniqueList((rel.toLowerCase().match(/[a-z0-9]+/g) || []).filter((token) => token.length > 1));
|
|
480
|
-
|
|
481
|
-
const extractPrimaryPathHints = (task) => {
|
|
482
|
-
const lowerTask = task.toLowerCase();
|
|
483
|
-
const hints = new Set(
|
|
484
|
-
(lowerTask.match(QUERY_TOKEN_RE) || [])
|
|
485
|
-
.map((token) => token.toLowerCase())
|
|
486
|
-
.filter((token) => token.length > 2 && !STOP_WORDS.has(token) && !LOW_SIGNAL_QUERY_WORDS.has(token))
|
|
487
|
-
);
|
|
488
|
-
|
|
489
|
-
for (const entry of PRIMARY_PATH_HINT_MAP) {
|
|
490
|
-
if (entry.test.test(lowerTask)) {
|
|
491
|
-
for (const hint of entry.hints) hints.add(hint);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return [...hints];
|
|
496
|
-
};
|
|
497
|
-
|
|
498
|
-
const scorePrimarySeed = (seed, task, intent) => {
|
|
499
|
-
const rel = seed.rel ?? '';
|
|
500
|
-
const relLower = rel.toLowerCase();
|
|
501
|
-
const basename = path.basename(relLower, path.extname(relLower));
|
|
502
|
-
const pathTokens = new Set(tokenizePath(relLower));
|
|
503
|
-
const pathHints = extractPrimaryPathHints(task);
|
|
504
|
-
let score = 0;
|
|
505
|
-
|
|
506
|
-
for (const evidence of seed.evidence ?? []) {
|
|
507
|
-
if (evidence.type !== 'searchHit') continue;
|
|
508
|
-
score += Math.max(0, 40 - ((evidence.rank ?? 1) - 1) * 8);
|
|
509
|
-
if (!evidence.query) continue;
|
|
510
|
-
|
|
511
|
-
const query = evidence.query.toLowerCase();
|
|
512
|
-
if (basename === query) score += 28;
|
|
513
|
-
else if (relLower.includes(query)) score += 18;
|
|
514
|
-
else if (pathTokens.has(query)) score += 14;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
let hintHits = 0;
|
|
518
|
-
for (const hint of pathHints) {
|
|
519
|
-
if (basename === hint) {
|
|
520
|
-
score += 28;
|
|
521
|
-
hintHits++;
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
if (pathTokens.has(hint) || relLower.includes(hint)) {
|
|
525
|
-
score += 18;
|
|
526
|
-
hintHits++;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const targetsApiSurface = pathHints.includes('api') || pathHints.includes('routes');
|
|
531
|
-
if (targetsApiSurface) {
|
|
532
|
-
if (/(^|\/)(api|routes)(\/|$)/.test(relLower)) score += 28;
|
|
533
|
-
if (/(^|\/)(models?|schemas?)(\/|$)/.test(relLower)) score -= 12;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (TEST_FILE_RE.test(relLower)) {
|
|
537
|
-
score += intent === 'tests' ? 24 : -40;
|
|
538
|
-
} else if (intent === 'tests') {
|
|
539
|
-
score -= 10;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (intent === 'implementation' && relLower.startsWith('src/')) score += 10;
|
|
543
|
-
if ((intent === 'debug' || intent === 'review') && relLower.startsWith('src/')) score += 8;
|
|
544
|
-
if (hintHits > 0 && relLower.startsWith('src/')) score += 6;
|
|
545
|
-
|
|
546
|
-
return score;
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
const rerankPrimarySeeds = (primarySeeds, task, intent) =>
|
|
550
|
-
[...primarySeeds].sort((a, b) =>
|
|
551
|
-
scorePrimarySeed(b, task, intent) - scorePrimarySeed(a, task, intent)
|
|
552
|
-
|| a.rel.localeCompare(b.rel)
|
|
553
|
-
);
|
|
554
|
-
|
|
555
55
|
const expandWithGraph = (primarySeeds, index, root) => {
|
|
556
56
|
const files = new Map();
|
|
557
57
|
|
|
@@ -833,8 +333,12 @@ export const getChangedFiles = async (diff, root) => {
|
|
|
833
333
|
'git', ['ls-files', '--others', '--exclude-standard'],
|
|
834
334
|
{ cwd: root, timeout: 10000 },
|
|
835
335
|
);
|
|
336
|
+
const pathSet = new Set(allPaths);
|
|
836
337
|
for (const u of untrackedOut.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
837
|
-
if (!
|
|
338
|
+
if (!pathSet.has(u)) {
|
|
339
|
+
allPaths.push(u);
|
|
340
|
+
pathSet.add(u);
|
|
341
|
+
}
|
|
838
342
|
}
|
|
839
343
|
} catch { /* ignore — untracked listing is best-effort */ }
|
|
840
344
|
}
|
|
@@ -952,10 +456,12 @@ export const smartContext = async ({
|
|
|
952
456
|
if (changed.error) diffSummary.error = changed.error;
|
|
953
457
|
searchIndexFreshness = null;
|
|
954
458
|
} else {
|
|
459
|
+
const literalPatterns = extractLiteralPatterns(task);
|
|
955
460
|
const queries = extractSearchQueries(task);
|
|
956
461
|
const expandedQueries = extractExpandedQueries(task);
|
|
957
462
|
const fallbackKeywords = extractKeywordQueries(task, { allowIntentKeywords: true });
|
|
958
463
|
const queryCandidates = uniqueList([
|
|
464
|
+
...literalPatterns,
|
|
959
465
|
...expandedQueries,
|
|
960
466
|
...queries,
|
|
961
467
|
...fallbackKeywords,
|