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
@@ -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
+ ]
@@ -0,0 +1,58 @@
1
+ """Cumulative budget accounting for a bounded-loop run.
2
+
3
+ The meter tracks laps, tokens, and wall-clock time and reports when any bound
4
+ in effect has been exceeded. It is intentionally tiny and side-effect-free
5
+ apart from its own internal counters, and the clock is injected so tests are
6
+ deterministic.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Callable
12
+
13
+ from superlocalmemory.loops.models import Bounds
14
+
15
+
16
+ class BudgetMeter:
17
+ """Accumulate spend across laps and answer "have we gone over?".
18
+
19
+ ``now`` is a zero-argument callable returning monotonic-ish seconds
20
+ (``time.monotonic`` in production, a fake in tests). The start time is
21
+ captured at construction so wall-clock enforcement needs no globals.
22
+ """
23
+
24
+ def __init__(self, now: Callable[[], float]) -> None:
25
+ self._now = now
26
+ self._start = now()
27
+ self._tokens = 0
28
+
29
+ def spend(self, tokens: int) -> None:
30
+ """Record token spend for a completed lap (negative values ignored)."""
31
+ if tokens > 0:
32
+ self._tokens += tokens
33
+
34
+ def exceeded(self, lap: int, bounds: Bounds) -> tuple[bool, str]:
35
+ """Return ``(tripped, reason)`` for the bounds checked before a lap runs.
36
+
37
+ Checked in priority order: iteration cap, token budget, wall-clock.
38
+ ``lap`` is the 1-based number of the lap about to run, so exceeding
39
+ ``max_iterations`` is reported when the (max+1)-th lap is attempted.
40
+ """
41
+ if lap > bounds.max_iterations:
42
+ return True, "max-iterations"
43
+ # >= so a budget of N never permits an (N+1)-th lap's worth of spend:
44
+ # once cumulative tokens reach the ceiling, the next lap is refused.
45
+ if bounds.max_tokens is not None and self._tokens >= bounds.max_tokens:
46
+ return True, "token-budget"
47
+ if bounds.max_wallclock_s is not None:
48
+ elapsed = self._now() - self._start
49
+ if elapsed > bounds.max_wallclock_s:
50
+ return True, "wallclock"
51
+ return False, ""
52
+
53
+ def snapshot(self) -> dict:
54
+ """Point-in-time spend, suitable for a ledger entry."""
55
+ return {
56
+ "tokens": self._tokens,
57
+ "wallclock_s": round(self._now() - self._start, 3),
58
+ }
@@ -0,0 +1,164 @@
1
+ """The bounded-loop orchestrator.
2
+
3
+ ``run_bounded_loop`` executes a loop for a single goal under a fixed set of
4
+ :class:`Bounds`. Its one non-negotiable invariant: **the independent gate
5
+ decides when the loop is finished — never the agent.** A runner's
6
+ ``agent_claimed_done`` flag is written to the ledger for audit and is never
7
+ read when deciding to terminate.
8
+
9
+ Each lap, in strict order:
10
+
11
+ 1. Poll the kill switch (highest priority — checked before any work).
12
+ 2. Check the budget bounds (iteration cap, tokens, wall-clock).
13
+ 3. Run the agent's proposer for one lap.
14
+ 4. Accumulate token spend.
15
+ 5. Ask the *independent* gate for a verdict.
16
+ 6. Decide: a passing gate (plus any required approval) ends the run; an
17
+ exhausted no-progress window halts it; otherwise continue.
18
+
19
+ Runner and gate are plain callables taking the 1-based lap number, so this
20
+ engine carries no subprocess, sandbox, or framework machinery — SLM loops
21
+ converge on a checkable memory/verification condition, and heavier isolation
22
+ belongs to the standalone bounded-loops engine, not here.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import time
29
+ import uuid
30
+ from datetime import datetime, timezone
31
+ from typing import Callable, Optional
32
+
33
+ from superlocalmemory.loops.budget import BudgetMeter
34
+ from superlocalmemory.loops.ledger import InMemoryLedger, LedgerEntry, LedgerStore
35
+ from superlocalmemory.loops.models import (
36
+ Bounds,
37
+ LapResult,
38
+ Outcome,
39
+ Rung,
40
+ Status,
41
+ Verdict,
42
+ )
43
+ from superlocalmemory.loops.rules import (
44
+ no_progress,
45
+ rung_requires_approval,
46
+ stop_condition_met,
47
+ )
48
+
49
+ RunnerFn = Callable[[int], LapResult]
50
+ GateFn = Callable[[int], Verdict]
51
+ ApproverFn = Callable[[Verdict], bool]
52
+ KillSwitchFn = Callable[[], bool]
53
+ ClockFn = Callable[[], str]
54
+
55
+ _KILL_ENV = "SLM_LOOP_KILL"
56
+
57
+
58
+ def _utc_now_iso() -> str:
59
+ return datetime.now(timezone.utc).isoformat()
60
+
61
+
62
+ def _env_killed() -> bool:
63
+ return bool(os.environ.get(_KILL_ENV))
64
+
65
+
66
+ def run_bounded_loop(
67
+ name: str,
68
+ *,
69
+ bounds: Bounds,
70
+ runner: RunnerFn,
71
+ gate: GateFn,
72
+ rung: Rung = Rung.L1,
73
+ ledger: Optional[LedgerStore] = None,
74
+ approver: Optional[ApproverFn] = None,
75
+ killswitch: Optional[KillSwitchFn] = None,
76
+ clock: Optional[ClockFn] = None,
77
+ monotonic: Optional[Callable[[], float]] = None,
78
+ run_id: Optional[str] = None,
79
+ ) -> Outcome:
80
+ """Run one bounded loop and return its :class:`Outcome`.
81
+
82
+ Only ``name``, ``bounds``, ``runner`` and ``gate`` are required. Every
83
+ other dependency is injected for determinism in tests; sensible defaults
84
+ (UTC clock, monotonic timer, env-var kill switch, in-memory ledger) apply
85
+ otherwise.
86
+ """
87
+ run_id = run_id or f"{name}-{uuid.uuid4().hex[:8]}"
88
+ ledger = ledger if ledger is not None else InMemoryLedger()
89
+ clock = clock or _utc_now_iso
90
+ monotonic = monotonic or time.monotonic
91
+ killswitch = killswitch or _env_killed
92
+ budget = BudgetMeter(monotonic)
93
+
94
+ lap_changes: list[bool] = []
95
+ lap = 0
96
+
97
+ def emit(decision: str, verdict: Verdict, result: LapResult | None = None) -> None:
98
+ ledger.record(
99
+ LedgerEntry(
100
+ run_id=run_id,
101
+ name=name,
102
+ lap=lap,
103
+ ts=clock(),
104
+ decision=decision,
105
+ passed=verdict.passed,
106
+ detail=verdict.detail,
107
+ # The agent's own claim is recorded for audit only — never used
108
+ # to terminate (see the loop invariant). Log is capped so a
109
+ # verbose runner can't bloat the ledger row.
110
+ agent_claimed_done=bool(result.agent_claimed_done) if result else False,
111
+ runner_log=(result.log or "")[:2000] if result else "",
112
+ budget=budget.snapshot(),
113
+ )
114
+ )
115
+
116
+ while True:
117
+ lap += 1
118
+
119
+ # 1. Kill switch — before any work (so laps reports completed laps).
120
+ if killswitch():
121
+ emit("killed", Verdict(False, "kill switch tripped"))
122
+ return Outcome(Status.KILLED, "killed", lap - 1, run_id)
123
+
124
+ # 2. Budget bounds — before running the agent.
125
+ tripped, why = budget.exceeded(lap, bounds)
126
+ if tripped:
127
+ emit("halt", Verdict(False, why))
128
+ return Outcome(Status.HALT, why, lap - 1, run_id)
129
+
130
+ # 3. Run the proposer for one lap.
131
+ try:
132
+ result = runner(lap)
133
+ except Exception as exc: # runner failure is a terminal ERROR
134
+ detail = f"runner error: {type(exc).__name__}: {exc}"
135
+ emit("error", Verdict(False, detail))
136
+ return Outcome(Status.ERROR, detail, lap, run_id)
137
+
138
+ # 4. Accumulate spend.
139
+ budget.spend(result.tokens)
140
+ lap_changes.append(result.changed)
141
+
142
+ # 5. Independent gate — agent's own claim is never consulted here.
143
+ try:
144
+ verdict = gate(lap)
145
+ except Exception as exc:
146
+ detail = f"gate error: {type(exc).__name__}: {exc}"
147
+ emit("error", Verdict(False, detail), result)
148
+ return Outcome(Status.ERROR, detail, lap, run_id)
149
+
150
+ # 6. Decide.
151
+ if stop_condition_met(verdict):
152
+ if rung_requires_approval(rung, bounds):
153
+ granted = bool(approver(verdict)) if approver is not None else False
154
+ if not granted:
155
+ emit("pause", verdict, result)
156
+ return Outcome(Status.PAUSE, "awaiting-approval", lap, run_id)
157
+ emit("done", verdict, result)
158
+ return Outcome(Status.DONE, "gate-passed", lap, run_id)
159
+
160
+ if no_progress(lap_changes, bounds.no_progress_window):
161
+ emit("halt", Verdict(False, "no-progress"), result)
162
+ return Outcome(Status.HALT, "no-progress", lap, run_id)
163
+
164
+ emit("continue", verdict, result)