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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +9 -1
  3. package/src/superlocalmemory/cli/commands.py +138 -22
  4. package/src/superlocalmemory/cli/daemon.py +372 -0
  5. package/src/superlocalmemory/cli/main.py +8 -0
  6. package/src/superlocalmemory/cli/pending_store.py +158 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +39 -6
  8. package/src/superlocalmemory/code_graph/__init__.py +46 -0
  9. package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
  10. package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
  11. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
  12. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
  13. package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
  14. package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
  15. package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
  16. package/src/superlocalmemory/code_graph/changes.py +363 -0
  17. package/src/superlocalmemory/code_graph/communities.py +299 -0
  18. package/src/superlocalmemory/code_graph/config.py +88 -0
  19. package/src/superlocalmemory/code_graph/database.py +482 -0
  20. package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
  21. package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
  22. package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
  23. package/src/superlocalmemory/code_graph/flows.py +350 -0
  24. package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
  25. package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
  26. package/src/superlocalmemory/code_graph/graph_store.py +158 -0
  27. package/src/superlocalmemory/code_graph/incremental.py +200 -0
  28. package/src/superlocalmemory/code_graph/models.py +130 -0
  29. package/src/superlocalmemory/code_graph/parser.py +507 -0
  30. package/src/superlocalmemory/code_graph/resolver.py +321 -0
  31. package/src/superlocalmemory/code_graph/search.py +460 -0
  32. package/src/superlocalmemory/code_graph/service.py +95 -0
  33. package/src/superlocalmemory/code_graph/watcher.py +207 -0
  34. package/src/superlocalmemory/core/embedding_worker.py +4 -2
  35. package/src/superlocalmemory/core/embeddings.py +8 -2
  36. package/src/superlocalmemory/core/engine.py +32 -0
  37. package/src/superlocalmemory/core/engine_wiring.py +5 -0
  38. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  39. package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
  40. package/src/superlocalmemory/infra/event_bus.py +5 -0
  41. package/src/superlocalmemory/mcp/server.py +23 -0
  42. package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
  43. package/src/superlocalmemory/retrieval/engine.py +137 -2
  44. package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
  45. package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
  46. package/src/superlocalmemory/retrieval/strategy.py +16 -0
  47. package/src/superlocalmemory/server/api.py +4 -2
  48. package/src/superlocalmemory/server/ui.py +5 -2
  49. package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
  50. package/src/superlocalmemory/ui/index.html +1879 -0
  51. package/src/superlocalmemory/ui/js/agents.js +192 -0
  52. package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
  53. package/src/superlocalmemory/ui/js/behavioral.js +276 -0
  54. package/src/superlocalmemory/ui/js/clusters.js +206 -0
  55. package/src/superlocalmemory/ui/js/compliance.js +252 -0
  56. package/src/superlocalmemory/ui/js/core.js +246 -0
  57. package/src/superlocalmemory/ui/js/dashboard.js +110 -0
  58. package/src/superlocalmemory/ui/js/events.js +178 -0
  59. package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
  60. package/src/superlocalmemory/ui/js/feedback.js +333 -0
  61. package/src/superlocalmemory/ui/js/graph-core.js +447 -0
  62. package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
  63. package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
  64. package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
  65. package/src/superlocalmemory/ui/js/ide-status.js +102 -0
  66. package/src/superlocalmemory/ui/js/init.js +45 -0
  67. package/src/superlocalmemory/ui/js/learning.js +435 -0
  68. package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
  69. package/src/superlocalmemory/ui/js/math-health.js +98 -0
  70. package/src/superlocalmemory/ui/js/memories.js +264 -0
  71. package/src/superlocalmemory/ui/js/modal.js +357 -0
  72. package/src/superlocalmemory/ui/js/patterns.js +93 -0
  73. package/src/superlocalmemory/ui/js/profiles.js +236 -0
  74. package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
  75. package/src/superlocalmemory/ui/js/search.js +59 -0
  76. package/src/superlocalmemory/ui/js/settings.js +224 -0
  77. package/src/superlocalmemory/ui/js/timeline.js +32 -0
  78. 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
+ )