superlocalmemory 4.0.5 → 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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +8 -9
  3. package/package.json +3 -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/access/rbac.py +106 -0
  27. package/src/superlocalmemory/brain/truth.py +80 -10
  28. package/src/superlocalmemory/cli/__main__.py +17 -0
  29. package/src/superlocalmemory/cli/commands.py +28 -3
  30. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  31. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  32. package/src/superlocalmemory/cli/main.py +85 -0
  33. package/src/superlocalmemory/cli/summary_cmd.py +195 -0
  34. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
  35. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
  37. package/src/superlocalmemory/code_graph/config.py +65 -1
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  44. package/src/superlocalmemory/core/fact_consolidator.py +24 -1
  45. package/src/superlocalmemory/core/maintenance.py +93 -1
  46. package/src/superlocalmemory/core/recall_worker.py +33 -12
  47. package/src/superlocalmemory/infra/backup.py +138 -0
  48. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  49. package/src/superlocalmemory/learning/engagement.py +165 -0
  50. package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
  51. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  52. package/src/superlocalmemory/retrieval/engine.py +21 -0
  53. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  54. package/src/superlocalmemory/server/routes/brain.py +283 -15
  55. package/src/superlocalmemory/server/routes/learning.py +13 -25
  56. package/src/superlocalmemory/server/routes/memories.py +61 -0
  57. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  58. package/src/superlocalmemory/storage/database.py +36 -0
  59. package/src/superlocalmemory/storage/models.py +12 -4
  60. package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
  61. package/src/superlocalmemory/summaries/__init__.py +37 -0
  62. package/src/superlocalmemory/summaries/base.py +108 -0
  63. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  64. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  65. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  66. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  67. package/src/superlocalmemory/ui/index.html +29 -12
  68. package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
  69. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  70. package/src/superlocalmemory/ui/js/od-brain.js +257 -77
  71. package/src/superlocalmemory/ui/js/od-graph.js +147 -6
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Shared I/O helpers and compliance constants for ``slm gdpr`` subcommands.
6
+
7
+ Separated from gdpr_cmd.py to honour the 800-line file cap (coding-style.md).
8
+ All symbols here are internal (prefixed ``_``) or ALL_CAPS constants.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json as _json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ # ─────────────────────────────────────────────────────────────────────────────
20
+ # Output helpers
21
+ # ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ def _die(message: str, code: int = 1) -> None:
24
+ """Print an actionable error to stderr and exit non-zero."""
25
+ print(f"error: {message}", file=sys.stderr)
26
+ sys.exit(code)
27
+
28
+
29
+ def _print_json(data: Any) -> None:
30
+ """Emit pretty-printed JSON to stdout (evidence-pipeline format)."""
31
+ print(_json.dumps(data, indent=2, default=str))
32
+
33
+
34
+ def _json_envelope(
35
+ command: str, *, data: dict | None = None, error: dict | None = None
36
+ ) -> dict:
37
+ """Build the standard agent-native JSON envelope used across all SLM CLIs."""
38
+ from superlocalmemory.cli.json_output import _get_version
39
+
40
+ env: dict = {
41
+ "success": error is None,
42
+ "command": command,
43
+ "version": _get_version(),
44
+ }
45
+ if error is not None:
46
+ env["error"] = error
47
+ else:
48
+ env["data"] = data if data is not None else {}
49
+ return env
50
+
51
+
52
+ # ─────────────────────────────────────────────────────────────────────────────
53
+ # Path helpers
54
+ # ─────────────────────────────────────────────────────────────────────────────
55
+
56
+ def _data_root() -> Path:
57
+ """Resolve the canonical SLM data root (honours SLM_DATA_DIR env var)."""
58
+ from superlocalmemory.infra.data_root import canonical_data_root
59
+
60
+ return canonical_data_root()
61
+
62
+
63
+ def _db_path() -> Path:
64
+ return _data_root() / "memory.db"
65
+
66
+
67
+ def _audit_chain_path() -> Path:
68
+ return _data_root() / "audit_chain.db"
69
+
70
+
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # GDPR compliance constants (DPO-facing, surfaced in ``status`` output)
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ # Known gaps in this release — reported honestly so a DPO can plan remediation.
76
+ # Gaps C1 and C2 are tracked as open items in the Wave 3 backlog.
77
+ KNOWN_GAPS = [
78
+ {
79
+ "ref": "C1",
80
+ "summary": "backups/ directory is outside erasure scope",
81
+ "detail": (
82
+ "Up to 10 rotating snapshots of memory.db, learning.db and "
83
+ "related DB files survive an Art.17 erasure. The erasure receipt "
84
+ "records a 'backup_obligation' so the obligation is tracked, but "
85
+ "the snapshots are not automatically purged. Snapshots are "
86
+ "re-erased on restore."
87
+ ),
88
+ },
89
+ {
90
+ "ref": "C2",
91
+ "summary": "code_graph.db is not in erasure scope",
92
+ "detail": (
93
+ "Repository paths, file names and symbol names (identifying data "
94
+ "in a work context) are stored in code_graph.db. This file is "
95
+ "covered by compliance/gdpr.py but the per-table discovery "
96
+ "operates on the main memory.db only."
97
+ ),
98
+ },
99
+ ]
100
+
101
+ # Art. coverage flags that compliance/gdpr.py actively implements.
102
+ ART_COVERAGE = {
103
+ "art15_right_to_access": True,
104
+ "art17_right_to_erasure": True,
105
+ "art20_right_to_portability": True,
106
+ "backup_scope": "outstanding_obligation_recorded_on_receipt",
107
+ "audit_chain": "HMAC-chained tamper-evident",
108
+ "erasure_fails_closed": True,
109
+ }
@@ -88,6 +88,11 @@ _NO_DAEMON_COMMANDS = {
88
88
  "help",
89
89
  # Lifecycle orchestration must run before any global auto-start hook.
90
90
  "serve", "restart",
91
+ # V4.0.6: GDPR CLI accesses the DB directly; no daemon required.
92
+ "gdpr",
93
+ # V4.0.7: summaries read memory.db directly and are extractive by default,
94
+ # so they need neither the daemon nor a language model.
95
+ "summary",
91
96
  }
92
97
 
93
98
 
@@ -966,6 +971,86 @@ def main() -> None:
966
971
  _sp.add_argument("--json", action="store_true",
967
972
  help="Output structured JSON (agent-native)")
968
973
 
974
+ # V4.0.7: the readable summary layer from issue #113. The generators shipped
975
+ # in 4.0.6 with no caller; this is the surface that makes them reachable.
976
+ from superlocalmemory.cli.summary_cmd import register_summary_parser
977
+
978
+ register_summary_parser(sub)
979
+
980
+ # Wave-3 / V4.0.6: GDPR subject-rights CLI (Art.15/17/20)
981
+ gdpr_p = sub.add_parser(
982
+ "gdpr",
983
+ help="GDPR subject rights: status, export (Art.15/20), erase (Art.17), verify",
984
+ )
985
+ gdpr_sub = gdpr_p.add_subparsers(dest="gdpr_command", title="gdpr subcommands")
986
+
987
+ gdpr_status_p = gdpr_sub.add_parser(
988
+ "status",
989
+ help="Compliance posture: receipts, audit events, known gaps (read-only)",
990
+ )
991
+ gdpr_status_p.add_argument(
992
+ "--profile", default=None, metavar="PROFILE",
993
+ help="Scope to a specific profile (default: report all)",
994
+ )
995
+ gdpr_status_p.add_argument(
996
+ "--json", action="store_true", help="Output structured JSON (agent-native)",
997
+ )
998
+
999
+ gdpr_export_p = gdpr_sub.add_parser(
1000
+ "export",
1001
+ help="Art.15/20 subject access / data portability export",
1002
+ )
1003
+ gdpr_export_p.add_argument(
1004
+ "--profile", required=True, metavar="PROFILE",
1005
+ help="Profile to export (required)",
1006
+ )
1007
+ gdpr_export_p.add_argument(
1008
+ "--output", default=None, metavar="FILE",
1009
+ help="Write export JSON to FILE (default: stdout)",
1010
+ )
1011
+ gdpr_export_p.add_argument(
1012
+ "--json", action="store_true", help="Output structured JSON envelope",
1013
+ )
1014
+
1015
+ gdpr_erase_p = gdpr_sub.add_parser(
1016
+ "erase",
1017
+ help=(
1018
+ "Art.17 right to erasure — IRREVERSIBLE. "
1019
+ "Requires --profile PROFILE AND --yes."
1020
+ ),
1021
+ )
1022
+ gdpr_erase_p.add_argument(
1023
+ "--profile", default=None, metavar="PROFILE",
1024
+ help="Profile to erase (required for live erasure)",
1025
+ )
1026
+ gdpr_erase_p.add_argument(
1027
+ "--yes", action="store_true",
1028
+ help="Confirm irreversible erasure (required together with --profile)",
1029
+ )
1030
+ gdpr_erase_p.add_argument(
1031
+ "--dry-run", action="store_true", dest="dry_run",
1032
+ help="Preview what would be erased without deleting anything",
1033
+ )
1034
+ gdpr_erase_p.add_argument(
1035
+ "--json", action="store_true", help="Output structured JSON",
1036
+ )
1037
+
1038
+ gdpr_verify_p = gdpr_sub.add_parser(
1039
+ "verify",
1040
+ help="Verify HMAC integrity of an erasure receipt (exit 0=ok, 1=tampered, 2=not-found)",
1041
+ )
1042
+ gdpr_verify_p.add_argument(
1043
+ "--receipt-id", dest="receipt_id", default=None, metavar="ID",
1044
+ help="Erasure receipt ID to verify",
1045
+ )
1046
+ gdpr_verify_p.add_argument(
1047
+ "--profile", default=None, metavar="PROFILE",
1048
+ help="Scope receipt lookup to a specific profile",
1049
+ )
1050
+ gdpr_verify_p.add_argument(
1051
+ "--json", action="store_true", help="Output structured JSON",
1052
+ )
1053
+
969
1054
  # Wave-3: operational recovery & admin remediation
970
1055
  ops_p = sub.add_parser(
971
1056
  "ops",
@@ -0,0 +1,195 @@
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 _emit(result: Any, as_json: bool) -> None:
90
+ """Print a SummaryResult as JSON or as prose."""
91
+ if as_json:
92
+ print(json.dumps({
93
+ "kind": result.kind,
94
+ "profile_id": result.profile_id,
95
+ "content": result.content,
96
+ "source_fact_ids": result.source_fact_ids,
97
+ "coverage": result.coverage,
98
+ "generated_by": result.generated_by,
99
+ "metadata": result.metadata,
100
+ }, indent=2, default=str))
101
+ return
102
+
103
+ print()
104
+ print(result.content.rstrip() or "(nothing recorded)")
105
+ print()
106
+
107
+ # Coverage is not decoration. A summary built from a fraction of the data
108
+ # that presents itself as the whole is the failure mode issue #113 called
109
+ # out by name, so it is stated on every single result, not only bad ones.
110
+ n = len(result.source_fact_ids)
111
+ line = f"Built from {n} memor{'y' if n == 1 else 'ies'} · coverage: {result.coverage}"
112
+ if not _coverage_is_complete(result.coverage):
113
+ line += " — treat as a partial view, not a complete record"
114
+ print(line)
115
+ if result.generated_by:
116
+ print(f"Method: {result.generated_by}")
117
+ print("Use --json to see the exact memories this came from.")
118
+
119
+
120
+ def cmd_summary(args: Namespace) -> None:
121
+ """Dispatch ``slm summary <subcommand>``."""
122
+ sub = getattr(args, "summary_command", None)
123
+ as_json = bool(getattr(args, "json", False))
124
+ profile = getattr(args, "profile", None) or _active_profile()
125
+ db = _db_path()
126
+
127
+ if not db.exists():
128
+ print(f"No memory database at {db}. Run `slm status` first.")
129
+ return
130
+
131
+ if sub == "session":
132
+ from superlocalmemory.summaries import generate_session_summary
133
+
134
+ _emit(generate_session_summary(db, args.session_id, profile), as_json)
135
+ return
136
+
137
+ if sub == "day":
138
+ from superlocalmemory.summaries import generate_daily_reflection
139
+
140
+ target = getattr(args, "date", None) or date.today().isoformat()
141
+ if target == "yesterday":
142
+ target = (date.today() - timedelta(days=1)).isoformat()
143
+ elif target == "today":
144
+ target = date.today().isoformat()
145
+ _emit(generate_daily_reflection(db, target, profile), as_json)
146
+ return
147
+
148
+ if sub == "project":
149
+ from superlocalmemory.summaries import generate_project_work_log
150
+
151
+ path = getattr(args, "path", None) or str(Path.cwd())
152
+ _emit(generate_project_work_log(db, path, profile), as_json)
153
+ return
154
+
155
+ print("Usage: slm summary {session <id> | day [DATE] | project [PATH]}")
156
+ print()
157
+ print(" slm summary day what you recorded today")
158
+ print(" slm summary day yesterday ...or yesterday")
159
+ print(" slm summary day 2026-08-17 ...or a specific date")
160
+ print(" slm summary project work log for the current directory")
161
+ print(" slm summary session <id> what one session covered")
162
+ print()
163
+ print("Add --json to include the ids of the memories a summary came from.")
164
+
165
+
166
+ def register_summary_parser(sub: Any) -> None:
167
+ """Attach the ``summary`` parser. Called from cli/main.py."""
168
+ p = sub.add_parser(
169
+ "summary",
170
+ help="Readable summaries of your memories (session, day, project)",
171
+ )
172
+ p.add_argument("--json", action="store_true", help="machine-readable output")
173
+ p.add_argument("--profile", help="profile to summarise (default: active)")
174
+ ssub = p.add_subparsers(dest="summary_command", title="summary subcommands")
175
+
176
+ s = ssub.add_parser("session", help="what one session covered")
177
+ s.add_argument("session_id", help="session id (see `slm status`)")
178
+ s.add_argument("--json", action="store_true")
179
+ s.add_argument("--profile")
180
+
181
+ d = ssub.add_parser("day", help="what a day's main topics were")
182
+ d.add_argument(
183
+ "date", nargs="?",
184
+ help="YYYY-MM-DD, 'today' or 'yesterday' (default: today)",
185
+ )
186
+ d.add_argument("--json", action="store_true")
187
+ d.add_argument("--profile")
188
+
189
+ pr = ssub.add_parser("project", help="what was worked on in a project")
190
+ pr.add_argument(
191
+ "path", nargs="?",
192
+ help="project directory (default: current directory)",
193
+ )
194
+ pr.add_argument("--json", action="store_true")
195
+ 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
  ]