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.
- package/CHANGELOG.md +55 -4
- package/README.md +6 -11
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +14 -0
- package/src/superlocalmemory/cli/main.py +9 -0
- package/src/superlocalmemory/cli/summary_cmd.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +51 -1
- package/src/superlocalmemory/mcp/tools_code_graph.py +47 -3
- package/src/superlocalmemory/server/routes/memories.py +61 -0
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- package/src/superlocalmemory/ui/index.html +1 -1
- 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
|
|
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":
|
|
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
|
-
|
|
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>
|