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.
- package/package.json +1 -1
- package/pyproject.toml +9 -1
- package/src/superlocalmemory/cli/commands.py +140 -23
- package/src/superlocalmemory/cli/daemon.py +372 -0
- package/src/superlocalmemory/cli/main.py +10 -2
- 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/config.py +4 -3
- 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/recall_pipeline.py +7 -3
- 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/agentic.py +89 -17
- 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,464 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.4 — CodeGraph Bridge Module
|
|
4
|
+
|
|
5
|
+
"""Entity Resolver — match SLM fact text against code graph nodes.
|
|
6
|
+
|
|
7
|
+
Extracts code mentions from natural language using regex patterns,
|
|
8
|
+
then matches against graph_nodes by name, qualified_name, and file_path.
|
|
9
|
+
Creates code_memory_links entries in code_graph.db.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from superlocalmemory.code_graph.models import CodeMemoryLink, LinkType
|
|
21
|
+
from superlocalmemory.storage.models import _new_id
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
25
|
+
from superlocalmemory.code_graph.models import GraphNode
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Regex patterns (Appendix A from LLD)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
RE_FILE_PATH = re.compile(
|
|
34
|
+
r'(?:[\w./\\]+\.(?:py|ts|tsx|js|jsx|go|rs|java|rb|cpp|c|cs|kt|swift|php))',
|
|
35
|
+
re.IGNORECASE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
RE_QUALIFIED_CALL = re.compile(
|
|
39
|
+
r'(\w+(?:\.\w+)+)\s*\(\)',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
RE_BACKTICK = re.compile(
|
|
43
|
+
r'`(\w{3,})`',
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
RE_CAMEL_CASE = re.compile(
|
|
47
|
+
r'\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
RE_SNAKE_CALL = re.compile(
|
|
51
|
+
r'\b(\w+_\w+)\s*\(\)',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
RE_QUOTED = re.compile(
|
|
55
|
+
r"""['"]([\w]{3,})['"]""",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Bare snake_case identifiers (e.g., "authenticate_user" without parens)
|
|
59
|
+
RE_SNAKE_BARE = re.compile(
|
|
60
|
+
r'\b([a-z]\w*_\w+)\b',
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Link type classification keywords (Appendix B from LLD)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
BUG_FIX_KEYWORDS: frozenset[str] = frozenset({
|
|
68
|
+
"bug", "fix", "fixed", "broken", "error", "issue", "crash",
|
|
69
|
+
"fault", "defect", "patch", "hotfix", "workaround",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
DECISION_KEYWORDS: frozenset[str] = frozenset({
|
|
73
|
+
"decided", "decision", "chose", "chosen", "should use",
|
|
74
|
+
"instead of", "agreed", "approved", "selected", "opted",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
REFACTOR_KEYWORDS: frozenset[str] = frozenset({
|
|
78
|
+
"refactor", "refactored", "rename", "renamed", "extract",
|
|
79
|
+
"extracted", "move", "moved", "split", "merge", "cleanup",
|
|
80
|
+
"restructure", "reorganize",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
RATIONALE_KEYWORDS: frozenset[str] = frozenset({
|
|
84
|
+
"because", "reason", "rationale", "why we", "designed to",
|
|
85
|
+
"purpose of", "motivation", "trade-off", "tradeoff",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Confidence scores
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
CONF_EXACT_NAME = 0.90
|
|
93
|
+
CONF_QUALIFIED_CONTAINS = 0.85
|
|
94
|
+
CONF_FILE_PATH = 0.80
|
|
95
|
+
CONF_SUBSTRING = 0.60
|
|
96
|
+
CONF_BOOST_BACKTICK = 0.05
|
|
97
|
+
CONF_BOOST_CALL_SYNTAX = 0.05
|
|
98
|
+
CONF_CAP = 0.95
|
|
99
|
+
|
|
100
|
+
MIN_SUBSTRING_LEN = 4
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Data classes
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class MatchedNode:
|
|
109
|
+
"""A code graph node matched against a fact's text."""
|
|
110
|
+
node_id: str
|
|
111
|
+
qualified_name: str
|
|
112
|
+
kind: str
|
|
113
|
+
file_path: str
|
|
114
|
+
confidence: float
|
|
115
|
+
match_source: str # "exact_name" | "qualified_name" | "file_path" | "substring"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class CandidateMention:
|
|
120
|
+
"""A code mention extracted from fact text."""
|
|
121
|
+
text: str
|
|
122
|
+
is_backtick: bool = False
|
|
123
|
+
is_call_syntax: bool = False
|
|
124
|
+
is_file_path: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Node index (cached)
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class _NodeIndex:
|
|
133
|
+
"""Cached lookup structures for fast matching."""
|
|
134
|
+
by_name: dict[str, list[GraphNode]] # name.lower() -> nodes
|
|
135
|
+
by_file_stem: dict[str, list[GraphNode]] # file stem -> nodes
|
|
136
|
+
all_nodes: tuple[GraphNode, ...]
|
|
137
|
+
version: int
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# EntityResolver
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
class EntityResolver:
|
|
145
|
+
"""Matches SLM fact text against code graph nodes."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, code_graph_db: CodeGraphDatabase) -> None:
|
|
148
|
+
self._db = code_graph_db
|
|
149
|
+
self._cache: _NodeIndex | None = None
|
|
150
|
+
|
|
151
|
+
def resolve(
|
|
152
|
+
self,
|
|
153
|
+
fact_text: str,
|
|
154
|
+
fact_id: str,
|
|
155
|
+
) -> list[CodeMemoryLink]:
|
|
156
|
+
"""Resolve code entity mentions in fact text and create links.
|
|
157
|
+
|
|
158
|
+
Returns list of CodeMemoryLink objects created.
|
|
159
|
+
"""
|
|
160
|
+
if not fact_text or not fact_id:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
index = self._get_index()
|
|
164
|
+
candidates = self._extract_candidates(fact_text)
|
|
165
|
+
if not candidates:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
# Match candidates against graph nodes
|
|
169
|
+
matches: dict[str, MatchedNode] = {} # node_id -> best match
|
|
170
|
+
for candidate in candidates:
|
|
171
|
+
matched = self._match_candidate(candidate, index)
|
|
172
|
+
for m in matched:
|
|
173
|
+
existing = matches.get(m.node_id)
|
|
174
|
+
if existing is None or m.confidence > existing.confidence:
|
|
175
|
+
matches[m.node_id] = m
|
|
176
|
+
|
|
177
|
+
if not matches:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
# Classify link type
|
|
181
|
+
link_type = self._classify_link_type(fact_text)
|
|
182
|
+
now_str = datetime.now(timezone.utc).isoformat()
|
|
183
|
+
|
|
184
|
+
# Create links
|
|
185
|
+
links: list[CodeMemoryLink] = []
|
|
186
|
+
for matched_node in matches.values():
|
|
187
|
+
link = CodeMemoryLink(
|
|
188
|
+
link_id=_new_id(),
|
|
189
|
+
code_node_id=matched_node.node_id,
|
|
190
|
+
slm_fact_id=fact_id,
|
|
191
|
+
slm_entity_id=None,
|
|
192
|
+
link_type=link_type,
|
|
193
|
+
confidence=matched_node.confidence,
|
|
194
|
+
created_at=now_str,
|
|
195
|
+
last_verified=now_str,
|
|
196
|
+
is_stale=False,
|
|
197
|
+
)
|
|
198
|
+
self._db.upsert_link(link)
|
|
199
|
+
links.append(link)
|
|
200
|
+
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Resolved %d code entities for fact %s",
|
|
203
|
+
len(links), fact_id,
|
|
204
|
+
)
|
|
205
|
+
return links
|
|
206
|
+
|
|
207
|
+
def get_links_for_fact(self, fact_id: str) -> list[CodeMemoryLink]:
|
|
208
|
+
"""Get all code_memory_links for a given SLM fact ID."""
|
|
209
|
+
return self._db.get_links_for_fact(fact_id)
|
|
210
|
+
|
|
211
|
+
def get_links_for_node(self, node_id: str) -> list[CodeMemoryLink]:
|
|
212
|
+
"""Get all code_memory_links for a given code graph node."""
|
|
213
|
+
return self._db.get_links_for_node(node_id)
|
|
214
|
+
|
|
215
|
+
def invalidate_cache(self) -> None:
|
|
216
|
+
"""Clear the cached node lookup dict."""
|
|
217
|
+
self._cache = None
|
|
218
|
+
|
|
219
|
+
def get_matched_nodes(self, fact_text: str) -> list[MatchedNode]:
|
|
220
|
+
"""Extract and match code mentions without creating links.
|
|
221
|
+
|
|
222
|
+
Useful for testing and preview operations.
|
|
223
|
+
"""
|
|
224
|
+
if not fact_text:
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
index = self._get_index()
|
|
228
|
+
candidates = self._extract_candidates(fact_text)
|
|
229
|
+
if not candidates:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
matches: dict[str, MatchedNode] = {}
|
|
233
|
+
for candidate in candidates:
|
|
234
|
+
matched = self._match_candidate(candidate, index)
|
|
235
|
+
for m in matched:
|
|
236
|
+
existing = matches.get(m.node_id)
|
|
237
|
+
if existing is None or m.confidence > existing.confidence:
|
|
238
|
+
matches[m.node_id] = m
|
|
239
|
+
|
|
240
|
+
return list(matches.values())
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Internals
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _get_index(self) -> _NodeIndex:
|
|
247
|
+
"""Get or rebuild the node index."""
|
|
248
|
+
current_version = self._db.version
|
|
249
|
+
if self._cache is not None and self._cache.version == current_version:
|
|
250
|
+
return self._cache
|
|
251
|
+
|
|
252
|
+
all_nodes = self._db.get_all_nodes()
|
|
253
|
+
by_name: dict[str, list[GraphNode]] = {}
|
|
254
|
+
by_file_stem: dict[str, list[GraphNode]] = {}
|
|
255
|
+
|
|
256
|
+
for node in all_nodes:
|
|
257
|
+
key = node.name.lower()
|
|
258
|
+
by_name.setdefault(key, []).append(node)
|
|
259
|
+
|
|
260
|
+
# Index by file stem (e.g., "handler" from "handler.py")
|
|
261
|
+
if node.file_path:
|
|
262
|
+
import os
|
|
263
|
+
stem = os.path.splitext(os.path.basename(node.file_path))[0].lower()
|
|
264
|
+
by_file_stem.setdefault(stem, []).append(node)
|
|
265
|
+
|
|
266
|
+
self._cache = _NodeIndex(
|
|
267
|
+
by_name=by_name,
|
|
268
|
+
by_file_stem=by_file_stem,
|
|
269
|
+
all_nodes=tuple(all_nodes),
|
|
270
|
+
version=current_version,
|
|
271
|
+
)
|
|
272
|
+
return self._cache
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _extract_candidates(text: str) -> list[CandidateMention]:
|
|
276
|
+
"""Extract code mention candidates from text using regex."""
|
|
277
|
+
candidates: list[CandidateMention] = []
|
|
278
|
+
seen: set[str] = set()
|
|
279
|
+
|
|
280
|
+
def _add(raw: str, *, backtick: bool = False,
|
|
281
|
+
call: bool = False, file_path: bool = False) -> None:
|
|
282
|
+
normalized = raw.strip().rstrip("()")
|
|
283
|
+
if normalized and normalized not in seen:
|
|
284
|
+
seen.add(normalized)
|
|
285
|
+
candidates.append(CandidateMention(
|
|
286
|
+
text=normalized,
|
|
287
|
+
is_backtick=backtick,
|
|
288
|
+
is_call_syntax=call,
|
|
289
|
+
is_file_path=file_path,
|
|
290
|
+
))
|
|
291
|
+
|
|
292
|
+
# File paths
|
|
293
|
+
for m in RE_FILE_PATH.finditer(text):
|
|
294
|
+
_add(m.group(0), file_path=True)
|
|
295
|
+
|
|
296
|
+
# Qualified calls
|
|
297
|
+
for m in RE_QUALIFIED_CALL.finditer(text):
|
|
298
|
+
_add(m.group(1), call=True)
|
|
299
|
+
|
|
300
|
+
# Backtick-quoted
|
|
301
|
+
for m in RE_BACKTICK.finditer(text):
|
|
302
|
+
_add(m.group(1), backtick=True)
|
|
303
|
+
|
|
304
|
+
# CamelCase
|
|
305
|
+
for m in RE_CAMEL_CASE.finditer(text):
|
|
306
|
+
_add(m.group(1))
|
|
307
|
+
|
|
308
|
+
# snake_case calls
|
|
309
|
+
for m in RE_SNAKE_CALL.finditer(text):
|
|
310
|
+
_add(m.group(1), call=True)
|
|
311
|
+
|
|
312
|
+
# Quoted names
|
|
313
|
+
for m in RE_QUOTED.finditer(text):
|
|
314
|
+
_add(m.group(1))
|
|
315
|
+
|
|
316
|
+
# Bare snake_case identifiers (lowest priority — after all specific patterns)
|
|
317
|
+
for m in RE_SNAKE_BARE.finditer(text):
|
|
318
|
+
_add(m.group(1))
|
|
319
|
+
|
|
320
|
+
return candidates
|
|
321
|
+
|
|
322
|
+
def _match_candidate(
|
|
323
|
+
self, candidate: CandidateMention, index: _NodeIndex
|
|
324
|
+
) -> list[MatchedNode]:
|
|
325
|
+
"""Match a single candidate against the node index."""
|
|
326
|
+
results: list[MatchedNode] = []
|
|
327
|
+
text_lower = candidate.text.lower()
|
|
328
|
+
|
|
329
|
+
# 1. Exact name match
|
|
330
|
+
exact_matches = index.by_name.get(text_lower, [])
|
|
331
|
+
for node in exact_matches:
|
|
332
|
+
conf = CONF_EXACT_NAME
|
|
333
|
+
if candidate.is_backtick:
|
|
334
|
+
conf += CONF_BOOST_BACKTICK
|
|
335
|
+
if candidate.is_call_syntax:
|
|
336
|
+
conf += CONF_BOOST_CALL_SYNTAX
|
|
337
|
+
conf = min(conf, CONF_CAP)
|
|
338
|
+
results.append(MatchedNode(
|
|
339
|
+
node_id=node.node_id,
|
|
340
|
+
qualified_name=node.qualified_name,
|
|
341
|
+
kind=node.kind.value,
|
|
342
|
+
file_path=node.file_path,
|
|
343
|
+
confidence=conf,
|
|
344
|
+
match_source="exact_name",
|
|
345
|
+
))
|
|
346
|
+
|
|
347
|
+
if results:
|
|
348
|
+
return results
|
|
349
|
+
|
|
350
|
+
# 2. File path match
|
|
351
|
+
if candidate.is_file_path:
|
|
352
|
+
import os
|
|
353
|
+
stem = os.path.splitext(os.path.basename(candidate.text))[0].lower()
|
|
354
|
+
# Match nodes whose file_path ends with the candidate
|
|
355
|
+
for node in index.all_nodes:
|
|
356
|
+
if node.file_path and node.file_path.endswith(candidate.text):
|
|
357
|
+
conf = CONF_FILE_PATH
|
|
358
|
+
conf = min(conf, CONF_CAP)
|
|
359
|
+
results.append(MatchedNode(
|
|
360
|
+
node_id=node.node_id,
|
|
361
|
+
qualified_name=node.qualified_name,
|
|
362
|
+
kind=node.kind.value,
|
|
363
|
+
file_path=node.file_path,
|
|
364
|
+
confidence=conf,
|
|
365
|
+
match_source="file_path",
|
|
366
|
+
))
|
|
367
|
+
# Also match by file stem in by_file_stem
|
|
368
|
+
if not results:
|
|
369
|
+
file_stem_matches = index.by_file_stem.get(stem, [])
|
|
370
|
+
for node in file_stem_matches:
|
|
371
|
+
conf = CONF_FILE_PATH
|
|
372
|
+
results.append(MatchedNode(
|
|
373
|
+
node_id=node.node_id,
|
|
374
|
+
qualified_name=node.qualified_name,
|
|
375
|
+
kind=node.kind.value,
|
|
376
|
+
file_path=node.file_path,
|
|
377
|
+
confidence=conf,
|
|
378
|
+
match_source="file_path",
|
|
379
|
+
))
|
|
380
|
+
|
|
381
|
+
if results:
|
|
382
|
+
return results
|
|
383
|
+
|
|
384
|
+
# 3. Qualified name contains (substring match)
|
|
385
|
+
if len(text_lower) >= MIN_SUBSTRING_LEN:
|
|
386
|
+
for node in index.all_nodes:
|
|
387
|
+
qname_lower = node.qualified_name.lower()
|
|
388
|
+
if text_lower in qname_lower and text_lower != qname_lower:
|
|
389
|
+
conf = CONF_QUALIFIED_CONTAINS
|
|
390
|
+
if candidate.is_backtick:
|
|
391
|
+
conf += CONF_BOOST_BACKTICK
|
|
392
|
+
if candidate.is_call_syntax:
|
|
393
|
+
conf += CONF_BOOST_CALL_SYNTAX
|
|
394
|
+
conf = min(conf, CONF_CAP)
|
|
395
|
+
results.append(MatchedNode(
|
|
396
|
+
node_id=node.node_id,
|
|
397
|
+
qualified_name=node.qualified_name,
|
|
398
|
+
kind=node.kind.value,
|
|
399
|
+
file_path=node.file_path,
|
|
400
|
+
confidence=conf,
|
|
401
|
+
match_source="qualified_name",
|
|
402
|
+
))
|
|
403
|
+
|
|
404
|
+
if results:
|
|
405
|
+
return results
|
|
406
|
+
|
|
407
|
+
# 4. Substring match (node name is substring of candidate or vice versa)
|
|
408
|
+
if len(text_lower) >= MIN_SUBSTRING_LEN:
|
|
409
|
+
for node in index.all_nodes:
|
|
410
|
+
name_lower = node.name.lower()
|
|
411
|
+
if len(name_lower) < MIN_SUBSTRING_LEN:
|
|
412
|
+
continue
|
|
413
|
+
if name_lower in text_lower or text_lower in name_lower:
|
|
414
|
+
conf = CONF_SUBSTRING
|
|
415
|
+
if candidate.is_backtick:
|
|
416
|
+
conf += CONF_BOOST_BACKTICK
|
|
417
|
+
conf = min(conf, CONF_CAP)
|
|
418
|
+
results.append(MatchedNode(
|
|
419
|
+
node_id=node.node_id,
|
|
420
|
+
qualified_name=node.qualified_name,
|
|
421
|
+
kind=node.kind.value,
|
|
422
|
+
file_path=node.file_path,
|
|
423
|
+
confidence=conf,
|
|
424
|
+
match_source="substring",
|
|
425
|
+
))
|
|
426
|
+
|
|
427
|
+
return results
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _classify_link_type(fact_text: str) -> LinkType:
|
|
431
|
+
"""Classify the link type based on keywords in the fact text."""
|
|
432
|
+
text_lower = fact_text.lower()
|
|
433
|
+
words = set(text_lower.split())
|
|
434
|
+
|
|
435
|
+
# Check multi-word keywords by testing if they appear in text
|
|
436
|
+
for kw in BUG_FIX_KEYWORDS:
|
|
437
|
+
if " " in kw:
|
|
438
|
+
if kw in text_lower:
|
|
439
|
+
return LinkType.BUG_FIX
|
|
440
|
+
elif kw in words:
|
|
441
|
+
return LinkType.BUG_FIX
|
|
442
|
+
|
|
443
|
+
for kw in DECISION_KEYWORDS:
|
|
444
|
+
if " " in kw:
|
|
445
|
+
if kw in text_lower:
|
|
446
|
+
return LinkType.DECISION_ABOUT
|
|
447
|
+
elif kw in words:
|
|
448
|
+
return LinkType.DECISION_ABOUT
|
|
449
|
+
|
|
450
|
+
for kw in REFACTOR_KEYWORDS:
|
|
451
|
+
if " " in kw:
|
|
452
|
+
if kw in text_lower:
|
|
453
|
+
return LinkType.REFACTOR
|
|
454
|
+
elif kw in words:
|
|
455
|
+
return LinkType.REFACTOR
|
|
456
|
+
|
|
457
|
+
for kw in RATIONALE_KEYWORDS:
|
|
458
|
+
if " " in kw:
|
|
459
|
+
if kw in text_lower:
|
|
460
|
+
return LinkType.DESIGN_RATIONALE
|
|
461
|
+
elif kw in words:
|
|
462
|
+
return LinkType.DESIGN_RATIONALE
|
|
463
|
+
|
|
464
|
+
return LinkType.MENTIONS
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory v3.4 — CodeGraph Bridge Module
|
|
4
|
+
|
|
5
|
+
"""Event Listeners — bidirectional bridge between SLM events and code graph.
|
|
6
|
+
|
|
7
|
+
Three listeners:
|
|
8
|
+
- memory.stored → entity resolution + enrichment + Hebbian linking
|
|
9
|
+
- code_graph.node_changed → mark affected links for re-verification
|
|
10
|
+
- code_graph.node_deleted → mark affected links as stale
|
|
11
|
+
|
|
12
|
+
HR-5: ALL listeners catch ALL exceptions and log them — NEVER raise.
|
|
13
|
+
Bridge failures must not break SLM memory operations.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from superlocalmemory.code_graph.bridge.entity_resolver import EntityResolver
|
|
23
|
+
from superlocalmemory.code_graph.bridge.fact_enricher import FactEnricher
|
|
24
|
+
from superlocalmemory.code_graph.bridge.hebbian_linker import HebbianLinker
|
|
25
|
+
from superlocalmemory.code_graph.bridge.temporal_checker import TemporalChecker
|
|
26
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BridgeEventListeners:
|
|
32
|
+
"""Registers and manages bidirectional event listeners for the bridge.
|
|
33
|
+
|
|
34
|
+
All listener callbacks are wrapped in try/except to satisfy HR-5:
|
|
35
|
+
bridge failures NEVER propagate to the event bus.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
entity_resolver: EntityResolver,
|
|
41
|
+
fact_enricher: FactEnricher,
|
|
42
|
+
hebbian_linker: HebbianLinker,
|
|
43
|
+
temporal_checker: TemporalChecker,
|
|
44
|
+
code_graph_db: CodeGraphDatabase,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._entity_resolver = entity_resolver
|
|
47
|
+
self._fact_enricher = fact_enricher
|
|
48
|
+
self._hebbian_linker = hebbian_linker
|
|
49
|
+
self._temporal_checker = temporal_checker
|
|
50
|
+
self._db = code_graph_db
|
|
51
|
+
self._listener_ids: list[tuple[str, Callable[..., Any]]] = []
|
|
52
|
+
self._started = False
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_started(self) -> bool:
|
|
56
|
+
"""Whether listeners are currently registered."""
|
|
57
|
+
return self._started
|
|
58
|
+
|
|
59
|
+
def start(self, event_bus: Any) -> None:
|
|
60
|
+
"""Register all listeners on the event bus.
|
|
61
|
+
|
|
62
|
+
Registers:
|
|
63
|
+
- on_memory_stored: listens to "memory.stored"
|
|
64
|
+
- on_code_node_deleted: listens to "code_graph.node_deleted"
|
|
65
|
+
- on_code_node_changed: listens to "code_graph.node_changed"
|
|
66
|
+
"""
|
|
67
|
+
if self._started:
|
|
68
|
+
logger.warning("BridgeEventListeners already started")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
self._event_bus = event_bus
|
|
72
|
+
|
|
73
|
+
listeners: list[tuple[str, Callable[..., Any]]] = [
|
|
74
|
+
("memory.stored", self.on_memory_stored),
|
|
75
|
+
("code_graph.node_deleted", self.on_code_node_deleted),
|
|
76
|
+
("code_graph.node_changed", self.on_code_node_changed),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for event_type, callback in listeners:
|
|
80
|
+
try:
|
|
81
|
+
event_bus.subscribe(event_type, callback)
|
|
82
|
+
self._listener_ids.append((event_type, callback))
|
|
83
|
+
except Exception:
|
|
84
|
+
logger.error(
|
|
85
|
+
"Failed to register listener for %s",
|
|
86
|
+
event_type, exc_info=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self._started = True
|
|
90
|
+
logger.info(
|
|
91
|
+
"Bridge event listeners started (%d registered)",
|
|
92
|
+
len(self._listener_ids),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def stop(self) -> None:
|
|
96
|
+
"""Unregister all listeners from the event bus."""
|
|
97
|
+
if not self._started:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
bus = getattr(self, "_event_bus", None)
|
|
101
|
+
if bus is not None:
|
|
102
|
+
for event_type, callback in self._listener_ids:
|
|
103
|
+
try:
|
|
104
|
+
bus.unsubscribe(event_type, callback)
|
|
105
|
+
except Exception:
|
|
106
|
+
logger.debug(
|
|
107
|
+
"Failed to unsubscribe %s listener",
|
|
108
|
+
event_type, exc_info=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._listener_ids.clear()
|
|
112
|
+
self._started = False
|
|
113
|
+
logger.info("Bridge event listeners stopped")
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Listener callbacks — NEVER raise (HR-5)
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def on_memory_stored(self, event: dict[str, Any]) -> None:
|
|
120
|
+
"""Handle memory.stored event.
|
|
121
|
+
|
|
122
|
+
Chains: entity_resolver → fact_enricher → hebbian_linker
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
payload = event.get("payload", {})
|
|
126
|
+
fact_id = payload.get("fact_id")
|
|
127
|
+
content = payload.get("content_preview", "")
|
|
128
|
+
|
|
129
|
+
if not fact_id or not content:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Step 1: Entity resolution
|
|
133
|
+
links = self._entity_resolver.resolve(content, fact_id)
|
|
134
|
+
|
|
135
|
+
if not links:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Step 2: Fact enrichment
|
|
139
|
+
matched_nodes = self._entity_resolver.get_matched_nodes(content)
|
|
140
|
+
if matched_nodes:
|
|
141
|
+
self._fact_enricher.enrich(
|
|
142
|
+
fact_id, matched_nodes, content,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Step 3: Hebbian linking
|
|
146
|
+
node_ids = [link.code_node_id for link in links]
|
|
147
|
+
self._hebbian_linker.link(fact_id, node_ids)
|
|
148
|
+
|
|
149
|
+
except Exception:
|
|
150
|
+
logger.error(
|
|
151
|
+
"Bridge: on_memory_stored failed (non-fatal)",
|
|
152
|
+
exc_info=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def on_code_node_deleted(self, event: dict[str, Any]) -> None:
|
|
156
|
+
"""Handle code_graph.node_deleted event."""
|
|
157
|
+
try:
|
|
158
|
+
payload = event.get("payload", {})
|
|
159
|
+
node_id = payload.get("node_id")
|
|
160
|
+
|
|
161
|
+
if not node_id:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
self._temporal_checker.mark_links_stale(node_id)
|
|
165
|
+
|
|
166
|
+
except Exception:
|
|
167
|
+
logger.error(
|
|
168
|
+
"Bridge: on_code_node_deleted failed (non-fatal)",
|
|
169
|
+
exc_info=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def on_code_node_changed(self, event: dict[str, Any]) -> None:
|
|
173
|
+
"""Handle code_graph.node_changed event.
|
|
174
|
+
|
|
175
|
+
Flags links for re-verification by clearing last_verified.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
payload = event.get("payload", {})
|
|
179
|
+
node_id = payload.get("node_id")
|
|
180
|
+
|
|
181
|
+
if not node_id:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Mark links for re-verification
|
|
185
|
+
self._db.execute_write(
|
|
186
|
+
"UPDATE code_memory_links SET last_verified = NULL "
|
|
187
|
+
"WHERE code_node_id = ? AND is_stale = 0",
|
|
188
|
+
(node_id,),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
except Exception:
|
|
192
|
+
logger.error(
|
|
193
|
+
"Bridge: on_code_node_changed failed (non-fatal)",
|
|
194
|
+
exc_info=True,
|
|
195
|
+
)
|