devsmind-mcp 2.0.4 → 2.1.1

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.
@@ -45,6 +45,7 @@ const path = __importStar(require("path"));
45
45
  const zlib = __importStar(require("zlib"));
46
46
  const schema_1 = require("./schema");
47
47
  const config_1 = require("../utils/config");
48
+ const ast_1 = require("../utils/ast");
48
49
  function compressText(text) {
49
50
  return zlib.deflateSync(Buffer.from(text, 'utf-8'));
50
51
  }
@@ -146,6 +147,72 @@ class DevMindDatabase {
146
147
  console.warn('⚠️ SQLite VACUUM failed:', err);
147
148
  }
148
149
  }
150
+ /**
151
+ * Wipes all nodes, connections, history, and system_meta from the DB, and clears
152
+ * the committed graph/ and history/ JSON directories on disk. Used by `--from-scratch`
153
+ * reindexing. This is destructive and irreversible from within the app — callers are
154
+ * responsible for confirming with the user first.
155
+ */
156
+ resetAll() {
157
+ this.db.exec('DELETE FROM node_connections');
158
+ this.db.exec('DELETE FROM history');
159
+ this.db.exec('DELETE FROM nodes');
160
+ this.db.exec('DELETE FROM system_meta');
161
+ const workspaceRoot = path.dirname(this.dbPath);
162
+ for (const dir of ['graph', 'history']) {
163
+ const p = path.join(workspaceRoot, dir);
164
+ if (fs.existsSync(p)) {
165
+ fs.rmSync(p, { recursive: true, force: true });
166
+ }
167
+ fs.mkdirSync(p, { recursive: true });
168
+ }
169
+ this.vacuum();
170
+ }
171
+ /**
172
+ * Deletes every connection from both the DB and (by rewriting each affected file's
173
+ * graph JSON) from disk. Used by `--edges-only` to rebuild the edge graph from
174
+ * scratch without touching nodes or history.
175
+ */
176
+ clearAllConnections() {
177
+ const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes WHERE deprecated = 0').all();
178
+ const affectedFilePaths = new Set();
179
+ for (const row of rows) {
180
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
181
+ affectedFilePaths.add(p);
182
+ }
183
+ }
184
+ this.db.exec('DELETE FROM node_connections');
185
+ for (const filePath of affectedFilePaths) {
186
+ this.writeGraphToDisk(filePath);
187
+ }
188
+ }
189
+ /**
190
+ * Deletes only the OUTGOING connections of the given source nodes (and re-syncs the
191
+ * affected files' graph JSON). Used by repo-scoped `--edges-only` so that rebuilding
192
+ * one repo's edges doesn't wipe every other repo's edges.
193
+ */
194
+ clearConnectionsForSources(nodeIds) {
195
+ if (nodeIds.length === 0)
196
+ return;
197
+ const affectedFilePaths = new Set();
198
+ const del = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
199
+ const getFp = this.db.prepare('SELECT file_path FROM nodes WHERE id = ?');
200
+ const tx = this.db.transaction((ids) => {
201
+ for (const id of ids) {
202
+ const row = getFp.get(id);
203
+ if (row?.file_path) {
204
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
205
+ affectedFilePaths.add(p);
206
+ }
207
+ }
208
+ del.run(id);
209
+ }
210
+ });
211
+ tx(nodeIds);
212
+ for (const filePath of affectedFilePaths) {
213
+ this.writeGraphToDisk(filePath);
214
+ }
215
+ }
149
216
  // --- Node Operations ---
150
217
  upsertNode(node) {
151
218
  const existing = this.getNode(node.id);
@@ -194,15 +261,26 @@ class DevMindDatabase {
194
261
  deleteNode(id) {
195
262
  const node = this.getNode(id);
196
263
  const resolvedId = node ? node.id : id;
264
+ // Capture caller files, and delete the node's history JSONs, BEFORE the row (and its
265
+ // cascade-deleted history rows / edges) is gone. Without the JSON cleanup, syncFromDisk()
266
+ // would resurrect the node from its lingering history/[id].json on the next server start.
267
+ const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
268
+ this.deleteHistoryFilesForNode(resolvedId);
197
269
  const stmt = this.db.prepare('DELETE FROM nodes WHERE id = ?');
198
270
  stmt.run(resolvedId);
199
271
  if (node && node.file_path) {
200
272
  this.writeGraphToDisk(node.file_path);
201
273
  }
274
+ for (const p of inboundSourceFiles) {
275
+ this.writeGraphToDisk(p);
276
+ }
202
277
  }
203
278
  deprecateNode(id) {
204
279
  const node = this.getNode(id);
205
280
  const resolvedId = node ? node.id : id;
281
+ // Capture the caller files BEFORE we delete the inbound edges — afterwards the join
282
+ // that finds them returns nothing.
283
+ const inboundSourceFiles = this.collectInboundSourceFiles(resolvedId);
206
284
  const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
207
285
  const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
208
286
  const tx = this.db.transaction(() => {
@@ -210,9 +288,14 @@ class DevMindDatabase {
210
288
  deleteConnStmt.run(resolvedId, resolvedId);
211
289
  });
212
290
  tx();
291
+ // Rewrite the node's own file (now carrying deprecated:1) and every caller file (so their
292
+ // stale inbound edges don't resurrect the connection on the next syncFromDisk()).
213
293
  if (node && node.file_path) {
214
294
  this.writeGraphToDisk(node.file_path);
215
295
  }
296
+ for (const p of inboundSourceFiles) {
297
+ this.writeGraphToDisk(p);
298
+ }
216
299
  }
217
300
  renameNode(oldId, newId, newName) {
218
301
  const node = this.getNode(oldId);
@@ -247,17 +330,118 @@ class DevMindDatabase {
247
330
  if (node.file_path) {
248
331
  this.writeGraphToDisk(node.file_path);
249
332
  }
333
+ // Edges pointing INTO the renamed node live in the SOURCE nodes' files' graph JSONs
334
+ // (which still reference oldId on disk). The DB was already repointed to newId above,
335
+ // so rewrite each such file — otherwise syncFromDisk reloads the stale oldId edge and
336
+ // the renamed node silently loses all its inbound ("used-by") edges.
337
+ this.rewriteInboundSourceFiles(newId);
338
+ // Keep the committed history/*.json files in sync with the rename. Without this,
339
+ // syncFromDisk() on the next server start would find the old node_id (which no
340
+ // longer exists in the DB) and re-insert it right back, undoing the rename.
341
+ const historyIds = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(newId);
342
+ for (const row of historyIds) {
343
+ this.patchHistoryDiskIdentity(row.id, newId, name, node.type, node.file_path, node.signature);
344
+ }
250
345
  }
251
346
  finally {
252
347
  this.db.pragma('foreign_keys = ON');
253
348
  }
254
349
  }
350
+ /**
351
+ * Rewrites a history/[id].json file's identifying fields (node_id, node_metadata) in
352
+ * place, leaving code_snapshot/reasoning/timestamps untouched. Used after a rename so
353
+ * disk stays consistent with the DB without needing the full code_snapshot/reasoning
354
+ * to be re-passed in.
355
+ */
356
+ patchHistoryDiskIdentity(historyId, nodeId, name, type, filePath, signature) {
357
+ try {
358
+ const historyDir = path.join(path.dirname(this.dbPath), 'history');
359
+ const filePathOnDisk = path.join(historyDir, `${historyId}.json`);
360
+ if (!fs.existsSync(filePathOnDisk))
361
+ return;
362
+ const data = JSON.parse(fs.readFileSync(filePathOnDisk, 'utf-8'));
363
+ data.node_id = nodeId;
364
+ data.node_metadata = {
365
+ name,
366
+ type,
367
+ file_path: this.toRepoRelativePath(filePath),
368
+ signature
369
+ };
370
+ fs.writeFileSync(filePathOnDisk, JSON.stringify(data, null, 2), 'utf-8');
371
+ }
372
+ catch (err) {
373
+ console.warn('⚠️ SQLite warning: Failed to patch history JSON identity on disk:', err);
374
+ }
375
+ }
376
+ /**
377
+ * Collects the distinct file paths of every SOURCE node that has an OUTGOING edge pointing
378
+ * INTO `nodeId` (i.e. this node's "used-by" callers). Those inbound edges live on disk in the
379
+ * source nodes' files, not in the target's own file. Callers that DELETE the inbound edges
380
+ * (deprecate/delete) must call this BEFORE the deletion to capture the affected files;
381
+ * callers that merely repoint them (rename) can rewrite after the fact.
382
+ */
383
+ collectInboundSourceFiles(nodeId) {
384
+ const rows = this.db.prepare(`
385
+ SELECT DISTINCT n.file_path AS file_path
386
+ FROM node_connections c JOIN nodes n ON n.id = c.source_node_id
387
+ WHERE c.target_node_id = ?
388
+ `).all(nodeId);
389
+ const files = new Set();
390
+ for (const row of rows) {
391
+ if (!row.file_path)
392
+ continue;
393
+ for (const p of row.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
394
+ files.add(p);
395
+ }
396
+ }
397
+ return Array.from(files);
398
+ }
399
+ /**
400
+ * Re-syncs each given source file's graph JSON. Used after the DB has been mutated so that
401
+ * syncFromDisk() won't reload a stale inbound ("used-by") edge on the next server start.
402
+ */
403
+ rewriteInboundSourceFiles(nodeId) {
404
+ for (const p of this.collectInboundSourceFiles(nodeId)) {
405
+ this.writeGraphToDisk(p);
406
+ }
407
+ }
408
+ /**
409
+ * Deletes the committed history/[id].json files for every history record of `nodeId`.
410
+ * Used on HARD delete so that syncFromDisk()'s history pass can't resurrect the node
411
+ * (and its metadata) from a lingering JSON on the next server start. Reads the history
412
+ * ids BEFORE the DB rows are removed, so call this while they still exist (or pass ids in).
413
+ */
414
+ deleteHistoryFilesForNode(nodeId) {
415
+ try {
416
+ const historyDir = path.join(path.dirname(this.dbPath), 'history');
417
+ const rows = this.db.prepare('SELECT id FROM history WHERE node_id = ?').all(nodeId);
418
+ for (const row of rows) {
419
+ const filePath = path.join(historyDir, `${row.id}.json`);
420
+ if (fs.existsSync(filePath))
421
+ fs.unlinkSync(filePath);
422
+ }
423
+ }
424
+ catch (err) {
425
+ console.warn('⚠️ SQLite warning: Failed to delete history JSON(s) on disk:', err);
426
+ }
427
+ }
255
428
  // --- Connection Operations ---
256
429
  addConnection(sourceNodeId, targetNodeId) {
257
430
  const srcNode = this.getNode(sourceNodeId);
258
431
  const tgtNode = this.getNode(targetNodeId);
259
432
  const resolvedSrc = srcNode ? srcNode.id : sourceNodeId;
260
433
  const resolvedTgt = tgtNode ? tgtNode.id : targetNodeId;
434
+ // The on-disk graph format is node-anchored: each file's JSON lists its nodes and their
435
+ // OUTGOING edges. An edge whose SOURCE node doesn't exist has nowhere to be written on
436
+ // disk, so it would live only in brain.db and be silently dropped by syncFromDisk() on the
437
+ // next server start. Rather than leak that DB-only orphan, refuse the edge and tell the
438
+ // caller to add the source node first (the two-phase indexing protocol already does this).
439
+ if (!srcNode) {
440
+ console.warn(`⚠️ DevsMind: connection skipped — source node "${sourceNodeId}" does not exist in ` +
441
+ `the graph. Add it (stage_change / update_history) before connecting it, otherwise the edge ` +
442
+ `cannot be persisted to disk and would not survive a restart.`);
443
+ return;
444
+ }
261
445
  this.db.pragma('foreign_keys = OFF');
262
446
  try {
263
447
  const stmt = this.db.prepare(`
@@ -265,7 +449,7 @@ class DevMindDatabase {
265
449
  VALUES (?, ?)
266
450
  `);
267
451
  stmt.run(resolvedSrc, resolvedTgt);
268
- if (srcNode && srcNode.file_path) {
452
+ if (srcNode.file_path) {
269
453
  this.writeGraphToDisk(srcNode.file_path);
270
454
  }
271
455
  }
@@ -350,6 +534,13 @@ class DevMindDatabase {
350
534
  const rows = stmt.all(resolvedId);
351
535
  return rows.map(row => this.populateHistoryFromDisk(row));
352
536
  }
537
+ /** Distinct source node ids of edges pointing INTO this node (its "used-by" callers). */
538
+ getInboundSources(nodeId) {
539
+ const rows = this.db
540
+ .prepare('SELECT DISTINCT source_node_id FROM node_connections WHERE target_node_id = ?')
541
+ .all(nodeId);
542
+ return rows.map(r => r.source_node_id);
543
+ }
353
544
  getLatestCode(nodeId) {
354
545
  const node = this.getNode(nodeId);
355
546
  const resolvedId = node ? node.id : nodeId;
@@ -361,10 +552,74 @@ class DevMindDatabase {
361
552
  code_snapshot: history.code_snapshot
362
553
  };
363
554
  }
364
- getGraph(nodeId, maxDepth = 6) {
555
+ /**
556
+ * Parse a node's CURRENT source straight off disk via the AST, bypassing the stored snapshot.
557
+ * `nodes.file_path` is already absolute, and may be a ", "-joined list when a symbol spans
558
+ * files — try each until one resolves. Returns null for non-TS/JS files, or when the symbol
559
+ * no longer exists in the file (renamed / moved / deleted).
560
+ */
561
+ extractLiveCode(node) {
562
+ const parsed = (0, ast_1.parseNodeId)(node.id);
563
+ // Pass the FULL symbol name ("Foo.bar") — extractNodeFromFile re-derives the class itself.
564
+ const symbol = parsed ? parsed.symbolName : node.id.split('#').pop() || node.name;
565
+ if (!symbol)
566
+ return null;
567
+ for (const p of String(node.file_path).split(',').map(s => s.trim()).filter(Boolean)) {
568
+ const derived = (0, ast_1.extractNodeFromFile)(p, symbol);
569
+ if (derived)
570
+ return derived.codeSnapshot;
571
+ }
572
+ return null;
573
+ }
574
+ /**
575
+ * Current code for a node, read from the file on disk (the source of truth) rather than the
576
+ * cached snapshot. Falls back to the snapshot only when the file can't be parsed for this
577
+ * symbol, and flags that fallback as unverified. When live code IS available, comparing it to
578
+ * the snapshot is free — so drift between the graph and disk is reported rather than hidden.
579
+ */
580
+ getLiveCode(nodeId) {
581
+ const node = this.getNode(nodeId);
582
+ const resolvedId = node ? node.id : nodeId;
583
+ const snapshot = this.getLatestCode(resolvedId);
584
+ if (node) {
585
+ const live = this.extractLiveCode(node);
586
+ if (live !== null) {
587
+ return {
588
+ exists: true,
589
+ node_id: node.id,
590
+ file_path: node.file_path,
591
+ code: live,
592
+ source: 'live',
593
+ // Snapshot exists but disagrees with disk → the graph has drifted.
594
+ snapshot_outdated: snapshot ? snapshot.code_snapshot !== live : undefined,
595
+ updated_at: snapshot?.updated_at
596
+ };
597
+ }
598
+ }
599
+ if (snapshot) {
600
+ return {
601
+ exists: true,
602
+ node_id: resolvedId,
603
+ file_path: node?.file_path,
604
+ code: snapshot.code_snapshot,
605
+ source: 'cached',
606
+ // Could not confirm against disk (non-TS/JS file, or symbol gone) — treat as suspect.
607
+ snapshot_outdated: true,
608
+ updated_at: snapshot.updated_at,
609
+ message: 'Could not locate this symbol in its source file — the file may not be TS/JS, or the symbol was renamed, moved, or deleted. Returning the last cached snapshot, which may be out of date. Verify against the file before relying on it.'
610
+ };
611
+ }
612
+ return {
613
+ exists: false,
614
+ node_id: resolvedId,
615
+ message: 'No code found on disk or in cache. Read the source file, then stage_change + commit_changes so future agents skip the file read entirely.'
616
+ };
617
+ }
618
+ getGraph(nodeId, maxDepth = 6, opts = {}) {
619
+ const direction = opts.direction ?? 'both';
620
+ const codeCharBudget = opts.codeCharBudget ?? 60_000;
365
621
  const maxNodesLimit = 500;
366
622
  const visited = new Set();
367
- const queue = [{ id: nodeId, depth: 0 }];
368
623
  const nodes = [];
369
624
  const connections = [];
370
625
  const connSet = new Set();
@@ -372,7 +627,11 @@ class DevMindDatabase {
372
627
  if (!rootNode) {
373
628
  return { nodes, connections };
374
629
  }
375
- visited.add(nodeId);
630
+ // Seed with the CANONICAL id. getNode() resolves a bare, unqualified symbol name, but
631
+ // node_connections is keyed by the fully-qualified id — seeding the queue with the raw
632
+ // argument would find zero edges and return a lone root.
633
+ const queue = [{ id: rootNode.id, depth: 0 }];
634
+ visited.add(rootNode.id);
376
635
  nodes.push(rootNode);
377
636
  const usesStmt = this.db.prepare(`
378
637
  SELECT target_node_id FROM node_connections WHERE source_node_id = ?
@@ -385,50 +644,84 @@ class DevMindDatabase {
385
644
  if (current.depth >= maxDepth) {
386
645
  continue;
387
646
  }
388
- // Get outbound connections (what this node uses)
389
- const outbound = usesStmt.all(current.id);
390
- for (const row of outbound) {
391
- const targetId = row.target_node_id;
392
- const connKey = `${current.id}->${targetId}`;
393
- if (!connSet.has(connKey)) {
394
- connSet.add(connKey);
395
- connections.push({ source_node_id: current.id, target_node_id: targetId });
396
- }
397
- if (!visited.has(targetId)) {
398
- visited.add(targetId);
399
- const targetNode = this.getNode(targetId);
400
- if (targetNode) {
401
- nodes.push(targetNode);
402
- if (nodes.length >= maxNodesLimit)
403
- break;
647
+ // Outbound what this node uses (callees). Skipped when tracing callers only.
648
+ if (direction !== 'in') {
649
+ const outbound = usesStmt.all(current.id);
650
+ for (const row of outbound) {
651
+ const targetId = row.target_node_id;
652
+ const connKey = `${current.id}->${targetId}`;
653
+ if (!connSet.has(connKey)) {
654
+ connSet.add(connKey);
655
+ connections.push({ source_node_id: current.id, target_node_id: targetId });
656
+ }
657
+ if (!visited.has(targetId)) {
658
+ visited.add(targetId);
659
+ const targetNode = this.getNode(targetId);
660
+ if (targetNode) {
661
+ nodes.push(targetNode);
662
+ if (nodes.length >= maxNodesLimit)
663
+ break;
664
+ }
665
+ queue.push({ id: targetId, depth: current.depth + 1 });
404
666
  }
405
- queue.push({ id: targetId, depth: current.depth + 1 });
406
667
  }
407
668
  }
408
669
  if (nodes.length >= maxNodesLimit)
409
670
  break;
410
- // Get inbound connections (what uses this node)
411
- const inbound = usedByStmt.all(current.id);
412
- for (const row of inbound) {
413
- const sourceId = row.source_node_id;
414
- const connKey = `${sourceId}->${current.id}`;
415
- if (!connSet.has(connKey)) {
416
- connSet.add(connKey);
417
- connections.push({ source_node_id: sourceId, target_node_id: current.id });
418
- }
419
- if (!visited.has(sourceId)) {
420
- visited.add(sourceId);
421
- const sourceNode = this.getNode(sourceId);
422
- if (sourceNode) {
423
- nodes.push(sourceNode);
424
- if (nodes.length >= maxNodesLimit)
425
- break;
671
+ // Inbound what uses this node (callers). Skipped when tracing a call flow outward.
672
+ if (direction !== 'out') {
673
+ const inbound = usedByStmt.all(current.id);
674
+ for (const row of inbound) {
675
+ const sourceId = row.source_node_id;
676
+ const connKey = `${sourceId}->${current.id}`;
677
+ if (!connSet.has(connKey)) {
678
+ connSet.add(connKey);
679
+ connections.push({ source_node_id: sourceId, target_node_id: current.id });
680
+ }
681
+ if (!visited.has(sourceId)) {
682
+ visited.add(sourceId);
683
+ const sourceNode = this.getNode(sourceId);
684
+ if (sourceNode) {
685
+ nodes.push(sourceNode);
686
+ if (nodes.length >= maxNodesLimit)
687
+ break;
688
+ }
689
+ queue.push({ id: sourceId, depth: current.depth + 1 });
426
690
  }
427
- queue.push({ id: sourceId, depth: current.depth + 1 });
428
691
  }
429
692
  }
430
693
  }
431
- return { nodes, connections };
694
+ const result = { nodes, connections };
695
+ if (opts.includeCode) {
696
+ let spent = 0;
697
+ let withoutCode = 0;
698
+ // `nodes` is in BFS order (nearest the root first), so the budget is spent on the most
699
+ // relevant code before anything is dropped.
700
+ for (const [i, n] of nodes.entries()) {
701
+ const live = this.extractLiveCode(n);
702
+ const code = live ?? this.getLatestCode(n.id)?.code_snapshot ?? null;
703
+ if (!code) {
704
+ withoutCode++;
705
+ continue;
706
+ }
707
+ // The root always gets its code — it is what was asked for, and dropping it would make
708
+ // the response useless. Every other node must fit in the REMAINING budget, so a single
709
+ // large node can't blow past the cap (it is skipped and counted, not truncated).
710
+ if (i > 0 && spent + code.length > codeCharBudget) {
711
+ withoutCode++;
712
+ continue;
713
+ }
714
+ n.code = code;
715
+ n.code_source = live !== null ? 'live' : 'cached';
716
+ spent += code.length;
717
+ }
718
+ result.code_chars = spent;
719
+ if (withoutCode > 0) {
720
+ result.code_truncated = true;
721
+ result.nodes_without_code = withoutCode;
722
+ }
723
+ }
724
+ return result;
432
725
  }
433
726
  updateHistory(params) {
434
727
  const { node_id, code_snapshot, reasoning } = params;
@@ -561,6 +854,73 @@ class DevMindDatabase {
561
854
  const wildcard = `%Decision: %${query}%`;
562
855
  return stmt.all(wildcard);
563
856
  }
857
+ searchCode(params) {
858
+ const { query, is_regex = false, case_insensitive = true } = params;
859
+ const historyDir = path.join(path.dirname(this.dbPath), 'history');
860
+ const stmt = this.db.prepare(`
861
+ SELECT h.id, n.id AS node_id, n.name AS node_name, n.file_path
862
+ FROM nodes n
863
+ JOIN history h ON h.node_id = n.id
864
+ WHERE n.deprecated = 0
865
+ AND h.id = (
866
+ SELECT id FROM history
867
+ WHERE node_id = n.id
868
+ ORDER BY updated_at DESC
869
+ LIMIT 1
870
+ )
871
+ `);
872
+ const rows = stmt.all();
873
+ let matcher;
874
+ if (is_regex) {
875
+ try {
876
+ matcher = new RegExp(query, case_insensitive ? 'i' : '');
877
+ }
878
+ catch (err) {
879
+ throw new Error(`Invalid regex pattern: ${err.message}`);
880
+ }
881
+ }
882
+ else {
883
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
884
+ matcher = new RegExp(escaped, case_insensitive ? 'i' : '');
885
+ }
886
+ const results = [];
887
+ for (const row of rows) {
888
+ const filePath = path.join(historyDir, `${row.id}.json`);
889
+ if (!fs.existsSync(filePath))
890
+ continue;
891
+ try {
892
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
893
+ const code = data.code_snapshot || '';
894
+ if (!code)
895
+ continue;
896
+ const lines = code.split('\n');
897
+ const nodeMatches = [];
898
+ lines.forEach((line, idx) => {
899
+ if (matcher.test(line)) {
900
+ nodeMatches.push({
901
+ line_number: idx + 1,
902
+ line_content: line
903
+ });
904
+ }
905
+ });
906
+ if (nodeMatches.length > 0) {
907
+ results.push({
908
+ node_id: row.node_id,
909
+ node_name: row.node_name,
910
+ file_path: row.file_path,
911
+ matches: nodeMatches,
912
+ match_count: nodeMatches.length,
913
+ total_lines: lines.length,
914
+ match_ratio: parseFloat((nodeMatches.length / lines.length).toFixed(4))
915
+ });
916
+ }
917
+ }
918
+ catch {
919
+ // Skip corrupted or unreadable history files
920
+ }
921
+ }
922
+ return results.sort((a, b) => b.match_count - a.match_count);
923
+ }
564
924
  getOrphanedNodes() {
565
925
  const stmt = this.db.prepare(`
566
926
  SELECT * FROM nodes
@@ -613,6 +973,7 @@ class DevMindDatabase {
613
973
  const candidates = stmt.all();
614
974
  const idsToDelete = [];
615
975
  const namesDeleted = [];
976
+ const affectedFilePaths = new Set();
616
977
  for (const node of candidates) {
617
978
  const lowerName = node.name.toLowerCase();
618
979
  // 1. Check if name is in the spurious list
@@ -636,9 +997,24 @@ class DevMindDatabase {
636
997
  if (isSpurious || fileMissing) {
637
998
  idsToDelete.push(node.id);
638
999
  namesDeleted.push(`${node.name} (${node.id})`);
1000
+ if (node.file_path) {
1001
+ for (const p of node.file_path.split(',').map(s => s.trim()).filter(Boolean)) {
1002
+ affectedFilePaths.add(p);
1003
+ }
1004
+ }
639
1005
  }
640
1006
  }
641
1007
  if (idsToDelete.length > 0) {
1008
+ // Capture caller files and drop the pruned nodes' history JSONs BEFORE the tx: the
1009
+ // inbound-edge join goes empty once edges are deleted, and the history rows (whose ids
1010
+ // name the JSON files) are removed by deleteHistoryStmt. Without the JSON cleanup, a
1011
+ // pruned node would resurrect from its history/[id].json on the next syncFromDisk().
1012
+ for (const id of idsToDelete) {
1013
+ for (const p of this.collectInboundSourceFiles(id)) {
1014
+ affectedFilePaths.add(p);
1015
+ }
1016
+ this.deleteHistoryFilesForNode(id);
1017
+ }
642
1018
  const updateStmt = this.db.prepare('UPDATE nodes SET deprecated = 1 WHERE id = ?');
643
1019
  const deleteConnStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ? OR target_node_id = ?');
644
1020
  const deleteHistoryStmt = this.db.prepare('DELETE FROM history WHERE node_id = ?');
@@ -650,6 +1026,12 @@ class DevMindDatabase {
650
1026
  }
651
1027
  });
652
1028
  deprecateTx(idsToDelete);
1029
+ // Keep the committed graph/*.json files in sync with the DB. Pruned nodes are written
1030
+ // with deprecated:1 (so they don't come back as active), and every caller file is
1031
+ // rewritten so its stale inbound edge doesn't resurrect the connection on next start.
1032
+ for (const filePath of affectedFilePaths) {
1033
+ this.writeGraphToDisk(filePath);
1034
+ }
653
1035
  }
654
1036
  return {
655
1037
  prunedCount: idsToDelete.length,
@@ -716,7 +1098,7 @@ class DevMindDatabase {
716
1098
  const repoPath = (0, config_1.resolveRepoPath)(this.context, repo.name);
717
1099
  if (repoPath) {
718
1100
  const normalizedRepoPath = path.resolve(repoPath).replace(/\\/g, '/');
719
- if (abs.startsWith(normalizedRepoPath)) {
1101
+ if (abs === normalizedRepoPath || abs.startsWith(normalizedRepoPath + '/')) {
720
1102
  const relative = path.relative(normalizedRepoPath, abs).replace(/\\/g, '/');
721
1103
  return `{${repo.name}}/${relative}`;
722
1104
  }
@@ -805,11 +1187,11 @@ class DevMindDatabase {
805
1187
  };
806
1188
  const jsonFiles = walkSync(graphDir);
807
1189
  if (jsonFiles.length > 0) {
808
- const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ? OR file_path LIKE ?');
1190
+ const deleteNodesForFileStmt = this.db.prepare('DELETE FROM nodes WHERE file_path = ?');
809
1191
  const deleteConnsForNodesStmt = this.db.prepare('DELETE FROM node_connections WHERE source_node_id = ?');
810
1192
  const insertNodeStmt = this.db.prepare(`
811
1193
  INSERT OR REPLACE INTO nodes (id, type, name, file_path, signature, deprecated)
812
- VALUES (?, ?, ?, ?, ?, 0)
1194
+ VALUES (?, ?, ?, ?, ?, ?)
813
1195
  `);
814
1196
  const insertConnStmt = this.db.prepare(`
815
1197
  INSERT OR IGNORE INTO node_connections (source_node_id, target_node_id)
@@ -824,16 +1206,17 @@ class DevMindDatabase {
824
1206
  continue;
825
1207
  const fileRelPath = data.file_path; // E.g. "{harrir-web}/app/page.tsx" or relative path
826
1208
  const fileAbsPath = this.toAbsolutePath(fileRelPath);
827
- // Strip leading {repo} or ../ and normalize separators for matching
828
- const cleanRelPath = fileRelPath.replace(/^\{[^}]+\}\//, '').replace(/^(\.\.\/)+/, '').replace(/\\/g, '/');
829
- const fileQueryPath = `%${cleanRelPath.replace(/\//g, path.sep)}`;
830
- // Clean existing nodes in SQLite for this file
831
- deleteNodesForFileStmt.run(fileAbsPath, fileQueryPath);
1209
+ // Clean existing nodes in SQLite for this file BEFORE re-inserting from
1210
+ // the JSON (so removed/renamed symbols are cleared). Match ONLY the exact
1211
+ // absolute path: the previous suffix `LIKE '%<relpath>'` matched the same
1212
+ // relative path in EVERY repo, so syncing one repo's file deleted another
1213
+ // repo's same-named file nodes (cross-repo data loss).
1214
+ deleteNodesForFileStmt.run(fileAbsPath);
832
1215
  // Insert nodes
833
1216
  const nodes = data.nodes || [];
834
1217
  for (const n of nodes) {
835
1218
  deleteConnsForNodesStmt.run(n.id);
836
- insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null);
1219
+ insertNodeStmt.run(n.id, n.type, n.name, fileAbsPath, n.signature || null, n.deprecated ? 1 : 0);
837
1220
  }
838
1221
  // Insert connections
839
1222
  const connections = data.connections || [];
@@ -857,6 +1240,10 @@ class DevMindDatabase {
857
1240
  this.db.pragma('foreign_keys = ON');
858
1241
  }
859
1242
  }
1243
+ /** Escape LIKE metacharacters so a path is matched literally (use with ESCAPE '\\'). */
1244
+ likeEscape(s) {
1245
+ return s.replace(/[\\%_]/g, ch => '\\' + ch);
1246
+ }
860
1247
  writeGraphToDisk(filePath) {
861
1248
  try {
862
1249
  if (!filePath)
@@ -864,17 +1251,29 @@ class DevMindDatabase {
864
1251
  const workspaceRoot = path.dirname(this.dbPath);
865
1252
  // Clean/resolve the file path
866
1253
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
867
- const relPath = path.relative(workspaceRoot, absPath).replace(/\\/g, '/');
868
1254
  const repoRelPath = this.toRepoRelativePath(absPath);
869
1255
  // E.g., "{harrir-web}/app/page.tsx" -> "graph/harrir-web/app/page.json"
870
1256
  const diskRelPath = repoRelPath.replace(/^\{([^}]+)\}/, '$1').replace(/\.[^/.]+$/, '.json');
871
1257
  const graphJsonPath = path.join(workspaceRoot, 'graph', diskRelPath);
872
- // Get all active nodes in this file
1258
+ // Get all nodes in this file (active AND deprecated). A node's file_path is either
1259
+ // exactly this absolute path, or (for the rare node spanning multiple files) a ", "-joined
1260
+ // list containing it. We anchor on the FULL absolute path with ", " boundaries and escape
1261
+ // LIKE metacharacters — the old `%<relpath>%` / `%<relpath>` matched short relative
1262
+ // suffixes shared across repos, pulling in (and later corrupting) other repos' nodes.
1263
+ // Deprecated nodes are INCLUDED (and carry deprecated:1 in the JSON) so that deprecation
1264
+ // is durable across a syncFromDisk() restart and propagates to teammates via git —
1265
+ // otherwise the node's history JSON would resurrect it as active on the next start.
1266
+ const absEsc = this.likeEscape(absPath);
873
1267
  const stmtNodes = this.db.prepare(`
874
1268
  SELECT * FROM nodes
875
- WHERE deprecated = 0 AND (file_path = ? OR file_path LIKE ? OR file_path LIKE ? OR file_path LIKE ?)
1269
+ WHERE (
1270
+ file_path = ? OR
1271
+ file_path LIKE ? ESCAPE '\\' OR
1272
+ file_path LIKE ? ESCAPE '\\' OR
1273
+ file_path LIKE ? ESCAPE '\\'
1274
+ )
876
1275
  `);
877
- const nodes = stmtNodes.all(absPath, `%${relPath}%`, `%${absPath}%`, `%${relPath}`);
1276
+ const nodes = stmtNodes.all(absPath, `${absEsc}, %`, `%, ${absEsc}`, `%, ${absEsc}, %`);
878
1277
  if (nodes.length === 0) {
879
1278
  // If no nodes left, delete the JSON file if it exists
880
1279
  if (fs.existsSync(graphJsonPath)) {
@@ -902,7 +1301,8 @@ class DevMindDatabase {
902
1301
  id: n.id,
903
1302
  name: n.name,
904
1303
  type: n.type,
905
- signature: n.signature
1304
+ signature: n.signature,
1305
+ deprecated: n.deprecated ? 1 : 0
906
1306
  })),
907
1307
  connections: connections.map(c => ({
908
1308
  source_node_id: c.source_node_id,