superlocalmemory 4.0.6 → 4.0.7

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 (39) hide show
  1. package/CHANGELOG.md +55 -4
  2. package/README.md +6 -11
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +14 -0
  27. package/src/superlocalmemory/cli/main.py +9 -0
  28. package/src/superlocalmemory/cli/summary_cmd.py +195 -0
  29. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  30. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  31. package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
  32. package/src/superlocalmemory/code_graph/config.py +65 -1
  33. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  34. package/src/superlocalmemory/core/maintenance.py +51 -1
  35. package/src/superlocalmemory/mcp/tools_code_graph.py +47 -3
  36. package/src/superlocalmemory/server/routes/memories.py +61 -0
  37. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  38. package/src/superlocalmemory/ui/index.html +1 -1
  39. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
@@ -57,12 +57,23 @@ class BridgeEventListeners:
57
57
  return self._started
58
58
 
59
59
  def start(self, event_bus: Any) -> None:
60
- """Register all listeners on the event bus.
60
+ """Register code-graph listeners on the event bus.
61
61
 
62
62
  Registers:
63
- - on_memory_stored: listens to "memory.stored"
64
63
  - on_code_node_deleted: listens to "code_graph.node_deleted"
65
64
  - on_code_node_changed: listens to "code_graph.node_changed"
65
+
66
+ DELIBERATELY NOT REGISTERED: ``on_memory_stored``.
67
+ ``EventBus._notify_listeners`` invokes every listener synchronously on
68
+ the emitting thread, so subscribing to ``memory.stored`` would run entity
69
+ resolution, enrichment and Hebbian linking inside each ``remember`` —
70
+ putting all three on the write path. That work now runs in background
71
+ maintenance instead; see ``code_graph.bridge.maintenance``.
72
+
73
+ ``on_memory_stored`` is kept as a callable so a caller that genuinely
74
+ wants synchronous linking for one fact can invoke it directly, but
75
+ nothing subscribes it to the bus. ``tests/test_code_graph/
76
+ test_bridge_off_write_path.py`` fails if that changes.
66
77
  """
67
78
  if self._started:
68
79
  logger.warning("BridgeEventListeners already started")
@@ -70,8 +81,8 @@ class BridgeEventListeners:
70
81
 
71
82
  self._event_bus = event_bus
72
83
 
84
+ # Both events are emitted by code-graph builds, never by memory writes.
73
85
  listeners: list[tuple[str, Callable[..., Any]]] = [
74
- ("memory.stored", self.on_memory_stored),
75
86
  ("code_graph.node_deleted", self.on_code_node_deleted),
76
87
  ("code_graph.node_changed", self.on_code_node_changed),
77
88
  ]
@@ -0,0 +1,206 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory | https://qualixar.com
4
+
5
+ """Bridge pass — runs the code↔memory bridge as background maintenance.
6
+
7
+ WHY THIS MODULE EXISTS
8
+ ----------------------
9
+ The bridge was authored to run from ``BridgeEventListeners.on_memory_stored``,
10
+ i.e. once per ``memory.stored`` event. ``EventBus._notify_listeners`` calls
11
+ listeners **synchronously on the emitting thread**, so that design puts entity
12
+ resolution, enrichment and Hebbian linking inside every single ``remember``.
13
+ The owner's constraint for this release is explicit: remember and recall timing
14
+ must not move. So the memory-stored listener is gone and the work happens here,
15
+ in the same background pass that already runs ``consolidate_facts``.
16
+
17
+ WHAT THIS PASS TOUCHES
18
+ ----------------------
19
+ Writes to ``code_graph.db`` only:
20
+ * ``code_memory_links`` — EntityResolver output
21
+ * ``code_memory_links.enriched_description`` — FactEnricher output
22
+
23
+ Recall never opens ``code_graph.db`` — verified: nothing under ``retrieval/`` or
24
+ ``core/`` references ``code_memory_links`` or ``CodeGraphDatabase``. That makes
25
+ this half of the bridge recall-neutral by construction rather than by
26
+ measurement.
27
+
28
+ DELIBERATELY NOT HERE: Hebbian association edges. ``HebbianLinker`` produces
29
+ edges for ``association_edges`` in **memory.db**, which
30
+ ``retrieval/spreading_activation.py`` reads via a UNION with ``graph_edges``.
31
+ Every such edge is an extra neighbour recall must traverse and changes which
32
+ memories come back — not just how fast. That cannot be made safe by moving it
33
+ into this pass, so it is deferred to its own release, where the edge volume at
34
+ production scale can be measured against the recall baseline first. No writer for
35
+ it ships here; ``bridge/hebbian_linker.py`` remains unwired on purpose.
36
+
37
+ IDEMPOTENCE
38
+ -----------
39
+ A watermark in ``graph_metadata`` records the ``created_at`` of the newest fact
40
+ processed. Re-running the pass processes only facts newer than that, so a
41
+ maintenance cycle every few minutes does not rescan the whole store. Links use
42
+ ``INSERT OR REPLACE`` on a deterministic key, so reprocessing a fact cannot
43
+ duplicate its links.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import logging
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ if TYPE_CHECKING: # pragma: no cover - typing only
52
+ from superlocalmemory.code_graph.database import CodeGraphDatabase
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ #: Watermark key in graph_metadata.
57
+ _WATERMARK_KEY = "bridge.last_fact_created_at"
58
+
59
+ #: Facts examined in a single pass. Bounds the pass so a first run on a large
60
+ #: store cannot occupy the maintenance thread indefinitely; the watermark means
61
+ #: the next cycle resumes where this one stopped.
62
+ MAX_FACTS_PER_PASS = 500
63
+
64
+ #: Links kept for one fact, highest confidence first. A single file-path mention
65
+ #: matches every node in that file — "the parser in code_graph/parser.py was
66
+ #: dropping edges" produced 17 links at confidence 0.6-0.8, against one link at
67
+ #: 0.95 for a backticked function name. Without a bound the broad matches bury
68
+ #: the precise ones in the UI and hand a large node set to the Hebbian pass.
69
+ MAX_LINKS_PER_FACT = 10
70
+
71
+
72
+ def _fact_rows(
73
+ memory_db: Any,
74
+ profile_id: str,
75
+ since: str | None,
76
+ limit: int,
77
+ ) -> list[tuple[str, str, str]]:
78
+ """Return (fact_id, content, created_at) for facts newer than *since*.
79
+
80
+ Ordered by ``created_at`` so the watermark advances monotonically even when
81
+ the pass stops at ``limit``.
82
+ """
83
+ sql = (
84
+ "SELECT fact_id, content, created_at FROM atomic_facts "
85
+ "WHERE profile_id = ? AND content IS NOT NULL AND content != '' "
86
+ )
87
+ # M011 (archive_status) is a DEFERRED migration, so the column is absent on
88
+ # a database where it has not run yet. DatabaseManager._has_archive_status
89
+ # exists for exactly this and its docstring is explicit: "callers must not
90
+ # filter on a column that may not exist." Filtering unconditionally made
91
+ # this query raise "no such column" on any fresh install, which the caller's
92
+ # except swallowed into a warning and zero links — the bridge would simply
93
+ # never have run for a new user.
94
+ try:
95
+ has_archive = memory_db._has_archive_status()
96
+ except Exception: # pragma: no cover - helper absent on an unusual manager
97
+ has_archive = False
98
+ if has_archive:
99
+ sql += "AND (archive_status IS NULL OR archive_status = '') "
100
+
101
+ params: list[Any] = [profile_id]
102
+ if since:
103
+ sql += "AND created_at > ? "
104
+ params.append(since)
105
+ sql += "ORDER BY created_at ASC LIMIT ?"
106
+ params.append(limit)
107
+
108
+ # DatabaseManager.execute serves both reads and writes (see
109
+ # core/maintenance.py, which uses it for each). It returns sqlite3.Row;
110
+ # index by position so a plain-tuple factory also works.
111
+ rows = memory_db.execute(sql, tuple(params))
112
+ return [(r[0], r[1], r[2]) for r in rows]
113
+
114
+
115
+ def run_bridge_pass(
116
+ memory_db: Any,
117
+ code_graph_db: CodeGraphDatabase,
118
+ profile_id: str,
119
+ *,
120
+ max_facts: int = MAX_FACTS_PER_PASS,
121
+ ) -> dict[str, int]:
122
+ """Resolve code mentions in new facts and enrich the resulting links.
123
+
124
+ Returns counts. Never raises — the caller is background maintenance and a
125
+ bridge failure must not abort the rest of the cycle.
126
+ """
127
+ counts = {"facts_scanned": 0, "links_created": 0, "enriched": 0}
128
+
129
+ try:
130
+ from superlocalmemory.code_graph.bridge.entity_resolver import EntityResolver
131
+ from superlocalmemory.code_graph.bridge.fact_enricher import FactEnricher
132
+ except Exception as exc: # pragma: no cover - import guard
133
+ logger.debug("bridge pass unavailable: %s", exc)
134
+ return counts
135
+
136
+ # Nothing to match against — skip before touching memory.db at all.
137
+ stats = code_graph_db.get_stats()
138
+ if not stats.get("nodes"):
139
+ logger.debug("bridge pass: code graph is empty, nothing to resolve against")
140
+ return counts
141
+
142
+ watermark = code_graph_db.get_metadata(_WATERMARK_KEY)
143
+ try:
144
+ rows = _fact_rows(memory_db, profile_id, watermark, max_facts)
145
+ except Exception as exc:
146
+ logger.warning("bridge pass could not read facts: %s", exc)
147
+ return counts
148
+
149
+ if not rows:
150
+ return counts
151
+
152
+ resolver = EntityResolver(code_graph_db)
153
+ enricher = FactEnricher(code_graph_db)
154
+ newest = watermark
155
+
156
+ for fact_id, content, created_at in rows:
157
+ counts["facts_scanned"] += 1
158
+ newest = created_at if newest is None or created_at > newest else newest
159
+ try:
160
+ links = resolver.resolve(content, fact_id, max_links=MAX_LINKS_PER_FACT)
161
+ except Exception as exc:
162
+ logger.debug("bridge resolve failed for %s: %s", fact_id, exc)
163
+ continue
164
+ if not links:
165
+ continue
166
+ counts["links_created"] += len(links)
167
+
168
+ # Enrichment is derived from (fact text, matched nodes) and is stored
169
+ # beside the link in code_graph.db. The user's own fact wording in
170
+ # memory.db is never rewritten: doing that would invalidate the fact's
171
+ # embedding, and would compound a suffix on every maintenance cycle.
172
+ try:
173
+ matched = resolver.get_matched_nodes(content)
174
+ if not matched:
175
+ continue
176
+ enriched = enricher.enrich(fact_id, matched, content)
177
+ if enriched and enriched != content:
178
+ _store_enrichment(code_graph_db, fact_id, enriched)
179
+ counts["enriched"] += 1
180
+ except Exception as exc:
181
+ logger.debug("bridge enrichment failed for %s: %s", fact_id, exc)
182
+
183
+ if newest and newest != watermark:
184
+ try:
185
+ code_graph_db.set_metadata(_WATERMARK_KEY, newest)
186
+ except Exception as exc:
187
+ logger.warning("bridge watermark not advanced: %s", exc)
188
+
189
+ # One summary line per pass, never one per fact. A 3,527-fact store must not
190
+ # produce 3,527 log lines; per-fact detail stays at debug.
191
+ if counts["links_created"]:
192
+ logger.info(
193
+ "Code bridge: %d facts scanned, %d links, %d enriched",
194
+ counts["facts_scanned"], counts["links_created"], counts["enriched"],
195
+ )
196
+ return counts
197
+
198
+
199
+ def _store_enrichment(
200
+ code_graph_db: CodeGraphDatabase, fact_id: str, enriched: str
201
+ ) -> None:
202
+ """Persist enrichment text onto every link for *fact_id*."""
203
+ code_graph_db.execute_write(
204
+ "UPDATE code_memory_links SET enriched_description = ? WHERE slm_fact_id = ?",
205
+ (enriched, fact_id),
206
+ )
@@ -9,7 +9,7 @@ Frozen dataclass with all tunables. Sensible defaults for typical repos.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from dataclasses import dataclass, field
12
+ from dataclasses import dataclass, field, fields
13
13
  from pathlib import Path
14
14
 
15
15
  from superlocalmemory.infra.data_root import state_path
@@ -88,3 +88,67 @@ class CodeGraphConfig:
88
88
  if slm_base_dir is not None:
89
89
  return slm_base_dir / "code_graph.db"
90
90
  return state_path("code_graph.db")
91
+
92
+ @classmethod
93
+ def load(cls, **overrides: object) -> CodeGraphConfig:
94
+ """Build a config from ``code_graph_config.json``, then apply overrides.
95
+
96
+ WHY THIS EXISTS (4.0.7). ``cli/setup_wizard.py`` has always written
97
+ ``code_graph_config.json`` with ``enabled`` and ``bridge_enabled``, and
98
+ until now **nothing read it**. There was no loader on this class at all,
99
+ and every call site constructed ``CodeGraphConfig(enabled=True)`` with
100
+ hardcoded defaults. So a user could answer "yes, enable the code graph"
101
+ in setup, get ``bridge_enabled: true`` written to disk, and have it
102
+ affect nothing — silently, with no way to tell from the outside.
103
+
104
+ Unknown keys in the file are ignored rather than raising: the file is
105
+ user-editable, and a stray key should not stop the code graph from
106
+ loading. Malformed JSON falls back to defaults with a warning, because
107
+ failing closed here would disable a working code graph over a typo.
108
+ """
109
+ import json
110
+ import logging
111
+
112
+ data: dict[str, object] = {}
113
+ path = state_path("code_graph_config.json")
114
+ try:
115
+ if path.exists():
116
+ loaded = json.loads(path.read_text(encoding="utf-8"))
117
+ if isinstance(loaded, dict):
118
+ data = loaded
119
+ else:
120
+ logging.getLogger(__name__).warning(
121
+ "%s does not contain a JSON object; using defaults", path,
122
+ )
123
+ except (OSError, json.JSONDecodeError) as exc:
124
+ logging.getLogger(__name__).warning(
125
+ "could not read %s (%s); using defaults", path, exc,
126
+ )
127
+
128
+ data.update(overrides)
129
+
130
+ valid = {f.name for f in fields(cls)}
131
+ unknown = sorted(set(data) - valid)
132
+ if unknown:
133
+ logging.getLogger(__name__).debug(
134
+ "ignoring unknown code_graph config keys: %s", ", ".join(unknown),
135
+ )
136
+
137
+ kwargs = {k: v for k, v in data.items() if k in valid}
138
+
139
+ # JSON has no frozenset/Path; coerce the fields that need it.
140
+ if isinstance(kwargs.get("languages"), list):
141
+ kwargs["languages"] = frozenset(kwargs["languages"])
142
+ if isinstance(kwargs.get("exclude_dirs"), list):
143
+ kwargs["exclude_dirs"] = frozenset(kwargs["exclude_dirs"])
144
+ for key in ("repo_root", "db_path"):
145
+ if isinstance(kwargs.get(key), str):
146
+ kwargs[key] = Path(kwargs[key])
147
+
148
+ try:
149
+ return cls(**kwargs) # type: ignore[arg-type]
150
+ except TypeError as exc:
151
+ logging.getLogger(__name__).warning(
152
+ "code_graph config rejected (%s); using defaults", exc,
153
+ )
154
+ return cls()
@@ -159,7 +159,30 @@ def consolidate_facts(
159
159
  stats["error_detail"] = str(exc)
160
160
  return stats
161
161
 
162
- # Backward-compat: str | Path — open own connection.
162
+ # Backward-compat: str | Path — open our own connection.
163
+ #
164
+ # Type-checked, not assumed. This branch used to run for ANYTHING that was
165
+ # not a DatabaseManager, stringify it, and hand the result to
166
+ # sqlite3.connect — which creates whatever filename it is given. A test
167
+ # passing a MagicMock therefore had a real 4 KB SQLite file named
168
+ # "<MagicMock id='4422448000'>" written into the repository root, one per
169
+ # test. 42 of them had accumulated, and tests/test_ci_guards/
170
+ # test_no_magicmock_artifacts.py failed after any full-suite run.
171
+ #
172
+ # 4.0.6 is where this started firing: it wired consolidate_facts into
173
+ # run_maintenance, so every caller with a mock config reached this line.
174
+ #
175
+ # Refusing an unusable argument is also right beyond the test symptom —
176
+ # silently creating a database at a nonsense path cannot be what any caller
177
+ # wanted, and it hides the real bug (the caller passed the wrong thing).
178
+ if not isinstance(db_or_path, (str, Path)):
179
+ raise TypeError(
180
+ "consolidate_facts() expects a DatabaseManager, or a str/Path to "
181
+ f"memory.db for backward compatibility; got {type(db_or_path).__name__}. "
182
+ "Passing anything else previously created a database file named after "
183
+ "the object's repr."
184
+ )
185
+
163
186
  logger.warning(
164
187
  "consolidate_facts: passing a db_path is deprecated — pass a "
165
188
  "DatabaseManager instead (Fix A backward-compat shim active)"
@@ -678,12 +678,62 @@ def run_maintenance(
678
678
  "which is distinct from 0 = nothing to merge): %s", exc,
679
679
  )
680
680
 
681
+ # 5. Code↔memory bridge (4.0.7).
682
+ # Resolves code entity mentions in new facts against the code graph and
683
+ # stores the links, plus derived enrichment text, in code_graph.db.
684
+ #
685
+ # RUNS HERE, NOT ON THE WRITE PATH. The bridge was authored to fire from
686
+ # BridgeEventListeners.on_memory_stored, and EventBus._notify_listeners
687
+ # dispatches synchronously on the emitting thread — which would have put
688
+ # entity resolution and enrichment inside every remember. The owner's
689
+ # constraint for 4.0.7 is that remember/recall timing must not move, so the
690
+ # memory-stored subscription is gone and the work happens in this pass.
691
+ # tests/test_code_graph/test_bridge_off_write_path.py fails if it comes back.
692
+ #
693
+ # Writes only code_graph.db, which no recall path opens, so this step cannot
694
+ # affect recall latency or results. Hebbian edges are the one exception and
695
+ # are NOT run here — they land in association_edges, which spreading
696
+ # activation reads; they are generated on explicit request instead.
697
+ counts["bridge_links"] = 0
698
+ counts["bridge_enriched"] = 0
699
+ try:
700
+ from superlocalmemory.code_graph.config import CodeGraphConfig
701
+
702
+ cg_cfg = CodeGraphConfig.load()
703
+ if cg_cfg.enabled and cg_cfg.bridge_enabled:
704
+ from superlocalmemory.code_graph.bridge.maintenance import run_bridge_pass
705
+ from superlocalmemory.code_graph.database import CodeGraphDatabase
706
+
707
+ cg_path = cg_cfg.get_db_path()
708
+ if cg_path.exists():
709
+ bridge_stats = run_bridge_pass(
710
+ db, CodeGraphDatabase(cg_path), profile_id,
711
+ )
712
+ counts["bridge_links"] = bridge_stats.get("links_created", 0)
713
+ counts["bridge_enriched"] = bridge_stats.get("enriched", 0)
714
+ except Exception as exc:
715
+ # -1 rather than 0, for the same reason fact consolidation uses it:
716
+ # a step that never worked must not report the same numbers as a
717
+ # healthy step with no work to do.
718
+ counts["bridge_links"] = -1
719
+ logger.warning(
720
+ "Code bridge pass FAILED during maintenance (reported as -1, "
721
+ "which is distinct from 0 = nothing to link): %s", exc,
722
+ )
723
+
681
724
  logger.info(
682
725
  "Maintenance complete: %d backfilled, %d Langevin, %d Fisher-coupled, "
683
- "%d Sheaf, %d entity-summaries, %d facts-consolidated",
726
+ "%d Sheaf, %d entity-summaries, %d facts-consolidated, %d code-links",
684
727
  counts["langevin_backfilled"], counts["langevin_updated"],
685
728
  counts["fisher_coupled"], counts["sheaf_checked"],
686
729
  counts["entity_summaries_consolidated"],
687
730
  counts["facts_consolidated"],
731
+ counts["bridge_links"],
688
732
  )
689
733
  return counts
734
+
735
+
736
+ # The bridge gate reads code_graph_config.json via CodeGraphConfig.load(), not
737
+ # SLMConfig: SLMConfig has no code_graph block, so there is nothing to read
738
+ # there. cli/setup_wizard.py writes that file; before 4.0.7 no loader existed
739
+ # and every call site hardcoded CodeGraphConfig(enabled=True) instead.
@@ -75,11 +75,36 @@ def _graph_not_built_error() -> dict[str, Any]:
75
75
  }
76
76
 
77
77
 
78
+ def _bridge_is_enabled() -> bool:
79
+ """Whether the code↔memory bridge is switched on in the saved settings."""
80
+ try:
81
+ cfg = _get_service()
82
+ if cfg is not None and getattr(cfg.config, "bridge_enabled", False):
83
+ return True
84
+ # No live service yet (e.g. a tool called before any build): read the
85
+ # saved settings directly rather than reporting "off" by default.
86
+ from superlocalmemory.code_graph.config import CodeGraphConfig
87
+ return bool(CodeGraphConfig.load().bridge_enabled)
88
+ except Exception:
89
+ return False
90
+
91
+
78
92
  def _bridge_not_enabled_error() -> dict[str, Any]:
79
- """Standard error when bridge is not enabled."""
93
+ """Standard error when the code↔memory bridge is not enabled.
94
+
95
+ The remediation text used to name ``code_graph.bridge.enabled``, which is not
96
+ a key that exists anywhere. The real field is ``bridge_enabled`` in
97
+ ``code_graph_config.json``, so anyone who followed this message edited
98
+ nothing that mattered. This helper was also never called from any tool, so
99
+ the message could not appear even when it was correct.
100
+ """
80
101
  return {
81
102
  "success": False,
82
- "error": "Bridge not enabled. Set code_graph.bridge.enabled = true in config.",
103
+ "error": (
104
+ 'Code↔memory bridge not enabled. Set "bridge_enabled": true in '
105
+ "~/.superlocalmemory/code_graph_config.json, then run "
106
+ "build_code_graph. Links are created during background maintenance."
107
+ ),
83
108
  }
84
109
 
85
110
 
@@ -167,7 +192,13 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
167
192
  p.strip() for p in exclude_patterns.split(",") if p.strip()
168
193
  )
169
194
 
170
- config = CodeGraphConfig(**config_kwargs)
195
+ # Start from the user's saved settings, then apply this call's
196
+ # arguments. Previously this constructed CodeGraphConfig(**kwargs)
197
+ # from scratch, so every field the caller did not name reverted to a
198
+ # class default — including bridge_enabled, which the setup wizard
199
+ # writes to code_graph_config.json. A user who enabled the code graph
200
+ # during setup therefore had the flag on disk and off in every build.
201
+ config = CodeGraphConfig.load(**config_kwargs)
171
202
  global _service
172
203
  _service = CodeGraphService(config)
173
204
 
@@ -727,6 +758,11 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
727
758
  "total_edges": stats.get("edges", 0),
728
759
  "total_code_memory_links": total_links,
729
760
  "stale_links": stale_links,
761
+ # Without this, total_code_memory_links == 0 is ambiguous: it
762
+ # means either "no memory mentions your code" or "the feature
763
+ # that creates links is switched off". Those call for opposite
764
+ # actions, so the reader has to be told which one it is.
765
+ "bridge_enabled": _bridge_is_enabled(),
730
766
  "built": stats.get("built", False),
731
767
  "db_path": stats.get("db_path", ""),
732
768
  }
@@ -1514,6 +1550,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
1514
1550
  if err is not None:
1515
1551
  return err
1516
1552
 
1553
+ # Every answer this tool can give comes from code_memory_links, and
1554
+ # only the bridge populates that table. With the bridge off the query
1555
+ # returns an empty list, which reads as "nothing is stale" — the
1556
+ # strongest possible reassurance, produced by a feature that never
1557
+ # ran. Say so instead.
1558
+ if not _bridge_is_enabled():
1559
+ return _bridge_not_enabled_error()
1560
+
1517
1561
  db = _get_db()
1518
1562
 
1519
1563
  if scope == "all":
@@ -1061,6 +1061,7 @@ async def get_fact_detail(request: Request, fact_id: str):
1061
1061
  )
1062
1062
  except Exception:
1063
1063
  row["canonical_entities"] = []
1064
+ row["code_links"] = _code_links_for_fact(fact_id)
1064
1065
  return row
1065
1066
  except HTTPException:
1066
1067
  raise
@@ -1068,6 +1069,66 @@ async def get_fact_detail(request: Request, fact_id: str):
1068
1069
  raise _internal_error("Fact detail error")
1069
1070
 
1070
1071
 
1072
+ def _code_links_for_fact(fact_id: str) -> list[dict]:
1073
+ """Code entities this fact mentions, from the code graph.
1074
+
1075
+ Fail-open by design: this is a display extra on a detail popup. No code graph
1076
+ built, bridge switched off, or database missing all mean "no section shown",
1077
+ never an error on the fact itself. A user who has never touched the code
1078
+ graph must not see a failure because of a feature they do not use.
1079
+
1080
+ Reads code_graph.db, which is a separate database from memory.db and is never
1081
+ opened by the recall path — so nothing here can affect recall.
1082
+ """
1083
+ try:
1084
+ from superlocalmemory.code_graph.config import CodeGraphConfig
1085
+
1086
+ cfg = CodeGraphConfig.load()
1087
+ if not (cfg.enabled and cfg.bridge_enabled):
1088
+ return []
1089
+ db_path = cfg.get_db_path()
1090
+ if not db_path.exists():
1091
+ return []
1092
+
1093
+ import sqlite3
1094
+
1095
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
1096
+ try:
1097
+ conn.row_factory = dict_factory
1098
+ rows = conn.execute(
1099
+ "SELECT cml.link_type, cml.confidence, cml.is_stale, "
1100
+ " cml.enriched_description, "
1101
+ " gn.name, gn.qualified_name, gn.kind, gn.file_path "
1102
+ "FROM code_memory_links cml "
1103
+ "LEFT JOIN graph_nodes gn ON gn.node_id = cml.code_node_id "
1104
+ "WHERE cml.slm_fact_id = ? "
1105
+ "ORDER BY cml.confidence DESC, gn.qualified_name",
1106
+ (fact_id,),
1107
+ ).fetchall()
1108
+ finally:
1109
+ conn.close()
1110
+
1111
+ return [
1112
+ {
1113
+ "name": r.get("name") or "",
1114
+ "qualified_name": r.get("qualified_name") or "",
1115
+ "kind": r.get("kind") or "",
1116
+ "file_path": r.get("file_path") or "",
1117
+ "link_type": r.get("link_type") or "mentions",
1118
+ "confidence": r.get("confidence"),
1119
+ "is_stale": bool(r.get("is_stale")),
1120
+ "description": r.get("enriched_description") or "",
1121
+ }
1122
+ for r in rows
1123
+ # A link whose node is gone (LEFT JOIN produced no row) is a stale
1124
+ # pointer, not something to render as a blank entry.
1125
+ if r.get("qualified_name")
1126
+ ]
1127
+ except Exception:
1128
+ logger.debug("code links unavailable for %s", fact_id, exc_info=True)
1129
+ return []
1130
+
1131
+
1071
1132
  @router.delete("/api/memories/{fact_id}")
1072
1133
  async def delete_memory(request: Request, fact_id: str):
1073
1134
  """Delete a specific memory (atomic fact) by ID."""
@@ -104,11 +104,49 @@ _DDL_STATEMENTS: tuple[str, ...] = (
104
104
  confidence REAL NOT NULL DEFAULT 0.8 CHECK (confidence >= 0.0 AND confidence <= 1.0),
105
105
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
106
106
  last_verified TEXT,
107
- is_stale INTEGER NOT NULL DEFAULT 0
107
+ is_stale INTEGER NOT NULL DEFAULT 0,
108
+ enriched_description TEXT
108
109
  )
109
110
  """,
110
111
  )
111
112
 
113
+ #: Columns added to existing tables after their first release.
114
+ #:
115
+ #: This file's DDL is all ``CREATE TABLE IF NOT EXISTS`` and code_graph.db has
116
+ #: no migration framework, so appending a column to a _DDL_STATEMENTS block
117
+ #: reaches NEW databases only — every database created by an earlier version
118
+ #: skips the statement entirely and never gains the column. That silent
119
+ #: divergence is what this list exists to close.
120
+ #:
121
+ #: ADDITIVE ONLY: ``ALTER TABLE ... ADD COLUMN`` with no NOT NULL and no
122
+ #: default, so it cannot fail on a populated table and cannot rewrite a row.
123
+ #: Never put a DROP, a RENAME, or a type change here.
124
+ _ADDITIVE_COLUMNS: tuple[tuple[str, str, str], ...] = (
125
+ # (table, column, type) — enrichment text for a code↔memory link. Lives
126
+ # here rather than in memory.db so the user's own fact wording is never
127
+ # overwritten, and so recall (which never opens code_graph.db) is unaffected.
128
+ ("code_memory_links", "enriched_description", "TEXT"),
129
+ )
130
+
131
+
132
+ def _apply_additive_columns(cursor: sqlite3.Cursor) -> None:
133
+ """Add any missing column from _ADDITIVE_COLUMNS. Idempotent."""
134
+ for table, column, coltype in _ADDITIVE_COLUMNS:
135
+ try:
136
+ existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})")}
137
+ except sqlite3.Error as exc: # table absent on a partial database
138
+ logger.debug("additive column probe skipped for %s: %s", table, exc)
139
+ continue
140
+ if not existing or column in existing:
141
+ continue
142
+ try:
143
+ cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coltype}")
144
+ logger.info("code_graph schema: added %s.%s", table, column)
145
+ except sqlite3.OperationalError as exc:
146
+ # Concurrent initialiser won the race, or the column appeared
147
+ # between the probe and the ALTER. Both are benign.
148
+ logger.debug("additive column %s.%s not applied: %s", table, column, exc)
149
+
112
150
  # Indexes (separate from tables for clarity)
113
151
  _INDEX_STATEMENTS: tuple[str, ...] = (
114
152
  # graph_nodes indexes
@@ -194,6 +232,11 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
194
232
  for ddl in _DDL_STATEMENTS:
195
233
  cursor.execute(ddl)
196
234
 
235
+ # Columns added after a table's first release. Must run AFTER the CREATEs
236
+ # (so a fresh database already has them and this is a no-op) and BEFORE the
237
+ # indexes (in case one is ever declared on an added column).
238
+ _apply_additive_columns(cursor)
239
+
197
240
  # Indexes
198
241
  for idx in _INDEX_STATEMENTS:
199
242
  cursor.execute(idx)
@@ -1575,7 +1575,7 @@
1575
1575
  <script src="static/js/math-health.js"></script>
1576
1576
  <script src="static/js/auto-settings.js"></script>
1577
1577
  <script src="static/js/ide-status.js"></script>
1578
- <script src="static/js/fact-detail.js"></script>
1578
+ <script src="static/js/fact-detail.js?v=e9076ee3"></script>
1579
1579
 
1580
1580
  <!-- Neural Glass shell (v3.4.21 restructured) -->
1581
1581
  <script src="static/js/ng-health.js?v=345"></script>