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.
@@ -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 && srcNode.file_path) {
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 = '', updated_at = ?
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
- insertHistoryStmt.run(data.id, data.node_id, data.session_id, data.created_at, data.updated_at);
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 = ? OR file_path LIKE ?');
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 (?, ?, ?, ?, ?, 0)
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
- // Strip leading {repo} or ../ and normalize separators for matching
824
- const cleanRelPath = fileRelPath.replace(/^\{[^}]+\}\//, '').replace(/^(\.\.\/)+/, '').replace(/\\/g, '/');
825
- const fileQueryPath = `%${cleanRelPath.replace(/\//g, path.sep)}`;
826
- // Clean existing nodes in SQLite for this file
827
- deleteNodesForFileStmt.run(fileAbsPath, fileQueryPath);
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 active nodes in this file
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 deprecated = 0 AND (file_path = ? OR file_path LIKE ? OR file_path LIKE ? OR file_path LIKE ?)
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, `%${relPath}%`, `%${absPath}%`, `%${relPath}`);
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,