smart-context-mcp 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Cache warming for intelligent index preloading.
3
+ *
4
+ * Analyzes usage patterns and preloads frequently accessed files into memory
5
+ * to reduce cold-start latency on first queries.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { loadIndex } from './index.js';
11
+ import { withStateDb, getStateDbPath } from './storage/sqlite.js';
12
+ import { projectRoot } from './utils/paths.js';
13
+
14
+ const isCacheWarmingEnabled = () => process.env.DEVCTX_CACHE_WARMING !== 'false';
15
+ const WARM_TOP_N_FILES = parseInt(process.env.DEVCTX_WARM_FILES || '50', 10);
16
+ const MIN_ACCESS_COUNT = 3;
17
+
18
+ export const getFrequentlyAccessedFiles = async (root = projectRoot, limit = WARM_TOP_N_FILES) => {
19
+ if (!isCacheWarmingEnabled()) return [];
20
+
21
+ try {
22
+ const dbPath = getStateDbPath(root);
23
+ if (!fs.existsSync(dbPath)) return [];
24
+
25
+ const files = await withStateDb(async (db) => {
26
+ const rows = db.prepare(`
27
+ SELECT file_path, COUNT(*) as access_count
28
+ FROM context_access
29
+ WHERE timestamp > datetime('now', '-30 days')
30
+ GROUP BY file_path
31
+ HAVING access_count >= ?
32
+ ORDER BY access_count DESC, MAX(timestamp) DESC
33
+ LIMIT ?
34
+ `).all(MIN_ACCESS_COUNT, limit);
35
+
36
+ return rows.map(r => r.file_path);
37
+ }, { filePath: dbPath });
38
+
39
+ return files;
40
+ } catch {
41
+ return [];
42
+ }
43
+ };
44
+
45
+ export const warmCache = async (root = projectRoot, progress = null) => {
46
+ if (!isCacheWarmingEnabled()) {
47
+ return { warmed: 0, skipped: 0, reason: 'disabled' };
48
+ }
49
+
50
+ const index = loadIndex(root);
51
+ if (!index) {
52
+ return { warmed: 0, skipped: 0, reason: 'no_index' };
53
+ }
54
+
55
+ const frequentFiles = await getFrequentlyAccessedFiles(root);
56
+ if (frequentFiles.length === 0) {
57
+ return { warmed: 0, skipped: 0, reason: 'no_frequent_files' };
58
+ }
59
+
60
+ let warmed = 0;
61
+ let skipped = 0;
62
+
63
+ for (let i = 0; i < frequentFiles.length; i++) {
64
+ const relPath = frequentFiles[i];
65
+ const absPath = path.join(root, relPath);
66
+
67
+ if (progress && i % 10 === 0) {
68
+ progress.report({
69
+ phase: 'warming',
70
+ processed: i,
71
+ total: frequentFiles.length,
72
+ percentage: Math.round((i / frequentFiles.length) * 100),
73
+ });
74
+ }
75
+
76
+ try {
77
+ if (!fs.existsSync(absPath)) {
78
+ skipped++;
79
+ continue;
80
+ }
81
+
82
+ const stats = fs.statSync(absPath);
83
+ if (stats.size > 1024 * 1024) {
84
+ skipped++;
85
+ continue;
86
+ }
87
+
88
+ fs.readFileSync(absPath, 'utf8');
89
+ warmed++;
90
+ } catch {
91
+ skipped++;
92
+ }
93
+ }
94
+
95
+ if (progress) {
96
+ progress.report({
97
+ phase: 'warming',
98
+ processed: frequentFiles.length,
99
+ total: frequentFiles.length,
100
+ percentage: 100,
101
+ });
102
+ }
103
+
104
+ return { warmed, skipped, totalCandidates: frequentFiles.length };
105
+ };
106
+
107
+ export const shouldWarmCache = async (root = projectRoot) => {
108
+ if (!isCacheWarmingEnabled()) return false;
109
+
110
+ const index = loadIndex(root);
111
+ if (!index) return false;
112
+
113
+ const frequentFiles = await getFrequentlyAccessedFiles(root, 10);
114
+ return frequentFiles.length >= 5;
115
+ };
116
+
117
+ export const getCacheStats = async (root = projectRoot) => {
118
+ const frequentFiles = await getFrequentlyAccessedFiles(root, 100);
119
+
120
+ const byExtension = {};
121
+ for (const file of frequentFiles) {
122
+ const ext = path.extname(file) || 'no-ext';
123
+ byExtension[ext] = (byExtension[ext] || 0) + 1;
124
+ }
125
+
126
+ return {
127
+ totalFrequentFiles: frequentFiles.length,
128
+ byExtension,
129
+ topFiles: frequentFiles.slice(0, 10),
130
+ };
131
+ };
@@ -0,0 +1,192 @@
1
+ import { withStateDb, withStateDbSnapshot } from './storage/sqlite.js';
2
+
3
+ const PATTERN_CONFIDENCE_THRESHOLD = 0.6;
4
+ const MIN_PATTERN_OCCURRENCES = 3;
5
+ const MAX_PREDICTED_FILES = 8;
6
+ const PATTERN_DECAY_DAYS = 30;
7
+
8
+ const initPatternTables = (db) => {
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS context_patterns (
11
+ pattern_id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ task_signature TEXT NOT NULL,
13
+ intent TEXT,
14
+ occurrences INTEGER DEFAULT 1,
15
+ last_seen_at TEXT NOT NULL,
16
+ created_at TEXT NOT NULL,
17
+ UNIQUE(task_signature, intent)
18
+ );
19
+
20
+ CREATE TABLE IF NOT EXISTS pattern_files (
21
+ pattern_id INTEGER NOT NULL,
22
+ file_path TEXT NOT NULL,
23
+ access_order INTEGER NOT NULL,
24
+ access_count INTEGER DEFAULT 1,
25
+ avg_relevance REAL DEFAULT 1.0,
26
+ last_accessed_at TEXT NOT NULL,
27
+ FOREIGN KEY(pattern_id) REFERENCES context_patterns(pattern_id) ON DELETE CASCADE,
28
+ PRIMARY KEY(pattern_id, file_path)
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_pattern_signature ON context_patterns(task_signature);
32
+ CREATE INDEX IF NOT EXISTS idx_pattern_files_lookup ON pattern_files(pattern_id, avg_relevance DESC);
33
+ `);
34
+ };
35
+
36
+ const normalizeTaskSignature = (task) => {
37
+ return task
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9\s]/g, ' ')
40
+ .replace(/\s+/g, ' ')
41
+ .trim()
42
+ .split(' ')
43
+ .filter(word => word.length > 2)
44
+ .slice(0, 8)
45
+ .join(' ');
46
+ };
47
+
48
+ const computeTaskSimilarity = (task1, task2) => {
49
+ const words1 = new Set(task1.split(' '));
50
+ const words2 = new Set(task2.split(' '));
51
+ const intersection = [...words1].filter(w => words2.has(w)).length;
52
+ const union = new Set([...words1, ...words2]).size;
53
+ return union > 0 ? intersection / union : 0;
54
+ };
55
+
56
+ export const recordContextAccess = async ({ task, intent, files }) => {
57
+ if (!task || !Array.isArray(files) || files.length === 0) return;
58
+
59
+ const signature = normalizeTaskSignature(task);
60
+ if (!signature) return;
61
+
62
+ return withStateDb((db) => {
63
+ initPatternTables(db);
64
+
65
+ const now = new Date().toISOString();
66
+
67
+ db.exec('BEGIN');
68
+ try {
69
+ const existing = db.prepare(
70
+ 'SELECT pattern_id, occurrences FROM context_patterns WHERE task_signature = ? AND intent = ?'
71
+ ).get(signature, intent || 'explore');
72
+
73
+ let patternId;
74
+ if (existing) {
75
+ db.prepare(
76
+ 'UPDATE context_patterns SET occurrences = occurrences + 1, last_seen_at = ? WHERE pattern_id = ?'
77
+ ).run(now, existing.pattern_id);
78
+ patternId = existing.pattern_id;
79
+ } else {
80
+ const result = db.prepare(
81
+ 'INSERT INTO context_patterns (task_signature, intent, last_seen_at, created_at) VALUES (?, ?, ?, ?)'
82
+ ).run(signature, intent || 'explore', now, now);
83
+ patternId = result.lastInsertRowid;
84
+ }
85
+
86
+ for (let i = 0; i < files.length; i++) {
87
+ const file = files[i];
88
+ const existingFile = db.prepare(
89
+ 'SELECT access_count, avg_relevance FROM pattern_files WHERE pattern_id = ? AND file_path = ?'
90
+ ).get(patternId, file.path);
91
+
92
+ if (existingFile) {
93
+ const newCount = existingFile.access_count + 1;
94
+ const newRelevance = (existingFile.avg_relevance * existingFile.access_count + (file.relevance || 1.0)) / newCount;
95
+
96
+ db.prepare(
97
+ 'UPDATE pattern_files SET access_count = ?, avg_relevance = ?, last_accessed_at = ? WHERE pattern_id = ? AND file_path = ?'
98
+ ).run(newCount, newRelevance, now, patternId, file.path);
99
+ } else {
100
+ db.prepare(
101
+ 'INSERT INTO pattern_files (pattern_id, file_path, access_order, avg_relevance, last_accessed_at) VALUES (?, ?, ?, ?, ?)'
102
+ ).run(patternId, file.path, i, file.relevance || 1.0, now);
103
+ }
104
+ }
105
+
106
+ db.exec('COMMIT');
107
+ } catch (error) {
108
+ db.exec('ROLLBACK');
109
+ throw error;
110
+ }
111
+ });
112
+ };
113
+
114
+ export const predictContextFiles = async ({ task, intent, maxFiles = MAX_PREDICTED_FILES }) => {
115
+ const signature = normalizeTaskSignature(task);
116
+ if (!signature) return { predicted: [], confidence: 0, matchedPattern: null };
117
+
118
+ return withStateDbSnapshot((db) => {
119
+ initPatternTables(db);
120
+
121
+ const cutoffDate = new Date(Date.now() - PATTERN_DECAY_DAYS * 24 * 60 * 60 * 1000).toISOString();
122
+
123
+ const patterns = db.prepare(`
124
+ SELECT pattern_id, task_signature, intent, occurrences, last_seen_at
125
+ FROM context_patterns
126
+ WHERE last_seen_at > ?
127
+ ORDER BY occurrences DESC
128
+ LIMIT 20
129
+ `).all(cutoffDate);
130
+
131
+ let bestMatch = null;
132
+ let bestScore = 0;
133
+
134
+ for (const pattern of patterns) {
135
+ if (intent && pattern.intent !== intent) continue;
136
+
137
+ const similarity = computeTaskSimilarity(signature, pattern.task_signature);
138
+ const recencyBonus = pattern.occurrences >= MIN_PATTERN_OCCURRENCES ? 0.1 : 0;
139
+ const score = similarity + recencyBonus;
140
+
141
+ if (score > bestScore && score >= PATTERN_CONFIDENCE_THRESHOLD) {
142
+ bestScore = score;
143
+ bestMatch = pattern;
144
+ }
145
+ }
146
+
147
+ if (!bestMatch) {
148
+ return { predicted: [], confidence: 0, matchedPattern: null };
149
+ }
150
+
151
+ const files = db.prepare(`
152
+ SELECT file_path, access_order, access_count, avg_relevance
153
+ FROM pattern_files
154
+ WHERE pattern_id = ?
155
+ ORDER BY avg_relevance DESC, access_count DESC, access_order ASC
156
+ LIMIT ?
157
+ `).all(bestMatch.pattern_id, maxFiles);
158
+
159
+ return {
160
+ predicted: files.map(f => ({
161
+ path: f.file_path,
162
+ confidence: f.avg_relevance,
163
+ accessCount: f.access_count,
164
+ order: f.access_order
165
+ })),
166
+ confidence: bestScore,
167
+ matchedPattern: {
168
+ signature: bestMatch.task_signature,
169
+ intent: bestMatch.intent,
170
+ occurrences: bestMatch.occurrences
171
+ }
172
+ };
173
+ }, {});
174
+ };
175
+
176
+ export const cleanupStalePatterns = async ({ retentionDays = PATTERN_DECAY_DAYS } = {}) => {
177
+ return withStateDb((db) => {
178
+ initPatternTables(db);
179
+
180
+ const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
181
+
182
+ const result = db.prepare(
183
+ 'DELETE FROM context_patterns WHERE last_seen_at < ?'
184
+ ).run(cutoffDate);
185
+
186
+ return {
187
+ action: 'cleanup_patterns',
188
+ deletedPatterns: result.changes,
189
+ retentionDays
190
+ };
191
+ });
192
+ };
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Cross-project context for monorepos and related projects.
3
+ *
4
+ * Enables context sharing across multiple related codebases.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { loadIndex } from './index.js';
10
+ import { smartSearch } from './tools/smart-search.js';
11
+ import { smartRead } from './tools/smart-read.js';
12
+ import { projectRoot, setProjectRoot } from './utils/paths.js';
13
+
14
+ const CROSS_PROJECT_CONFIG_FILE = '.devctx-projects.json';
15
+
16
+ /**
17
+ * Load cross-project configuration.
18
+ *
19
+ * @param {string} root - Project root
20
+ * @returns {object|null} Configuration or null if not found
21
+ */
22
+ export const loadCrossProjectConfig = (root = projectRoot) => {
23
+ const configPath = path.join(root, CROSS_PROJECT_CONFIG_FILE);
24
+
25
+ if (!fs.existsSync(configPath)) {
26
+ return null;
27
+ }
28
+
29
+ try {
30
+ const content = fs.readFileSync(configPath, 'utf8');
31
+ return JSON.parse(content);
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ /**
38
+ * Discover related projects from config.
39
+ *
40
+ * @param {string} root - Project root
41
+ * @returns {Array<object>} Array of { name, path, type, description }
42
+ */
43
+ export const discoverRelatedProjects = (root = projectRoot) => {
44
+ const config = loadCrossProjectConfig(root);
45
+ if (!config?.projects) return [];
46
+
47
+ const projects = [];
48
+
49
+ for (const project of config.projects) {
50
+ const projectPath = path.isAbsolute(project.path)
51
+ ? project.path
52
+ : path.resolve(root, project.path);
53
+
54
+ if (!fs.existsSync(projectPath)) continue;
55
+
56
+ const indexPath = path.join(projectPath, '.devctx/index.json');
57
+ const hasIndex = fs.existsSync(indexPath);
58
+
59
+ projects.push({
60
+ name: project.name,
61
+ path: projectPath,
62
+ type: project.type || 'related',
63
+ description: project.description || '',
64
+ hasIndex,
65
+ });
66
+ }
67
+
68
+ return projects;
69
+ };
70
+
71
+ /**
72
+ * Search across multiple related projects.
73
+ *
74
+ * @param {string} query - Search query
75
+ * @param {object} options - Search options
76
+ * @returns {Promise<Array>} Results from all projects
77
+ */
78
+ export const searchAcrossProjects = async (query, options = {}) => {
79
+ const {
80
+ root = projectRoot,
81
+ intent = 'implementation',
82
+ maxResultsPerProject = 5,
83
+ includeProjects = null,
84
+ excludeProjects = null,
85
+ } = options;
86
+
87
+ const relatedProjects = discoverRelatedProjects(root);
88
+
89
+ const projectsToSearch = relatedProjects.filter(p => {
90
+ if (!p.hasIndex) return false;
91
+ if (includeProjects && !includeProjects.includes(p.name)) return false;
92
+ if (excludeProjects && excludeProjects.includes(p.name)) return false;
93
+ return true;
94
+ });
95
+
96
+ const results = [];
97
+
98
+ for (const project of projectsToSearch) {
99
+ try {
100
+ const searchResult = await smartSearch({
101
+ query,
102
+ cwd: project.path,
103
+ intent,
104
+ maxResults: maxResultsPerProject,
105
+ });
106
+
107
+ if (searchResult.results && searchResult.results.length > 0) {
108
+ results.push({
109
+ project: project.name,
110
+ projectPath: project.path,
111
+ projectType: project.type,
112
+ matches: searchResult.results.length,
113
+ results: searchResult.results.map(r => ({
114
+ ...r,
115
+ projectName: project.name,
116
+ absolutePath: path.join(project.path, r.file),
117
+ })),
118
+ });
119
+ }
120
+ } catch {
121
+ continue;
122
+ }
123
+ }
124
+
125
+ return results;
126
+ };
127
+
128
+ /**
129
+ * Read files from multiple projects.
130
+ *
131
+ * @param {Array<object>} fileRefs - Array of { project, file, mode }
132
+ * @param {string} root - Project root
133
+ * @returns {Promise<Array>} Read results
134
+ */
135
+ export const readAcrossProjects = async (fileRefs, root = projectRoot) => {
136
+ const relatedProjects = discoverRelatedProjects(root);
137
+ const projectMap = new Map(relatedProjects.map(p => [p.name, p]));
138
+
139
+ const originalRoot = projectRoot;
140
+ const results = [];
141
+
142
+ for (const ref of fileRefs) {
143
+ const project = projectMap.get(ref.project);
144
+ if (!project) {
145
+ results.push({
146
+ project: ref.project,
147
+ file: ref.file,
148
+ error: 'Project not found',
149
+ });
150
+ continue;
151
+ }
152
+
153
+ try {
154
+ setProjectRoot(project.path);
155
+
156
+ const readResult = await smartRead({
157
+ filePath: ref.file,
158
+ mode: ref.mode || 'outline',
159
+ });
160
+
161
+ results.push({
162
+ project: ref.project,
163
+ projectPath: project.path,
164
+ file: ref.file,
165
+ mode: readResult.mode,
166
+ content: readResult.content,
167
+ parser: readResult.parser,
168
+ });
169
+ } catch (err) {
170
+ results.push({
171
+ project: ref.project,
172
+ file: ref.file,
173
+ error: err.message,
174
+ });
175
+ }
176
+ }
177
+
178
+ setProjectRoot(originalRoot);
179
+ return results;
180
+ };
181
+
182
+ /**
183
+ * Find symbol definitions across projects.
184
+ *
185
+ * @param {string} symbolName - Symbol to find
186
+ * @param {string} root - Project root
187
+ * @returns {Promise<Array>} Symbol locations across projects
188
+ */
189
+ export const findSymbolAcrossProjects = async (symbolName, root = projectRoot) => {
190
+ const relatedProjects = discoverRelatedProjects(root).filter(p => p.hasIndex);
191
+ const results = [];
192
+
193
+ for (const project of relatedProjects) {
194
+ try {
195
+ const index = loadIndex(project.path);
196
+ if (!index?.files) continue;
197
+
198
+ for (const [filePath, fileInfo] of Object.entries(index.files)) {
199
+ if (!fileInfo.symbols) continue;
200
+
201
+ const matchingSymbols = fileInfo.symbols.filter(s =>
202
+ s.name === symbolName || s.name.includes(symbolName)
203
+ );
204
+
205
+ for (const symbol of matchingSymbols) {
206
+ results.push({
207
+ project: project.name,
208
+ projectPath: project.path,
209
+ projectType: project.type,
210
+ file: filePath,
211
+ symbol: symbol.name,
212
+ kind: symbol.kind,
213
+ line: symbol.line,
214
+ signature: symbol.signature,
215
+ });
216
+ }
217
+ }
218
+ } catch {
219
+ continue;
220
+ }
221
+ }
222
+
223
+ return results;
224
+ };
225
+
226
+ /**
227
+ * Get dependency graph across projects.
228
+ *
229
+ * @param {string} root - Project root
230
+ * @returns {object} Cross-project dependency graph
231
+ */
232
+ export const getCrossProjectDependencies = (root = projectRoot) => {
233
+ const relatedProjects = discoverRelatedProjects(root).filter(p => p.hasIndex);
234
+ const dependencies = {
235
+ projects: [],
236
+ edges: [],
237
+ };
238
+
239
+ for (const project of relatedProjects) {
240
+ dependencies.projects.push({
241
+ name: project.name,
242
+ path: project.path,
243
+ type: project.type,
244
+ });
245
+
246
+ try {
247
+ const index = loadIndex(project.path);
248
+ if (!index?.graph?.edges) continue;
249
+
250
+ for (const edge of index.graph.edges) {
251
+ if (edge.kind !== 'import') continue;
252
+
253
+ const fromAbs = path.join(project.path, edge.from);
254
+ const toAbs = path.join(project.path, edge.to);
255
+
256
+ const toProject = relatedProjects.find(p =>
257
+ toAbs.startsWith(p.path) && p.path !== project.path
258
+ );
259
+
260
+ if (toProject) {
261
+ const toRel = path.relative(toProject.path, toAbs);
262
+ dependencies.edges.push({
263
+ from: project.name,
264
+ fromFile: edge.from,
265
+ to: toProject.name,
266
+ toFile: toRel,
267
+ kind: 'cross-project-import',
268
+ });
269
+ }
270
+ }
271
+ } catch {
272
+ continue;
273
+ }
274
+ }
275
+
276
+ return dependencies;
277
+ };
278
+
279
+ /**
280
+ * Get statistics about cross-project usage.
281
+ *
282
+ * @param {string} root - Project root
283
+ * @returns {object} Usage statistics
284
+ */
285
+ export const getCrossProjectStats = (root = projectRoot) => {
286
+ const relatedProjects = discoverRelatedProjects(root);
287
+ const deps = getCrossProjectDependencies(root);
288
+
289
+ const stats = {
290
+ totalProjects: relatedProjects.length,
291
+ indexedProjects: relatedProjects.filter(p => p.hasIndex).length,
292
+ projectTypes: {},
293
+ crossProjectImports: deps.edges.length,
294
+ importsByProject: {},
295
+ };
296
+
297
+ for (const project of relatedProjects) {
298
+ const type = project.type || 'related';
299
+ stats.projectTypes[type] = (stats.projectTypes[type] || 0) + 1;
300
+ }
301
+
302
+ for (const edge of deps.edges) {
303
+ stats.importsByProject[edge.from] = (stats.importsByProject[edge.from] || 0) + 1;
304
+ }
305
+
306
+ return stats;
307
+ };
308
+
309
+ /**
310
+ * Create a sample cross-project configuration.
311
+ *
312
+ * @param {string} root - Project root
313
+ * @returns {object} Sample configuration
314
+ */
315
+ export const createSampleConfig = (root = projectRoot) => {
316
+ return {
317
+ version: '1.0',
318
+ projects: [
319
+ {
320
+ name: 'main-app',
321
+ path: '.',
322
+ type: 'main',
323
+ description: 'Main application',
324
+ },
325
+ {
326
+ name: 'shared-lib',
327
+ path: '../shared-lib',
328
+ type: 'library',
329
+ description: 'Shared utilities library',
330
+ },
331
+ {
332
+ name: 'api-service',
333
+ path: '../api-service',
334
+ type: 'service',
335
+ description: 'Backend API service',
336
+ },
337
+ ],
338
+ searchDefaults: {
339
+ maxResultsPerProject: 5,
340
+ includeTypes: ['main', 'library', 'service'],
341
+ },
342
+ };
343
+ };