superlocalmemory 3.3.19 → 3.3.21

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.
Files changed (81) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +9 -1
  3. package/src/superlocalmemory/cli/commands.py +140 -23
  4. package/src/superlocalmemory/cli/daemon.py +372 -0
  5. package/src/superlocalmemory/cli/main.py +10 -2
  6. package/src/superlocalmemory/cli/pending_store.py +158 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +39 -6
  8. package/src/superlocalmemory/code_graph/__init__.py +46 -0
  9. package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
  10. package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
  11. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
  12. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
  13. package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
  14. package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
  15. package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
  16. package/src/superlocalmemory/code_graph/changes.py +363 -0
  17. package/src/superlocalmemory/code_graph/communities.py +299 -0
  18. package/src/superlocalmemory/code_graph/config.py +88 -0
  19. package/src/superlocalmemory/code_graph/database.py +482 -0
  20. package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
  21. package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
  22. package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
  23. package/src/superlocalmemory/code_graph/flows.py +350 -0
  24. package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
  25. package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
  26. package/src/superlocalmemory/code_graph/graph_store.py +158 -0
  27. package/src/superlocalmemory/code_graph/incremental.py +200 -0
  28. package/src/superlocalmemory/code_graph/models.py +130 -0
  29. package/src/superlocalmemory/code_graph/parser.py +507 -0
  30. package/src/superlocalmemory/code_graph/resolver.py +321 -0
  31. package/src/superlocalmemory/code_graph/search.py +460 -0
  32. package/src/superlocalmemory/code_graph/service.py +95 -0
  33. package/src/superlocalmemory/code_graph/watcher.py +207 -0
  34. package/src/superlocalmemory/core/config.py +4 -3
  35. package/src/superlocalmemory/core/embedding_worker.py +4 -2
  36. package/src/superlocalmemory/core/embeddings.py +8 -2
  37. package/src/superlocalmemory/core/engine.py +32 -0
  38. package/src/superlocalmemory/core/engine_wiring.py +5 -0
  39. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  40. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  41. package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
  42. package/src/superlocalmemory/infra/event_bus.py +5 -0
  43. package/src/superlocalmemory/mcp/server.py +23 -0
  44. package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
  45. package/src/superlocalmemory/retrieval/agentic.py +89 -17
  46. package/src/superlocalmemory/retrieval/engine.py +137 -2
  47. package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
  48. package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
  49. package/src/superlocalmemory/retrieval/strategy.py +16 -0
  50. package/src/superlocalmemory/server/api.py +4 -2
  51. package/src/superlocalmemory/server/ui.py +5 -2
  52. package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
  53. package/src/superlocalmemory/ui/index.html +1879 -0
  54. package/src/superlocalmemory/ui/js/agents.js +192 -0
  55. package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
  56. package/src/superlocalmemory/ui/js/behavioral.js +276 -0
  57. package/src/superlocalmemory/ui/js/clusters.js +206 -0
  58. package/src/superlocalmemory/ui/js/compliance.js +252 -0
  59. package/src/superlocalmemory/ui/js/core.js +246 -0
  60. package/src/superlocalmemory/ui/js/dashboard.js +110 -0
  61. package/src/superlocalmemory/ui/js/events.js +178 -0
  62. package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
  63. package/src/superlocalmemory/ui/js/feedback.js +333 -0
  64. package/src/superlocalmemory/ui/js/graph-core.js +447 -0
  65. package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
  66. package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
  67. package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
  68. package/src/superlocalmemory/ui/js/ide-status.js +102 -0
  69. package/src/superlocalmemory/ui/js/init.js +45 -0
  70. package/src/superlocalmemory/ui/js/learning.js +435 -0
  71. package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
  72. package/src/superlocalmemory/ui/js/math-health.js +98 -0
  73. package/src/superlocalmemory/ui/js/memories.js +264 -0
  74. package/src/superlocalmemory/ui/js/modal.js +357 -0
  75. package/src/superlocalmemory/ui/js/patterns.js +93 -0
  76. package/src/superlocalmemory/ui/js/profiles.js +236 -0
  77. package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
  78. package/src/superlocalmemory/ui/js/search.js +59 -0
  79. package/src/superlocalmemory/ui/js/settings.js +224 -0
  80. package/src/superlocalmemory/ui/js/timeline.js +32 -0
  81. package/src/superlocalmemory/ui/js/trust-dashboard.js +73 -0
@@ -0,0 +1,1592 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4 — CodeGraph MCP Tools
4
+
5
+ """22 MCP tools for CodeGraph: 17 graph + 5 bridge.
6
+
7
+ Registered against `server` (NOT `_target`) so they are always visible
8
+ when the code-graph extra is installed. Each tool self-guards (returns
9
+ error if graph not built) — no risk of confusing users.
10
+
11
+ All tools return {"success": bool, ...} envelope. Never raise.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Lazy service singleton
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _service = None
28
+ _service_config = None
29
+
30
+
31
+ def _get_service():
32
+ """Lazy-create the CodeGraphService singleton."""
33
+ global _service
34
+ if _service is not None:
35
+ return _service
36
+
37
+ try:
38
+ from superlocalmemory.code_graph.config import CodeGraphConfig
39
+ from superlocalmemory.code_graph.service import CodeGraphService
40
+
41
+ config = CodeGraphConfig(enabled=True)
42
+ _service = CodeGraphService(config)
43
+ return _service
44
+ except Exception as exc:
45
+ logger.warning("Failed to create CodeGraphService: %s", exc)
46
+ return None
47
+
48
+
49
+ def _get_db():
50
+ """Get the CodeGraphDatabase from the service."""
51
+ svc = _get_service()
52
+ if svc is None:
53
+ return None
54
+ try:
55
+ return svc.db
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def _graph_not_built_error() -> dict[str, Any]:
61
+ """Standard error when graph is not built."""
62
+ return {
63
+ "success": False,
64
+ "error": "Code graph not built. Run build_code_graph first.",
65
+ }
66
+
67
+
68
+ def _bridge_not_enabled_error() -> dict[str, Any]:
69
+ """Standard error when bridge is not enabled."""
70
+ return {
71
+ "success": False,
72
+ "error": "Bridge not enabled. Set code_graph.bridge.enabled = true in config.",
73
+ }
74
+
75
+
76
+ def _error_response(msg: str) -> dict[str, Any]:
77
+ """Standard error response."""
78
+ return {"success": False, "error": msg}
79
+
80
+
81
+ def _check_graph_exists() -> dict[str, Any] | None:
82
+ """Check if graph has been built. Returns error dict or None if OK."""
83
+ db = _get_db()
84
+ if db is None:
85
+ return _graph_not_built_error()
86
+ stats = db.get_stats()
87
+ if stats.get("nodes", 0) == 0 and stats.get("files", 0) == 0:
88
+ return _graph_not_built_error()
89
+ return None
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Registration function
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def register_code_graph_tools(server, get_engine: Callable) -> None:
98
+ """Register 22 code graph MCP tools on *server*."""
99
+
100
+ # ==================================================================
101
+ # Tool 1: build_code_graph
102
+ # ==================================================================
103
+
104
+ @server.tool()
105
+ async def build_code_graph(
106
+ repo_path: str,
107
+ languages: str = "",
108
+ exclude_patterns: str = "",
109
+ ) -> dict:
110
+ """Build a complete code knowledge graph from a repository.
111
+
112
+ Parses all supported source files, extracts functions/classes/imports,
113
+ builds call graph, detects flows, and identifies communities.
114
+
115
+ Args:
116
+ repo_path: Absolute path to repository root.
117
+ languages: Comma-separated language filter (e.g. "python,typescript"). Empty = all.
118
+ exclude_patterns: Comma-separated glob patterns to exclude.
119
+ """
120
+ try:
121
+ repo = Path(repo_path)
122
+ if not repo.exists():
123
+ return _error_response(
124
+ f"Repository path does not exist: {repo_path}"
125
+ )
126
+
127
+ from superlocalmemory.code_graph.config import CodeGraphConfig
128
+ from superlocalmemory.code_graph.service import CodeGraphService
129
+ from superlocalmemory.code_graph.parser import CodeParser
130
+ from superlocalmemory.code_graph.graph_store import GraphStore
131
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
132
+ from superlocalmemory.code_graph.search import HybridSearch
133
+ from superlocalmemory.code_graph.flows import FlowDetector
134
+ from superlocalmemory.code_graph.communities import CommunityDetector
135
+
136
+ t0 = time.time()
137
+
138
+ # Build config
139
+ config_kwargs: dict[str, Any] = {
140
+ "enabled": True,
141
+ "repo_root": repo,
142
+ }
143
+ if languages:
144
+ config_kwargs["languages"] = frozenset(
145
+ l.strip() for l in languages.split(",") if l.strip()
146
+ )
147
+ if exclude_patterns:
148
+ config_kwargs["exclude_patterns"] = tuple(
149
+ p.strip() for p in exclude_patterns.split(",") if p.strip()
150
+ )
151
+
152
+ config = CodeGraphConfig(**config_kwargs)
153
+ global _service
154
+ _service = CodeGraphService(config)
155
+
156
+ # Parse
157
+ parser = CodeParser(config)
158
+ nodes, edges, file_records = parser.parse_all(repo)
159
+
160
+ if not nodes:
161
+ return {
162
+ "success": True,
163
+ "files_parsed": 0,
164
+ "nodes": 0,
165
+ "edges": 0,
166
+ "flows": 0,
167
+ "communities": 0,
168
+ "duration_ms": int((time.time() - t0) * 1000),
169
+ "message": f"No supported source files found in {repo_path}.",
170
+ }
171
+
172
+ # Store in DB
173
+ db = _service.db
174
+ store = GraphStore(db)
175
+
176
+ # Group by file for atomic replacement
177
+ file_groups: dict[str, tuple[list, list, Any]] = {}
178
+ for fr in file_records:
179
+ file_groups[fr.file_path] = ([], [], fr)
180
+ for n in nodes:
181
+ fp = n.file_path
182
+ if fp in file_groups:
183
+ file_groups[fp][0].append(n)
184
+ for e in edges:
185
+ fp = e.file_path
186
+ if fp in file_groups:
187
+ file_groups[fp][1].append(e)
188
+
189
+ for fp, (ns, es, fr) in file_groups.items():
190
+ store.store_file_nodes_edges(fp, ns, es, fr)
191
+
192
+ # Build in-memory graph
193
+ engine = GraphEngine(store)
194
+ engine.build_graph()
195
+
196
+ # Detect flows
197
+ flow_detector = FlowDetector(db)
198
+ entry_points = flow_detector.detect_entry_points()
199
+ flows = []
200
+ for ep in entry_points[:50]:
201
+ try:
202
+ flow = flow_detector.trace_flow(ep)
203
+ if flow is not None:
204
+ flows.append(flow)
205
+ except Exception:
206
+ pass
207
+
208
+ # Detect communities
209
+ comm_detector = CommunityDetector(db)
210
+ communities = comm_detector.detect_communities()
211
+
212
+ duration_ms = int((time.time() - t0) * 1000)
213
+
214
+ return {
215
+ "success": True,
216
+ "files_parsed": len(file_records),
217
+ "nodes": db.get_node_count(),
218
+ "edges": db.get_edge_count(),
219
+ "flows": len(flows),
220
+ "communities": len(communities),
221
+ "duration_ms": duration_ms,
222
+ }
223
+ except Exception as exc:
224
+ logger.exception("build_code_graph failed")
225
+ return _error_response(str(exc))
226
+
227
+ # ==================================================================
228
+ # Tool 2: update_code_graph
229
+ # ==================================================================
230
+
231
+ @server.tool()
232
+ async def update_code_graph(
233
+ repo_path: str = "",
234
+ changed_files: str = "",
235
+ ) -> dict:
236
+ """Incrementally update the code graph for changed files.
237
+
238
+ If changed_files is empty, auto-detects changes via git diff HEAD~1.
239
+
240
+ Args:
241
+ repo_path: Absolute path to repository root. Empty = use last built repo.
242
+ changed_files: Comma-separated file paths (relative to repo root).
243
+ """
244
+ try:
245
+ err = _check_graph_exists()
246
+ if err is not None:
247
+ return err
248
+
249
+ t0 = time.time()
250
+ svc = _get_service()
251
+ db = svc.db
252
+
253
+ files_list = [
254
+ f.strip() for f in changed_files.split(",") if f.strip()
255
+ ] if changed_files else []
256
+
257
+ if not files_list:
258
+ # Auto-detect via git
259
+ try:
260
+ import subprocess
261
+ repo = Path(repo_path) if repo_path else svc.config.repo_root
262
+ result = subprocess.run(
263
+ ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
264
+ capture_output=True, text=True, timeout=30,
265
+ cwd=str(repo),
266
+ )
267
+ files_list = [
268
+ f.strip() for f in result.stdout.strip().split("\n")
269
+ if f.strip()
270
+ ]
271
+ except Exception as exc:
272
+ return _error_response(f"Git not available or not a git repository: {exc}")
273
+
274
+ if not files_list:
275
+ return {
276
+ "success": True,
277
+ "files_updated": 0,
278
+ "nodes_added": 0,
279
+ "nodes_removed": 0,
280
+ "edges_added": 0,
281
+ "edges_removed": 0,
282
+ "duration_ms": 0,
283
+ }
284
+
285
+ # Re-parse changed files
286
+ from superlocalmemory.code_graph.parser import CodeParser
287
+ from superlocalmemory.code_graph.graph_store import GraphStore
288
+
289
+ config = svc.config
290
+ parser = CodeParser(config)
291
+ store = GraphStore(db)
292
+ repo = Path(repo_path) if repo_path else config.repo_root
293
+
294
+ nodes_before = db.get_node_count()
295
+ edges_before = db.get_edge_count()
296
+
297
+ for fp in files_list:
298
+ full = repo / fp
299
+ if not full.exists():
300
+ store.remove_file(fp)
301
+ continue
302
+ ext = full.suffix
303
+ lang = config.extension_map.get(ext)
304
+ if lang is None:
305
+ continue
306
+ try:
307
+ source = full.read_bytes()
308
+ file_nodes, file_edges = parser.parse_file(
309
+ Path(fp), source, lang
310
+ )
311
+ import hashlib
312
+ from superlocalmemory.code_graph.models import FileRecord
313
+ fr = FileRecord(
314
+ file_path=fp,
315
+ content_hash=hashlib.sha256(source).hexdigest(),
316
+ mtime=full.stat().st_mtime,
317
+ language=lang,
318
+ node_count=len(file_nodes),
319
+ edge_count=len(file_edges),
320
+ last_indexed=time.time(),
321
+ )
322
+ store.store_file_nodes_edges(fp, file_nodes, file_edges, fr)
323
+ except Exception as exc:
324
+ logger.warning("Failed to update %s: %s", fp, exc)
325
+
326
+ duration_ms = int((time.time() - t0) * 1000)
327
+ nodes_after = db.get_node_count()
328
+ edges_after = db.get_edge_count()
329
+
330
+ return {
331
+ "success": True,
332
+ "files_updated": len(files_list),
333
+ "nodes_added": max(0, nodes_after - nodes_before),
334
+ "nodes_removed": max(0, nodes_before - nodes_after),
335
+ "edges_added": max(0, edges_after - edges_before),
336
+ "edges_removed": max(0, edges_before - edges_after),
337
+ "duration_ms": duration_ms,
338
+ }
339
+ except Exception as exc:
340
+ logger.exception("update_code_graph failed")
341
+ return _error_response(str(exc))
342
+
343
+ # ==================================================================
344
+ # Tool 3: get_blast_radius
345
+ # ==================================================================
346
+
347
+ @server.tool()
348
+ async def get_blast_radius(
349
+ changed_files: str,
350
+ max_depth: int = 2,
351
+ max_nodes: int = 500,
352
+ ) -> dict:
353
+ """Compute impact radius for changed files.
354
+
355
+ Uses BFS in both directions (callers + callees) to find all impacted
356
+ code entities. Essential for code review.
357
+
358
+ Args:
359
+ changed_files: Comma-separated file paths (relative to repo root).
360
+ max_depth: Maximum BFS depth (default 2).
361
+ max_nodes: Maximum nodes to return (default 500).
362
+ """
363
+ try:
364
+ err = _check_graph_exists()
365
+ if err is not None:
366
+ return err
367
+
368
+ files_list = [
369
+ f.strip() for f in changed_files.split(",") if f.strip()
370
+ ]
371
+ if not files_list:
372
+ return _error_response("No changed files provided.")
373
+
374
+ db = _get_db()
375
+ from superlocalmemory.code_graph.graph_store import GraphStore
376
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
377
+ from superlocalmemory.code_graph.blast_radius import BlastRadius
378
+
379
+ store = GraphStore(db)
380
+ engine = GraphEngine(store)
381
+ br = BlastRadius(engine)
382
+
383
+ result = br.compute(
384
+ changed_files=files_list,
385
+ max_depth=max_depth,
386
+ max_nodes=max_nodes,
387
+ )
388
+
389
+ return {
390
+ "success": True,
391
+ "changed_nodes": list(result.changed_nodes),
392
+ "impacted_nodes": list(result.impacted_nodes),
393
+ "impacted_files": list(result.impacted_files),
394
+ "edges": [
395
+ {"source": s, "target": t, "kind": d.get("kind", "")}
396
+ for s, t, d in result.edges
397
+ ],
398
+ "depth_reached": result.depth_reached,
399
+ "truncated": result.truncated,
400
+ }
401
+ except Exception as exc:
402
+ logger.exception("get_blast_radius failed")
403
+ return _error_response(str(exc))
404
+
405
+ # ==================================================================
406
+ # Tool 4: get_review_context
407
+ # ==================================================================
408
+
409
+ @server.tool()
410
+ async def get_review_context(
411
+ changed_files: str,
412
+ include_source: bool = True,
413
+ ) -> dict:
414
+ """Get token-optimized review context for changed files.
415
+
416
+ Args:
417
+ changed_files: Comma-separated file paths.
418
+ include_source: Whether to include source code snippets (default True).
419
+ """
420
+ try:
421
+ err = _check_graph_exists()
422
+ if err is not None:
423
+ return err
424
+
425
+ files_list = [
426
+ f.strip() for f in changed_files.split(",") if f.strip()
427
+ ]
428
+ db = _get_db()
429
+ from superlocalmemory.code_graph.changes import ChangeAnalyzer
430
+
431
+ analyzer = ChangeAnalyzer(db)
432
+ ctx = analyzer.get_review_context(files_list)
433
+
434
+ return {
435
+ "success": True,
436
+ "summary": ctx.summary,
437
+ "review_items": [
438
+ {
439
+ "node_id": n.node_id,
440
+ "name": n.name,
441
+ "kind": n.kind,
442
+ "file_path": n.file_path,
443
+ "risk_score": round(n.risk_score, 3),
444
+ }
445
+ for n in ctx.changed_nodes
446
+ ],
447
+ "test_gaps": [
448
+ {"node_id": n.node_id, "name": n.name, "file_path": n.file_path}
449
+ for n in ctx.test_gaps
450
+ ],
451
+ "risk_score": round(ctx.overall_risk, 3),
452
+ }
453
+ except Exception as exc:
454
+ logger.exception("get_review_context failed")
455
+ return _error_response(str(exc))
456
+
457
+ # ==================================================================
458
+ # Tool 5: query_graph
459
+ # ==================================================================
460
+
461
+ VALID_PATTERNS = frozenset({
462
+ "callers_of", "callees_of", "imports_of", "imported_by",
463
+ "tests_for", "inherits_from", "inherited_by", "contains",
464
+ })
465
+
466
+ @server.tool()
467
+ async def query_graph(
468
+ pattern: str,
469
+ target: str = "",
470
+ limit: int = 20,
471
+ ) -> dict:
472
+ """Query the code graph for relationships.
473
+
474
+ Args:
475
+ pattern: Query type: callers_of, callees_of, imports_of, imported_by,
476
+ tests_for, inherits_from, inherited_by, contains.
477
+ target: Qualified name or partial name to match.
478
+ limit: Maximum results (default 20).
479
+ """
480
+ try:
481
+ if pattern not in VALID_PATTERNS:
482
+ return _error_response(
483
+ f"Invalid pattern '{pattern}'. Must be one of: "
484
+ + ", ".join(sorted(VALID_PATTERNS))
485
+ )
486
+
487
+ err = _check_graph_exists()
488
+ if err is not None:
489
+ return err
490
+
491
+ db = _get_db()
492
+ from superlocalmemory.code_graph.graph_store import GraphStore
493
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
494
+ from superlocalmemory.code_graph.models import EdgeKind
495
+
496
+ store = GraphStore(db)
497
+ engine = GraphEngine(store)
498
+
499
+ # Resolve target to node_id
500
+ node_id = _resolve_target(db, target)
501
+ if node_id is None:
502
+ return {
503
+ "success": True,
504
+ "pattern": pattern,
505
+ "target": target,
506
+ "results": [],
507
+ "message": f"No node found matching '{target}'.",
508
+ }
509
+
510
+ # Map pattern to engine query
511
+ results: list[dict[str, Any]] = []
512
+
513
+ edge_kind_map = {
514
+ "callers_of": (True, {EdgeKind.CALLS.value}),
515
+ "callees_of": (False, {EdgeKind.CALLS.value}),
516
+ "imports_of": (False, {EdgeKind.IMPORTS.value}),
517
+ "imported_by": (True, {EdgeKind.IMPORTS.value}),
518
+ "inherits_from": (False, {EdgeKind.INHERITS.value}),
519
+ "inherited_by": (True, {EdgeKind.INHERITS.value}),
520
+ "contains": (False, {EdgeKind.CONTAINS.value}),
521
+ }
522
+
523
+ if pattern == "tests_for":
524
+ raw = engine.get_tests_for(node_id)
525
+ results = [
526
+ {
527
+ "qualified_name": r.get("qualified_name", ""),
528
+ "kind": r.get("kind", ""),
529
+ "file_path": r.get("file_path", ""),
530
+ "name": r.get("name", ""),
531
+ }
532
+ for r in raw[:limit]
533
+ ]
534
+ elif pattern in edge_kind_map:
535
+ is_incoming, kinds = edge_kind_map[pattern]
536
+ if is_incoming:
537
+ raw = engine.get_callers(node_id, edge_kinds=kinds)
538
+ else:
539
+ raw = engine.get_callees(node_id, edge_kinds=kinds)
540
+
541
+ results = [
542
+ {
543
+ "qualified_name": r["node"].get("qualified_name", ""),
544
+ "kind": r["node"].get("kind", ""),
545
+ "file_path": r["node"].get("file_path", ""),
546
+ "name": r["node"].get("name", ""),
547
+ }
548
+ for r in raw[:limit]
549
+ ]
550
+
551
+ return {
552
+ "success": True,
553
+ "pattern": pattern,
554
+ "target": target,
555
+ "results": results,
556
+ }
557
+ except Exception as exc:
558
+ logger.exception("query_graph failed")
559
+ return _error_response(str(exc))
560
+
561
+ # ==================================================================
562
+ # Tool 6: semantic_search_code
563
+ # ==================================================================
564
+
565
+ @server.tool()
566
+ async def semantic_search_code(
567
+ query: str,
568
+ kind: str = "",
569
+ limit: int = 20,
570
+ ) -> dict:
571
+ """Search code entities by semantic meaning using hybrid FTS5 + vector search.
572
+
573
+ Args:
574
+ query: Natural language query (e.g. "authentication handler").
575
+ kind: Filter by node kind: "Function", "Class", "File", "Test", or "" for all.
576
+ limit: Maximum results (default 20).
577
+ """
578
+ try:
579
+ err = _check_graph_exists()
580
+ if err is not None:
581
+ return err
582
+
583
+ db = _get_db()
584
+ from superlocalmemory.code_graph.search import HybridSearch
585
+
586
+ searcher = HybridSearch(db)
587
+ raw = searcher.search(query, limit=limit * 2)
588
+
589
+ # Kind filter
590
+ if kind:
591
+ kind_lower = kind.lower()
592
+ raw = [r for r in raw if r.kind.lower() == kind_lower]
593
+
594
+ results = [
595
+ {
596
+ "qualified_name": r.qualified_name,
597
+ "kind": r.kind,
598
+ "file_path": r.file_path,
599
+ "score": round(r.score, 4),
600
+ "line_start": r.line_start,
601
+ "name": r.name,
602
+ }
603
+ for r in raw[:limit]
604
+ ]
605
+
606
+ return {"success": True, "results": results}
607
+ except Exception as exc:
608
+ logger.exception("semantic_search_code failed")
609
+ return _error_response(str(exc))
610
+
611
+ # ==================================================================
612
+ # Tool 7: list_graph_stats
613
+ # ==================================================================
614
+
615
+ @server.tool()
616
+ async def list_graph_stats() -> dict:
617
+ """Get code graph size and health metrics."""
618
+ try:
619
+ svc = _get_service()
620
+ if svc is None:
621
+ return _graph_not_built_error()
622
+
623
+ stats = svc.get_stats()
624
+
625
+ # Count code_memory_links
626
+ stale_links = 0
627
+ total_links = 0
628
+ db = _get_db()
629
+ if db is not None:
630
+ try:
631
+ rows = db.execute(
632
+ "SELECT COUNT(*) as cnt FROM code_memory_links", ()
633
+ )
634
+ total_links = rows[0]["cnt"] if rows else 0
635
+ rows = db.execute(
636
+ "SELECT COUNT(*) as cnt FROM code_memory_links WHERE is_stale = 1",
637
+ (),
638
+ )
639
+ stale_links = rows[0]["cnt"] if rows else 0
640
+ except Exception:
641
+ pass
642
+
643
+ return {
644
+ "success": True,
645
+ "repo_root": stats.get("repo_root", ""),
646
+ "total_files": stats.get("files", 0),
647
+ "total_nodes": stats.get("nodes", 0),
648
+ "total_edges": stats.get("edges", 0),
649
+ "total_code_memory_links": total_links,
650
+ "stale_links": stale_links,
651
+ "built": stats.get("built", False),
652
+ "db_path": stats.get("db_path", ""),
653
+ }
654
+ except Exception as exc:
655
+ logger.exception("list_graph_stats failed")
656
+ return _error_response(str(exc))
657
+
658
+ # ==================================================================
659
+ # Tool 8: find_large_functions
660
+ # ==================================================================
661
+
662
+ @server.tool()
663
+ async def find_large_functions(
664
+ threshold: int = 50,
665
+ limit: int = 20,
666
+ ) -> dict:
667
+ """Find functions exceeding a line count threshold.
668
+
669
+ Args:
670
+ threshold: Minimum lines to flag (default 50).
671
+ limit: Maximum results (default 20).
672
+ """
673
+ try:
674
+ err = _check_graph_exists()
675
+ if err is not None:
676
+ return err
677
+
678
+ db = _get_db()
679
+ rows = db.execute(
680
+ """SELECT node_id, name, qualified_name, kind, file_path,
681
+ line_start, line_end
682
+ FROM graph_nodes
683
+ WHERE kind IN ('function', 'method')
684
+ AND (line_end - line_start) >= ?
685
+ ORDER BY (line_end - line_start) DESC
686
+ LIMIT ?""",
687
+ (threshold, limit),
688
+ )
689
+
690
+ functions = [
691
+ {
692
+ "qualified_name": r["qualified_name"],
693
+ "lines": r["line_end"] - r["line_start"],
694
+ "file_path": r["file_path"],
695
+ "name": r["name"],
696
+ }
697
+ for r in rows
698
+ ]
699
+
700
+ return {"success": True, "functions": functions}
701
+ except Exception as exc:
702
+ logger.exception("find_large_functions failed")
703
+ return _error_response(str(exc))
704
+
705
+ # ==================================================================
706
+ # Tool 9: list_flows
707
+ # ==================================================================
708
+
709
+ @server.tool()
710
+ async def list_flows(
711
+ sort_by: str = "criticality",
712
+ limit: int = 20,
713
+ ) -> dict:
714
+ """List execution flows sorted by criticality or size.
715
+
716
+ Args:
717
+ sort_by: "criticality" (default) or "size".
718
+ limit: Maximum flows to return (default 20).
719
+ """
720
+ try:
721
+ err = _check_graph_exists()
722
+ if err is not None:
723
+ return err
724
+
725
+ db = _get_db()
726
+ from superlocalmemory.code_graph.flows import FlowDetector
727
+
728
+ detector = FlowDetector(db)
729
+ entries = detector.detect_entry_points()[:50]
730
+
731
+ flows = []
732
+ for ep in entries:
733
+ try:
734
+ flow = detector.trace_flow(ep)
735
+ if flow is not None:
736
+ flows.append(flow)
737
+ except Exception:
738
+ pass
739
+
740
+ if sort_by == "size":
741
+ flows.sort(key=lambda f: -f.node_count)
742
+ else:
743
+ flows.sort(key=lambda f: -f.criticality)
744
+
745
+ result = [
746
+ {
747
+ "name": f.name,
748
+ "entry_point": f.entry_node_id,
749
+ "depth": f.depth,
750
+ "node_count": f.node_count,
751
+ "file_count": f.file_count,
752
+ "criticality": round(f.criticality, 3),
753
+ }
754
+ for f in flows[:limit]
755
+ ]
756
+
757
+ return {"success": True, "flows": result}
758
+ except Exception as exc:
759
+ logger.exception("list_flows failed")
760
+ return _error_response(str(exc))
761
+
762
+ # ==================================================================
763
+ # Tool 10: get_flow
764
+ # ==================================================================
765
+
766
+ @server.tool()
767
+ async def get_flow(
768
+ flow_name: str,
769
+ ) -> dict:
770
+ """Get detailed information about a single execution flow.
771
+
772
+ Args:
773
+ flow_name: The flow name or entry point name.
774
+ """
775
+ try:
776
+ err = _check_graph_exists()
777
+ if err is not None:
778
+ return err
779
+
780
+ db = _get_db()
781
+ from superlocalmemory.code_graph.flows import FlowDetector
782
+
783
+ detector = FlowDetector(db)
784
+ entries = detector.detect_entry_points()
785
+
786
+ for ep in entries:
787
+ try:
788
+ flow = detector.trace_flow(ep)
789
+ if flow is not None and (
790
+ flow.name == flow_name
791
+ or flow.entry_node_id == flow_name
792
+ ):
793
+ return {
794
+ "success": True,
795
+ "flow": {
796
+ "name": flow.name,
797
+ "entry_point": flow.entry_node_id,
798
+ "depth": flow.depth,
799
+ "node_count": flow.node_count,
800
+ "file_count": flow.file_count,
801
+ "criticality": round(flow.criticality, 3),
802
+ "path": list(flow.path_node_ids),
803
+ },
804
+ }
805
+ except Exception:
806
+ pass
807
+
808
+ return _error_response(f"Flow '{flow_name}' not found.")
809
+ except Exception as exc:
810
+ logger.exception("get_flow failed")
811
+ return _error_response(str(exc))
812
+
813
+ # ==================================================================
814
+ # Tool 11: get_affected_flows
815
+ # ==================================================================
816
+
817
+ @server.tool()
818
+ async def get_affected_flows(
819
+ changed_files: str,
820
+ ) -> dict:
821
+ """Find execution flows impacted by file changes.
822
+
823
+ Args:
824
+ changed_files: Comma-separated file paths.
825
+ """
826
+ try:
827
+ err = _check_graph_exists()
828
+ if err is not None:
829
+ return err
830
+
831
+ files_list = [
832
+ f.strip() for f in changed_files.split(",") if f.strip()
833
+ ]
834
+ if not files_list:
835
+ return _error_response("No changed files provided.")
836
+
837
+ db = _get_db()
838
+ from superlocalmemory.code_graph.flows import FlowDetector
839
+
840
+ # Get all changed node IDs
841
+ changed_node_ids: set[str] = set()
842
+ for fp in files_list:
843
+ rows = db.execute(
844
+ "SELECT node_id FROM graph_nodes WHERE file_path = ?",
845
+ (fp,),
846
+ )
847
+ changed_node_ids.update(r["node_id"] for r in rows)
848
+
849
+ detector = FlowDetector(db)
850
+ entries = detector.detect_entry_points()[:50]
851
+
852
+ affected = []
853
+ for ep in entries:
854
+ try:
855
+ flow = detector.trace_flow(ep)
856
+ if flow is None:
857
+ continue
858
+ overlap = changed_node_ids.intersection(flow.path_node_ids)
859
+ if overlap:
860
+ affected.append({
861
+ "name": flow.name,
862
+ "criticality": round(flow.criticality, 3),
863
+ "affected_nodes": list(overlap),
864
+ })
865
+ except Exception:
866
+ pass
867
+
868
+ return {"success": True, "affected_flows": affected}
869
+ except Exception as exc:
870
+ logger.exception("get_affected_flows failed")
871
+ return _error_response(str(exc))
872
+
873
+ # ==================================================================
874
+ # Tool 12: list_communities
875
+ # ==================================================================
876
+
877
+ @server.tool()
878
+ async def list_communities(
879
+ sort_by: str = "cohesion",
880
+ limit: int = 20,
881
+ ) -> dict:
882
+ """List detected code communities (clusters of related code).
883
+
884
+ Args:
885
+ sort_by: "cohesion" (default) or "size".
886
+ limit: Maximum communities (default 20).
887
+ """
888
+ try:
889
+ err = _check_graph_exists()
890
+ if err is not None:
891
+ return err
892
+
893
+ db = _get_db()
894
+ from superlocalmemory.code_graph.communities import CommunityDetector
895
+
896
+ detector = CommunityDetector(db)
897
+ comms = detector.detect_communities()
898
+
899
+ if sort_by == "size":
900
+ comms.sort(key=lambda c: -c.size)
901
+ else:
902
+ comms.sort(key=lambda c: -c.cohesion)
903
+
904
+ result = [
905
+ {
906
+ "community_id": c.community_id,
907
+ "name": c.name,
908
+ "size": c.size,
909
+ "cohesion": round(c.cohesion, 3),
910
+ "dominant_language": c.dominant_language,
911
+ }
912
+ for c in comms[:limit]
913
+ ]
914
+
915
+ return {"success": True, "communities": result}
916
+ except Exception as exc:
917
+ logger.exception("list_communities failed")
918
+ return _error_response(str(exc))
919
+
920
+ # ==================================================================
921
+ # Tool 13: get_community
922
+ # ==================================================================
923
+
924
+ @server.tool()
925
+ async def get_community(
926
+ community_id: int,
927
+ ) -> dict:
928
+ """Get detailed information about a single code community.
929
+
930
+ Args:
931
+ community_id: The community ID.
932
+ """
933
+ try:
934
+ err = _check_graph_exists()
935
+ if err is not None:
936
+ return err
937
+
938
+ db = _get_db()
939
+ from superlocalmemory.code_graph.communities import CommunityDetector
940
+
941
+ detector = CommunityDetector(db)
942
+ comms = detector.detect_communities()
943
+
944
+ for c in comms:
945
+ if c.community_id == community_id:
946
+ return {
947
+ "success": True,
948
+ "community": {
949
+ "community_id": c.community_id,
950
+ "name": c.name,
951
+ "size": c.size,
952
+ "cohesion": round(c.cohesion, 3),
953
+ "dominant_language": c.dominant_language,
954
+ "directory": c.directory,
955
+ "file_count": c.file_count,
956
+ "members": list(c.node_ids[:100]),
957
+ },
958
+ }
959
+
960
+ return _error_response(
961
+ f"Community {community_id} not found."
962
+ )
963
+ except Exception as exc:
964
+ logger.exception("get_community failed")
965
+ return _error_response(str(exc))
966
+
967
+ # ==================================================================
968
+ # Tool 14: get_architecture_overview
969
+ # ==================================================================
970
+
971
+ @server.tool()
972
+ async def get_architecture_overview() -> dict:
973
+ """Get high-level architecture map showing communities and their relationships."""
974
+ try:
975
+ err = _check_graph_exists()
976
+ if err is not None:
977
+ return err
978
+
979
+ db = _get_db()
980
+ from superlocalmemory.code_graph.communities import CommunityDetector
981
+
982
+ detector = CommunityDetector(db)
983
+ overview = detector.get_architecture_overview()
984
+
985
+ communities = [
986
+ {
987
+ "community_id": c.community_id,
988
+ "name": c.name,
989
+ "size": c.size,
990
+ "cohesion": round(c.cohesion, 3),
991
+ }
992
+ for c in overview.communities
993
+ ]
994
+
995
+ warnings = [
996
+ {
997
+ "from": w.source_community,
998
+ "to": w.target_community,
999
+ "edge_count": w.edge_count,
1000
+ "severity": w.severity,
1001
+ }
1002
+ for w in overview.coupling_warnings
1003
+ ]
1004
+
1005
+ return {
1006
+ "success": True,
1007
+ "communities": communities,
1008
+ "cross_community_edges": warnings,
1009
+ "total_nodes": overview.total_nodes,
1010
+ "total_communities": overview.total_communities,
1011
+ }
1012
+ except Exception as exc:
1013
+ logger.exception("get_architecture_overview failed")
1014
+ return _error_response(str(exc))
1015
+
1016
+ # ==================================================================
1017
+ # Tool 15: detect_changes
1018
+ # ==================================================================
1019
+
1020
+ @server.tool()
1021
+ async def detect_changes(
1022
+ base: str = "HEAD~1",
1023
+ ) -> dict:
1024
+ """Detect code changes and compute risk scores.
1025
+
1026
+ Args:
1027
+ base: Git ref to diff against (default "HEAD~1").
1028
+ """
1029
+ try:
1030
+ err = _check_graph_exists()
1031
+ if err is not None:
1032
+ return err
1033
+
1034
+ db = _get_db()
1035
+ svc = _get_service()
1036
+ from superlocalmemory.code_graph.changes import ChangeAnalyzer
1037
+
1038
+ try:
1039
+ hunks = ChangeAnalyzer.parse_git_diff(svc.config.repo_root, base)
1040
+ except Exception as exc:
1041
+ return _error_response(
1042
+ f"Git not available or not a git repository: {exc}"
1043
+ )
1044
+
1045
+ changed_files = list({h.file_path for h in hunks})
1046
+ analyzer = ChangeAnalyzer(db)
1047
+ ctx = analyzer.analyze_changes(changed_files)
1048
+
1049
+ return {
1050
+ "success": True,
1051
+ "summary": ctx.summary,
1052
+ "risk_score": round(ctx.overall_risk, 3),
1053
+ "changed_functions": [
1054
+ {
1055
+ "name": n.name,
1056
+ "kind": n.kind,
1057
+ "file_path": n.file_path,
1058
+ "risk_score": round(n.risk_score, 3),
1059
+ }
1060
+ for n in ctx.changed_nodes
1061
+ ],
1062
+ "test_gaps": [
1063
+ {"name": n.name, "file_path": n.file_path}
1064
+ for n in ctx.test_gaps
1065
+ ],
1066
+ "review_priorities": [
1067
+ {"name": n.name, "risk_score": round(n.risk_score, 3)}
1068
+ for n in ctx.review_priorities
1069
+ ],
1070
+ }
1071
+ except Exception as exc:
1072
+ logger.exception("detect_changes failed")
1073
+ return _error_response(str(exc))
1074
+
1075
+ # ==================================================================
1076
+ # Tool 16: refactor_preview
1077
+ # ==================================================================
1078
+
1079
+ @server.tool()
1080
+ async def refactor_preview(
1081
+ action: str,
1082
+ target: str,
1083
+ new_name: str = "",
1084
+ ) -> dict:
1085
+ """Preview a refactoring operation without executing it.
1086
+
1087
+ Args:
1088
+ action: "rename" | "find_dead_code" | "find_duplicates"
1089
+ target: Qualified name or partial name of the target.
1090
+ new_name: New name (required for "rename" action).
1091
+ """
1092
+ try:
1093
+ err = _check_graph_exists()
1094
+ if err is not None:
1095
+ return err
1096
+
1097
+ if action == "rename" and not new_name:
1098
+ return _error_response(
1099
+ "new_name is required for rename action."
1100
+ )
1101
+
1102
+ db = _get_db()
1103
+
1104
+ if action == "rename":
1105
+ node_id = _resolve_target(db, target)
1106
+ if node_id is None:
1107
+ return _error_response(f"Target '{target}' not found.")
1108
+
1109
+ # Find all references
1110
+ from superlocalmemory.code_graph.graph_store import GraphStore
1111
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
1112
+
1113
+ store = GraphStore(db)
1114
+ engine = GraphEngine(store)
1115
+ callers = engine.get_callers(node_id)
1116
+
1117
+ affected_files = set()
1118
+ node_data = engine.get_node_data(node_id)
1119
+ affected_files.add(node_data.get("file_path", ""))
1120
+ for c in callers:
1121
+ affected_files.add(c["node"].get("file_path", ""))
1122
+
1123
+ return {
1124
+ "success": True,
1125
+ "action": "rename",
1126
+ "affected_files": list(affected_files),
1127
+ "affected_references": len(callers),
1128
+ "estimated_changes": len(callers) + 1,
1129
+ }
1130
+
1131
+ elif action == "find_dead_code":
1132
+ from superlocalmemory.code_graph.graph_store import GraphStore
1133
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
1134
+
1135
+ store = GraphStore(db)
1136
+ engine = GraphEngine(store)
1137
+ graph = engine.graph
1138
+ index = engine.index
1139
+
1140
+ dead = []
1141
+ for node_id_str, rx_idx in index.id_to_rx.items():
1142
+ data = graph[rx_idx]
1143
+ if data.get("kind") in ("function", "method"):
1144
+ in_edges = list(graph.in_edges(rx_idx))
1145
+ # No callers and not a test and not an entry point
1146
+ if not in_edges and not data.get("is_test"):
1147
+ dead.append({
1148
+ "qualified_name": data.get("qualified_name", ""),
1149
+ "file_path": data.get("file_path", ""),
1150
+ "kind": data.get("kind", ""),
1151
+ })
1152
+
1153
+ return {
1154
+ "success": True,
1155
+ "action": "find_dead_code",
1156
+ "affected_files": list({d["file_path"] for d in dead}),
1157
+ "affected_references": dead[:50],
1158
+ "estimated_changes": len(dead),
1159
+ }
1160
+
1161
+ return _error_response(
1162
+ f"Unsupported action: {action}. Use rename or find_dead_code."
1163
+ )
1164
+ except Exception as exc:
1165
+ logger.exception("refactor_preview failed")
1166
+ return _error_response(str(exc))
1167
+
1168
+ # ==================================================================
1169
+ # Tool 17: apply_refactor
1170
+ # ==================================================================
1171
+
1172
+ @server.tool()
1173
+ async def apply_refactor(
1174
+ action: str,
1175
+ target: str,
1176
+ new_name: str = "",
1177
+ dry_run: bool = True,
1178
+ ) -> dict:
1179
+ """Execute a refactoring operation (stub for MVP).
1180
+
1181
+ Args:
1182
+ action: "rename" (only supported action currently).
1183
+ target: Qualified name of the target to rename.
1184
+ new_name: New name.
1185
+ dry_run: If True (default), show what would change without modifying files.
1186
+ """
1187
+ try:
1188
+ # MVP stub — always dry_run
1189
+ preview = await refactor_preview(action, target, new_name)
1190
+ if not preview.get("success"):
1191
+ return preview
1192
+
1193
+ preview["dry_run"] = True
1194
+ preview["message"] = (
1195
+ "Apply refactor is a stub in MVP. "
1196
+ "Use the preview to guide manual refactoring."
1197
+ )
1198
+ return preview
1199
+ except Exception as exc:
1200
+ logger.exception("apply_refactor failed")
1201
+ return _error_response(str(exc))
1202
+
1203
+ # ==================================================================
1204
+ # Tool 18 (BRIDGE): code_memory_search
1205
+ # ==================================================================
1206
+
1207
+ @server.tool()
1208
+ async def code_memory_search(
1209
+ code_entity: str,
1210
+ link_type: str = "",
1211
+ limit: int = 10,
1212
+ ) -> dict:
1213
+ """Search SLM memories linked to a code entity.
1214
+
1215
+ BRIDGE TOOL: Combines code graph structure with SLM memory content.
1216
+
1217
+ Args:
1218
+ code_entity: Function/class/file name or qualified name.
1219
+ link_type: Filter by link type. Empty = all.
1220
+ limit: Maximum results (default 10).
1221
+ """
1222
+ try:
1223
+ err = _check_graph_exists()
1224
+ if err is not None:
1225
+ return err
1226
+
1227
+ db = _get_db()
1228
+ node_id = _resolve_target(db, code_entity)
1229
+ if node_id is None:
1230
+ return {
1231
+ "success": True,
1232
+ "code_entity": code_entity,
1233
+ "matched_node": None,
1234
+ "memories": [],
1235
+ }
1236
+
1237
+ # Get links
1238
+ links = db.get_links_for_node(node_id)
1239
+ if link_type:
1240
+ links = [lnk for lnk in links if lnk.link_type.value == link_type]
1241
+
1242
+ # Get node data
1243
+ node = db.get_node(node_id)
1244
+ node_info = {
1245
+ "node_id": node.node_id,
1246
+ "name": node.name,
1247
+ "qualified_name": node.qualified_name,
1248
+ "kind": node.kind.value,
1249
+ "file_path": node.file_path,
1250
+ } if node else None
1251
+
1252
+ memories = [
1253
+ {
1254
+ "fact_id": lnk.slm_fact_id,
1255
+ "link_type": lnk.link_type.value,
1256
+ "confidence": round(lnk.confidence, 3),
1257
+ "created_at": lnk.created_at,
1258
+ "is_stale": lnk.is_stale,
1259
+ }
1260
+ for lnk in links[:limit]
1261
+ ]
1262
+
1263
+ return {
1264
+ "success": True,
1265
+ "code_entity": code_entity,
1266
+ "matched_node": node_info,
1267
+ "memories": memories,
1268
+ }
1269
+ except Exception as exc:
1270
+ logger.exception("code_memory_search failed")
1271
+ return _error_response(str(exc))
1272
+
1273
+ # ==================================================================
1274
+ # Tool 19 (BRIDGE): code_entity_history
1275
+ # ==================================================================
1276
+
1277
+ @server.tool()
1278
+ async def code_entity_history(
1279
+ code_entity: str,
1280
+ ) -> dict:
1281
+ """Get the complete memory timeline for a code entity.
1282
+
1283
+ BRIDGE TOOL: Shows all memories about a function/class ordered by time.
1284
+
1285
+ Args:
1286
+ code_entity: Function/class/file name or qualified name.
1287
+ """
1288
+ try:
1289
+ err = _check_graph_exists()
1290
+ if err is not None:
1291
+ return err
1292
+
1293
+ db = _get_db()
1294
+ node_id = _resolve_target(db, code_entity)
1295
+ if node_id is None:
1296
+ return {
1297
+ "success": True,
1298
+ "code_entity": code_entity,
1299
+ "node": None,
1300
+ "timeline": [],
1301
+ "total_memories": 0,
1302
+ "stale_count": 0,
1303
+ }
1304
+
1305
+ node = db.get_node(node_id)
1306
+ links = db.get_links_for_node(node_id)
1307
+
1308
+ # Sort by created_at
1309
+ links_sorted = sorted(links, key=lambda lnk: lnk.created_at)
1310
+ stale_count = sum(1 for lnk in links if lnk.is_stale)
1311
+
1312
+ node_info = {
1313
+ "node_id": node.node_id,
1314
+ "name": node.name,
1315
+ "qualified_name": node.qualified_name,
1316
+ "kind": node.kind.value,
1317
+ "file_path": node.file_path,
1318
+ } if node else None
1319
+
1320
+ timeline = [
1321
+ {
1322
+ "fact_id": lnk.slm_fact_id,
1323
+ "link_type": lnk.link_type.value,
1324
+ "created_at": lnk.created_at,
1325
+ "is_stale": lnk.is_stale,
1326
+ "confidence": round(lnk.confidence, 3),
1327
+ }
1328
+ for lnk in links_sorted
1329
+ ]
1330
+
1331
+ return {
1332
+ "success": True,
1333
+ "code_entity": code_entity,
1334
+ "node": node_info,
1335
+ "timeline": timeline,
1336
+ "total_memories": len(links),
1337
+ "stale_count": stale_count,
1338
+ }
1339
+ except Exception as exc:
1340
+ logger.exception("code_entity_history failed")
1341
+ return _error_response(str(exc))
1342
+
1343
+ # ==================================================================
1344
+ # Tool 20 (BRIDGE): enrich_blast_radius
1345
+ # ==================================================================
1346
+
1347
+ @server.tool()
1348
+ async def enrich_blast_radius(
1349
+ changed_files: str,
1350
+ max_depth: int = 2,
1351
+ ) -> dict:
1352
+ """Compute blast radius PLUS institutional memory for each impacted node.
1353
+
1354
+ BRIDGE TOOL: Returns impact analysis enriched with relevant SLM memories.
1355
+
1356
+ Args:
1357
+ changed_files: Comma-separated file paths.
1358
+ max_depth: BFS depth for blast radius (default 2).
1359
+ """
1360
+ try:
1361
+ err = _check_graph_exists()
1362
+ if err is not None:
1363
+ return err
1364
+
1365
+ files_list = [
1366
+ f.strip() for f in changed_files.split(",") if f.strip()
1367
+ ]
1368
+ if not files_list:
1369
+ return _error_response("No changed files provided.")
1370
+
1371
+ db = _get_db()
1372
+ from superlocalmemory.code_graph.graph_store import GraphStore
1373
+ from superlocalmemory.code_graph.graph_engine import GraphEngine
1374
+ from superlocalmemory.code_graph.blast_radius import BlastRadius
1375
+
1376
+ store = GraphStore(db)
1377
+ engine = GraphEngine(store)
1378
+ br = BlastRadius(engine)
1379
+ result = br.compute(changed_files=files_list, max_depth=max_depth)
1380
+
1381
+ # Enrich each impacted node with memories
1382
+ total_memories = 0
1383
+ impacted = []
1384
+ for nid in result.impacted_nodes:
1385
+ links = db.get_links_for_node(nid)
1386
+ try:
1387
+ data = engine.get_node_data(nid)
1388
+ except Exception:
1389
+ data = {}
1390
+ memories = [
1391
+ {
1392
+ "fact_id": lnk.slm_fact_id,
1393
+ "link_type": lnk.link_type.value,
1394
+ "is_stale": lnk.is_stale,
1395
+ }
1396
+ for lnk in links[:5]
1397
+ ]
1398
+ total_memories += len(memories)
1399
+ impacted.append({
1400
+ "qualified_name": data.get("qualified_name", nid),
1401
+ "kind": data.get("kind", ""),
1402
+ "file_path": data.get("file_path", ""),
1403
+ "memories": memories,
1404
+ "memory_count": len(links),
1405
+ })
1406
+
1407
+ return {
1408
+ "success": True,
1409
+ "changed_nodes": list(result.changed_nodes),
1410
+ "impacted_nodes": impacted,
1411
+ "total_memories_surfaced": total_memories,
1412
+ }
1413
+ except Exception as exc:
1414
+ logger.exception("enrich_blast_radius failed")
1415
+ return _error_response(str(exc))
1416
+
1417
+ # ==================================================================
1418
+ # Tool 21 (BRIDGE): code_stale_check
1419
+ # ==================================================================
1420
+
1421
+ @server.tool()
1422
+ async def code_stale_check(
1423
+ scope: str = "all",
1424
+ ) -> dict:
1425
+ """Find SLM memories that reference deleted or changed code.
1426
+
1427
+ BRIDGE TOOL: Identifies memories that may be outdated.
1428
+
1429
+ Args:
1430
+ scope: "all" (check everything) or a file path to scope the check.
1431
+ """
1432
+ try:
1433
+ err = _check_graph_exists()
1434
+ if err is not None:
1435
+ return err
1436
+
1437
+ db = _get_db()
1438
+
1439
+ if scope == "all":
1440
+ rows = db.execute(
1441
+ """SELECT cml.link_id, cml.slm_fact_id, cml.code_node_id,
1442
+ cml.is_stale, cml.last_verified,
1443
+ gn.qualified_name, gn.file_path
1444
+ FROM code_memory_links cml
1445
+ LEFT JOIN graph_nodes gn ON cml.code_node_id = gn.node_id
1446
+ WHERE cml.is_stale = 1""",
1447
+ (),
1448
+ )
1449
+ else:
1450
+ rows = db.execute(
1451
+ """SELECT cml.link_id, cml.slm_fact_id, cml.code_node_id,
1452
+ cml.is_stale, cml.last_verified,
1453
+ gn.qualified_name, gn.file_path
1454
+ FROM code_memory_links cml
1455
+ LEFT JOIN graph_nodes gn ON cml.code_node_id = gn.node_id
1456
+ WHERE cml.is_stale = 1 AND gn.file_path = ?""",
1457
+ (scope,),
1458
+ )
1459
+
1460
+ stale_memories = [
1461
+ {
1462
+ "fact_id": r["slm_fact_id"],
1463
+ "code_entity": r["qualified_name"] or r["code_node_id"],
1464
+ "reason": "Code entity deleted or changed",
1465
+ "stale_since": r["last_verified"] or "",
1466
+ }
1467
+ for r in rows
1468
+ ]
1469
+
1470
+ # Total links count
1471
+ total_rows = db.execute(
1472
+ "SELECT COUNT(*) as cnt FROM code_memory_links", ()
1473
+ )
1474
+ total_links = total_rows[0]["cnt"] if total_rows else 0
1475
+
1476
+ return {
1477
+ "success": True,
1478
+ "stale_memories": stale_memories,
1479
+ "total_stale": len(stale_memories),
1480
+ "total_links": total_links,
1481
+ }
1482
+ except Exception as exc:
1483
+ logger.exception("code_stale_check failed")
1484
+ return _error_response(str(exc))
1485
+
1486
+ # ==================================================================
1487
+ # Tool 22 (BRIDGE): link_memory_to_code
1488
+ # ==================================================================
1489
+
1490
+ VALID_LINK_TYPES = frozenset({
1491
+ "mentions", "decision_about", "bug_fix", "refactor", "design_rationale",
1492
+ })
1493
+
1494
+ @server.tool()
1495
+ async def link_memory_to_code(
1496
+ fact_id: str,
1497
+ code_entity: str,
1498
+ link_type: str = "mentions",
1499
+ ) -> dict:
1500
+ """Manually link an SLM memory to a code graph node.
1501
+
1502
+ BRIDGE TOOL: Creates an explicit link between a memory and a code entity.
1503
+
1504
+ Args:
1505
+ fact_id: The SLM atomic fact ID.
1506
+ code_entity: Function/class/file qualified name or partial name.
1507
+ link_type: One of: mentions, decision_about, bug_fix, refactor, design_rationale.
1508
+ """
1509
+ try:
1510
+ if link_type not in VALID_LINK_TYPES:
1511
+ return _error_response(
1512
+ f"Invalid link_type '{link_type}'. Must be one of: "
1513
+ + ", ".join(sorted(VALID_LINK_TYPES))
1514
+ )
1515
+
1516
+ err = _check_graph_exists()
1517
+ if err is not None:
1518
+ return err
1519
+
1520
+ db = _get_db()
1521
+ node_id = _resolve_target(db, code_entity)
1522
+ if node_id is None:
1523
+ return _error_response(
1524
+ f"Code entity '{code_entity}' not found in graph."
1525
+ )
1526
+
1527
+ from superlocalmemory.code_graph.models import CodeMemoryLink, LinkType
1528
+ from superlocalmemory.storage.models import _new_id
1529
+ from datetime import datetime, timezone
1530
+
1531
+ now_str = datetime.now(timezone.utc).isoformat()
1532
+ link = CodeMemoryLink(
1533
+ link_id=_new_id(),
1534
+ code_node_id=node_id,
1535
+ slm_fact_id=fact_id,
1536
+ link_type=LinkType(link_type),
1537
+ confidence=1.0,
1538
+ created_at=now_str,
1539
+ last_verified=now_str,
1540
+ is_stale=False,
1541
+ )
1542
+ db.upsert_link(link)
1543
+
1544
+ node = db.get_node(node_id)
1545
+ node_info = {
1546
+ "node_id": node.node_id,
1547
+ "name": node.name,
1548
+ "qualified_name": node.qualified_name,
1549
+ } if node else {"node_id": node_id}
1550
+
1551
+ return {
1552
+ "success": True,
1553
+ "link_id": link.link_id,
1554
+ "code_node": node_info,
1555
+ "confidence": 1.0,
1556
+ }
1557
+ except Exception as exc:
1558
+ logger.exception("link_memory_to_code failed")
1559
+ return _error_response(str(exc))
1560
+
1561
+
1562
+ # ---------------------------------------------------------------------------
1563
+ # Helpers
1564
+ # ---------------------------------------------------------------------------
1565
+
1566
+
1567
+ def _resolve_target(db, target: str) -> str | None:
1568
+ """Resolve a target name to a node_id. Returns None if not found."""
1569
+ if not target:
1570
+ return None
1571
+
1572
+ # Try exact qualified_name match
1573
+ node = db.get_node_by_qualified_name(target)
1574
+ if node is not None:
1575
+ return node.node_id
1576
+
1577
+ # Try exact node_id match
1578
+ node = db.get_node(target)
1579
+ if node is not None:
1580
+ return node.node_id
1581
+
1582
+ # Try LIKE match on name or qualified_name
1583
+ rows = db.execute(
1584
+ """SELECT node_id FROM graph_nodes
1585
+ WHERE name = ? OR qualified_name LIKE ?
1586
+ LIMIT 1""",
1587
+ (target, f"%{target}%"),
1588
+ )
1589
+ if rows:
1590
+ return rows[0]["node_id"]
1591
+
1592
+ return None