devsmind-mcp 2.0.2 → 2.1.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/LICENSE +21 -21
- package/README.md +176 -73
- package/dist/cli/index.js +46 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/rule.js +31 -8
- package/dist/cli/rule.js.map +1 -1
- package/dist/cli/runner.d.ts +24 -0
- package/dist/cli/runner.js +502 -376
- package/dist/cli/runner.js.map +1 -1
- package/dist/db/database.d.ts +66 -0
- package/dist/db/database.js +329 -28
- package/dist/db/database.js.map +1 -1
- package/dist/db/edges.d.ts +42 -0
- package/dist/db/edges.js +162 -0
- package/dist/db/edges.js.map +1 -0
- package/dist/db/indexer.d.ts +3 -3
- package/dist/db/indexer.js +8 -8
- package/dist/db/indexer.js.map +1 -1
- package/dist/db/schema.js +39 -39
- package/dist/db/staging.d.ts +39 -0
- package/dist/db/staging.js +137 -0
- package/dist/db/staging.js.map +1 -0
- package/dist/mcp/server.js +168 -200
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/visualizer_2d.html +635 -635
- package/dist/mcp/visualizer_3d.html +613 -628
- package/dist/utils/ast.d.ts +38 -0
- package/dist/utils/ast.js +1079 -0
- package/dist/utils/ast.js.map +1 -0
- package/package.json +4 -4
package/dist/db/database.js
CHANGED
|
@@ -146,6 +146,72 @@ class DevMindDatabase {
|
|
|
146
146
|
console.warn('⚠️ SQLite VACUUM failed:', err);
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Wipes all nodes, connections, history, and system_meta from the DB, and clears
|
|
151
|
+
* the committed graph/ and history/ JSON directories on disk. Used by `--from-scratch`
|
|
152
|
+
* reindexing. This is destructive and irreversible from within the app — callers are
|
|
153
|
+
* responsible for confirming with the user first.
|
|
154
|
+
*/
|
|
155
|
+
resetAll() {
|
|
156
|
+
this.db.exec('DELETE FROM node_connections');
|
|
157
|
+
this.db.exec('DELETE FROM history');
|
|
158
|
+
this.db.exec('DELETE FROM nodes');
|
|
159
|
+
this.db.exec('DELETE FROM system_meta');
|
|
160
|
+
const workspaceRoot = path.dirname(this.dbPath);
|
|
161
|
+
for (const dir of ['graph', 'history']) {
|
|
162
|
+
const p = path.join(workspaceRoot, dir);
|
|
163
|
+
if (fs.existsSync(p)) {
|
|
164
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
fs.mkdirSync(p, { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
this.vacuum();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Deletes every connection from both the DB and (by rewriting each affected file's
|
|
172
|
+
* graph JSON) from disk. Used by `--edges-only` to rebuild the edge graph from
|
|
173
|
+
* scratch without touching nodes or history.
|
|
174
|
+
*/
|
|
175
|
+
clearAllConnections() {
|
|
176
|
+
const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes WHERE deprecated = 0').all();
|
|
177
|
+
const affectedFilePaths = new Set();
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
180
|
+
affectedFilePaths.add(p);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.db.exec('DELETE FROM node_connections');
|
|
184
|
+
for (const filePath of affectedFilePaths) {
|
|
185
|
+
this.writeGraphToDisk(filePath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Deletes only the OUTGOING connections of the given source nodes (and re-syncs the
|
|
190
|
+
* affected files' graph JSON). Used by repo-scoped `--edges-only` so that rebuilding
|
|
191
|
+
* one repo's edges doesn't wipe every other repo's edges.
|
|
192
|
+
*/
|
|
193
|
+
clearConnectionsForSources(nodeIds) {
|
|
194
|
+
if (nodeIds.length === 0)
|
|
195
|
+
return;
|
|
196
|
+
const affectedFilePaths = new Set();
|
|
197
|
+
const del = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
|
|
198
|
+
const getFp = this.db.prepare('SELECT file_path FROM nodes WHERE id = ?');
|
|
199
|
+
const tx = this.db.transaction((ids) => {
|
|
200
|
+
for (const id of ids) {
|
|
201
|
+
const row = getFp.get(id);
|
|
202
|
+
if (row?.file_path) {
|
|
203
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
204
|
+
affectedFilePaths.add(p);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
del.run(id);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
tx(nodeIds);
|
|
211
|
+
for (const filePath of affectedFilePaths) {
|
|
212
|
+
this.writeGraphToDisk(filePath);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
149
215
|
// --- Node Operations ---
|
|
150
216
|
upsertNode(node) {
|
|
151
217
|
const existing = this.getNode(node.id);
|
|
@@ -194,15 +260,26 @@ class DevMindDatabase {
|
|
|
194
260
|
deleteNode(id) {
|
|
195
261
|
const node = this.getNode(id);
|
|
196
262
|
const resolvedId = node ? node.id : id;
|
|
263
|
+
// Capture caller files, and delete the node's history JSONs, BEFORE the row (and its
|
|
264
|
+
// cascade-deleted history rows / edges) is gone. Without the JSON cleanup, syncFromDisk()
|
|
265
|
+
// would resurrect the node from its lingering history/[id].json on the next server start.
|
|
266
|
+
const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
|
|
267
|
+
this.deleteHistoryFilesForNode(resolvedId);
|
|
197
268
|
const stmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
|
|
198
269
|
stmt.run(resolvedId);
|
|
199
270
|
if (node && node.file_path) {
|
|
200
271
|
this.writeGraphToDisk(node.file_path);
|
|
201
272
|
}
|
|
273
|
+
for (const p of inboundSourceFiles) {
|
|
274
|
+
this.writeGraphToDisk(p);
|
|
275
|
+
}
|
|
202
276
|
}
|
|
203
277
|
deprecateNode(id) {
|
|
204
278
|
const node = this.getNode(id);
|
|
205
279
|
const resolvedId = node ? node.id : id;
|
|
280
|
+
// Capture the caller files BEFORE we delete the inbound edges — afterwards the join
|
|
281
|
+
// that finds them returns nothing.
|
|
282
|
+
const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
|
|
206
283
|
const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
|
|
207
284
|
const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
|
|
208
285
|
const tx = this.db.transaction(() => {
|
|
@@ -210,9 +287,14 @@ class DevMindDatabase {
|
|
|
210
287
|
deleteConnStmt.run(resolvedId, resolvedId);
|
|
211
288
|
});
|
|
212
289
|
tx();
|
|
290
|
+
// Rewrite the node's own file (now carrying deprecated:1) and every caller file (so their
|
|
291
|
+
// stale inbound edges don't resurrect the connection on the next syncFromDisk()).
|
|
213
292
|
if (node && node.file_path) {
|
|
214
293
|
this.writeGraphToDisk(node.file_path);
|
|
215
294
|
}
|
|
295
|
+
for (const p of inboundSourceFiles) {
|
|
296
|
+
this.writeGraphToDisk(p);
|
|
297
|
+
}
|
|
216
298
|
}
|
|
217
299
|
renameNode(oldId, newId, newName) {
|
|
218
300
|
const node = this.getNode(oldId);
|
|
@@ -247,17 +329,118 @@ class DevMindDatabase {
|
|
|
247
329
|
if (node.file_path) {
|
|
248
330
|
this.writeGraphToDisk(node.file_path);
|
|
249
331
|
}
|
|
332
|
+
// Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
|
|
333
|
+
// (which still reference oldId on disk). The DB was already repointed to newId above,
|
|
334
|
+
// so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
|
|
335
|
+
// the renamed node silently loses all its inbound ("used-by") edges.
|
|
336
|
+
this.rewriteInboundSourceFiles(newId);
|
|
337
|
+
// Keep the committed history/*.json files in sync with the rename. Without this,
|
|
338
|
+
// syncFromDisk() on the next server start would find the old node_id (which no
|
|
339
|
+
// longer exists in the DB) and re-insert it right back, undoing the rename.
|
|
340
|
+
const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
|
|
341
|
+
for (const row of historyIds) {
|
|
342
|
+
this.patchHistoryDiskIdentity(row.id, newId, name, node.type, node.file_path, node.signature);
|
|
343
|
+
}
|
|
250
344
|
}
|
|
251
345
|
finally {
|
|
252
346
|
this.db.pragma('foreign_keys = ON');
|
|
253
347
|
}
|
|
254
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Rewrites a history/[id].json file's identifying fields (node_id, node_metadata) in
|
|
351
|
+
* place, leaving code_snapshot/reasoning/timestamps untouched. Used after a rename so
|
|
352
|
+
* disk stays consistent with the DB without needing the full code_snapshot/reasoning
|
|
353
|
+
* to be re-passed in.
|
|
354
|
+
*/
|
|
355
|
+
patchHistoryDiskIdentity(historyId, nodeId, name, type, filePath, signature) {
|
|
356
|
+
try {
|
|
357
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
358
|
+
const filePathOnDisk = path.join(historyDir, `${historyId}.json`);
|
|
359
|
+
if (!fs.existsSync(filePathOnDisk))
|
|
360
|
+
return;
|
|
361
|
+
const data = JSON.parse(fs.readFileSync(filePathOnDisk, 'utf-8'));
|
|
362
|
+
data.node_id = nodeId;
|
|
363
|
+
data.node_metadata = {
|
|
364
|
+
name,
|
|
365
|
+
type,
|
|
366
|
+
file_path: this.toRepoRelativePath(filePath),
|
|
367
|
+
signature
|
|
368
|
+
};
|
|
369
|
+
fs.writeFileSync(filePathOnDisk, JSON.stringify(data, null, 2), 'utf-8');
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
console.warn('⚠️ SQLite warning: Failed to patch history JSON identity on disk:', err);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Collects the distinct file paths of every SOURCE node that has an OUTGOING edge pointing
|
|
377
|
+
* INTO `nodeId` (i.e. this node's "used-by" callers). Those inbound edges live on disk in the
|
|
378
|
+
* source nodes' files, not in the target's own file. Callers that DELETE the inbound edges
|
|
379
|
+
* (deprecate/delete) must call this BEFORE the deletion to capture the affected files;
|
|
380
|
+
* callers that merely repoint them (rename) can rewrite after the fact.
|
|
381
|
+
*/
|
|
382
|
+
collectInboundSourceFiles(nodeId) {
|
|
383
|
+
const rows = this.db.prepare(`
|
|
384
|
+
SELECT DISTINCT n.file_path AS file_path
|
|
385
|
+
FROM node_connections c JOIN nodes n ON n.id = c.source_node_id
|
|
386
|
+
WHERE c.target_node_id = ?
|
|
387
|
+
`).all(nodeId);
|
|
388
|
+
const files = new Set();
|
|
389
|
+
for (const row of rows) {
|
|
390
|
+
if (!row.file_path)
|
|
391
|
+
continue;
|
|
392
|
+
for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
393
|
+
files.add(p);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return Array.from(files);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Re-syncs each given source file's graph JSON. Used after the DB has been mutated so that
|
|
400
|
+
* syncFromDisk() won't reload a stale inbound ("used-by") edge on the next server start.
|
|
401
|
+
*/
|
|
402
|
+
rewriteInboundSourceFiles(nodeId) {
|
|
403
|
+
for (const p of this.collectInboundSourceFiles(nodeId)) {
|
|
404
|
+
this.writeGraphToDisk(p);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Deletes the committed history/[id].json files for every history record of `nodeId`.
|
|
409
|
+
* Used on HARD delete so that syncFromDisk()'s history pass can't resurrect the node
|
|
410
|
+
* (and its metadata) from a lingering JSON on the next server start. Reads the history
|
|
411
|
+
* ids BEFORE the DB rows are removed, so call this while they still exist (or pass ids in).
|
|
412
|
+
*/
|
|
413
|
+
deleteHistoryFilesForNode(nodeId) {
|
|
414
|
+
try {
|
|
415
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
416
|
+
const rows = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(nodeId);
|
|
417
|
+
for (const row of rows) {
|
|
418
|
+
const filePath = path.join(historyDir, `${row.id}.json`);
|
|
419
|
+
if (fs.existsSync(filePath))
|
|
420
|
+
fs.unlinkSync(filePath);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
console.warn('⚠️ SQLite warning: Failed to delete history JSON(s) on disk:', err);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
255
427
|
// --- Connection Operations ---
|
|
256
428
|
addConnection(sourceNodeId, targetNodeId) {
|
|
257
429
|
const srcNode = this.getNode(sourceNodeId);
|
|
258
430
|
const tgtNode = this.getNode(targetNodeId);
|
|
259
431
|
const resolvedSrc = srcNode ? srcNode.id : sourceNodeId;
|
|
260
432
|
const resolvedTgt = tgtNode ? tgtNode.id : targetNodeId;
|
|
433
|
+
// The on-disk graph format is node-anchored: each file's JSON lists its nodes and their
|
|
434
|
+
// OUTGOING edges. An edge whose SOURCE node doesn't exist has nowhere to be written on
|
|
435
|
+
// disk, so it would live only in brain.db and be silently dropped by syncFromDisk() on the
|
|
436
|
+
// next server start. Rather than leak that DB-only orphan, refuse the edge and tell the
|
|
437
|
+
// caller to add the source node first (the two-phase indexing protocol already does this).
|
|
438
|
+
if (!srcNode) {
|
|
439
|
+
console.warn(`⚠️ DevsMind: connection skipped — source node "${sourceNodeId}" does not exist in ` +
|
|
440
|
+
`the graph. Add it (stage_change / update_history) before connecting it, otherwise the edge ` +
|
|
441
|
+
`cannot be persisted to disk and would not survive a restart.`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
261
444
|
this.db.pragma('foreign_keys = OFF');
|
|
262
445
|
try {
|
|
263
446
|
const stmt = this.db.prepare(`
|
|
@@ -265,7 +448,7 @@ class DevMindDatabase {
|
|
|
265
448
|
VALUES (?, ?)
|
|
266
449
|
`);
|
|
267
450
|
stmt.run(resolvedSrc, resolvedTgt);
|
|
268
|
-
if (srcNode
|
|
451
|
+
if (srcNode.file_path) {
|
|
269
452
|
this.writeGraphToDisk(srcNode.file_path);
|
|
270
453
|
}
|
|
271
454
|
}
|
|
@@ -350,11 +533,18 @@ class DevMindDatabase {
|
|
|
350
533
|
const rows = stmt.all(resolvedId);
|
|
351
534
|
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
352
535
|
}
|
|
536
|
+
/** Distinct source node ids of edges pointing INTO this node (its "used-by" callers). */
|
|
537
|
+
getInboundSources(nodeId) {
|
|
538
|
+
const rows = this.db
|
|
539
|
+
.prepare('SELECT DISTINCT source_node_id FROM node_connections WHERE target_node_id = ?')
|
|
540
|
+
.all(nodeId);
|
|
541
|
+
return rows.map(r => r.source_node_id);
|
|
542
|
+
}
|
|
353
543
|
getLatestCode(nodeId) {
|
|
354
544
|
const node = this.getNode(nodeId);
|
|
355
545
|
const resolvedId = node ? node.id : nodeId;
|
|
356
546
|
const history = this.getLatestHistory(resolvedId);
|
|
357
|
-
if (!history)
|
|
547
|
+
if (!history || !history.code_snapshot || history.code_snapshot.trim() === '')
|
|
358
548
|
return null;
|
|
359
549
|
return {
|
|
360
550
|
updated_at: history.updated_at,
|
|
@@ -447,10 +637,10 @@ class DevMindDatabase {
|
|
|
447
637
|
if (diffMs < 3600000) {
|
|
448
638
|
const updateStmt = this.db.prepare(`
|
|
449
639
|
UPDATE history
|
|
450
|
-
SET code_snapshot = '', reasoning =
|
|
640
|
+
SET code_snapshot = '', reasoning = ?, updated_at = ?
|
|
451
641
|
WHERE id = ?
|
|
452
642
|
`);
|
|
453
|
-
updateStmt.run(nowStr, latest.id);
|
|
643
|
+
updateStmt.run(formattedReasoning, nowStr, latest.id);
|
|
454
644
|
// Write/Update on disk
|
|
455
645
|
this.writeHistoryToDisk(latest.id, resolvedId, latest.session_id, latest.created_at, nowStr, code_snapshot, formattedReasoning);
|
|
456
646
|
return {
|
|
@@ -466,9 +656,9 @@ class DevMindDatabase {
|
|
|
466
656
|
const sessionId = params.session_id || crypto.randomUUID();
|
|
467
657
|
const insertStmt = this.db.prepare(`
|
|
468
658
|
INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
|
|
469
|
-
VALUES (?, ?, ?, ?, ?, '',
|
|
659
|
+
VALUES (?, ?, ?, ?, ?, '', ?)
|
|
470
660
|
`);
|
|
471
|
-
insertStmt.run(newId, resolvedId, sessionId, nowStr, nowStr);
|
|
661
|
+
insertStmt.run(newId, resolvedId, sessionId, nowStr, nowStr, formattedReasoning);
|
|
472
662
|
// Write to disk
|
|
473
663
|
this.writeHistoryToDisk(newId, resolvedId, sessionId, nowStr, nowStr, code_snapshot, formattedReasoning);
|
|
474
664
|
return {
|
|
@@ -494,13 +684,18 @@ class DevMindDatabase {
|
|
|
494
684
|
}
|
|
495
685
|
getRecentChanges(hours = 24, analyzeImpact = true) {
|
|
496
686
|
const stmt = this.db.prepare(`
|
|
497
|
-
SELECT h.node_id, n.name as node_name, n.file_path, h.updated_at, h.reasoning
|
|
687
|
+
SELECT h.id, h.node_id, n.name as node_name, n.file_path, h.updated_at, h.reasoning
|
|
498
688
|
FROM history h
|
|
499
689
|
JOIN nodes n ON h.node_id = n.id
|
|
500
690
|
WHERE h.updated_at >= datetime('now', ?)
|
|
501
691
|
ORDER BY h.updated_at DESC
|
|
502
692
|
`);
|
|
503
693
|
const recentChanges = stmt.all(`-${hours} hours`);
|
|
694
|
+
for (const change of recentChanges) {
|
|
695
|
+
const populated = this.populateHistoryFromDisk({ id: change.id, reasoning: change.reasoning });
|
|
696
|
+
change.reasoning = populated.reasoning;
|
|
697
|
+
delete change.id;
|
|
698
|
+
}
|
|
504
699
|
if (!analyzeImpact) {
|
|
505
700
|
return recentChanges;
|
|
506
701
|
}
|
|
@@ -556,6 +751,73 @@ class DevMindDatabase {
|
|
|
556
751
|
const wildcard = `%Decision: %${query}%`;
|
|
557
752
|
return stmt.all(wildcard);
|
|
558
753
|
}
|
|
754
|
+
searchCode(params) {
|
|
755
|
+
const { query, is_regex = false, case_insensitive = true } = params;
|
|
756
|
+
const historyDir = path.join(path.dirname(this.dbPath), 'history');
|
|
757
|
+
const stmt = this.db.prepare(`
|
|
758
|
+
SELECT h.id, n.id AS node_id, n.name AS node_name, n.file_path
|
|
759
|
+
FROM nodes n
|
|
760
|
+
JOIN history h ON h.node_id = n.id
|
|
761
|
+
WHERE n.deprecated = 0
|
|
762
|
+
AND h.id = (
|
|
763
|
+
SELECT id FROM history
|
|
764
|
+
WHERE node_id = n.id
|
|
765
|
+
ORDER BY updated_at DESC
|
|
766
|
+
LIMIT 1
|
|
767
|
+
)
|
|
768
|
+
`);
|
|
769
|
+
const rows = stmt.all();
|
|
770
|
+
let matcher;
|
|
771
|
+
if (is_regex) {
|
|
772
|
+
try {
|
|
773
|
+
matcher = new RegExp(query, case_insensitive ? 'i' : '');
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
throw new Error(`Invalid regex pattern: ${err.message}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
781
|
+
matcher = new RegExp(escaped, case_insensitive ? 'i' : '');
|
|
782
|
+
}
|
|
783
|
+
const results = [];
|
|
784
|
+
for (const row of rows) {
|
|
785
|
+
const filePath = path.join(historyDir, `${row.id}.json`);
|
|
786
|
+
if (!fs.existsSync(filePath))
|
|
787
|
+
continue;
|
|
788
|
+
try {
|
|
789
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
790
|
+
const code = data.code_snapshot || '';
|
|
791
|
+
if (!code)
|
|
792
|
+
continue;
|
|
793
|
+
const lines = code.split('\n');
|
|
794
|
+
const nodeMatches = [];
|
|
795
|
+
lines.forEach((line, idx) => {
|
|
796
|
+
if (matcher.test(line)) {
|
|
797
|
+
nodeMatches.push({
|
|
798
|
+
line_number: idx + 1,
|
|
799
|
+
line_content: line
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
if (nodeMatches.length > 0) {
|
|
804
|
+
results.push({
|
|
805
|
+
node_id: row.node_id,
|
|
806
|
+
node_name: row.node_name,
|
|
807
|
+
file_path: row.file_path,
|
|
808
|
+
matches: nodeMatches,
|
|
809
|
+
match_count: nodeMatches.length,
|
|
810
|
+
total_lines: lines.length,
|
|
811
|
+
match_ratio: parseFloat((nodeMatches.length / lines.length).toFixed(4))
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// Skip corrupted or unreadable history files
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return results.sort((a, b) => b.match_count - a.match_count);
|
|
820
|
+
}
|
|
559
821
|
getOrphanedNodes() {
|
|
560
822
|
const stmt = this.db.prepare(`
|
|
561
823
|
SELECT * FROM nodes
|
|
@@ -592,11 +854,7 @@ class DevMindDatabase {
|
|
|
592
854
|
getAllHistory() {
|
|
593
855
|
const stmt = this.db.prepare('SELECT * FROM history ORDER BY updated_at DESC');
|
|
594
856
|
const rows = stmt.all();
|
|
595
|
-
return rows.map(row => (
|
|
596
|
-
...row,
|
|
597
|
-
code_snapshot: decompressText(row.code_snapshot),
|
|
598
|
-
reasoning: decompressText(row.reasoning)
|
|
599
|
-
}));
|
|
857
|
+
return rows.map(row => this.populateHistoryFromDisk(row));
|
|
600
858
|
}
|
|
601
859
|
pruneSpuriousNodes(workspaceRoot) {
|
|
602
860
|
const spuriousNames = new Set([
|
|
@@ -612,6 +870,7 @@ class DevMindDatabase {
|
|
|
612
870
|
const candidates = stmt.all();
|
|
613
871
|
const idsToDelete = [];
|
|
614
872
|
const namesDeleted = [];
|
|
873
|
+
const affectedFilePaths = new Set();
|
|
615
874
|
for (const node of candidates) {
|
|
616
875
|
const lowerName = node.name.toLowerCase();
|
|
617
876
|
// 1. Check if name is in the spurious list
|
|
@@ -635,9 +894,24 @@ class DevMindDatabase {
|
|
|
635
894
|
if (isSpurious || fileMissing) {
|
|
636
895
|
idsToDelete.push(node.id);
|
|
637
896
|
namesDeleted.push(`${node.name} (${node.id})`);
|
|
897
|
+
if (node.file_path) {
|
|
898
|
+
for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
899
|
+
affectedFilePaths.add(p);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
638
902
|
}
|
|
639
903
|
}
|
|
640
904
|
if (idsToDelete.length > 0) {
|
|
905
|
+
// Capture caller files and drop the pruned nodes' history JSONs BEFORE the tx: the
|
|
906
|
+
// inbound-edge join goes empty once edges are deleted, and the history rows (whose ids
|
|
907
|
+
// name the JSON files) are removed by deleteHistoryStmt. Without the JSON cleanup, a
|
|
908
|
+
// pruned node would resurrect from its history/[id].json on the next syncFromDisk().
|
|
909
|
+
for (const id of idsToDelete) {
|
|
910
|
+
for (const p of this.collectInboundSourceFiles(id)) {
|
|
911
|
+
affectedFilePaths.add(p);
|
|
912
|
+
}
|
|
913
|
+
this.deleteHistoryFilesForNode(id);
|
|
914
|
+
}
|
|
641
915
|
const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
|
|
642
916
|
const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
|
|
643
917
|
const deleteHistoryStmt = this.db.prepare('DELETE FROM history WHERE node_id = ?');
|
|
@@ -649,6 +923,12 @@ class DevMindDatabase {
|
|
|
649
923
|
}
|
|
650
924
|
});
|
|
651
925
|
deprecateTx(idsToDelete);
|
|
926
|
+
// Keep the committed graph/*.json files in sync with the DB. Pruned nodes are written
|
|
927
|
+
// with deprecated:1 (so they don't come back as active), and every caller file is
|
|
928
|
+
// rewritten so its stale inbound edge doesn't resurrect the connection on next start.
|
|
929
|
+
for (const filePath of affectedFilePaths) {
|
|
930
|
+
this.writeGraphToDisk(filePath);
|
|
931
|
+
}
|
|
652
932
|
}
|
|
653
933
|
return {
|
|
654
934
|
prunedCount: idsToDelete.length,
|
|
@@ -715,7 +995,7 @@ class DevMindDatabase {
|
|
|
715
995
|
const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
|
|
716
996
|
if (repoPath) {
|
|
717
997
|
const normalizedRepoPath = path.resolve(repoPath).replace(/\\/g, '/');
|
|
718
|
-
if (abs.startsWith(normalizedRepoPath)) {
|
|
998
|
+
if (abs === normalizedRepoPath || abs.startsWith(normalizedRepoPath + '/')) {
|
|
719
999
|
const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
|
|
720
1000
|
return `{${repo.name}}/${relative}`;
|
|
721
1001
|
}
|
|
@@ -758,7 +1038,7 @@ class DevMindDatabase {
|
|
|
758
1038
|
`);
|
|
759
1039
|
const insertHistoryStmt = this.db.prepare(`
|
|
760
1040
|
INSERT INTO history (id, node_id, session_id, created_at, updated_at, code_snapshot, reasoning)
|
|
761
|
-
VALUES (?, ?, ?, ?, ?, '',
|
|
1041
|
+
VALUES (?, ?, ?, ?, ?, '', ?)
|
|
762
1042
|
`);
|
|
763
1043
|
const syncHistoryTx = this.db.transaction(() => {
|
|
764
1044
|
for (const file of files) {
|
|
@@ -772,7 +1052,10 @@ class DevMindDatabase {
|
|
|
772
1052
|
if (!checkNodeStmt.get(data.node_id) && data.node_metadata) {
|
|
773
1053
|
insertNodeStmt.run(data.node_id, data.node_metadata.type, data.node_metadata.name, this.toAbsolutePath(data.node_metadata.file_path), data.node_metadata.signature);
|
|
774
1054
|
}
|
|
775
|
-
|
|
1055
|
+
const formattedReasoning = typeof data.reasoning === 'string'
|
|
1056
|
+
? data.reasoning
|
|
1057
|
+
: formatReasoning(data.reasoning || '');
|
|
1058
|
+
insertHistoryStmt.run(data.id, data.node_id, data.session_id, data.created_at, data.updated_at, formattedReasoning);
|
|
776
1059
|
}
|
|
777
1060
|
catch (err) {
|
|
778
1061
|
// ignore
|
|
@@ -801,11 +1084,11 @@ class DevMindDatabase {
|
|
|
801
1084
|
};
|
|
802
1085
|
const jsonFiles = walkSync(graphDir);
|
|
803
1086
|
if (jsonFiles.length > 0) {
|
|
804
|
-
const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?
|
|
1087
|
+
const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
|
|
805
1088
|
const deleteConnsForNodesStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
|
|
806
1089
|
const insertNodeStmt = this.db.prepare(`
|
|
807
1090
|
INSERT OR REPLACE INTO nodes (id, type, name, file_path, signature, deprecated)
|
|
808
|
-
VALUES (?, ?, ?, ?, ?,
|
|
1091
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
809
1092
|
`);
|
|
810
1093
|
const insertConnStmt = this.db.prepare(`
|
|
811
1094
|
INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
|
|
@@ -820,16 +1103,17 @@ class DevMindDatabase {
|
|
|
820
1103
|
continue;
|
|
821
1104
|
const fileRelPath = data.file_path; // E.g. "{harrir-web}/app/page.tsx" or relative path
|
|
822
1105
|
const fileAbsPath = this.toAbsolutePath(fileRelPath);
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
//
|
|
827
|
-
|
|
1106
|
+
// Clean existing nodes in SQLite for this file BEFORE re-inserting from
|
|
1107
|
+
// the JSON (so removed/renamed symbols are cleared). Match ONLY the exact
|
|
1108
|
+
// absolute path: the previous suffix `LIKE '%<relpath>'` matched the same
|
|
1109
|
+
// relative path in EVERY repo, so syncing one repo's file deleted another
|
|
1110
|
+
// repo's same-named file nodes (cross-repo data loss).
|
|
1111
|
+
deleteNodesForFileStmt.run(fileAbsPath);
|
|
828
1112
|
// Insert nodes
|
|
829
1113
|
const nodes = data.nodes || [];
|
|
830
1114
|
for (const n of nodes) {
|
|
831
1115
|
deleteConnsForNodesStmt.run(n.id);
|
|
832
|
-
insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null);
|
|
1116
|
+
insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null, n.deprecated ? 1 : 0);
|
|
833
1117
|
}
|
|
834
1118
|
// Insert connections
|
|
835
1119
|
const connections = data.connections || [];
|
|
@@ -853,6 +1137,10 @@ class DevMindDatabase {
|
|
|
853
1137
|
this.db.pragma('foreign_keys = ON');
|
|
854
1138
|
}
|
|
855
1139
|
}
|
|
1140
|
+
/** Escape LIKE metacharacters so a path is matched literally (use with ESCAPE '\\'). */
|
|
1141
|
+
likeEscape(s) {
|
|
1142
|
+
return s.replace(/[\\%_]/g, ch => '\\' + ch);
|
|
1143
|
+
}
|
|
856
1144
|
writeGraphToDisk(filePath) {
|
|
857
1145
|
try {
|
|
858
1146
|
if (!filePath)
|
|
@@ -860,17 +1148,29 @@ class DevMindDatabase {
|
|
|
860
1148
|
const workspaceRoot = path.dirname(this.dbPath);
|
|
861
1149
|
// Clean/resolve the file path
|
|
862
1150
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
|
|
863
|
-
const relPath = path.relative(workspaceRoot, absPath).replace(/\\/g, '/');
|
|
864
1151
|
const repoRelPath = this.toRepoRelativePath(absPath);
|
|
865
1152
|
// E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
|
|
866
1153
|
const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
|
|
867
1154
|
const graphJsonPath = path.join(workspaceRoot, 'graph', diskRelPath);
|
|
868
|
-
// Get all
|
|
1155
|
+
// Get all nodes in this file (active AND deprecated). A node's file_path is either
|
|
1156
|
+
// exactly this absolute path, or (for the rare node spanning multiple files) a ", "-joined
|
|
1157
|
+
// list containing it. We anchor on the FULL absolute path with ", " boundaries and escape
|
|
1158
|
+
// LIKE metacharacters — the old `%<relpath>%` / `%<relpath>` matched short relative
|
|
1159
|
+
// suffixes shared across repos, pulling in (and later corrupting) other repos' nodes.
|
|
1160
|
+
// Deprecated nodes are INCLUDED (and carry deprecated:1 in the JSON) so that deprecation
|
|
1161
|
+
// is durable across a syncFromDisk() restart and propagates to teammates via git —
|
|
1162
|
+
// otherwise the node's history JSON would resurrect it as active on the next start.
|
|
1163
|
+
const absEsc = this.likeEscape(absPath);
|
|
869
1164
|
const stmtNodes = this.db.prepare(`
|
|
870
1165
|
SELECT * FROM nodes
|
|
871
|
-
WHERE
|
|
1166
|
+
WHERE (
|
|
1167
|
+
file_path = ? OR
|
|
1168
|
+
file_path LIKE ? ESCAPE '\\' OR
|
|
1169
|
+
file_path LIKE ? ESCAPE '\\' OR
|
|
1170
|
+
file_path LIKE ? ESCAPE '\\'
|
|
1171
|
+
)
|
|
872
1172
|
`);
|
|
873
|
-
const nodes = stmtNodes.all(absPath,
|
|
1173
|
+
const nodes = stmtNodes.all(absPath, `${absEsc}, %`, `%, ${absEsc}`, `%, ${absEsc}, %`);
|
|
874
1174
|
if (nodes.length === 0) {
|
|
875
1175
|
// If no nodes left, delete the JSON file if it exists
|
|
876
1176
|
if (fs.existsSync(graphJsonPath)) {
|
|
@@ -898,7 +1198,8 @@ class DevMindDatabase {
|
|
|
898
1198
|
id: n.id,
|
|
899
1199
|
name: n.name,
|
|
900
1200
|
type: n.type,
|
|
901
|
-
signature: n.signature
|
|
1201
|
+
signature: n.signature,
|
|
1202
|
+
deprecated: n.deprecated ? 1 : 0
|
|
902
1203
|
})),
|
|
903
1204
|
connections: connections.map(c => ({
|
|
904
1205
|
source_node_id: c.source_node_id,
|