superlocalmemory 3.3.20 → 3.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/pyproject.toml +9 -1
- package/src/superlocalmemory/cli/commands.py +138 -22
- package/src/superlocalmemory/cli/daemon.py +372 -0
- package/src/superlocalmemory/cli/main.py +8 -0
- package/src/superlocalmemory/cli/pending_store.py +158 -0
- package/src/superlocalmemory/cli/setup_wizard.py +39 -6
- package/src/superlocalmemory/code_graph/__init__.py +46 -0
- package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
- package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
- package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
- package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
- package/src/superlocalmemory/code_graph/changes.py +363 -0
- package/src/superlocalmemory/code_graph/communities.py +299 -0
- package/src/superlocalmemory/code_graph/config.py +88 -0
- package/src/superlocalmemory/code_graph/database.py +482 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
- package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
- package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
- package/src/superlocalmemory/code_graph/flows.py +350 -0
- package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
- package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
- package/src/superlocalmemory/code_graph/graph_store.py +158 -0
- package/src/superlocalmemory/code_graph/incremental.py +200 -0
- package/src/superlocalmemory/code_graph/models.py +130 -0
- package/src/superlocalmemory/code_graph/parser.py +507 -0
- package/src/superlocalmemory/code_graph/resolver.py +321 -0
- package/src/superlocalmemory/code_graph/search.py +460 -0
- package/src/superlocalmemory/code_graph/service.py +95 -0
- package/src/superlocalmemory/code_graph/watcher.py +207 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +32 -0
- package/src/superlocalmemory/core/engine_wiring.py +5 -0
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
- package/src/superlocalmemory/infra/event_bus.py +5 -0
- package/src/superlocalmemory/mcp/server.py +23 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
- package/src/superlocalmemory/retrieval/engine.py +137 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
- package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
- package/src/superlocalmemory/retrieval/strategy.py +16 -0
- package/src/superlocalmemory/server/api.py +4 -2
- package/src/superlocalmemory/server/ui.py +5 -2
- package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
- package/src/superlocalmemory/ui/index.html +1879 -0
- package/src/superlocalmemory/ui/js/agents.js +192 -0
- package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
- package/src/superlocalmemory/ui/js/behavioral.js +276 -0
- package/src/superlocalmemory/ui/js/clusters.js +206 -0
- package/src/superlocalmemory/ui/js/compliance.js +252 -0
- package/src/superlocalmemory/ui/js/core.js +246 -0
- package/src/superlocalmemory/ui/js/dashboard.js +110 -0
- package/src/superlocalmemory/ui/js/events.js +178 -0
- package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
- package/src/superlocalmemory/ui/js/feedback.js +333 -0
- package/src/superlocalmemory/ui/js/graph-core.js +447 -0
- package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
- package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
- package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
- package/src/superlocalmemory/ui/js/ide-status.js +102 -0
- package/src/superlocalmemory/ui/js/init.js +45 -0
- package/src/superlocalmemory/ui/js/learning.js +435 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
- package/src/superlocalmemory/ui/js/math-health.js +98 -0
- package/src/superlocalmemory/ui/js/memories.js +264 -0
- package/src/superlocalmemory/ui/js/modal.js +357 -0
- package/src/superlocalmemory/ui/js/patterns.js +93 -0
- package/src/superlocalmemory/ui/js/profiles.js +236 -0
- package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
- package/src/superlocalmemory/ui/js/search.js +59 -0
- package/src/superlocalmemory/ui/js/settings.js +224 -0
- package/src/superlocalmemory/ui/js/timeline.js +32 -0
- 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
|