superlocalmemory 3.7.7 → 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 (262) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +85 -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 -2
  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 +96 -12
  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/auth_middleware.py +28 -0
  116. package/src/superlocalmemory/infra/backup.py +12 -1
  117. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  118. package/src/superlocalmemory/infra/data_root.py +43 -4
  119. package/src/superlocalmemory/infra/event_bus.py +107 -24
  120. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  121. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  122. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  123. package/src/superlocalmemory/learning/cross_project.py +28 -19
  124. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  125. package/src/superlocalmemory/loops/__init__.py +56 -0
  126. package/src/superlocalmemory/loops/budget.py +58 -0
  127. package/src/superlocalmemory/loops/engine.py +164 -0
  128. package/src/superlocalmemory/loops/ledger.py +243 -0
  129. package/src/superlocalmemory/loops/models.py +152 -0
  130. package/src/superlocalmemory/loops/rules.py +52 -0
  131. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  132. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  133. package/src/superlocalmemory/mcp/profiles.py +103 -0
  134. package/src/superlocalmemory/mcp/server.py +21 -49
  135. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  136. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  137. package/src/superlocalmemory/mcp/tools_core.py +50 -5
  138. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  139. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  140. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  141. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  142. package/src/superlocalmemory/mesh/broker.py +237 -129
  143. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  144. package/src/superlocalmemory/optimize/NOTICE +1 -6
  145. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  147. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  148. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  149. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  150. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  151. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  152. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  153. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  154. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  155. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  156. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  157. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  158. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  159. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  160. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  161. package/src/superlocalmemory/retrieval/engine.py +168 -26
  162. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  163. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  164. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  165. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  166. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  167. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  168. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  169. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  170. package/src/superlocalmemory/server/api.py +21 -4
  171. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  172. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  173. package/src/superlocalmemory/server/recall_health.py +24 -3
  174. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  175. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  176. package/src/superlocalmemory/server/routes/agents.py +128 -38
  177. package/src/superlocalmemory/server/routes/backup.py +34 -10
  178. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  179. package/src/superlocalmemory/server/routes/brain.py +21 -5
  180. package/src/superlocalmemory/server/routes/chat.py +72 -16
  181. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  182. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  183. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  184. package/src/superlocalmemory/server/routes/entity.py +9 -4
  185. package/src/superlocalmemory/server/routes/events.py +24 -8
  186. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  187. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  188. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  189. package/src/superlocalmemory/server/routes/insights.py +3 -3
  190. package/src/superlocalmemory/server/routes/learning.py +14 -14
  191. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  192. package/src/superlocalmemory/server/routes/memories.py +221 -49
  193. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  194. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  195. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  196. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  197. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  198. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  199. package/src/superlocalmemory/server/routes/stats.py +13 -6
  200. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  201. package/src/superlocalmemory/server/routes/v3_api.py +194 -81
  202. package/src/superlocalmemory/server/routes/ws.py +5 -2
  203. package/src/superlocalmemory/server/security_middleware.py +12 -5
  204. package/src/superlocalmemory/server/ui.py +30 -5
  205. package/src/superlocalmemory/server/unified_daemon.py +431 -75
  206. package/src/superlocalmemory/server/write_identity.py +38 -8
  207. package/src/superlocalmemory/storage/database.py +265 -53
  208. package/src/superlocalmemory/storage/migration_runner.py +53 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/models.py +4 -0
  217. package/src/superlocalmemory/storage/schema.py +87 -0
  218. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  219. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  220. package/src/superlocalmemory/trust/gate.py +49 -8
  221. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  222. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  223. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  224. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  225. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  226. package/src/superlocalmemory/ui/favicon.svg +35 -4
  227. package/src/superlocalmemory/ui/index.html +306 -173
  228. package/src/superlocalmemory/ui/js/brain.js +5 -20
  229. package/src/superlocalmemory/ui/js/core.js +47 -31
  230. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  231. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  232. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  233. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  234. package/src/superlocalmemory/ui/js/memories.js +15 -4
  235. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  236. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  237. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  238. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  239. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  240. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  241. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  242. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  243. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  244. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  245. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  246. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  247. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  248. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  249. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  250. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  251. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  252. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  253. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  254. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  255. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  256. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  257. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  258. package/src/superlocalmemory/ui/js/settings.js +2 -2
  259. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  260. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  261. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  262. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -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:
@@ -118,6 +118,22 @@ class RateLimiter:
118
118
  del self._requests[k]
119
119
  return len(stale)
120
120
 
121
+ def configure(
122
+ self,
123
+ max_requests: int | None = None,
124
+ window_seconds: int | None = None,
125
+ ) -> None:
126
+ """Reconfigure limits at runtime (thread-safe).
127
+
128
+ Recorded request timestamps are preserved; only the ceilings change,
129
+ so a raised limit takes effect immediately for the current window.
130
+ """
131
+ with self._lock:
132
+ if max_requests is not None:
133
+ self.max_requests = max(1, int(max_requests))
134
+ if window_seconds is not None:
135
+ self.window = max(1, int(window_seconds))
136
+
121
137
  def get_stats(self) -> dict:
122
138
  """Return a snapshot of limiter state."""
123
139
  with self._lock:
@@ -133,3 +149,80 @@ class RateLimiter:
133
149
  # ---------------------------------------------------------------------------
134
150
  write_limiter = RateLimiter(max_requests=WRITE_LIMIT, window_seconds=WINDOW_SECONDS)
135
151
  read_limiter = RateLimiter(max_requests=READ_LIMIT, window_seconds=WINDOW_SECONDS)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Runtime-configurable limits (task #47) — dashboard-editable thresholds.
156
+ #
157
+ # The enforcement middleware builds its own limiter instances; it registers
158
+ # them here by role so a single set_limits() call reconfigures every live
159
+ # limiter at once (no restart). Loopback limiters derive from write/read,
160
+ # matching the startup derivation in unified_daemon.
161
+ # ---------------------------------------------------------------------------
162
+
163
+ _MANAGED: List[Tuple[str, "RateLimiter"]] = []
164
+ _MANAGED_LOCK = threading.Lock()
165
+ _CURRENT: Dict[str, int] = {
166
+ "write": WRITE_LIMIT, "read": READ_LIMIT, "window": WINDOW_SECONDS,
167
+ }
168
+
169
+
170
+ def _loopback_write(write: int) -> int:
171
+ return max(300, int(write) * 10)
172
+
173
+
174
+ def _loopback_read(read: int) -> int:
175
+ return max(2000, int(read) * 20)
176
+
177
+
178
+ def register_managed(role: str, limiter: "RateLimiter") -> None:
179
+ """Register an enforcement limiter so set_limits() can reconfigure it.
180
+
181
+ role is one of: 'write', 'read', 'lb_write', 'lb_read'.
182
+ """
183
+ with _MANAGED_LOCK:
184
+ _MANAGED.append((role, limiter))
185
+
186
+
187
+ def reset_managed() -> None:
188
+ """Drop all registered limiters (app teardown / test isolation)."""
189
+ with _MANAGED_LOCK:
190
+ _MANAGED.clear()
191
+
192
+
193
+ def get_limits() -> Dict[str, int]:
194
+ """Return the current effective write/read/window limits."""
195
+ with _MANAGED_LOCK:
196
+ return dict(_CURRENT)
197
+
198
+
199
+ def set_limits(
200
+ write: int | None = None,
201
+ read: int | None = None,
202
+ window: int | None = None,
203
+ ) -> Dict[str, int]:
204
+ """Update limits and reconfigure every registered limiter live.
205
+
206
+ Partial updates are supported (pass only what changes). Loopback
207
+ limiters re-derive from the new write/read. Returns the new effective
208
+ limits. Values are floored at 1.
209
+ """
210
+ with _MANAGED_LOCK:
211
+ if write is not None:
212
+ _CURRENT["write"] = max(1, int(write))
213
+ if read is not None:
214
+ _CURRENT["read"] = max(1, int(read))
215
+ if window is not None:
216
+ _CURRENT["window"] = max(1, int(window))
217
+ w, r, win = _CURRENT["write"], _CURRENT["read"], _CURRENT["window"]
218
+ lb_w, lb_r = _loopback_write(w), _loopback_read(r)
219
+ for role, limiter in _MANAGED:
220
+ if role == "write":
221
+ limiter.configure(max_requests=w, window_seconds=win)
222
+ elif role == "read":
223
+ limiter.configure(max_requests=r, window_seconds=win)
224
+ elif role == "lb_write":
225
+ limiter.configure(max_requests=lb_w, window_seconds=win)
226
+ elif role == "lb_read":
227
+ limiter.configure(max_requests=lb_r, window_seconds=win)
228
+ return dict(_CURRENT)
@@ -175,7 +175,10 @@ def stop_adapter(name: str) -> dict:
175
175
  proc.terminate()
176
176
  proc.wait(timeout=10)
177
177
  except ImportError:
178
- os.kill(pid, 15) # SIGTERM
178
+ # No psutil: best-effort termination. On Windows os.kill(pid, 15) maps to
179
+ # TerminateProcess (a hard kill, not a graceful SIGTERM) — psutil's
180
+ # terminate() does the same there, so behavior is equivalent either way.
181
+ os.kill(pid, 15)
179
182
  except Exception:
180
183
  pass
181
184
 
@@ -42,7 +42,7 @@ def store_credential(service: str, key: str, value: str) -> bool:
42
42
  except Exception:
43
43
  pass
44
44
 
45
- # Fallback: encrypted file with restricted permissions
45
+ # Fallback: restricted-permission file (0600) when no OS keychain is present
46
46
  try:
47
47
  cred_dir = _credential_dir()
48
48
  cred_dir.mkdir(parents=True, exist_ok=True)
@@ -65,6 +65,7 @@ TECH_CATEGORIES: dict[str, list[str]] = {
65
65
  _SCHEMA = """
66
66
  CREATE TABLE IF NOT EXISTS transferable_patterns (
67
67
  id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ profile_id TEXT NOT NULL DEFAULT 'default',
68
69
  pattern_type TEXT NOT NULL DEFAULT 'preference',
69
70
  key TEXT NOT NULL,
70
71
  value TEXT NOT NULL,
@@ -75,7 +76,7 @@ CREATE TABLE IF NOT EXISTS transferable_patterns (
75
76
  contradictions TEXT DEFAULT '[]',
76
77
  first_seen TEXT,
77
78
  last_seen TEXT,
78
- UNIQUE(pattern_type, key)
79
+ UNIQUE(profile_id, pattern_type, key)
79
80
  );
80
81
 
81
82
  CREATE TABLE IF NOT EXISTS memories (
@@ -115,25 +116,29 @@ class CrossProjectAggregator:
115
116
  # Step 2: Merge with temporal decay
116
117
  merged = self._merge_with_decay(profile_patterns)
117
118
 
118
- # Step 3: Detect contradictions
119
+ # Step 3: Detect contradictions (scoped to the target profile)
119
120
  for key, pattern_data in merged.items():
120
121
  pattern_data["contradictions"] = self._detect_contradictions(
121
- key, pattern_data
122
+ key, pattern_data, target_profile
122
123
  )
123
124
 
124
- # Step 4: Store and return
125
- self._store_patterns(merged)
125
+ # Step 4: Store and return (scoped to the target profile)
126
+ self._store_patterns(merged, target_profile)
126
127
  return [{"key": k, **v} for k, v in merged.items()]
127
128
 
128
- def get_preferences(self, min_confidence: float = 0.6) -> dict[str, dict]:
129
- """Retrieve stored transferable preferences above *min_confidence*."""
129
+ def get_preferences(
130
+ self,
131
+ profile_id: str = "default",
132
+ min_confidence: float = 0.6,
133
+ ) -> dict[str, dict]:
134
+ """Retrieve stored transferable preferences for *profile_id* above *min_confidence*."""
130
135
  conn = sqlite3.connect(str(self._db_path))
131
136
  conn.row_factory = sqlite3.Row
132
137
  try:
133
138
  cur = conn.execute(
134
139
  "SELECT * FROM transferable_patterns "
135
- "WHERE confidence >= ? ORDER BY confidence DESC",
136
- (min_confidence,),
140
+ "WHERE profile_id = ? AND confidence >= ? ORDER BY confidence DESC",
141
+ (profile_id, min_confidence),
137
142
  )
138
143
  result: dict[str, dict] = {}
139
144
  for row in cur.fetchall():
@@ -277,18 +282,18 @@ class CrossProjectAggregator:
277
282
  }
278
283
 
279
284
  def _detect_contradictions(
280
- self, pattern_key: str, pattern_data: dict
285
+ self, pattern_key: str, pattern_data: dict, profile_id: str = "default"
281
286
  ) -> list[str]:
282
287
  contradictions: list[str] = []
283
288
 
284
- # Check stored value vs new value
289
+ # Check stored value vs new value, scoped to this profile.
285
290
  conn = sqlite3.connect(str(self._db_path))
286
291
  conn.row_factory = sqlite3.Row
287
292
  try:
288
293
  cur = conn.execute(
289
294
  "SELECT value, last_seen FROM transferable_patterns "
290
- "WHERE key = ? AND pattern_type = 'preference'",
291
- (pattern_key,),
295
+ "WHERE profile_id = ? AND key = ? AND pattern_type = 'preference'",
296
+ (profile_id, pattern_key),
292
297
  )
293
298
  row = cur.fetchone()
294
299
  if row:
@@ -316,18 +321,21 @@ class CrossProjectAggregator:
316
321
  finally:
317
322
  conn.close()
318
323
 
319
- def _store_patterns(self, merged: dict[str, dict]) -> None:
324
+ def _store_patterns(
325
+ self, merged: dict[str, dict], profile_id: str = "default"
326
+ ) -> None:
327
+ """Persist merged patterns for *profile_id*. Each profile has isolated rows."""
320
328
  conn = sqlite3.connect(str(self._db_path))
321
329
  now = datetime.now(UTC).isoformat()
322
330
  try:
323
331
  for key, data in merged.items():
324
332
  conn.execute(
325
333
  """INSERT INTO transferable_patterns
326
- (pattern_type, key, value, confidence, evidence_count,
327
- profiles_seen, decay_factor, contradictions,
328
- first_seen, last_seen)
329
- VALUES ('preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
330
- ON CONFLICT(pattern_type, key) DO UPDATE SET
334
+ (profile_id, pattern_type, key, value, confidence,
335
+ evidence_count, profiles_seen, decay_factor,
336
+ contradictions, first_seen, last_seen)
337
+ VALUES (?, 'preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ ON CONFLICT(profile_id, pattern_type, key) DO UPDATE SET
331
339
  value = excluded.value,
332
340
  confidence = excluded.confidence,
333
341
  evidence_count = excluded.evidence_count,
@@ -337,6 +345,7 @@ class CrossProjectAggregator:
337
345
  last_seen = excluded.last_seen
338
346
  """,
339
347
  (
348
+ profile_id,
340
349
  key,
341
350
  data["value"],
342
351
  data["confidence"],
@@ -114,8 +114,14 @@ def _tool_event_hit(
114
114
  memory_conn: sqlite3.Connection,
115
115
  played_at: datetime,
116
116
  fact_ids: list[str],
117
+ profile_id: str | None = None,
117
118
  ) -> bool:
118
- """True iff any tool_events row references any fact_id within +30 s."""
119
+ """True iff any tool_events row references any fact_id within +30 s.
120
+
121
+ Tenant-scoped: without a profile predicate, one tenant's reward signal
122
+ would be reinforced by another tenant's tool events firing in the same
123
+ 30 s window — cross-contaminated behavioral learning.
124
+ """
119
125
  if not fact_ids:
120
126
  return False
121
127
  start = played_at.isoformat(timespec="seconds")
@@ -139,12 +145,24 @@ def _tool_event_hit(
139
145
  # in Python — typically 0-few rows in a 30 s window, far cheaper than
140
146
  # N LIKE passes against a growing table. Still O(rows_in_window * len(fact_ids))
141
147
  # worst case but the constant factor is tiny (a substring check).
148
+ # Add the profile predicate only when tool_events actually has the column
149
+ # (schema varies by install).
150
+ scope_sql, scope_params = "", ()
151
+ if profile_id is not None:
152
+ try:
153
+ cols = {r[1] for r in memory_conn.execute(
154
+ "PRAGMA table_info(tool_events)").fetchall()}
155
+ if "profile_id" in cols:
156
+ scope_sql = " AND profile_id = ?"
157
+ scope_params = (profile_id,)
158
+ except sqlite3.Error:
159
+ pass
142
160
  try:
143
161
  candidate_rows = memory_conn.execute(
144
162
  "SELECT payload_json FROM tool_events "
145
163
  "WHERE occurred_at BETWEEN ? AND ? "
146
- " AND payload_json IS NOT NULL",
147
- (start, end),
164
+ " AND payload_json IS NOT NULL" + scope_sql,
165
+ (start, end, *scope_params),
148
166
  ).fetchall()
149
167
  except sqlite3.Error:
150
168
  return False
@@ -165,6 +183,7 @@ def _requery_detected(
165
183
  memory_conn: sqlite3.Connection,
166
184
  played_at: datetime,
167
185
  query_id: str,
186
+ profile_id: str | None = None,
168
187
  ) -> bool:
169
188
  """True iff a follow-up query within 30 s has matching NFC topic sig.
170
189
 
@@ -193,14 +212,28 @@ def _requery_detected(
193
212
  if tbl is None:
194
213
  return False
195
214
 
215
+ # Tenant scope (guarded — tool_events schema varies): without it, another
216
+ # profile's recall events in the same window count as this profile's
217
+ # requeries, corrupting reward attribution.
218
+ scope_sql, scope_params = "", ()
219
+ if profile_id is not None:
220
+ try:
221
+ cols = {r[1] for r in memory_conn.execute(
222
+ "PRAGMA table_info(tool_events)").fetchall()}
223
+ if "profile_id" in cols:
224
+ scope_sql = " AND profile_id = ?"
225
+ scope_params = (profile_id,)
226
+ except sqlite3.Error:
227
+ pass
228
+
196
229
  # Read *query* text from tool_events payload for within-window events;
197
230
  # compute topic sig on each, compare against the original query's sig.
198
231
  try:
199
232
  rows = memory_conn.execute(
200
233
  "SELECT payload_json FROM tool_events "
201
234
  "WHERE occurred_at > ? AND occurred_at <= ? "
202
- " AND tool_name = 'recall' LIMIT 20",
203
- (start, end),
235
+ " AND tool_name = 'recall'" + scope_sql + " LIMIT 20",
236
+ (start, end, *scope_params),
204
237
  ).fetchall()
205
238
  except sqlite3.Error:
206
239
  return False
@@ -212,9 +245,9 @@ def _requery_detected(
212
245
  try:
213
246
  seed_row = memory_conn.execute(
214
247
  "SELECT payload_json FROM tool_events "
215
- "WHERE occurred_at <= ? AND tool_name = 'recall' "
248
+ "WHERE occurred_at <= ? AND tool_name = 'recall'" + scope_sql + " "
216
249
  "ORDER BY occurred_at DESC LIMIT 1",
217
- (played_at.isoformat(timespec="seconds"),),
250
+ (played_at.isoformat(timespec="seconds"), *scope_params),
218
251
  ).fetchone()
219
252
  except sqlite3.Error:
220
253
  seed_row = None
@@ -288,12 +321,12 @@ def settle_stale_plays(
288
321
  reward: float | None = None
289
322
  kind = "default"
290
323
  if memory_conn is not None and _tool_event_hit(
291
- memory_conn, played, top3,
324
+ memory_conn, played, top3, profile_id=str(profile_id),
292
325
  ):
293
326
  reward = 1.0
294
327
  kind = "proxy_position"
295
328
  elif memory_conn is not None and _requery_detected(
296
- memory_conn, played, row["query_id"],
329
+ memory_conn, played, row["query_id"], profile_id=str(profile_id),
297
330
  ):
298
331
  reward = 0.0
299
332
  kind = "proxy_requery"
@@ -0,0 +1,56 @@
1
+ """SuperLocalMemory bounded loops.
2
+
3
+ Agent loops that stop when an independent gate passes — not when the agent
4
+ says it is done — with the run's history persisted in SLM's durable memory.
5
+
6
+ Public API::
7
+
8
+ from superlocalmemory.loops import (
9
+ Bounds, Rung, Status, Verdict, LapResult, Outcome,
10
+ run_bounded_loop, InMemoryLedger, SLMMemoryLedger, open_engine_store,
11
+ )
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from superlocalmemory.loops.engine import run_bounded_loop
17
+ from superlocalmemory.loops.ledger import (
18
+ InMemoryLedger,
19
+ LedgerEntry,
20
+ LedgerStore,
21
+ SLMMemoryLedger,
22
+ engine_backed_ledger,
23
+ open_engine_store,
24
+ )
25
+ from superlocalmemory.loops.models import (
26
+ Bounds,
27
+ LapResult,
28
+ Outcome,
29
+ Rung,
30
+ Status,
31
+ Verdict,
32
+ )
33
+ from superlocalmemory.loops.rules import (
34
+ no_progress,
35
+ rung_requires_approval,
36
+ stop_condition_met,
37
+ )
38
+
39
+ __all__ = [
40
+ "Bounds",
41
+ "Rung",
42
+ "Status",
43
+ "Verdict",
44
+ "LapResult",
45
+ "Outcome",
46
+ "run_bounded_loop",
47
+ "LedgerEntry",
48
+ "LedgerStore",
49
+ "InMemoryLedger",
50
+ "SLMMemoryLedger",
51
+ "engine_backed_ledger",
52
+ "open_engine_store",
53
+ "no_progress",
54
+ "rung_requires_approval",
55
+ "stop_condition_met",
56
+ ]