superlocalmemory 4.0.6 → 4.0.8

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 (63) hide show
  1. package/CHANGELOG.md +128 -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 +215 -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 +212 -0
  32. package/src/superlocalmemory/code_graph/config.py +65 -1
  33. package/src/superlocalmemory/core/consolidation_engine.py +14 -15
  34. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  35. package/src/superlocalmemory/core/maintenance.py +51 -1
  36. package/src/superlocalmemory/core/recall_worker.py +4 -0
  37. package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
  38. package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
  39. package/src/superlocalmemory/learning/pattern_miner.py +12 -7
  40. package/src/superlocalmemory/mcp/profiles.py +10 -3
  41. package/src/superlocalmemory/mcp/server.py +7 -0
  42. package/src/superlocalmemory/mcp/tools_code_graph.py +47 -3
  43. package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
  44. package/src/superlocalmemory/server/consolidation_runner.py +140 -0
  45. package/src/superlocalmemory/server/recall_serializer.py +34 -2
  46. package/src/superlocalmemory/server/routes/agents.py +52 -8
  47. package/src/superlocalmemory/server/routes/brain.py +108 -0
  48. package/src/superlocalmemory/server/routes/memories.py +214 -0
  49. package/src/superlocalmemory/server/routes/v3_api.py +24 -46
  50. package/src/superlocalmemory/server/unified_daemon.py +107 -0
  51. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  52. package/src/superlocalmemory/summaries/base.py +159 -0
  53. package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
  54. package/src/superlocalmemory/summaries/project_work_log.py +23 -7
  55. package/src/superlocalmemory/summaries/session_summary.py +9 -5
  56. package/src/superlocalmemory/ui/index.html +10 -4
  57. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  58. package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
  59. package/src/superlocalmemory/ui/js/od-memories.js +337 -12
  60. package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
  61. package/src/superlocalmemory/ui/js/od-operations.js +1 -150
  62. package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
  63. package/src/superlocalmemory/ui/js/od-shell.js +10 -0
@@ -0,0 +1,215 @@
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
+ """``slm summary`` — the readable layer over your memories (issue #113).
6
+
7
+ WHY THIS FILE EXISTS
8
+ --------------------
9
+ 4.0.6 shipped the three generators in ``superlocalmemory/summaries/`` with no way
10
+ to call them: no command, no MCP tool, no route. The changelog listed the feature
11
+ as added, the issue reply said it had landed, and a user could do nothing with it.
12
+ This is that missing surface.
13
+
14
+ Three summaries, each bounded and traceable:
15
+
16
+ ``slm summary session <id>`` what one session covered
17
+ ``slm summary day [DATE]`` what a day's main topics were
18
+ ``slm summary project <path>`` what was worked on in a project
19
+
20
+ Every result states its coverage. Session data in particular is sparse — roughly
21
+ 4% of facts carry a session id on a real store — so a session summary reports what
22
+ fraction it could actually see rather than presenting a slice as the whole.
23
+
24
+ No language model is required: the generators are extractive by default, so this
25
+ works in Local Guardian mode with nothing installed.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from argparse import Namespace
32
+ from datetime import date, timedelta
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from superlocalmemory.infra.data_root import state_path
37
+
38
+ def _coverage_is_complete(coverage: str) -> bool:
39
+ """Whether *coverage* means "this really is the whole picture".
40
+
41
+ Deliberately inverted. My first version listed the values that needed a
42
+ caveat — ``("partial", "sparse", "none", "empty")`` — and three of those four
43
+ are not values this system emits. The real vocabulary is COVERAGE_FULL /
44
+ PARTIAL / INSUFFICIENT / NO_SESSION / UNAVAILABLE, so a session summary
45
+ reporting "no_session" printed no caveat at all: the one honesty feature
46
+ issue #113 asked for, silently inactive.
47
+
48
+ Testing for completeness instead means any value that is not FULL — including
49
+ one added later — gets the caveat. The failure mode becomes an unnecessary
50
+ warning rather than a missing one.
51
+ """
52
+ try:
53
+ from superlocalmemory.summaries.base import COVERAGE_FULL
54
+
55
+ return coverage == COVERAGE_FULL
56
+ except Exception:
57
+ return coverage == "full"
58
+
59
+
60
+ def _db_path() -> Path:
61
+ return state_path("memory.db")
62
+
63
+
64
+ def _active_profile() -> str:
65
+ """Resolve the active profile without a daemon and without side effects.
66
+
67
+ Reads the ``active`` pointer out of ``profiles.json`` directly.
68
+ ``ProfileManager`` would give the same answer, but its constructor calls
69
+ ``mkdir(parents=True)`` — creating directories is not something a read-only
70
+ summary command should do. ``core.profiles`` also has no module-level
71
+ accessor; ``get_active_profile`` there is a method on the manager, and the
72
+ module-level one lives in ``server/routes/helpers.py``, which the CLI must
73
+ not import.
74
+ """
75
+ try:
76
+ from superlocalmemory.core.profiles import DEFAULT_PROFILES_FILE
77
+
78
+ path = state_path(DEFAULT_PROFILES_FILE)
79
+ if path.exists():
80
+ raw = json.loads(path.read_text(encoding="utf-8"))
81
+ active = raw.get("active")
82
+ if isinstance(active, str) and active:
83
+ return active
84
+ except Exception:
85
+ pass
86
+ return "default"
87
+
88
+
89
+ def _load_config() -> Any:
90
+ """The active SLMConfig, so Mode B/C get LLM-written summaries.
91
+
92
+ Without this the generators receive ``config=None``, ``get_mode_str`` returns
93
+ ``"a"``, and the LLM branch is unreachable — every summary comes out
94
+ extractive no matter which mode the user runs. That is not a graceful
95
+ fallback, it is the enrichment path never being offered.
96
+
97
+ Returns None on any failure, which lands on the extractive path. That is the
98
+ right fallback: a deterministic summary beats an error.
99
+ """
100
+ try:
101
+ from superlocalmemory.core.config import SLMConfig
102
+
103
+ return SLMConfig.load()
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def _emit(result: Any, as_json: bool) -> None:
109
+ """Print a SummaryResult as JSON or as prose."""
110
+ if as_json:
111
+ print(json.dumps({
112
+ "kind": result.kind,
113
+ "profile_id": result.profile_id,
114
+ "content": result.content,
115
+ "source_fact_ids": result.source_fact_ids,
116
+ "coverage": result.coverage,
117
+ "generated_by": result.generated_by,
118
+ "metadata": result.metadata,
119
+ }, indent=2, default=str))
120
+ return
121
+
122
+ print()
123
+ print(result.content.rstrip() or "(nothing recorded)")
124
+ print()
125
+
126
+ # Coverage is not decoration. A summary built from a fraction of the data
127
+ # that presents itself as the whole is the failure mode issue #113 called
128
+ # out by name, so it is stated on every single result, not only bad ones.
129
+ n = len(result.source_fact_ids)
130
+ line = f"Built from {n} memor{'y' if n == 1 else 'ies'} · coverage: {result.coverage}"
131
+ if not _coverage_is_complete(result.coverage):
132
+ line += " — treat as a partial view, not a complete record"
133
+ print(line)
134
+ if result.generated_by:
135
+ print(f"Method: {result.generated_by}")
136
+ print("Use --json to see the exact memories this came from.")
137
+
138
+
139
+ def cmd_summary(args: Namespace) -> None:
140
+ """Dispatch ``slm summary <subcommand>``."""
141
+ sub = getattr(args, "summary_command", None)
142
+ as_json = bool(getattr(args, "json", False))
143
+ profile = getattr(args, "profile", None) or _active_profile()
144
+ db = _db_path()
145
+ cfg = _load_config() # Mode B/C enrichment; None -> extractive
146
+
147
+ if not db.exists():
148
+ print(f"No memory database at {db}. Run `slm status` first.")
149
+ return
150
+
151
+ if sub == "session":
152
+ from superlocalmemory.summaries import generate_session_summary
153
+
154
+ _emit(generate_session_summary(db, args.session_id, profile, cfg), as_json)
155
+ return
156
+
157
+ if sub == "day":
158
+ from superlocalmemory.summaries import generate_daily_reflection
159
+
160
+ target = getattr(args, "date", None) or date.today().isoformat()
161
+ if target == "yesterday":
162
+ target = (date.today() - timedelta(days=1)).isoformat()
163
+ elif target == "today":
164
+ target = date.today().isoformat()
165
+ _emit(generate_daily_reflection(db, target, profile, cfg), as_json)
166
+ return
167
+
168
+ if sub == "project":
169
+ from superlocalmemory.summaries import generate_project_work_log
170
+
171
+ path = getattr(args, "path", None) or str(Path.cwd())
172
+ _emit(generate_project_work_log(db, path, profile, cfg), as_json)
173
+ return
174
+
175
+ print("Usage: slm summary {session <id> | day [DATE] | project [PATH]}")
176
+ print()
177
+ print(" slm summary day what you recorded today")
178
+ print(" slm summary day yesterday ...or yesterday")
179
+ print(" slm summary day 2026-08-17 ...or a specific date")
180
+ print(" slm summary project work log for the current directory")
181
+ print(" slm summary session <id> what one session covered")
182
+ print()
183
+ print("Add --json to include the ids of the memories a summary came from.")
184
+
185
+
186
+ def register_summary_parser(sub: Any) -> None:
187
+ """Attach the ``summary`` parser. Called from cli/main.py."""
188
+ p = sub.add_parser(
189
+ "summary",
190
+ help="Readable summaries of your memories (session, day, project)",
191
+ )
192
+ p.add_argument("--json", action="store_true", help="machine-readable output")
193
+ p.add_argument("--profile", help="profile to summarise (default: active)")
194
+ ssub = p.add_subparsers(dest="summary_command", title="summary subcommands")
195
+
196
+ s = ssub.add_parser("session", help="what one session covered")
197
+ s.add_argument("session_id", help="session id (see `slm status`)")
198
+ s.add_argument("--json", action="store_true")
199
+ s.add_argument("--profile")
200
+
201
+ d = ssub.add_parser("day", help="what a day's main topics were")
202
+ d.add_argument(
203
+ "date", nargs="?",
204
+ help="YYYY-MM-DD, 'today' or 'yesterday' (default: today)",
205
+ )
206
+ d.add_argument("--json", action="store_true")
207
+ d.add_argument("--profile")
208
+
209
+ pr = ssub.add_parser("project", help="what was worked on in a project")
210
+ pr.add_argument(
211
+ "path", nargs="?",
212
+ help="project directory (default: current directory)",
213
+ )
214
+ pr.add_argument("--json", action="store_true")
215
+ pr.add_argument("--profile")
@@ -152,9 +152,25 @@ class EntityResolver:
152
152
  self,
153
153
  fact_text: str,
154
154
  fact_id: str,
155
+ max_links: int | None = None,
155
156
  ) -> list[CodeMemoryLink]:
156
157
  """Resolve code entity mentions in fact text and create links.
157
158
 
159
+ Args:
160
+ fact_text: Text to scan for code mentions.
161
+ fact_id: The fact these links belong to.
162
+ max_links: Keep at most this many links, highest confidence first.
163
+ ``None`` (the default) is unbounded, preserving the behaviour the
164
+ manual ``link_memory_to_code`` path relies on.
165
+
166
+ A bound matters for automatic resolution. One file-path mention
167
+ matches EVERY node in that file: the fact "the parser in
168
+ code_graph/parser.py was dropping edges" produced 17 links at
169
+ confidence 0.6-0.8, while a backticked function name produces one
170
+ at 0.95. Unbounded, a fact naming a few files buries its own
171
+ high-signal links and hands a large node set to HebbianLinker,
172
+ whose neighbourhood expansion then grows accordingly.
173
+
158
174
  Returns list of CodeMemoryLink objects created.
159
175
  """
160
176
  if not fact_text or not fact_id:
@@ -177,6 +193,16 @@ class EntityResolver:
177
193
  if not matches:
178
194
  return []
179
195
 
196
+ selected = list(matches.values())
197
+ if max_links is not None and len(selected) > max_links:
198
+ # Confidence ranks the match kinds correctly already — backticked and
199
+ # call-syntax mentions score 0.95, a bare identifier 0.9, file-path
200
+ # fan-out 0.6-0.8 — so ordering by it keeps the precise mentions and
201
+ # drops the broad ones.
202
+ selected.sort(key=lambda m: m.confidence, reverse=True)
203
+ selected = selected[:max_links]
204
+ matches = {m.node_id: m for m in selected}
205
+
180
206
  # Classify link type
181
207
  link_type = self._classify_link_type(fact_text)
182
208
  now_str = datetime.now(timezone.utc).isoformat()
@@ -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,212 @@
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
+ # COALESCE form, matching storage/database.py:759 and the learning
100
+ # modules. My first version tested `IS NULL OR = ''`, which excluded
101
+ # every fact on a real store: live facts are marked 'live', not blank.
102
+ # The synthetic fixtures I developed against left the column unset, so
103
+ # the pass linked happily in tests and would have linked NOTHING for any
104
+ # actual user — 3,608 of 3,608 facts filtered out on this machine.
105
+ sql += "AND COALESCE(archive_status, 'live') != 'archived' "
106
+
107
+ params: list[Any] = [profile_id]
108
+ if since:
109
+ sql += "AND created_at > ? "
110
+ params.append(since)
111
+ sql += "ORDER BY created_at ASC LIMIT ?"
112
+ params.append(limit)
113
+
114
+ # DatabaseManager.execute serves both reads and writes (see
115
+ # core/maintenance.py, which uses it for each). It returns sqlite3.Row;
116
+ # index by position so a plain-tuple factory also works.
117
+ rows = memory_db.execute(sql, tuple(params))
118
+ return [(r[0], r[1], r[2]) for r in rows]
119
+
120
+
121
+ def run_bridge_pass(
122
+ memory_db: Any,
123
+ code_graph_db: CodeGraphDatabase,
124
+ profile_id: str,
125
+ *,
126
+ max_facts: int = MAX_FACTS_PER_PASS,
127
+ ) -> dict[str, int]:
128
+ """Resolve code mentions in new facts and enrich the resulting links.
129
+
130
+ Returns counts. Never raises — the caller is background maintenance and a
131
+ bridge failure must not abort the rest of the cycle.
132
+ """
133
+ counts = {"facts_scanned": 0, "links_created": 0, "enriched": 0}
134
+
135
+ try:
136
+ from superlocalmemory.code_graph.bridge.entity_resolver import EntityResolver
137
+ from superlocalmemory.code_graph.bridge.fact_enricher import FactEnricher
138
+ except Exception as exc: # pragma: no cover - import guard
139
+ logger.debug("bridge pass unavailable: %s", exc)
140
+ return counts
141
+
142
+ # Nothing to match against — skip before touching memory.db at all.
143
+ stats = code_graph_db.get_stats()
144
+ if not stats.get("nodes"):
145
+ logger.debug("bridge pass: code graph is empty, nothing to resolve against")
146
+ return counts
147
+
148
+ watermark = code_graph_db.get_metadata(_WATERMARK_KEY)
149
+ try:
150
+ rows = _fact_rows(memory_db, profile_id, watermark, max_facts)
151
+ except Exception as exc:
152
+ logger.warning("bridge pass could not read facts: %s", exc)
153
+ return counts
154
+
155
+ if not rows:
156
+ return counts
157
+
158
+ resolver = EntityResolver(code_graph_db)
159
+ enricher = FactEnricher(code_graph_db)
160
+ newest = watermark
161
+
162
+ for fact_id, content, created_at in rows:
163
+ counts["facts_scanned"] += 1
164
+ newest = created_at if newest is None or created_at > newest else newest
165
+ try:
166
+ links = resolver.resolve(content, fact_id, max_links=MAX_LINKS_PER_FACT)
167
+ except Exception as exc:
168
+ logger.debug("bridge resolve failed for %s: %s", fact_id, exc)
169
+ continue
170
+ if not links:
171
+ continue
172
+ counts["links_created"] += len(links)
173
+
174
+ # Enrichment is derived from (fact text, matched nodes) and is stored
175
+ # beside the link in code_graph.db. The user's own fact wording in
176
+ # memory.db is never rewritten: doing that would invalidate the fact's
177
+ # embedding, and would compound a suffix on every maintenance cycle.
178
+ try:
179
+ matched = resolver.get_matched_nodes(content)
180
+ if not matched:
181
+ continue
182
+ enriched = enricher.enrich(fact_id, matched, content)
183
+ if enriched and enriched != content:
184
+ _store_enrichment(code_graph_db, fact_id, enriched)
185
+ counts["enriched"] += 1
186
+ except Exception as exc:
187
+ logger.debug("bridge enrichment failed for %s: %s", fact_id, exc)
188
+
189
+ if newest and newest != watermark:
190
+ try:
191
+ code_graph_db.set_metadata(_WATERMARK_KEY, newest)
192
+ except Exception as exc:
193
+ logger.warning("bridge watermark not advanced: %s", exc)
194
+
195
+ # One summary line per pass, never one per fact. A 3,527-fact store must not
196
+ # produce 3,527 log lines; per-fact detail stays at debug.
197
+ if counts["links_created"]:
198
+ logger.info(
199
+ "Code bridge: %d facts scanned, %d links, %d enriched",
200
+ counts["facts_scanned"], counts["links_created"], counts["enriched"],
201
+ )
202
+ return counts
203
+
204
+
205
+ def _store_enrichment(
206
+ code_graph_db: CodeGraphDatabase, fact_id: str, enriched: str
207
+ ) -> None:
208
+ """Persist enrichment text onto every link for *fact_id*."""
209
+ code_graph_db.execute_write(
210
+ "UPDATE code_memory_links SET enriched_description = ? WHERE slm_fact_id = ?",
211
+ (enriched, fact_id),
212
+ )
@@ -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()
@@ -198,27 +198,26 @@ class ConsolidationEngine:
198
198
  from superlocalmemory.parameterization.prompt_injector import PromptInjector
199
199
  from superlocalmemory.parameterization.prompt_lifecycle import PromptLifecycleManager
200
200
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
201
- # cross_project and workflow_miner may not exist yet — stub them
202
- try:
203
- from superlocalmemory.parameterization.cross_project import CrossProjectAggregator
204
- except ImportError:
205
- class CrossProjectAggregator:
206
- def __init__(self, db): pass
207
- def get_preferences(self, *a, **kw): return {}
208
- try:
209
- from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
210
- except ImportError:
211
- class WorkflowMiner:
212
- def __init__(self, db): pass
213
- def mine(self, *a, **kw): return []
201
+ from superlocalmemory.parameterization.cross_project import CrossProjectAggregator
202
+ from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
214
203
  from superlocalmemory.hooks.auto_parameterize import AutoParameterizeHook
215
204
  from superlocalmemory.core.config import ParameterizationConfig
216
205
  p_config = getattr(self._config, "parameterization", ParameterizationConfig())
217
206
  from superlocalmemory.infra.data_root import state_path
218
207
  learning_db = str(state_path("learning.db"))
219
208
  beh_store = BehavioralPatternStore(learning_db)
220
- cross_proj = CrossProjectAggregator(self._db)
221
- wf_miner = WorkflowMiner(self._db)
209
+ # Both take a DB PATH, not a DatabaseManager. Passing
210
+ # self._db here made Path(db_path) raise "argument should be
211
+ # a str or an os.PathLike object ... not 'DatabaseManager'",
212
+ # which step 9 caught at debug level — so soft prompts were
213
+ # never generated and nothing said why.
214
+ #
215
+ # The ImportError stubs these replaced were also dead: both
216
+ # modules exist and re-export the canonical classes, so the
217
+ # stub branch could never run and only served to hide the
218
+ # real signatures.
219
+ cross_proj = CrossProjectAggregator(learning_db)
220
+ wf_miner = WorkflowMiner(learning_db)
222
221
  extractor = PatternExtractor(self._db, beh_store, cross_proj, wf_miner, p_config)
223
222
  generator = SoftPromptGenerator(p_config)
224
223
  injector = PromptInjector(self._db, generator, p_config)
@@ -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)"