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.
- package/README.md +24 -13
- package/package.json +5 -2
- package/server.json +2 -2
- package/src/embeddings/embedder.js +28 -0
- package/src/embeddings/hashing.js +77 -0
- package/src/embeddings/index.js +91 -0
- package/src/embeddings/tokenize.js +46 -0
- package/src/global-memory/scrub.js +46 -0
- package/src/global-memory/store.js +375 -0
- package/src/index-watcher.js +224 -0
- package/src/index.js +91 -9
- package/src/orchestration/base-orchestrator.js +37 -1
- package/src/parsers/registry.js +26 -0
- package/src/playbooks/builtin/debug-flake.yaml +17 -0
- package/src/playbooks/builtin/doc-sync.yaml +16 -0
- package/src/playbooks/builtin/preflight-merge.yaml +20 -0
- package/src/playbooks/builtin/ramp-up.yaml +14 -0
- package/src/playbooks/builtin/refactor-safe.yaml +18 -0
- package/src/playbooks/loader.js +123 -0
- package/src/playbooks/runner.js +182 -0
- package/src/playbooks/yaml-mini.js +162 -0
- package/src/server.js +108 -13
- package/src/storage/sqlite.js +75 -1
- package/src/task-runner.js +4 -0
- package/src/tools/global-memory.js +110 -0
- package/src/tools/smart-context.js +18 -4
- package/src/tools/smart-playbook.js +63 -0
- package/src/tools/smart-read-batch.js +26 -3
- package/src/tools/smart-read.js +128 -15
- package/src/tools/smart-search.js +692 -55
- package/src/tools/smart-status.js +13 -0
- package/src/tools/smart-turn.js +88 -4
- package/src/turn/next-actions.js +4 -1
- package/src/utils/task-budget.js +116 -0
|
@@ -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 };
|