superlocalmemory 3.7.8 → 3.8.0

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 (260) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +69 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -1
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +94 -10
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/backup.py +12 -1
  116. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  117. package/src/superlocalmemory/infra/data_root.py +43 -4
  118. package/src/superlocalmemory/infra/event_bus.py +107 -24
  119. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  120. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  121. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  122. package/src/superlocalmemory/learning/cross_project.py +28 -19
  123. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  124. package/src/superlocalmemory/loops/__init__.py +56 -0
  125. package/src/superlocalmemory/loops/budget.py +58 -0
  126. package/src/superlocalmemory/loops/engine.py +164 -0
  127. package/src/superlocalmemory/loops/ledger.py +243 -0
  128. package/src/superlocalmemory/loops/models.py +152 -0
  129. package/src/superlocalmemory/loops/rules.py +52 -0
  130. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  131. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  132. package/src/superlocalmemory/mcp/profiles.py +103 -0
  133. package/src/superlocalmemory/mcp/server.py +21 -49
  134. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  135. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  136. package/src/superlocalmemory/mcp/tools_core.py +8 -1
  137. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  138. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  139. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  140. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  141. package/src/superlocalmemory/mesh/broker.py +237 -129
  142. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  143. package/src/superlocalmemory/optimize/NOTICE +1 -6
  144. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  145. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  147. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  148. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  149. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  150. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  151. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  152. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  153. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  154. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  155. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  156. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  157. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  158. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  159. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  160. package/src/superlocalmemory/retrieval/engine.py +168 -26
  161. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  162. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  163. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  164. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  165. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  166. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  167. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  168. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  169. package/src/superlocalmemory/server/api.py +4 -4
  170. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  171. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  172. package/src/superlocalmemory/server/recall_health.py +24 -3
  173. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  174. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  175. package/src/superlocalmemory/server/routes/agents.py +128 -38
  176. package/src/superlocalmemory/server/routes/backup.py +34 -10
  177. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  178. package/src/superlocalmemory/server/routes/brain.py +21 -5
  179. package/src/superlocalmemory/server/routes/chat.py +10 -5
  180. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  181. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  182. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  183. package/src/superlocalmemory/server/routes/entity.py +9 -4
  184. package/src/superlocalmemory/server/routes/events.py +24 -8
  185. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  186. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  187. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  188. package/src/superlocalmemory/server/routes/insights.py +3 -3
  189. package/src/superlocalmemory/server/routes/learning.py +14 -14
  190. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  191. package/src/superlocalmemory/server/routes/memories.py +182 -57
  192. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  193. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  194. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  195. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  196. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  197. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  198. package/src/superlocalmemory/server/routes/stats.py +13 -6
  199. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  200. package/src/superlocalmemory/server/routes/v3_api.py +183 -69
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +384 -56
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  208. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  209. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  210. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  211. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  212. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  213. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  214. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  215. package/src/superlocalmemory/storage/models.py +4 -0
  216. package/src/superlocalmemory/storage/schema.py +87 -0
  217. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  218. package/src/superlocalmemory/trust/gate.py +49 -8
  219. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  220. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  221. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  222. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  223. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  224. package/src/superlocalmemory/ui/favicon.svg +35 -4
  225. package/src/superlocalmemory/ui/index.html +306 -173
  226. package/src/superlocalmemory/ui/js/brain.js +5 -20
  227. package/src/superlocalmemory/ui/js/core.js +47 -31
  228. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  229. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  230. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  231. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  232. package/src/superlocalmemory/ui/js/memories.js +15 -4
  233. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  234. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  235. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  236. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  237. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  238. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  239. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  240. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  241. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  242. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  243. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  244. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  245. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  246. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  247. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  248. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  249. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  250. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  251. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  252. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  253. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  254. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  255. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  256. package/src/superlocalmemory/ui/js/settings.js +2 -2
  257. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  258. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  259. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  260. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -25,6 +25,7 @@ import json
25
25
  import logging
26
26
  import os
27
27
  import socket
28
+ import sys
28
29
  import threading
29
30
  import time
30
31
  from pathlib import Path
@@ -35,6 +36,12 @@ logger = logging.getLogger(__name__)
35
36
 
36
37
  _DEFAULT_SOCK_NAME = "hook_daemon.sock"
37
38
 
39
+ # AF_UNIX is absent on Windows builds < 10.0.17063 and on Python < 3.9. When it
40
+ # is unavailable the hook daemon does not start and callers fall back to the
41
+ # subprocess recall path. Detect it explicitly (once, with a log) instead of
42
+ # relying on an AttributeError being swallowed by a broad except.
43
+ _AF_UNIX = getattr(socket, "AF_UNIX", None)
44
+
38
45
 
39
46
  def _default_sock_path() -> Path:
40
47
  return state_path(_DEFAULT_SOCK_NAME)
@@ -77,7 +84,13 @@ class HookDaemon:
77
84
  from superlocalmemory.core.recall_queue import RecallQueue
78
85
  self._queue = RecallQueue(self._queue_db_path)
79
86
 
80
- self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
87
+ if _AF_UNIX is None:
88
+ logger.info(
89
+ "HookDaemon: AF_UNIX unavailable on %s; hook recall uses the "
90
+ "subprocess fallback", sys.platform,
91
+ )
92
+ raise RuntimeError("AF_UNIX unavailable on this platform")
93
+ self._server_sock = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
81
94
  self._server_sock.bind(str(self._sock_path))
82
95
  self._server_sock.listen(8)
83
96
  self._server_sock.settimeout(1.0)
@@ -226,9 +239,11 @@ def try_socket_recall(
226
239
  path = sock_path or _default_sock_path()
227
240
  if not path.exists():
228
241
  return None
242
+ if _AF_UNIX is None:
243
+ return None
229
244
 
230
245
  try:
231
- client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
246
+ client = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
232
247
  client.settimeout(timeout)
233
248
  client.connect(str(path))
234
249
 
@@ -259,10 +274,12 @@ def ensure_hook_daemon(
259
274
  ) -> HookDaemon | None:
260
275
  """Start hook daemon if not already running. Returns daemon or None."""
261
276
  path = sock_path or _default_sock_path()
277
+ if _AF_UNIX is None:
278
+ return None
262
279
 
263
280
  if path.exists():
264
281
  try:
265
- test = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
282
+ test = socket.socket(_AF_UNIX, socket.SOCK_STREAM)
266
283
  test.settimeout(1.0)
267
284
  test.connect(str(path))
268
285
  test.close()
@@ -93,6 +93,58 @@ def memory_protocol_markdown() -> str:
93
93
  "learned>\", \"tags\": \"<comma-separated kebab-case keywords>\"}`.\n"
94
94
  "- A \"substantial task\" is anything you would write a commit "
95
95
  "message or handoff note about — not every tool call.\n"
96
+ "\n"
97
+ + optimize_protocol_markdown()
98
+ + "\n"
99
+ + loop_protocol_markdown()
100
+ )
101
+
102
+
103
+ def optimize_protocol_markdown() -> str:
104
+ """Return the agent-facing token-optimization protocol block (cache/compress).
105
+
106
+ Proxy-free: these MCP tools let the agent losslessly shrink large tool output
107
+ and reuse repeated reads through the same surface, so an agent on any
108
+ front-end (not just Claude Code) can save tokens without a proxy. Fail-open —
109
+ a non-ok result means "use the original and continue".
110
+ """
111
+ return (
112
+ "## Runtime token-optimization protocol (fail-open)\n"
113
+ "SLM can losslessly compress large tool output and cache repeated reads "
114
+ "through the same MCP surface — no proxy required. These calls only save "
115
+ "tokens; if one returns `ok: false`, use the original and continue.\n\n"
116
+ "- **Large tool output (>2000 chars)** → `hub__call_tool` with "
117
+ "`tool=\"slm__slm_compress\"` and `arguments={\"content\": \"<text>\", "
118
+ "\"mode\": \"auto\", \"reversible\": true}`; keep the returned `ccr_id` "
119
+ "and call `tool=\"slm__slm_retrieve\"` if you later need the full "
120
+ "original.\n"
121
+ "- **Repeated reads/searches** → `hub__call_tool` with "
122
+ "`tool=\"slm__slm_cache_get\"` and `arguments={\"key\": \"file:<path>\"}` "
123
+ "first; on a miss, store the result with `tool=\"slm__slm_cache_set\"` "
124
+ "(ttl ~1800).\n"
125
+ "- **Never compress or cache**: code you will edit, JSON you will parse, "
126
+ "secrets, ccr_ids, or anything under ~500 chars.\n"
127
+ )
128
+
129
+
130
+ def loop_protocol_markdown() -> str:
131
+ """Return the agent-facing bounded-loop protocol block.
132
+
133
+ Bounded loops make an agent stop when an INDEPENDENT gate passes — not when
134
+ the agent claims it is done. Persisting each lap to SLM memory makes a run
135
+ auditable and resumable. This block is appended to the shared memory
136
+ protocol so any connected front-end (Claude Code, Codex, Antigravity,
137
+ Cursor, and other IDEs) learns the feature exists and how to reach it.
138
+ """
139
+ return (
140
+ "## Runtime bounded-loop protocol\n"
141
+ "For a task with a checkable gate (tests, schema, lint, reconciliation), "
142
+ "run a *bounded loop*: iterate until an INDEPENDENT gate passes — never "
143
+ "on the agent's own claim, which is advisory only. Try `slm loop demo`; "
144
+ "inspect with `slm loop history` / `slm loop show <run_id>` (each lap "
145
+ "persists as SLM memory, tag `loop:<name>`). Statuses: DONE / HALT / "
146
+ "PAUSE / KILLED / ERROR — report exactly, never as success unless DONE. "
147
+ "Full guide: the slm-loop skill.\n"
96
148
  )
97
149
 
98
150
 
@@ -101,4 +153,6 @@ __all__ = (
101
153
  "SLM_MARKER_END",
102
154
  "strip_slm_block",
103
155
  "memory_protocol_markdown",
156
+ "optimize_protocol_markdown",
157
+ "loop_protocol_markdown",
104
158
  )
@@ -139,7 +139,7 @@ IDE_MATRIX: dict[str, IDEDescriptor] = {
139
139
  mcp_path_project=".mcp.json",
140
140
  server_key="mcpServers",
141
141
  fmt="json",
142
- agents_md_path=None,
142
+ agents_md_path=".junie/AGENTS.md", # Junie guidelines file (GA) — carries memory + optimize protocol
143
143
  server_block={"command": "slm", "args": ["mcp"], "type": "stdio"},
144
144
  caveats="path per product [CN-ONLINE]",
145
145
  ),
@@ -347,6 +347,54 @@ def connect_ide(
347
347
  return result
348
348
 
349
349
 
350
+ def connect_many(
351
+ ide_ids: list[str],
352
+ *,
353
+ home: Path | None = None,
354
+ project: Path | None = None,
355
+ here: bool = False,
356
+ profile: str | None = None,
357
+ agents_md_source: Callable[[], str] | None = None,
358
+ ) -> list[dict[str, Any]]:
359
+ """Wire SLM into multiple IDE configs via non-destructive merge.
360
+
361
+ Iterates over ``ide_ids`` and calls :func:`connect_ide` for each entry.
362
+ Each IDE is processed independently — a failure on one IDE does NOT abort
363
+ the remaining targets.
364
+
365
+ The underlying :func:`connect_ide` is MERGE-NOT-CLOBBER:
366
+ - Only the ``superlocalmemory`` server key is touched.
367
+ - All other MCP servers + top-level keys are preserved byte-for-byte.
368
+ - Writes are atomic (.tmp + os.replace).
369
+
370
+ Args:
371
+ ide_ids: IDE ids to wire (from :data:`IDE_MATRIX`). Pass an empty
372
+ list to no-op. Unknown ids produce error entries in the output.
373
+ home: Override ``$HOME`` (test hook).
374
+ project: Project root for ``here=True`` installs.
375
+ here: When True, write to project-relative path instead of global.
376
+ profile: Inject ``SLM_MCP_PROFILE`` env-var into every server block.
377
+ agents_md_source: Callable returning AGENTS.md content to append.
378
+
379
+ Returns:
380
+ List of per-IDE result dicts, one per input id. Each dict has the
381
+ same shape as :func:`connect_ide`'s return value::
382
+
383
+ {ide, mcp_config, mcp_path, agents_md, servers_preserved, error}
384
+ """
385
+ return [
386
+ connect_ide(
387
+ ide_id,
388
+ home=home,
389
+ project=project,
390
+ here=here,
391
+ profile=profile,
392
+ agents_md_source=agents_md_source,
393
+ )
394
+ for ide_id in ide_ids
395
+ ]
396
+
397
+
350
398
  # ---------------------------------------------------------------------------
351
399
  # Internal helpers
352
400
  # ---------------------------------------------------------------------------
@@ -504,3 +552,68 @@ def _handle_agents_md(
504
552
  _tmp.write_text(existing + section, encoding="utf-8")
505
553
  os.replace(_tmp, agents_path)
506
554
  return "wrote"
555
+
556
+
557
+ # ---------------------------------------------------------------------------
558
+ # __main__ — `python -m superlocalmemory.hooks.portable_kit [ids...]`
559
+ #
560
+ # Used by the npm installer to execute non-destructive IDE connects when
561
+ # Python is available at postinstall time. If Python / the package is not
562
+ # importable the installer records a pending_ide_connections.json instead.
563
+ # ---------------------------------------------------------------------------
564
+
565
+ if __name__ == "__main__": # pragma: no cover
566
+ import argparse as _argparse
567
+
568
+ _parser = _argparse.ArgumentParser(
569
+ description="SLM IDE connector — non-destructive merge-not-clobber."
570
+ )
571
+ _parser.add_argument(
572
+ "ide_ids",
573
+ nargs="*",
574
+ metavar="IDE",
575
+ help=(
576
+ "IDE ids to connect. Pass 'all' to connect every supported IDE "
577
+ "(excluding claude-code which uses the WP-06 plugin)."
578
+ ),
579
+ )
580
+ _parser.add_argument("--home", help="Override home directory (test hook).")
581
+ _parser.add_argument("--profile", help="SLM_MCP_PROFILE to inject.")
582
+ _parser.add_argument(
583
+ "--list", action="store_true",
584
+ help="Print supported IDE ids with display names and exit.",
585
+ )
586
+ _args = _parser.parse_args()
587
+
588
+ if _args.list:
589
+ for _id, _desc in IDE_MATRIX.items():
590
+ _flag = "[OUT]" if not _desc.fmt else ""
591
+ print(f" {_id:22s} {_desc.display} {_flag}".rstrip())
592
+ sys.exit(0)
593
+
594
+ _home = Path(_args.home) if _args.home else None
595
+
596
+ # Resolve targets: "all" → every MCP-capable IDE (fmt != ""), else explicit list.
597
+ if "all" in _args.ide_ids:
598
+ _targets = [k for k, d in IDE_MATRIX.items() if d.fmt]
599
+ else:
600
+ _targets = [t for t in _args.ide_ids if t]
601
+
602
+ if not _targets:
603
+ print("No IDE ids given. Pass ide ids or 'all'. Use --list to see options.")
604
+ sys.exit(0)
605
+
606
+ _results = connect_many(_targets, home=_home, profile=_args.profile)
607
+ _ok = 0
608
+ _fail = 0
609
+ for _r in _results:
610
+ _err = _r.get("error")
611
+ _status = _r.get("mcp_config", "error")
612
+ if _err:
613
+ print(f" ERROR {_r['ide']}: {_err}", file=sys.stderr)
614
+ _fail += 1
615
+ else:
616
+ print(f" {_status.upper():9s} {_r['ide']} → {_r.get('mcp_path', '')}")
617
+ _ok += 1
618
+ print(f"\n{_ok} connected, {_fail} errors.")
619
+ sys.exit(0 if _fail == 0 else 1)
@@ -266,7 +266,18 @@ class BackupManager:
266
266
 
267
267
  A safety snapshot of the current state is taken first.
268
268
  """
269
- backup_path = self.backup_dir / filename
269
+ # Containment: filename must be a bare .db name inside backup_dir no
270
+ # path separators or traversal. Prevents restoring (and thus copying
271
+ # over memory.db) an arbitrary file the daemon user can read.
272
+ if (not filename or "/" in filename or "\\" in filename
273
+ or ".." in filename or not filename.endswith(".db")):
274
+ logger.error("Restore rejected: invalid backup filename: %r", filename)
275
+ return False
276
+ backup_dir = self.backup_dir.resolve()
277
+ backup_path = (self.backup_dir / filename).resolve()
278
+ if backup_path.parent != backup_dir:
279
+ logger.error("Restore rejected: path escapes backup dir: %r", filename)
280
+ return False
270
281
  if not backup_path.exists():
271
282
  logger.error("Backup not found: %s", filename)
272
283
  return False
@@ -14,8 +14,11 @@ import getpass
14
14
  import hashlib
15
15
  import hmac
16
16
  import json
17
+ import logging
17
18
  import os
18
19
  import secrets
20
+ import subprocess
21
+ import sys
19
22
  import time
20
23
  import uuid
21
24
  from dataclasses import asdict, dataclass
@@ -30,6 +33,8 @@ DAEMON_SERVICE = "superlocalmemory-daemon"
30
33
  _NAMESPACE_DOMAIN = b"superlocalmemory-daemon-namespace-v1\0"
31
34
  _CAPABILITY_DOMAIN = b"superlocalmemory-daemon-capability-v1\0"
32
35
 
36
+ logger = logging.getLogger(__name__)
37
+
33
38
 
34
39
  def _canonical_path(value: str | Path) -> Path:
35
40
  expanded = Path(value).expanduser().resolve(strict=False)
@@ -144,6 +149,40 @@ def descriptor_path(data_root: str | Path | None = None) -> Path:
144
149
  return root / "daemon.json"
145
150
 
146
151
 
152
+ def _restrict_to_owner(path: Path) -> None:
153
+ """Restrict a sensitive file to the current user on every platform.
154
+
155
+ ``daemon.json`` holds the capability token that authorizes write requests.
156
+ POSIX gets a 0600 chmod. On Windows chmod is a no-op, so use icacls to strip
157
+ inherited ACEs and grant the current user only — otherwise the token can be
158
+ read by other local accounts (privilege escalation on shared machines).
159
+ Files under %USERPROFILE% usually inherit user-only ACLs already; this is
160
+ defense-in-depth. Fail-soft — a hardening failure warns, never crashes.
161
+ """
162
+ if sys.platform == "win32":
163
+ try:
164
+ user = getpass.getuser()
165
+ subprocess.run(
166
+ ["icacls", str(path), "/inheritance:r"],
167
+ check=False, capture_output=True,
168
+ )
169
+ if user:
170
+ subprocess.run(
171
+ ["icacls", str(path), "/grant:r", f"{user}:F"],
172
+ check=False, capture_output=True,
173
+ )
174
+ except Exception as exc: # noqa: BLE001
175
+ logger.warning(
176
+ "could not restrict ACL on %s (%s); the capability token may be "
177
+ "readable by other local users", path, exc,
178
+ )
179
+ else:
180
+ try:
181
+ os.chmod(path, 0o600)
182
+ except OSError as exc:
183
+ logger.warning("could not chmod %s to 0600: %s", path, exc)
184
+
185
+
147
186
  def write_descriptor(
148
187
  descriptor: DaemonDescriptor,
149
188
  *,
@@ -162,10 +201,7 @@ def write_descriptor(
162
201
  stream.flush()
163
202
  os.fsync(stream.fileno())
164
203
  os.replace(temporary, destination)
165
- try:
166
- os.chmod(destination, 0o600)
167
- except OSError:
168
- pass
204
+ _restrict_to_owner(destination)
169
205
  finally:
170
206
  temporary.unlink(missing_ok=True)
171
207
  return destination
@@ -11,6 +11,7 @@ explicit process contract and always wins over persisted configuration.
11
11
  from __future__ import annotations
12
12
 
13
13
  import json
14
+ import logging
14
15
  import os
15
16
  from pathlib import Path
16
17
 
@@ -24,6 +25,8 @@ _DURABLE_IDENTITY_NAMES = frozenset(
24
25
  },
25
26
  )
26
27
 
28
+ logger = logging.getLogger(__name__)
29
+
27
30
 
28
31
  class DataRootConflictError(RuntimeError):
29
32
  """Raised when two state-bearing roots make startup ambiguous."""
@@ -122,17 +125,53 @@ def assert_no_durable_root_conflict(
122
125
  *,
123
126
  home: str | Path | None = None,
124
127
  ) -> None:
125
- """Refuse ambiguous startup when selected and default roots hold state.
126
-
127
- This check never writes, copies, or deletes data. A default root that only
128
- contains the legacy relocation config is safe and does not trigger it.
128
+ """Refuse *ambiguous* startup when two state roots make the live namespace unclear.
129
+
130
+ The root actually selected for this process is always inspected: an
131
+ unreadable selected root fails closed. Beyond that, a conflict is only raised
132
+ when the selection was *implicit* — resolved from the legacy
133
+ ``config.json:base_dir`` relocation hint — and a separately-populated default
134
+ root leaves it genuinely ambiguous which namespace is live.
135
+
136
+ An explicit environment selection (``SLM_DATA_DIR`` / ``SL_MEMORY_PATH`` /
137
+ ``SLM_HOME``) is an unambiguous operator contract: a separately-populated
138
+ default root is then a deliberate multi-root / per-team / second-instance
139
+ layout, not an ambiguity, so startup proceeds. If the explicitly chosen root
140
+ is empty while the old default still holds data, that likely-mistyped path is
141
+ surfaced as a warning rather than a hard block.
142
+
143
+ This check never writes, copies, or deletes data.
129
144
  """
130
145
  home_path = _canonical_path(home if home is not None else Path.home())
131
146
  default_root = _canonical_path(home_path / ".superlocalmemory")
132
147
  selected_root = canonical_data_root(home=home_path)
133
148
  if selected_root == default_root:
134
149
  return
150
+
151
+ # The root about to be used must be inspectable regardless of how it was
152
+ # chosen; an unreadable selected root fails closed inside _durable_markers.
135
153
  selected_markers = _durable_markers(selected_root)
154
+
155
+ if environment_data_root() is not None:
156
+ # Explicit selection wins; a populated default root is a deliberate
157
+ # multi-root layout, not an ambiguity. Only warn on the "empty new root
158
+ # while the old default still holds data" case so a wrong SLM_DATA_DIR
159
+ # stays visible. Inspection of the unused default never blocks startup.
160
+ if not selected_markers:
161
+ try:
162
+ default_has_data = bool(_durable_markers(default_root))
163
+ except DataRootConflictError:
164
+ default_has_data = False
165
+ if default_has_data:
166
+ logger.warning(
167
+ "SLM_DATA_DIR selects an empty state root (%s) while the "
168
+ "default root (%s) still holds data; starting with the empty "
169
+ "root as explicitly requested.",
170
+ selected_root,
171
+ default_root,
172
+ )
173
+ return
174
+
136
175
  default_markers = _durable_markers(default_root)
137
176
  if not selected_markers or not default_markers:
138
177
  return
@@ -102,13 +102,20 @@ class EventBus:
102
102
  logger.info("EventBus initialized: db=%s", self.db_path)
103
103
 
104
104
  def _init_schema(self) -> None:
105
- """Create the memory_events table if it does not exist."""
105
+ """Create the memory_events table if it does not exist.
106
+
107
+ Self-migrates a pre-isolation DB (memory_events without profile_id) so a
108
+ dashboard viewing profile A never sees profile B's events. This table is
109
+ store-owned (created here, not by the migration runner), so the store
110
+ owns its upgrade. Existing rows backfill to the 'default' profile.
111
+ """
106
112
  conn = sqlite3.connect(str(self.db_path))
107
113
  try:
108
114
  cur = conn.cursor()
109
115
  cur.execute("""
110
116
  CREATE TABLE IF NOT EXISTS memory_events (
111
117
  id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ profile_id TEXT NOT NULL DEFAULT 'default',
112
119
  event_type TEXT NOT NULL,
113
120
  memory_id INTEGER,
114
121
  source_agent TEXT DEFAULT 'user',
@@ -119,13 +126,42 @@ class EventBus:
119
126
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
120
127
  )
121
128
  """)
129
+ existing = {r[1] for r in cur.execute(
130
+ "PRAGMA table_info(memory_events)").fetchall()}
131
+ if "profile_id" not in existing:
132
+ cur.execute(
133
+ "ALTER TABLE memory_events "
134
+ "ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
135
+ )
122
136
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
123
137
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
124
138
  cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
139
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
125
140
  conn.commit()
126
141
  finally:
127
142
  conn.close()
128
143
 
144
+ @staticmethod
145
+ def _resolve_profile(profile_id: Optional[str]) -> str:
146
+ """Resolve the active profile for an event when not passed explicitly.
147
+
148
+ Uses the request-runtime helper (ContextVar for HTTP, else the
149
+ profiles.json active_profile cache that every switch keeps in sync) so
150
+ MCP-in-daemon and CLI emits are attributed correctly too. Lazy import
151
+ keeps the infra layer free of a hard server dependency; any failure
152
+ falls back to 'default'.
153
+ """
154
+ if profile_id:
155
+ return profile_id
156
+ try:
157
+ from superlocalmemory.server.routes.helpers import get_active_profile
158
+ resolved = get_active_profile()
159
+ if resolved:
160
+ return resolved
161
+ except Exception:
162
+ pass
163
+ return "default"
164
+
129
165
  def emit(
130
166
  self,
131
167
  event_type: str,
@@ -134,8 +170,14 @@ class EventBus:
134
170
  source_agent: str = "user",
135
171
  source_protocol: str = "internal",
136
172
  importance: int = 5,
173
+ profile_id: Optional[str] = None,
137
174
  ) -> Optional[int]:
138
- """Emit an event to all subscribers and persist to database."""
175
+ """Emit an event to all subscribers and persist to database.
176
+
177
+ ``profile_id`` scopes the event to a memory profile. When omitted it is
178
+ resolved from the active profile so a dashboard viewing one profile
179
+ never sees another profile's real-time or historical events.
180
+ """
139
181
  if event_type not in VALID_EVENT_TYPES:
140
182
  raise ValueError(
141
183
  f"Invalid event type: {event_type}. "
@@ -143,6 +185,7 @@ class EventBus:
143
185
  )
144
186
 
145
187
  importance = max(1, min(10, importance))
188
+ profile_id = self._resolve_profile(profile_id)
146
189
 
147
190
  now = datetime.now(timezone.utc).isoformat()
148
191
  with self._counter_lock:
@@ -151,6 +194,7 @@ class EventBus:
151
194
 
152
195
  event: Dict[str, Any] = {
153
196
  "seq": seq,
197
+ "profile_id": profile_id,
154
198
  "event_type": event_type,
155
199
  "memory_id": memory_id,
156
200
  "source_agent": source_agent,
@@ -177,18 +221,24 @@ class EventBus:
177
221
  event_type, event_id, memory_id,
178
222
  )
179
223
 
180
- # Auto-prune heuristic
181
- self._write_count += 1
182
- if (
183
- self._write_count >= 100
184
- or (datetime.now() - self._last_prune).total_seconds() > 86400
185
- ):
224
+ # Auto-prune heuristic. Decide + reset the counter atomically under the
225
+ # lock so concurrent emit() calls cannot both cross the threshold and
226
+ # double-run the prune; run the prune itself OUTSIDE the lock.
227
+ should_prune = False
228
+ with self._counter_lock:
229
+ self._write_count += 1
230
+ if (
231
+ self._write_count >= 100
232
+ or (datetime.now() - self._last_prune).total_seconds() > 86400
233
+ ):
234
+ should_prune = True
235
+ self._write_count = 0
236
+ self._last_prune = datetime.now()
237
+ if should_prune:
186
238
  try:
187
239
  self.prune_events()
188
240
  except Exception:
189
241
  pass
190
- self._write_count = 0
191
- self._last_prune = datetime.now()
192
242
 
193
243
  return event_id
194
244
 
@@ -202,10 +252,12 @@ class EventBus:
202
252
  try:
203
253
  cur = conn.cursor()
204
254
  cur.execute(
205
- "INSERT INTO memory_events (event_type, memory_id, source_agent,"
206
- " source_protocol, payload, importance, tier, created_at)"
207
- " VALUES (?, ?, ?, ?, ?, ?, 'hot', ?)",
208
- (event["event_type"], event.get("memory_id"),
255
+ "INSERT INTO memory_events (profile_id, event_type, memory_id,"
256
+ " source_agent, source_protocol, payload, importance, tier,"
257
+ " created_at)"
258
+ " VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)",
259
+ (event.get("profile_id", "default"), event["event_type"],
260
+ event.get("memory_id"),
209
261
  event["source_agent"], event["source_protocol"],
210
262
  json.dumps(event["payload"]), event["importance"],
211
263
  event["timestamp"]),
@@ -253,9 +305,16 @@ class EventBus:
253
305
  since_id: Optional[int] = None,
254
306
  limit: int = 50,
255
307
  event_type: Optional[str] = None,
308
+ profile_id: Optional[str] = None,
256
309
  ) -> List[dict]:
257
- """Get recent events from the database."""
310
+ """Get recent events from the database, scoped to a profile.
311
+
312
+ ``profile_id`` defaults to the active profile so callers never leak
313
+ another profile's events. Pass ``profile_id="*"`` to bypass scoping
314
+ (internal maintenance/pruning only — never a client-facing path).
315
+ """
258
316
  limit = min(limit, 200)
317
+ scope = profile_id if profile_id == "*" else self._resolve_profile(profile_id)
259
318
 
260
319
  try:
261
320
  conn = sqlite3.connect(str(self.db_path))
@@ -263,10 +322,15 @@ class EventBus:
263
322
  cur = conn.cursor()
264
323
 
265
324
  query = ("SELECT id, event_type, memory_id, source_agent,"
266
- " source_protocol, payload, importance, tier, created_at"
325
+ " source_protocol, payload, importance, tier, created_at,"
326
+ " profile_id"
267
327
  " FROM memory_events WHERE 1=1")
268
328
  params: List[Any] = []
269
329
 
330
+ if scope != "*":
331
+ query += " AND profile_id = ?"
332
+ params.append(scope)
333
+
270
334
  if since_id is not None:
271
335
  query += " AND id > ?"
272
336
  params.append(since_id)
@@ -293,7 +357,7 @@ class EventBus:
293
357
  "id": row[0], "event_type": row[1], "memory_id": row[2],
294
358
  "source_agent": row[3], "source_protocol": row[4],
295
359
  "payload": parsed, "importance": row[6],
296
- "tier": row[7], "timestamp": row[8],
360
+ "tier": row[7], "timestamp": row[8], "profile_id": row[9],
297
361
  })
298
362
  return events
299
363
 
@@ -306,19 +370,32 @@ class EventBus:
306
370
  with self._buffer_lock:
307
371
  return [e for e in self._buffer if e.get("seq", 0) > since_seq]
308
372
 
309
- def get_event_stats(self) -> dict:
310
- """Get event system statistics."""
373
+ def get_event_stats(self, profile_id: Optional[str] = None) -> dict:
374
+ """Get event system statistics, scoped to a profile.
375
+
376
+ ``profile_id`` defaults to the active profile so dashboard event
377
+ counts never blend across profiles.
378
+ """
379
+ scope = self._resolve_profile(profile_id)
311
380
  try:
312
381
  conn = sqlite3.connect(str(self.db_path))
313
382
  try:
314
383
  cur = conn.cursor()
315
384
 
316
- total = cur.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0]
317
- cur.execute("SELECT event_type, COUNT(*) FROM memory_events GROUP BY event_type")
385
+ total = cur.execute(
386
+ "SELECT COUNT(*) FROM memory_events WHERE profile_id = ?",
387
+ (scope,)).fetchone()[0]
388
+ cur.execute(
389
+ "SELECT event_type, COUNT(*) FROM memory_events "
390
+ "WHERE profile_id = ? GROUP BY event_type", (scope,))
318
391
  by_type = dict(cur.fetchall())
319
- cur.execute("SELECT tier, COUNT(*) FROM memory_events GROUP BY tier")
392
+ cur.execute(
393
+ "SELECT tier, COUNT(*) FROM memory_events "
394
+ "WHERE profile_id = ? GROUP BY tier", (scope,))
320
395
  by_tier = dict(cur.fetchall())
321
- cur.execute("SELECT COUNT(*) FROM memory_events WHERE created_at >= datetime('now', '-24 hours')")
396
+ cur.execute(
397
+ "SELECT COUNT(*) FROM memory_events WHERE profile_id = ? "
398
+ "AND created_at >= datetime('now', '-24 hours')", (scope,))
322
399
  last_24h = cur.fetchone()[0]
323
400
  finally:
324
401
  conn.close()
@@ -343,7 +420,13 @@ class EventBus:
343
420
  warm_hours: int = DEFAULT_WARM_HOURS,
344
421
  cold_hours: int = DEFAULT_COLD_HOURS,
345
422
  ) -> dict:
346
- """Apply tiered retention policy to persisted events."""
423
+ """Apply tiered retention policy to persisted events.
424
+
425
+ INTENTIONALLY GLOBAL (all profiles): this is age/tier-based housekeeping
426
+ of the shared event log, not a per-tenant read or retention-policy
427
+ surface. Tenant isolation of event CONTENT is enforced on read via the
428
+ profile_id filter; this sweep only demotes/expires old rows by age.
429
+ """
347
430
  try:
348
431
  conn = sqlite3.connect(str(self.db_path))
349
432
  try: