ai-mind-map 1.14.0 → 1.15.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.
@@ -50,8 +50,9 @@ CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
50
50
  CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
51
51
  CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language);
52
52
  CREATE INDEX IF NOT EXISTS idx_nodes_hash ON nodes(hash);
53
- CREATE INDEX IF NOT EXISTS idx_edges_sourceId ON edges(sourceId);
54
- CREATE INDEX IF NOT EXISTS idx_edges_targetId ON edges(targetId);
53
+ -- Composite indexes for common edge queries (type is always in WHERE clause)
54
+ CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(sourceId, type);
55
+ CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(targetId, type);
55
56
  CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
56
57
 
57
58
  -- FTS5 virtual table for full-text search across names, signatures, and doc comments
@@ -123,7 +124,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS content_fts USING fts5(
123
124
  tokenize='unicode61'
124
125
  );
125
126
  `;
126
- const SCHEMA_VERSION = '5';
127
+ const SCHEMA_VERSION = '6';
127
128
  // ============================================================
128
129
  // KnowledgeGraph Class
129
130
  // ============================================================
@@ -135,6 +136,23 @@ const SCHEMA_VERSION = '5';
135
136
  */
136
137
  export class KnowledgeGraph {
137
138
  db;
139
+ // Cached prepared statements for hot-path queries (parse once, reuse forever)
140
+ _stmtCache = new Map();
141
+ /** Get or create a cached prepared statement */
142
+ stmt(sql) {
143
+ let s = this._stmtCache.get(sql);
144
+ if (!s) {
145
+ s = this.db.prepare(sql);
146
+ this._stmtCache.set(sql, s);
147
+ }
148
+ return s;
149
+ }
150
+ // In-memory adjacency cache for ultra-fast graph traversal
151
+ adjOut = new Map(); // nodeId → type → targetIds
152
+ adjIn = new Map(); // nodeId → type → sourceIds
153
+ adjDirty = true; // Rebuilt on first query after index
154
+ // Stats cache with 5-second TTL
155
+ _statsCache = null;
138
156
  /**
139
157
  * Create or open a knowledge graph database.
140
158
  *
@@ -232,7 +250,7 @@ export class KnowledgeGraph {
232
250
  * Used by changelog engine to diff old vs new nodes during re-indexing.
233
251
  */
234
252
  getNodesForFile(filePath) {
235
- const rows = this.db.prepare('SELECT * FROM nodes WHERE filePath = ?').all(filePath);
253
+ const rows = this.stmt('SELECT * FROM nodes WHERE filePath = ?').all(filePath);
236
254
  return rows.map((r) => this.rowToNode(r));
237
255
  }
238
256
  // ============================================================
@@ -360,21 +378,21 @@ export class KnowledgeGraph {
360
378
  * Get a node by its ID.
361
379
  */
362
380
  getNode(id) {
363
- const row = this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id);
381
+ const row = this.stmt('SELECT * FROM nodes WHERE id = ?').get(id);
364
382
  return row ? this.rowToNode(row) : null;
365
383
  }
366
384
  /**
367
385
  * Get nodes by name (may return multiple matches across files).
368
386
  */
369
387
  getNodesByName(name) {
370
- const rows = this.db.prepare('SELECT * FROM nodes WHERE name = ?').all(name);
388
+ const rows = this.stmt('SELECT * FROM nodes WHERE name = ?').all(name);
371
389
  return rows.map((r) => this.rowToNode(r));
372
390
  }
373
391
  /**
374
392
  * Get all nodes of a specific type.
375
393
  */
376
394
  getNodesByType(type) {
377
- const rows = this.db.prepare('SELECT * FROM nodes WHERE type = ?').all(type);
395
+ const rows = this.stmt('SELECT * FROM nodes WHERE type = ?').all(type);
378
396
  return rows.map((r) => this.rowToNode(r));
379
397
  }
380
398
  /**
@@ -460,7 +478,7 @@ export class KnowledgeGraph {
460
478
  * Get all edges originating from a node.
461
479
  */
462
480
  getOutEdges(nodeId) {
463
- const rows = this.db.prepare('SELECT * FROM edges WHERE sourceId = ?').all(nodeId);
481
+ const rows = this.stmt('SELECT * FROM edges WHERE sourceId = ?').all(nodeId);
464
482
  return rows.map(r => ({
465
483
  sourceId: r.sourceId,
466
484
  targetId: r.targetId,
@@ -472,7 +490,7 @@ export class KnowledgeGraph {
472
490
  * Get all edges pointing to a node.
473
491
  */
474
492
  getInEdges(nodeId) {
475
- const rows = this.db.prepare('SELECT * FROM edges WHERE targetId = ?').all(nodeId);
493
+ const rows = this.stmt('SELECT * FROM edges WHERE targetId = ?').all(nodeId);
476
494
  return rows.map(r => ({
477
495
  sourceId: r.sourceId,
478
496
  targetId: r.targetId,
@@ -484,7 +502,7 @@ export class KnowledgeGraph {
484
502
  * Get edges of a specific type originating from a node.
485
503
  */
486
504
  getOutEdgesByType(nodeId, type) {
487
- const rows = this.db.prepare('SELECT * FROM edges WHERE sourceId = ? AND type = ?').all(nodeId, type);
505
+ const rows = this.stmt('SELECT * FROM edges WHERE sourceId = ? AND type = ?').all(nodeId, type);
488
506
  return rows.map(r => ({
489
507
  sourceId: r.sourceId,
490
508
  targetId: r.targetId,
@@ -496,7 +514,7 @@ export class KnowledgeGraph {
496
514
  * Get edges of a specific type pointing to a node.
497
515
  */
498
516
  getInEdgesByType(nodeId, type) {
499
- const rows = this.db.prepare('SELECT * FROM edges WHERE targetId = ? AND type = ?').all(nodeId, type);
517
+ const rows = this.stmt('SELECT * FROM edges WHERE targetId = ? AND type = ?').all(nodeId, type);
500
518
  return rows.map(r => ({
501
519
  sourceId: r.sourceId,
502
520
  targetId: r.targetId,
@@ -511,7 +529,7 @@ export class KnowledgeGraph {
511
529
  * Find all nodes that call a given node (callers / "who calls this?").
512
530
  */
513
531
  findCallers(nodeId) {
514
- const rows = this.db.prepare(`
532
+ const rows = this.stmt(`
515
533
  SELECT n.* FROM nodes n
516
534
  JOIN edges e ON n.id = e.sourceId
517
535
  WHERE e.targetId = ? AND e.type = 'calls'
@@ -522,7 +540,7 @@ export class KnowledgeGraph {
522
540
  * Find all nodes that a given node calls (callees / "what does this call?").
523
541
  */
524
542
  findCallees(nodeId) {
525
- const rows = this.db.prepare(`
543
+ const rows = this.stmt(`
526
544
  SELECT n.* FROM nodes n
527
545
  JOIN edges e ON n.id = e.targetId
528
546
  WHERE e.sourceId = ? AND e.type = 'calls'
@@ -536,44 +554,26 @@ export class KnowledgeGraph {
536
554
  * @param maxDepth - Maximum traversal depth (default 10)
537
555
  */
538
556
  findAncestors(nodeId, maxDepth = 10) {
539
- const visited = new Set();
540
- const result = [];
541
- const queue = [{ id: nodeId, depth: 0 }];
542
- while (queue.length > 0) {
543
- const current = queue.shift();
544
- if (current.depth >= maxDepth || visited.has(current.id))
545
- continue;
546
- visited.add(current.id);
547
- // inherits/implements: sourceId=child, targetId=parent follow targetId
548
- const inheritEdges = this.db.prepare(`
549
- SELECT e.targetId FROM edges e
550
- WHERE e.sourceId = ? AND e.type IN ('inherits', 'implements')
551
- `).all(current.id);
552
- for (const { targetId } of inheritEdges) {
553
- if (!visited.has(targetId)) {
554
- const node = this.getNode(targetId);
555
- if (node) {
556
- result.push(node);
557
- queue.push({ id: targetId, depth: current.depth + 1 });
558
- }
559
- }
560
- }
561
- // contains: sourceId=parent, targetId=child → find parent via targetId=current
562
- const containsEdges = this.db.prepare(`
563
- SELECT e.sourceId FROM edges e
564
- WHERE e.targetId = ? AND e.type = 'contains'
565
- `).all(current.id);
566
- for (const { sourceId } of containsEdges) {
567
- if (!visited.has(sourceId)) {
568
- const node = this.getNode(sourceId);
569
- if (node) {
570
- result.push(node);
571
- queue.push({ id: sourceId, depth: current.depth + 1 });
572
- }
573
- }
574
- }
575
- }
576
- return result;
557
+ // Single recursive CTE replaces N+1 JavaScript BFS loop
558
+ const rows = this.db.prepare(`
559
+ WITH RECURSIVE anc(id, depth) AS (
560
+ SELECT @nodeId, 0
561
+ UNION
562
+ SELECT e.targetId, anc.depth + 1
563
+ FROM edges e
564
+ JOIN anc ON e.sourceId = anc.id
565
+ WHERE anc.depth < @maxDepth AND e.type IN ('inherits', 'implements')
566
+ UNION
567
+ SELECT e.sourceId, anc.depth + 1
568
+ FROM edges e
569
+ JOIN anc ON e.targetId = anc.id
570
+ WHERE anc.depth < @maxDepth AND e.type = 'contains'
571
+ )
572
+ SELECT DISTINCT n.* FROM nodes n
573
+ JOIN anc ON n.id = anc.id
574
+ WHERE n.id != @nodeId
575
+ `).all({ nodeId, maxDepth });
576
+ return rows.map((r) => this.rowToNode(r));
577
577
  }
578
578
  /**
579
579
  * Find descendants (child classes, implementors, contained members) — traverse downward.
@@ -582,44 +582,26 @@ export class KnowledgeGraph {
582
582
  * @param maxDepth - Maximum traversal depth (default 10)
583
583
  */
584
584
  findDescendants(nodeId, maxDepth = 10) {
585
- const visited = new Set();
586
- const result = [];
587
- const queue = [{ id: nodeId, depth: 0 }];
588
- while (queue.length > 0) {
589
- const current = queue.shift();
590
- if (current.depth >= maxDepth || visited.has(current.id))
591
- continue;
592
- visited.add(current.id);
593
- // contains: sourceId=parent, targetId=child follow targetId
594
- const containsEdges = this.db.prepare(`
595
- SELECT e.targetId FROM edges e
596
- WHERE e.sourceId = ? AND e.type = 'contains'
597
- `).all(current.id);
598
- for (const { targetId } of containsEdges) {
599
- if (!visited.has(targetId)) {
600
- const node = this.getNode(targetId);
601
- if (node) {
602
- result.push(node);
603
- queue.push({ id: targetId, depth: current.depth + 1 });
604
- }
605
- }
606
- }
607
- // inherits/implements: sourceId=child, targetId=parent → find children via targetId=current
608
- const inheritEdges = this.db.prepare(`
609
- SELECT e.sourceId FROM edges e
610
- WHERE e.targetId = ? AND e.type IN ('inherits', 'implements')
611
- `).all(current.id);
612
- for (const { sourceId } of inheritEdges) {
613
- if (!visited.has(sourceId)) {
614
- const node = this.getNode(sourceId);
615
- if (node) {
616
- result.push(node);
617
- queue.push({ id: sourceId, depth: current.depth + 1 });
618
- }
619
- }
620
- }
621
- }
622
- return result;
585
+ // Single recursive CTE replaces N+1 JavaScript BFS loop
586
+ const rows = this.db.prepare(`
587
+ WITH RECURSIVE desc_tree(id, depth) AS (
588
+ SELECT @nodeId, 0
589
+ UNION
590
+ SELECT e.targetId, desc_tree.depth + 1
591
+ FROM edges e
592
+ JOIN desc_tree ON e.sourceId = desc_tree.id
593
+ WHERE desc_tree.depth < @maxDepth AND e.type = 'contains'
594
+ UNION
595
+ SELECT e.sourceId, desc_tree.depth + 1
596
+ FROM edges e
597
+ JOIN desc_tree ON e.targetId = desc_tree.id
598
+ WHERE desc_tree.depth < @maxDepth AND e.type IN ('inherits', 'implements')
599
+ )
600
+ SELECT DISTINCT n.* FROM nodes n
601
+ JOIN desc_tree ON n.id = desc_tree.id
602
+ WHERE n.id != @nodeId
603
+ `).all({ nodeId, maxDepth });
604
+ return rows.map((r) => this.rowToNode(r));
623
605
  }
624
606
  /**
625
607
  * Blast radius analysis: find all nodes transitively affected if a node changes.
@@ -631,32 +613,23 @@ export class KnowledgeGraph {
631
613
  * @returns All nodes that depend on the changed node
632
614
  */
633
615
  blastRadius(nodeId, maxDepth = 5) {
634
- const visited = new Set();
635
- const result = [];
636
- const dependencyTypes = ['calls', 'imports', 'inherits', 'implements', 'uses', 'depends_on'];
637
- const typeFilter = dependencyTypes.map(t => `'${t}'`).join(',');
638
- const queue = [{ id: nodeId, depth: 0 }];
639
- while (queue.length > 0) {
640
- const current = queue.shift();
641
- if (current.depth >= maxDepth || visited.has(current.id))
642
- continue;
643
- visited.add(current.id);
644
- // Find nodes that depend ON this node (reverse dependency)
645
- const dependents = this.db.prepare(`
646
- SELECT e.sourceId FROM edges e
647
- WHERE e.targetId = ? AND e.type IN (${typeFilter})
648
- `).all(current.id);
649
- for (const { sourceId } of dependents) {
650
- if (!visited.has(sourceId)) {
651
- const node = this.getNode(sourceId);
652
- if (node) {
653
- result.push(node);
654
- queue.push({ id: sourceId, depth: current.depth + 1 });
655
- }
656
- }
657
- }
658
- }
659
- return result;
616
+ // Single recursive CTE replaces N+1 JavaScript BFS loop
617
+ // Traces reverse dependencies: who depends on this node, transitively
618
+ const rows = this.db.prepare(`
619
+ WITH RECURSIVE blast(id, depth) AS (
620
+ SELECT @nodeId, 0
621
+ UNION
622
+ SELECT e.sourceId, blast.depth + 1
623
+ FROM edges e
624
+ JOIN blast ON e.targetId = blast.id
625
+ WHERE blast.depth < @maxDepth
626
+ AND e.type IN ('calls','imports','inherits','implements','uses','depends_on')
627
+ )
628
+ SELECT DISTINCT n.* FROM nodes n
629
+ JOIN blast ON n.id = blast.id
630
+ WHERE n.id != @nodeId
631
+ `).all({ nodeId, maxDepth });
632
+ return rows.map((r) => this.rowToNode(r));
660
633
  }
661
634
  // ============================================================
662
635
  // Full-Text Search
@@ -828,14 +801,14 @@ export class KnowledgeGraph {
828
801
  * Get the content hash for a file node (used for change detection).
829
802
  */
830
803
  getFileHash(filePath) {
831
- const row = this.db.prepare("SELECT hash FROM nodes WHERE filePath = ? AND type = 'file' LIMIT 1").get(filePath);
804
+ const row = this.stmt("SELECT hash FROM nodes WHERE filePath = ? AND type = 'file' LIMIT 1").get(filePath);
832
805
  return row?.hash ?? null;
833
806
  }
834
807
  /**
835
808
  * Get all indexed file paths.
836
809
  */
837
810
  getIndexedFiles() {
838
- const rows = this.db.prepare("SELECT DISTINCT filePath FROM nodes WHERE type = 'file' ORDER BY filePath").all();
811
+ const rows = this.stmt("SELECT DISTINCT filePath FROM nodes WHERE type = 'file' ORDER BY filePath").all();
839
812
  return rows.map(r => r.filePath);
840
813
  }
841
814
  /**
@@ -895,25 +868,32 @@ export class KnowledgeGraph {
895
868
  * Get graph statistics.
896
869
  */
897
870
  getStats() {
898
- const totalNodes = this.db.prepare('SELECT COUNT(*) as count FROM nodes').get().count;
899
- const totalEdges = this.db.prepare('SELECT COUNT(*) as count FROM edges').get().count;
900
- const totalFiles = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE type = 'file'").get().count;
901
- const nodesByTypeRows = this.db.prepare('SELECT type, COUNT(*) as count FROM nodes GROUP BY type').all();
871
+ // Return cached stats if <5 seconds old (avoids 6 COUNT queries per tool response)
872
+ const now = Date.now();
873
+ if (this._statsCache && now - this._statsCache.time < 5000) {
874
+ return this._statsCache.data;
875
+ }
876
+ const totalNodes = this.stmt('SELECT COUNT(*) as count FROM nodes').get().count;
877
+ const totalEdges = this.stmt('SELECT COUNT(*) as count FROM edges').get().count;
878
+ const totalFiles = this.stmt("SELECT COUNT(*) as count FROM nodes WHERE type = 'file'").get().count;
879
+ const nodesByTypeRows = this.stmt('SELECT type, COUNT(*) as count FROM nodes GROUP BY type').all();
902
880
  const nodesByType = {};
903
881
  for (const { type, count } of nodesByTypeRows) {
904
882
  nodesByType[type] = count;
905
883
  }
906
- const edgesByTypeRows = this.db.prepare('SELECT type, COUNT(*) as count FROM edges GROUP BY type').all();
884
+ const edgesByTypeRows = this.stmt('SELECT type, COUNT(*) as count FROM edges GROUP BY type').all();
907
885
  const edgesByType = {};
908
886
  for (const { type, count } of edgesByTypeRows) {
909
887
  edgesByType[type] = count;
910
888
  }
911
- const langRows = this.db.prepare("SELECT language, COUNT(*) as count FROM nodes WHERE type = 'file' GROUP BY language").all();
889
+ const langRows = this.stmt("SELECT language, COUNT(*) as count FROM nodes WHERE type = 'file' GROUP BY language").all();
912
890
  const languageBreakdown = {};
913
891
  for (const { language, count } of langRows) {
914
892
  languageBreakdown[language] = count;
915
893
  }
916
- return { totalNodes, totalEdges, totalFiles, nodesByType, edgesByType, languageBreakdown };
894
+ const result = { totalNodes, totalEdges, totalFiles, nodesByType, edgesByType, languageBreakdown };
895
+ this._statsCache = { data: result, time: now };
896
+ return result;
917
897
  }
918
898
  // ============================================================
919
899
  // Bulk Operations
@@ -930,6 +910,8 @@ export class KnowledgeGraph {
930
910
  this.upsertNodes(nodes);
931
911
  this.upsertEdges(edges);
932
912
  })();
913
+ this.adjDirty = true;
914
+ this._statsCache = null;
933
915
  }
934
916
  /**
935
917
  * Replace data for MANY files in a single transaction.
@@ -1013,6 +995,8 @@ export class KnowledgeGraph {
1013
995
  }
1014
996
  }
1015
997
  })();
998
+ this.adjDirty = true;
999
+ this._statsCache = null;
1016
1000
  }
1017
1001
  /**
1018
1002
  * Optimized insert for full reindex (no existing data, skip conflict checks).
@@ -1075,13 +1059,14 @@ export class KnowledgeGraph {
1075
1059
  }
1076
1060
  }
1077
1061
  })();
1062
+ this.adjDirty = true;
1063
+ this._statsCache = null;
1078
1064
  }
1079
1065
  /**
1080
1066
  * Get all node IDs in the graph (used for PageRank).
1081
1067
  */
1082
1068
  getAllNodeIds() {
1083
- const rows = this.db.prepare('SELECT id FROM nodes').all();
1084
- return rows.map(r => r.id);
1069
+ return this.stmt('SELECT id FROM nodes').pluck().all();
1085
1070
  }
1086
1071
  /**
1087
1072
  * Get all edges in the graph (used for PageRank adjacency matrix).
@@ -1122,6 +1107,8 @@ export class KnowledgeGraph {
1122
1107
  this.db.prepare('DELETE FROM edges').run();
1123
1108
  this.db.prepare('DELETE FROM nodes').run();
1124
1109
  })();
1110
+ this.adjDirty = true;
1111
+ this._statsCache = null;
1125
1112
  }
1126
1113
  /**
1127
1114
  * Clear only nodes and edges belonging to files under a specific project root.
@@ -1150,6 +1137,8 @@ export class KnowledgeGraph {
1150
1137
  this.db.prepare('DELETE FROM file_index WHERE file_path LIKE ? OR file_path LIKE ?').run(`${prefix}%`, `${altPrefix}%`);
1151
1138
  return del.changes;
1152
1139
  })();
1140
+ this.adjDirty = true;
1141
+ this._statsCache = null;
1153
1142
  return result;
1154
1143
  }
1155
1144
  // ============================================================
@@ -1248,7 +1237,7 @@ export class KnowledgeGraph {
1248
1237
  * Get stored file index entry for staleness comparison.
1249
1238
  */
1250
1239
  getFileIndexEntry(filePath) {
1251
- return this.db.prepare('SELECT mtime_ms, size_bytes, content_hash, indexed_at FROM file_index WHERE file_path = ?').get(filePath) ?? null;
1240
+ return this.stmt('SELECT mtime_ms, size_bytes, content_hash, indexed_at FROM file_index WHERE file_path = ?').get(filePath) ?? null;
1252
1241
  }
1253
1242
  /**
1254
1243
  * Upsert a file index entry after indexing.
@@ -1275,7 +1264,7 @@ export class KnowledgeGraph {
1275
1264
  * Count tracked files in file_index.
1276
1265
  */
1277
1266
  getFileIndexCount() {
1278
- return this.db.prepare('SELECT COUNT(*) as c FROM file_index').get()?.c ?? 0;
1267
+ return this.stmt('SELECT COUNT(*) as c FROM file_index').get()?.c ?? 0;
1279
1268
  }
1280
1269
  /**
1281
1270
  * Remove orphaned edges (edges pointing to non-existent nodes).
@@ -1328,9 +1317,12 @@ export class KnowledgeGraph {
1328
1317
  this.db.exec('DROP INDEX IF EXISTS idx_nodes_name');
1329
1318
  this.db.exec('DROP INDEX IF EXISTS idx_nodes_language');
1330
1319
  this.db.exec('DROP INDEX IF EXISTS idx_nodes_hash');
1320
+ this.db.exec('DROP INDEX IF EXISTS idx_edges_source_type');
1321
+ this.db.exec('DROP INDEX IF EXISTS idx_edges_target_type');
1322
+ this.db.exec('DROP INDEX IF EXISTS idx_edges_type');
1323
+ // Also drop old single-column indexes if they exist from previous versions
1331
1324
  this.db.exec('DROP INDEX IF EXISTS idx_edges_sourceId');
1332
1325
  this.db.exec('DROP INDEX IF EXISTS idx_edges_targetId');
1333
- this.db.exec('DROP INDEX IF EXISTS idx_edges_type');
1334
1326
  }
1335
1327
  /** Recreate all performance indexes */
1336
1328
  recreateIndexes() {
@@ -1339,8 +1331,8 @@ export class KnowledgeGraph {
1339
1331
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name)');
1340
1332
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_language ON nodes(language)');
1341
1333
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_hash ON nodes(hash)');
1342
- this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_sourceId ON edges(sourceId)');
1343
- this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_targetId ON edges(targetId)');
1334
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(sourceId, type)');
1335
+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(targetId, type)');
1344
1336
  this.db.exec('CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type)');
1345
1337
  }
1346
1338
  /** Enter bulk insert mode - relaxes safety for maximum speed */
@@ -1355,6 +1347,7 @@ export class KnowledgeGraph {
1355
1347
  }
1356
1348
  /** Exit bulk insert mode - restores safety and rebuilds indexes/FTS */
1357
1349
  exitBulkMode() {
1350
+ this.invalidateAdjCache();
1358
1351
  this.recreateIndexes();
1359
1352
  this.recreateFtsTriggers();
1360
1353
  this.rebuildFts();
@@ -1365,6 +1358,92 @@ export class KnowledgeGraph {
1365
1358
  this.db.pragma('wal_checkpoint(TRUNCATE)');
1366
1359
  this.db.pragma('wal_autocheckpoint = 1000');
1367
1360
  }
1361
+ /**
1362
+ * Build in-memory adjacency cache for ultra-fast graph traversal.
1363
+ * Called automatically on first traversal query after index.
1364
+ * ~2MB RAM for 13K edges — negligible.
1365
+ */
1366
+ buildAdjacencyCache() {
1367
+ this.adjOut.clear();
1368
+ this.adjIn.clear();
1369
+ const rows = this.stmt('SELECT sourceId, targetId, type FROM edges').all();
1370
+ for (const r of rows) {
1371
+ // Forward: sourceId → type → [targetIds]
1372
+ if (!this.adjOut.has(r.sourceId))
1373
+ this.adjOut.set(r.sourceId, new Map());
1374
+ const fwd = this.adjOut.get(r.sourceId);
1375
+ if (!fwd.has(r.type))
1376
+ fwd.set(r.type, []);
1377
+ fwd.get(r.type).push(r.targetId);
1378
+ // Reverse: targetId → type → [sourceIds]
1379
+ if (!this.adjIn.has(r.targetId))
1380
+ this.adjIn.set(r.targetId, new Map());
1381
+ const rev = this.adjIn.get(r.targetId);
1382
+ if (!rev.has(r.type))
1383
+ rev.set(r.type, []);
1384
+ rev.get(r.type).push(r.sourceId);
1385
+ }
1386
+ this.adjDirty = false;
1387
+ }
1388
+ /** Invalidate adjacency cache (call after any edge mutation) */
1389
+ invalidateAdjCache() {
1390
+ this.adjDirty = true;
1391
+ this._statsCache = null;
1392
+ this._stmtCache.clear(); // Also clear stmt cache on schema changes
1393
+ }
1394
+ /** Get callers from adjacency cache (in-memory, sub-microsecond) */
1395
+ getCallersFromCache(nodeId) {
1396
+ if (this.adjDirty)
1397
+ this.buildAdjacencyCache();
1398
+ return this.adjIn.get(nodeId)?.get('calls') ?? [];
1399
+ }
1400
+ /** Get callees from adjacency cache (in-memory, sub-microsecond) */
1401
+ getCalleesFromCache(nodeId) {
1402
+ if (this.adjDirty)
1403
+ this.buildAdjacencyCache();
1404
+ return this.adjOut.get(nodeId)?.get('calls') ?? [];
1405
+ }
1406
+ /** Get reverse dependencies from adjacency cache for blast radius */
1407
+ getReverseDepsFromCache(nodeId, types) {
1408
+ if (this.adjDirty)
1409
+ this.buildAdjacencyCache();
1410
+ const byType = this.adjIn.get(nodeId);
1411
+ if (!byType)
1412
+ return [];
1413
+ const result = [];
1414
+ for (const t of types) {
1415
+ const ids = byType.get(t);
1416
+ if (ids)
1417
+ result.push(...ids);
1418
+ }
1419
+ return result;
1420
+ }
1421
+ /**
1422
+ * Shortest path between two nodes via dependency edges.
1423
+ * Returns the path as an array of node IDs, or empty if no path exists.
1424
+ */
1425
+ shortestPath(startId, endId, maxDepth = 10) {
1426
+ try {
1427
+ const rows = this.db.prepare(`
1428
+ WITH RECURSIVE path(id, depth, route) AS (
1429
+ SELECT ?1, 0, ?1
1430
+ UNION ALL
1431
+ SELECT e.targetId, path.depth + 1, path.route || '>' || e.targetId
1432
+ FROM edges e
1433
+ JOIN path ON e.sourceId = path.id
1434
+ WHERE path.depth < ?3
1435
+ AND e.type IN ('calls','imports','inherits','implements','uses','depends_on')
1436
+ AND instr(path.route, e.targetId) = 0
1437
+ )
1438
+ SELECT route FROM path WHERE id = ?2
1439
+ ORDER BY depth LIMIT 1
1440
+ `).get(startId, endId, maxDepth);
1441
+ return rows ? rows.route.split('>') : [];
1442
+ }
1443
+ catch {
1444
+ return [];
1445
+ }
1446
+ }
1368
1447
  /**
1369
1448
  * Close the database connection.
1370
1449
  */