smart-context-mcp 1.18.1 → 1.20.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,375 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { scrubContent, hashProjectPath } from './scrub.js';
5
+ import { embed, cosineSimilarity, buildCorpusIdf } from '../embeddings/hashing.js';
6
+
7
+ const DEFAULT_GLOBAL_DIR = path.join(os.homedir(), '.devctx');
8
+ const DEFAULT_GLOBAL_DB = path.join(DEFAULT_GLOBAL_DIR, 'global.db');
9
+ const SCHEMA_VERSION = 2;
10
+
11
+ let sqliteModulePromise = null;
12
+
13
+ const loadSqliteModule = async () => {
14
+ if (!sqliteModulePromise) {
15
+ sqliteModulePromise = import('node:sqlite')
16
+ .catch(() => {
17
+ throw new Error('Global memory requires Node 22+ (node:sqlite)');
18
+ });
19
+ }
20
+ return sqliteModulePromise;
21
+ };
22
+
23
+ export const getGlobalDbPath = () => {
24
+ const override = process.env.DEVCTX_GLOBAL_DB?.trim();
25
+ return override && override.length > 0 ? override : DEFAULT_GLOBAL_DB;
26
+ };
27
+
28
+ export const isGlobalMemoryEnabled = () => {
29
+ const value = String(process.env.DEVCTX_GLOBAL_MEMORY ?? '').trim().toLowerCase();
30
+ return value === '1' || value === 'true' || value === 'yes' || value === 'on';
31
+ };
32
+
33
+ const ensureDir = (filePath) => {
34
+ const dir = path.dirname(filePath);
35
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
36
+ };
37
+
38
+ const SCHEMA_SQL = `
39
+ CREATE TABLE IF NOT EXISTS meta (
40
+ key TEXT PRIMARY KEY,
41
+ value TEXT NOT NULL
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS entries (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ kind TEXT NOT NULL,
47
+ content TEXT NOT NULL,
48
+ tags TEXT,
49
+ project_hash TEXT,
50
+ created_at INTEGER NOT NULL,
51
+ updated_at INTEGER NOT NULL,
52
+ usage_count INTEGER NOT NULL DEFAULT 0,
53
+ last_used_at INTEGER
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_entries_kind ON entries(kind);
57
+ CREATE INDEX IF NOT EXISTS idx_entries_project ON entries(project_hash);
58
+ CREATE INDEX IF NOT EXISTS idx_entries_created ON entries(created_at DESC);
59
+
60
+ CREATE TABLE IF NOT EXISTS noise_hints (
61
+ project_hash TEXT NOT NULL,
62
+ hint_key TEXT NOT NULL,
63
+ reason TEXT NOT NULL DEFAULT 'search_noise',
64
+ hits INTEGER NOT NULL DEFAULT 1,
65
+ created_at INTEGER NOT NULL,
66
+ updated_at INTEGER NOT NULL,
67
+ PRIMARY KEY(project_hash, hint_key)
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_noise_hints_project ON noise_hints(project_hash, hits DESC, updated_at DESC);
71
+ `;
72
+
73
+ const VALID_KINDS = new Set(['decision', 'pattern', 'playbook', 'note']);
74
+
75
+ const withDb = async (fn, { filePath = getGlobalDbPath(), readOnly = false } = {}) => {
76
+ const { DatabaseSync } = await loadSqliteModule();
77
+ if (!readOnly) ensureDir(filePath);
78
+
79
+ if (readOnly && !fs.existsSync(filePath)) {
80
+ return fn(null);
81
+ }
82
+
83
+ const db = new DatabaseSync(filePath, { readOnly });
84
+ try {
85
+ if (!readOnly) {
86
+ db.exec(SCHEMA_SQL);
87
+ const meta = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
88
+ if (!meta) {
89
+ db.prepare('INSERT INTO meta(key, value) VALUES(?, ?)').run('schema_version', String(SCHEMA_VERSION));
90
+ }
91
+ }
92
+ return fn(db);
93
+ } finally {
94
+ db.close();
95
+ }
96
+ };
97
+
98
+ const normalizeTags = (tags) => {
99
+ if (!tags) return null;
100
+ if (typeof tags === 'string') return tags;
101
+ if (Array.isArray(tags)) return JSON.stringify(tags.filter((t) => typeof t === 'string'));
102
+ return null;
103
+ };
104
+
105
+ const parseTags = (raw) => {
106
+ if (!raw) return [];
107
+ try {
108
+ const parsed = JSON.parse(raw);
109
+ return Array.isArray(parsed) ? parsed : [];
110
+ } catch {
111
+ return typeof raw === 'string' ? raw.split(',').map((t) => t.trim()).filter(Boolean) : [];
112
+ }
113
+ };
114
+
115
+ export const saveEntry = async ({
116
+ kind,
117
+ content,
118
+ tags,
119
+ projectPath,
120
+ filePath = getGlobalDbPath(),
121
+ } = {}) => {
122
+ if (!VALID_KINDS.has(kind)) {
123
+ throw new Error(`Invalid kind: ${kind}. Must be one of: ${[...VALID_KINDS].join(', ')}`);
124
+ }
125
+ if (typeof content !== 'string' || content.trim().length === 0) {
126
+ throw new Error('content must be a non-empty string');
127
+ }
128
+
129
+ const scrubbed = scrubContent(content);
130
+ const projectHash = projectPath ? hashProjectPath(projectPath) : null;
131
+ const tagsJson = normalizeTags(tags);
132
+ const now = Date.now();
133
+
134
+ return withDb((db) => {
135
+ const stmt = db.prepare(`
136
+ INSERT INTO entries (kind, content, tags, project_hash, created_at, updated_at)
137
+ VALUES (?, ?, ?, ?, ?, ?)
138
+ `);
139
+ const result = stmt.run(kind, scrubbed, tagsJson, projectHash, now, now);
140
+ return {
141
+ id: Number(result.lastInsertRowid),
142
+ kind,
143
+ contentLength: scrubbed.length,
144
+ projectHash,
145
+ tags: parseTags(tagsJson),
146
+ createdAt: now,
147
+ };
148
+ }, { filePath });
149
+ };
150
+
151
+ export const recallEntries = async ({
152
+ kind,
153
+ query,
154
+ limit = 10,
155
+ projectPath,
156
+ filePath = getGlobalDbPath(),
157
+ } = {}) => {
158
+ return withDb((db) => {
159
+ if (!db) return { hits: [], total: 0 };
160
+ const conditions = [];
161
+ const params = [];
162
+ if (kind) {
163
+ conditions.push('kind = ?');
164
+ params.push(kind);
165
+ }
166
+ if (projectPath) {
167
+ conditions.push('project_hash = ?');
168
+ params.push(hashProjectPath(projectPath));
169
+ }
170
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
171
+
172
+ const rows = db.prepare(`
173
+ SELECT id, kind, content, tags, project_hash, created_at, updated_at, usage_count
174
+ FROM entries
175
+ ${where}
176
+ ORDER BY created_at DESC
177
+ LIMIT 500
178
+ `).all(...params);
179
+
180
+ if (!query || query.trim().length === 0) {
181
+ return {
182
+ hits: rows.slice(0, limit).map((r) => ({
183
+ id: r.id,
184
+ kind: r.kind,
185
+ content: r.content,
186
+ tags: parseTags(r.tags),
187
+ createdAt: r.created_at,
188
+ usageCount: r.usage_count,
189
+ score: 0,
190
+ })),
191
+ total: rows.length,
192
+ };
193
+ }
194
+
195
+ const idf = buildCorpusIdf(rows.map((r) => r.content));
196
+ const queryVec = embed(query, { idf });
197
+ const ranked = rows
198
+ .map((r) => {
199
+ const docVec = embed(r.content, { idf });
200
+ const score = cosineSimilarity(queryVec, docVec);
201
+ return { row: r, score };
202
+ })
203
+ .filter((x) => x.score > 0)
204
+ .sort((a, b) => b.score - a.score)
205
+ .slice(0, limit);
206
+
207
+ return {
208
+ hits: ranked.map(({ row, score }) => ({
209
+ id: row.id,
210
+ kind: row.kind,
211
+ content: row.content,
212
+ tags: parseTags(row.tags),
213
+ createdAt: row.created_at,
214
+ usageCount: row.usage_count,
215
+ score: Number(score.toFixed(4)),
216
+ })),
217
+ total: rows.length,
218
+ };
219
+ }, { filePath, readOnly: true });
220
+ };
221
+
222
+ export const markEntryUsed = async ({
223
+ id,
224
+ filePath = getGlobalDbPath(),
225
+ } = {}) => {
226
+ const now = Date.now();
227
+ return withDb((db) => {
228
+ const result = db.prepare(`
229
+ UPDATE entries
230
+ SET usage_count = usage_count + 1, last_used_at = ?
231
+ WHERE id = ?
232
+ `).run(now, id);
233
+ return { id, updated: Number(result.changes) > 0, lastUsedAt: now };
234
+ }, { filePath });
235
+ };
236
+
237
+ export const deleteEntry = async ({ id, filePath = getGlobalDbPath() } = {}) => {
238
+ return withDb((db) => {
239
+ const result = db.prepare('DELETE FROM entries WHERE id = ?').run(id);
240
+ return { id, deleted: Number(result.changes) > 0 };
241
+ }, { filePath });
242
+ };
243
+
244
+ export const listKinds = async ({ filePath = getGlobalDbPath() } = {}) => {
245
+ return withDb((db) => {
246
+ if (!db) return { kinds: [], total: 0 };
247
+ const rows = db.prepare(`
248
+ SELECT kind, COUNT(*) as count, MAX(created_at) as latest
249
+ FROM entries
250
+ GROUP BY kind
251
+ ORDER BY count DESC
252
+ `).all();
253
+ return {
254
+ kinds: rows.map((r) => ({ kind: r.kind, count: Number(r.count), latest: r.latest })),
255
+ total: rows.reduce((sum, r) => sum + Number(r.count), 0),
256
+ };
257
+ }, { filePath, readOnly: true });
258
+ };
259
+
260
+ export const recordNoiseHint = async ({
261
+ projectPath,
262
+ hintKey,
263
+ reason = 'search_noise',
264
+ filePath = getGlobalDbPath(),
265
+ } = {}) => {
266
+ if (!projectPath || !hintKey) {
267
+ return null;
268
+ }
269
+
270
+ const projectHash = hashProjectPath(projectPath);
271
+ const now = Date.now();
272
+ return withDb((db) => {
273
+ db.prepare(`
274
+ INSERT INTO noise_hints(project_hash, hint_key, reason, hits, created_at, updated_at)
275
+ VALUES(?, ?, ?, 1, ?, ?)
276
+ ON CONFLICT(project_hash, hint_key) DO UPDATE SET
277
+ reason = excluded.reason,
278
+ hits = noise_hints.hits + 1,
279
+ updated_at = excluded.updated_at
280
+ `).run(projectHash, hintKey, reason, now, now);
281
+
282
+ const row = db.prepare(`
283
+ SELECT hits, updated_at
284
+ FROM noise_hints
285
+ WHERE project_hash = ? AND hint_key = ?
286
+ `).get(projectHash, hintKey);
287
+
288
+ return {
289
+ projectHash,
290
+ hintKey,
291
+ hits: Number(row?.hits ?? 0),
292
+ updatedAt: Number(row?.updated_at ?? now),
293
+ };
294
+ }, { filePath });
295
+ };
296
+
297
+ export const getNoiseHints = async ({
298
+ projectPath,
299
+ limit = 50,
300
+ filePath = getGlobalDbPath(),
301
+ } = {}) => {
302
+ if (!projectPath) {
303
+ return { hints: [], total: 0 };
304
+ }
305
+
306
+ const projectHash = hashProjectPath(projectPath);
307
+ return withDb((db) => {
308
+ if (!db) return { hints: [], total: 0 };
309
+ const rows = db.prepare(`
310
+ SELECT hint_key, reason, hits, updated_at
311
+ FROM noise_hints
312
+ WHERE project_hash = ?
313
+ ORDER BY hits DESC, updated_at DESC
314
+ LIMIT ?
315
+ `).all(projectHash, limit);
316
+
317
+ return {
318
+ hints: rows.map((row) => ({
319
+ hintKey: row.hint_key,
320
+ reason: row.reason,
321
+ hits: Number(row.hits),
322
+ penalty: Math.min(Number(row.hits) * 2, 12),
323
+ updatedAt: Number(row.updated_at),
324
+ })),
325
+ total: rows.length,
326
+ };
327
+ }, { filePath, readOnly: true });
328
+ };
329
+
330
+ export const resetNoiseHints = async ({
331
+ projectPath,
332
+ hintKey,
333
+ filePath = getGlobalDbPath(),
334
+ } = {}) => {
335
+ if (!projectPath) {
336
+ return { deleted: 0 };
337
+ }
338
+
339
+ const projectHash = hashProjectPath(projectPath);
340
+ return withDb((db) => {
341
+ const result = hintKey
342
+ ? db.prepare('DELETE FROM noise_hints WHERE project_hash = ? AND hint_key = ?').run(projectHash, hintKey)
343
+ : db.prepare('DELETE FROM noise_hints WHERE project_hash = ?').run(projectHash);
344
+ return { deleted: Number(result.changes) };
345
+ }, { filePath });
346
+ };
347
+
348
+ export const getStats = async ({ filePath = getGlobalDbPath() } = {}) => {
349
+ return withDb((db) => {
350
+ if (!db) {
351
+ return {
352
+ exists: false,
353
+ filePath,
354
+ enabled: isGlobalMemoryEnabled(),
355
+ totalEntries: 0,
356
+ byKind: {},
357
+ };
358
+ }
359
+ const total = db.prepare('SELECT COUNT(*) as c FROM entries').get();
360
+ const byKindRows = db.prepare('SELECT kind, COUNT(*) as c FROM entries GROUP BY kind').all();
361
+ const byKind = {};
362
+ for (const r of byKindRows) byKind[r.kind] = Number(r.c);
363
+ const sizeBytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
364
+ return {
365
+ exists: true,
366
+ filePath,
367
+ enabled: isGlobalMemoryEnabled(),
368
+ totalEntries: Number(total.c),
369
+ byKind,
370
+ sizeBytes,
371
+ };
372
+ }, { filePath, readOnly: true });
373
+ };
374
+
375
+ export const VALID_GLOBAL_KINDS = VALID_KINDS;
@@ -0,0 +1,224 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ loadIndex,
5
+ reindexFile,
6
+ removeFileFromIndex,
7
+ persistIndex,
8
+ buildIndex,
9
+ } from './index.js';
10
+ import { projectRoot } from './utils/paths.js';
11
+ import { IGNORED_DIRS, IGNORED_FILE_NAMES, IGNORED_FILE_PATTERNS } from './config/ignored-paths.js';
12
+
13
+ const DEFAULT_DEBOUNCE_MS = 600;
14
+ const DEFAULT_BATCH_FLUSH_MS = 2000;
15
+ const MAX_BATCH_FILES = 50;
16
+
17
+ const IGNORED_DIRS_SET = new Set(IGNORED_DIRS);
18
+ const IGNORED_FILE_SET = new Set(IGNORED_FILE_NAMES);
19
+
20
+ const ALLOWED_EXT = new Set([
21
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
22
+ '.py', '.go', '.rs', '.java', '.cs', '.kt', '.kts', '.php', '.swift',
23
+ '.md', '.markdown',
24
+ ]);
25
+
26
+ export const isIgnoredPath = (relPath) => {
27
+ if (!relPath || typeof relPath !== 'string') return true;
28
+ const normalized = relPath.split(path.sep).join('/');
29
+ for (const segment of normalized.split('/')) {
30
+ if (IGNORED_DIRS_SET.has(segment)) return true;
31
+ }
32
+ const base = path.basename(normalized);
33
+ if (IGNORED_FILE_SET.has(base)) return true;
34
+ for (const re of IGNORED_FILE_PATTERNS) {
35
+ if (re.test(base)) return true;
36
+ }
37
+ const ext = path.extname(base).toLowerCase();
38
+ if (!ALLOWED_EXT.has(ext)) return true;
39
+ return false;
40
+ };
41
+
42
+ const classifyChange = (root, relPath) => {
43
+ const abs = path.join(root, relPath);
44
+ try {
45
+ const stat = fs.statSync(abs);
46
+ if (stat.isDirectory()) return 'directory';
47
+ return 'changed';
48
+ } catch (err) {
49
+ if (err && err.code === 'ENOENT') return 'removed';
50
+ return 'unknown';
51
+ }
52
+ };
53
+
54
+ export const applyChanges = ({ index, root, changes }) => {
55
+ let touched = 0;
56
+ let removed = 0;
57
+ for (const relPath of changes) {
58
+ const kind = classifyChange(root, relPath);
59
+ if (kind === 'removed') {
60
+ removeFileFromIndex(index, relPath);
61
+ removed += 1;
62
+ continue;
63
+ }
64
+ if (kind === 'changed') {
65
+ try {
66
+ reindexFile(index, root, relPath);
67
+ touched += 1;
68
+ } catch {
69
+ // best-effort; ignore individual failures
70
+ }
71
+ }
72
+ }
73
+ return { touched, removed };
74
+ };
75
+
76
+ const createWatcherState = () => ({
77
+ pending: new Set(),
78
+ debounceTimer: null,
79
+ flushing: false,
80
+ flushPromise: null,
81
+ watcher: null,
82
+ running: false,
83
+ stats: {
84
+ flushes: 0,
85
+ eventsObserved: 0,
86
+ filesReindexed: 0,
87
+ filesRemoved: 0,
88
+ errors: 0,
89
+ lastFlushAt: 0,
90
+ },
91
+ });
92
+
93
+ const flushNow = async (state, root) => {
94
+ if (state.flushing) {
95
+ return state.flushPromise;
96
+ }
97
+ if (state.pending.size === 0) return { touched: 0, removed: 0 };
98
+
99
+ const batch = [...state.pending];
100
+ state.pending.clear();
101
+
102
+ state.flushing = true;
103
+ state.flushPromise = (async () => {
104
+ try {
105
+ let index = loadIndex(root);
106
+ if (!index) {
107
+ index = buildIndex(root);
108
+ }
109
+ const { touched, removed } = applyChanges({ index, root, changes: batch });
110
+ if (touched > 0 || removed > 0) {
111
+ await persistIndex(index, root);
112
+ }
113
+ state.stats.flushes += 1;
114
+ state.stats.filesReindexed += touched;
115
+ state.stats.filesRemoved += removed;
116
+ state.stats.lastFlushAt = Date.now();
117
+ return { touched, removed };
118
+ } catch (error) {
119
+ state.stats.errors += 1;
120
+ return { error: error?.message ?? String(error) };
121
+ } finally {
122
+ state.flushing = false;
123
+ state.flushPromise = null;
124
+ }
125
+ })();
126
+ return state.flushPromise;
127
+ };
128
+
129
+ const scheduleFlush = (state, root, debounceMs) => {
130
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
131
+ state.debounceTimer = setTimeout(() => {
132
+ state.debounceTimer = null;
133
+ flushNow(state, root).catch(() => {});
134
+ }, debounceMs);
135
+ if (state.debounceTimer.unref) state.debounceTimer.unref();
136
+ };
137
+
138
+ let activeWatcher = null;
139
+
140
+ export const getActiveWatcher = () => activeWatcher;
141
+
142
+ export const setActiveWatcher = (handle) => {
143
+ activeWatcher = handle;
144
+ };
145
+
146
+ export const isWatchEnabled = () => {
147
+ const value = String(process.env.DEVCTX_WATCH_INDEX ?? '').trim().toLowerCase();
148
+ if (value === '' || value === '1' || value === 'true' || value === 'yes') return true;
149
+ return false;
150
+ };
151
+
152
+ export const startIndexWatcher = ({
153
+ root = projectRoot,
154
+ debounceMs = DEFAULT_DEBOUNCE_MS,
155
+ batchFlushMs = DEFAULT_BATCH_FLUSH_MS,
156
+ } = {}) => {
157
+ const state = createWatcherState();
158
+
159
+ if (!isWatchEnabled()) {
160
+ return {
161
+ stop: async () => ({ stopped: false, reason: 'disabled' }),
162
+ flush: async () => ({ touched: 0, removed: 0, skipped: true }),
163
+ stats: () => ({ ...state.stats, enabled: false }),
164
+ isRunning: () => false,
165
+ };
166
+ }
167
+
168
+ let watcher;
169
+ try {
170
+ watcher = fs.watch(root, { recursive: true, persistent: false }, (eventType, filename) => {
171
+ if (!filename) return;
172
+ const relPath = typeof filename === 'string' ? filename : filename.toString();
173
+ if (isIgnoredPath(relPath)) return;
174
+
175
+ state.stats.eventsObserved += 1;
176
+ state.pending.add(relPath);
177
+
178
+ if (state.pending.size >= MAX_BATCH_FILES) {
179
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
180
+ state.debounceTimer = null;
181
+ flushNow(state, root).catch(() => {});
182
+ return;
183
+ }
184
+ scheduleFlush(state, root, debounceMs);
185
+ });
186
+ } catch (error) {
187
+ return {
188
+ stop: async () => ({ stopped: false, error: error?.message ?? String(error) }),
189
+ flush: async () => ({ touched: 0, removed: 0, skipped: true }),
190
+ stats: () => ({ ...state.stats, enabled: false, error: error?.message ?? String(error) }),
191
+ isRunning: () => false,
192
+ };
193
+ }
194
+
195
+ state.watcher = watcher;
196
+ state.running = true;
197
+
198
+ const safetyInterval = setInterval(() => {
199
+ if (state.pending.size === 0) return;
200
+ flushNow(state, root).catch(() => {});
201
+ }, batchFlushMs);
202
+ if (safetyInterval.unref) safetyInterval.unref();
203
+
204
+ watcher.on('error', () => {
205
+ state.stats.errors += 1;
206
+ });
207
+
208
+ return {
209
+ stop: async () => {
210
+ if (!state.running) return { stopped: false };
211
+ state.running = false;
212
+ try { watcher.close(); } catch { /* noop */ }
213
+ clearInterval(safetyInterval);
214
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
215
+ const finalFlush = await flushNow(state, root).catch(() => ({}));
216
+ return { stopped: true, finalFlush };
217
+ },
218
+ flush: async () => flushNow(state, root),
219
+ stats: () => ({ ...state.stats, enabled: true, pending: state.pending.size }),
220
+ isRunning: () => state.running,
221
+ };
222
+ };
223
+
224
+ export const _internal = { classifyChange, createWatcherState, flushNow, scheduleFlush };