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
@@ -13,6 +13,81 @@ from typing import Any
13
13
  logger = logging.getLogger(__name__)
14
14
 
15
15
 
16
+ # ---------------------------------------------------------------------------
17
+ # Nested-group compatibility.
18
+ #
19
+ # The canonical serialized form (``OptimizeConfig.as_dict``) is FLAT
20
+ # (``compress_enabled``, ``compress_mode``, ``cache_enabled``, ...). But
21
+ # hand-authored ``optimize.json`` files — and older doc examples — group
22
+ # settings under nested blocks (``{"compress": {"enabled": true, ...}}``).
23
+ # ``from_dict`` accepts BOTH: be liberal in what we read, strict in what we
24
+ # write (Postel's law). Flat keys, when present, always win over a nested
25
+ # value for the same setting.
26
+ # ---------------------------------------------------------------------------
27
+ _NESTED_ALIASES: dict[str, dict[str, str]] = {
28
+ "compress": {
29
+ "enabled": "compress_enabled",
30
+ "mode": "compress_mode",
31
+ "prose": "compress_prose",
32
+ "protect_recent": "compress_protect_recent",
33
+ },
34
+ "cache": {
35
+ "enabled": "cache_enabled",
36
+ "ttl_seconds": "ttl_seconds",
37
+ "semantic": "semantic_enabled",
38
+ "semantic_enabled": "semantic_enabled",
39
+ },
40
+ "proxy": {
41
+ "enabled": "proxy_enabled",
42
+ },
43
+ }
44
+
45
+ # compress_mode aliases → canonical value. Any unknown mode normalizes to
46
+ # "safe" (fail-open) rather than crashing ``validate()`` at daemon boot.
47
+ _COMPRESS_MODE_ALIASES: dict[str, str] = {
48
+ "safe": "safe",
49
+ "aggressive": "aggressive",
50
+ "fast": "safe", # legacy alias: "fast" == lightweight == safe
51
+ "lossless": "safe",
52
+ "off": "safe",
53
+ }
54
+
55
+
56
+ def _flatten_optimize_dict(d: dict[str, Any]) -> dict[str, Any]:
57
+ """Return a NEW flat dict, merging nested config groups into flat keys.
58
+
59
+ Explicit flat keys always take precedence over a nested value for the
60
+ same setting. The input dict is never mutated (immutable transform).
61
+ """
62
+ flat: dict[str, Any] = dict(d)
63
+ for group, mapping in _NESTED_ALIASES.items():
64
+ block = d.get(group)
65
+ if not isinstance(block, dict):
66
+ continue
67
+ for nested_key, flat_key in mapping.items():
68
+ if nested_key in block and flat_key not in d:
69
+ flat[flat_key] = block[nested_key]
70
+ return flat
71
+
72
+
73
+ def _normalize_compress_mode(raw: Any) -> str:
74
+ """Map a compress_mode value (including legacy aliases) to a valid mode.
75
+
76
+ Unknown values fall back to "safe" with a warning — a single bad enum in
77
+ a hand-authored config must never crash the optimize subsystem at boot.
78
+ """
79
+ key = str(raw).strip().lower()
80
+ mode = _COMPRESS_MODE_ALIASES.get(key)
81
+ if mode is None:
82
+ logger.warning(
83
+ "optimize.json: unknown compress_mode %r — falling back to 'safe' "
84
+ "(valid: safe, aggressive)",
85
+ raw,
86
+ )
87
+ return "safe"
88
+ return mode
89
+
90
+
16
91
  @dataclass
17
92
  class TTLConfig:
18
93
  """TTL settings for each cache tier, in seconds."""
@@ -90,8 +165,12 @@ class OptimizeConfig:
90
165
  semantic_max_index_entries: int = 10000
91
166
  semantic_max_tenants: int = 10000
92
167
 
93
- # Compress
94
- compress_enabled: bool = False
168
+ # Compress. Safe lossless compression (whitespace normalize + JSON minify) is
169
+ # ON by default; it runs proxy-free via the slm_compress tool and never
170
+ # touches code. Lossy prose (LLMLingua) stays gated behind compress_mode=
171
+ # "aggressive" + compress_prose. Set compress_enabled=False (slm optimize off)
172
+ # to disable entirely.
173
+ compress_enabled: bool = True
95
174
  compress_mode: str = "safe"
96
175
  compress_prose: bool = False
97
176
  compress_protect_recent: int = 4
@@ -144,6 +223,10 @@ class OptimizeConfig:
144
223
 
145
224
  @classmethod
146
225
  def from_dict(cls, d: dict[str, Any]) -> "OptimizeConfig":
226
+ # Accept nested config groups (compress/cache/proxy) in addition to
227
+ # the canonical flat form. Flat keys win on conflict.
228
+ d = _flatten_optimize_dict(d)
229
+
147
230
  ttl_raw = d.get("ttl", {})
148
231
  ttl = TTLConfig.from_dict(ttl_raw) if isinstance(ttl_raw, dict) else TTLConfig()
149
232
 
@@ -181,8 +264,8 @@ class OptimizeConfig:
181
264
  ),
182
265
  semantic_max_index_entries=int(d.get("semantic_max_index_entries", 10000)),
183
266
  semantic_max_tenants=int(d.get("semantic_max_tenants", 10000)),
184
- compress_enabled=bool(d.get("compress_enabled", False)),
185
- compress_mode=str(d.get("compress_mode", "safe")),
267
+ compress_enabled=bool(d.get("compress_enabled", True)),
268
+ compress_mode=_normalize_compress_mode(d.get("compress_mode", "safe")),
186
269
  compress_prose=bool(d.get("compress_prose", False)),
187
270
  compress_protect_recent=int(d.get("compress_protect_recent", 4)),
188
271
  ttl_seconds=int(d.get("ttl_seconds", 86400)),
@@ -1,9 +1,6 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
- #
5
- # ATTRIBUTION: CacheMetrics design pattern adapted from:
6
- # omnicache_ai/core/metrics.py (MIT License)
7
4
 
8
5
  """Thread-safe atomic counters for all SLM Optimize operations."""
9
6
 
@@ -43,6 +40,7 @@ class MetricsCollector:
43
40
  self._latency_overhead_ms_sum: float = 0.0
44
41
  self._latency_samples: int = 0
45
42
  self._compress_runs: int = 0
43
+ self._lossy_compress_runs: int = 0
46
44
  self._compress_bytes_original: int = 0
47
45
  self._compress_bytes_after: int = 0
48
46
  self._cache_size_bytes: int = 0
@@ -69,18 +67,28 @@ class MetricsCollector:
69
67
  with self._data_lock:
70
68
  self._misses += 1
71
69
 
72
- def on_compress(self, tokens_before: int, tokens_after: int) -> None:
70
+ def on_compress(self, tokens_before: int, tokens_after: int, lossy: bool = False) -> None:
73
71
  """Record a compression run. Arguments are word-count proxy estimates from _token_estimate().
74
72
 
75
73
  M-03: consistent naming — these are token estimates, not byte counts.
76
74
  Stored in compress_bytes_original/after fields for DB schema compat; unit is word-count.
75
+
76
+ ``lossy`` distinguishes a Layer-2 lossy (LLMLingua prose) run from a
77
+ lossless Layer-1/JSON-minify run; tracked in memory (not yet persisted).
77
78
  """
78
79
  with self._data_lock:
79
80
  self._compress_runs += 1
81
+ if lossy:
82
+ self._lossy_compress_runs += 1
80
83
  self._compress_bytes_original += max(0, tokens_before)
81
84
  self._compress_bytes_after += max(0, tokens_after)
82
85
  self._tokens_saved_compress += max(0, tokens_before - tokens_after)
83
86
 
87
+ def lossy_compress_runs(self) -> int:
88
+ """Session-scoped count of lossy (Layer-2) compression runs."""
89
+ with self._data_lock:
90
+ return self._lossy_compress_runs
91
+
84
92
  def on_eviction(self) -> None:
85
93
  """Record an eviction."""
86
94
  with self._data_lock:
@@ -135,6 +143,7 @@ class MetricsCollector:
135
143
  self._latency_overhead_ms_sum = 0.0
136
144
  self._latency_samples = 0
137
145
  self._compress_runs = 0
146
+ self._lossy_compress_runs = 0
138
147
  self._compress_bytes_original = 0
139
148
  self._compress_bytes_after = 0
140
149
  self._cache_size_bytes = 0
@@ -1,9 +1,6 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
- #
5
- # ATTRIBUTION: PricingRegistry + stale-detection pattern adapted from:
6
- # headroom/pricing/registry.py (Apache-2.0)
7
4
 
8
5
  """Savings estimator — converts MetricsSnapshot to dollar/rupee savings."""
9
6
 
@@ -97,6 +97,9 @@ _HOP_BY_HOP = frozenset([
97
97
  "te", "trailer", "transfer-encoding", "upgrade", "host",
98
98
  "x-forwarded-for", "x-forwarded-host", "x-forwarded-proto",
99
99
  "x-real-ip", "x-original-forwarded-for",
100
+ # ponytail: httpx decompresses gzip automatically; forwarding this header
101
+ # causes clients to double-decompress → ZlibError. Strip it here.
102
+ "content-encoding",
100
103
  ])
101
104
 
102
105
  _ANTHROPIC_FORWARD_HEADERS = frozenset([
@@ -152,6 +155,21 @@ def _redact_headers(headers: dict) -> dict:
152
155
  }
153
156
 
154
157
 
158
+ def _active_profile_for_cache() -> str:
159
+ """Resolve the active memory profile for cache-tenant scoping.
160
+
161
+ Lazy import keeps the optimize layer free of a hard server dependency; any
162
+ failure falls back to 'default'. The profiles.json active_profile cache is
163
+ kept in sync on every switch, so this is correct for the daemon-mounted
164
+ proxy even outside an HTTP request context.
165
+ """
166
+ try:
167
+ from superlocalmemory.server.routes.helpers import get_active_profile
168
+ return get_active_profile() or "default"
169
+ except Exception:
170
+ return "default"
171
+
172
+
155
173
  def _derive_tenant_id(provider: str, raw_credential: "str | None") -> "str | None":
156
174
  """Derive a per-tenant isolation key from the raw (un-redacted) credential.
157
175
 
@@ -160,18 +178,27 @@ def _derive_tenant_id(provider: str, raw_credential: "str | None") -> "str | Non
160
178
  into CacheManager.check() / .store() so that two users sharing the same
161
179
  prompt but using different API keys receive independent cache namespaces.
162
180
 
181
+ ISOLATION (I-5): the active memory profile is folded into the key so two
182
+ profiles sharing the SAME API key never serve each other's cached LLM
183
+ responses. Cross-profile cache reuse is intentionally sacrificed for
184
+ tenant isolation (the accepted cost tradeoff for a multi-tenant memory DB).
185
+
163
186
  Returns None when no credential is present — callers must SKIP caching
164
187
  (never collapse to the default tenant) to prevent cross-tenant disclosure.
165
188
 
166
- Output: 64-char lowercase hex SHA-256 of ``f"{provider}:{raw_credential}"``.
167
- Provider is folded in so anthropic:K and openai:K are distinct tenants even
168
- if the literal key string coincidentally matches.
189
+ Output: 64-char lowercase hex SHA-256 of
190
+ ``f"{profile}:{provider}:{raw_credential}"``. Provider is folded in so
191
+ anthropic:K and openai:K are distinct tenants even if the literal key
192
+ string coincidentally matches.
169
193
  """
170
194
  import hashlib as _hashlib
171
195
 
172
196
  if not raw_credential:
173
197
  return None
174
- return _hashlib.sha256(f"{provider}:{raw_credential}".encode()).hexdigest()
198
+ profile = _active_profile_for_cache()
199
+ return _hashlib.sha256(
200
+ f"{profile}:{provider}:{raw_credential}".encode()
201
+ ).hexdigest()
175
202
 
176
203
 
177
204
  def _body_has_tools(body: dict) -> bool:
@@ -901,6 +901,8 @@ class CacheDB:
901
901
  ccr_id: str,
902
902
  original: bytes,
903
903
  ttl_expires: float | None = None,
904
+ *,
905
+ tenant_id: str = "default",
904
906
  ) -> None:
905
907
  import hashlib
906
908
  try:
@@ -908,10 +910,11 @@ class CacheDB:
908
910
  encrypted = self._encrypt(compressed)
909
911
  self._db.execute(
910
912
  "INSERT OR REPLACE INTO llmcache_ccr_originals "
911
- "(ccr_id, original_blob, compressed_hash, byte_size_orig, byte_size_comp, ttl_expires) "
912
- "VALUES (?, ?, ?, ?, ?, ?)",
913
+ "(ccr_id, tenant_id, original_blob, compressed_hash, "
914
+ " byte_size_orig, byte_size_comp, ttl_expires) "
915
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
913
916
  (
914
- ccr_id, encrypted,
917
+ ccr_id, tenant_id, encrypted,
915
918
  hashlib.sha256(original).hexdigest(),
916
919
  len(original), len(compressed),
917
920
  ttl_expires,
@@ -920,13 +923,14 @@ class CacheDB:
920
923
  except sqlite3.Error as exc:
921
924
  logger.warning("CacheDB.ccr_put failed: %s", exc)
922
925
 
923
- def ccr_get(self, ccr_id: str) -> bytes | None:
926
+ def ccr_get(self, ccr_id: str, *, tenant_id: str = "default") -> bytes | None:
924
927
  try:
925
928
  rows = self._db.execute(
926
929
  "SELECT original_blob FROM llmcache_ccr_originals "
927
930
  "WHERE ccr_id = ? "
931
+ "AND tenant_id = ? "
928
932
  "AND (ttl_expires IS NULL OR ttl_expires > ?)",
929
- (ccr_id, time.time()),
933
+ (ccr_id, tenant_id, time.time()),
930
934
  )
931
935
  if not rows:
932
936
  return None
@@ -948,15 +952,16 @@ class CacheDB:
948
952
  except sqlite3.Error as exc:
949
953
  logger.warning("CacheDB.ccr_update_compressed failed: %s", exc)
950
954
 
951
- def ccr_delete(self, ccr_id: str) -> None:
952
- """Delete a CCR row by ccr_id. Idempotent — warns on sqlite error, never raises.
955
+ def ccr_delete(self, ccr_id: str, *, tenant_id: str = "default") -> None:
956
+ """Delete a CCR row by ccr_id scoped to tenant. Idempotent — warns on sqlite error, never raises.
953
957
 
954
958
  WP-10 D6: defensive infra + sweep parity. Deleting a non-existent row is a no-op.
959
+ H-02: tenant_id guard prevents a tenant from deleting another tenant's CCR.
955
960
  """
956
961
  try:
957
962
  self._db.execute(
958
- "DELETE FROM llmcache_ccr_originals WHERE ccr_id = ?",
959
- (ccr_id,),
963
+ "DELETE FROM llmcache_ccr_originals WHERE ccr_id = ? AND tenant_id = ?",
964
+ (ccr_id, tenant_id),
960
965
  )
961
966
  except sqlite3.Error as exc:
962
967
  logger.warning("CacheDB.ccr_delete failed (non-fatal): %s", exc)
@@ -1049,6 +1054,30 @@ class CacheDB:
1049
1054
  except sqlite3.Error as exc:
1050
1055
  logger.warning("CacheDB.metrics_flush failed: %s", exc)
1051
1056
 
1057
+ # ---- KV counters (M2 — durable slm_cache_* hit/miss stats) ----
1058
+
1059
+ def kv_counter_incr(self, name: str, delta: int = 1) -> None:
1060
+ """Atomically add ``delta`` to a named KV counter (fail-open)."""
1061
+ try:
1062
+ with self._db.transaction():
1063
+ self._db.execute(
1064
+ "INSERT INTO llmcache_kv_counters(name, count) "
1065
+ "VALUES (:name, :delta) "
1066
+ "ON CONFLICT(name) DO UPDATE SET count = count + :delta",
1067
+ {"name": name, "delta": delta},
1068
+ )
1069
+ except sqlite3.Error as exc:
1070
+ logger.debug("CacheDB.kv_counter_incr(%s) failed (non-fatal): %s", name, exc)
1071
+
1072
+ def kv_counters_load(self) -> dict[str, int]:
1073
+ """Return all persisted KV counters as {name: count} (fail-open)."""
1074
+ try:
1075
+ rows = self._db.execute("SELECT name, count FROM llmcache_kv_counters")
1076
+ return {r["name"]: r["count"] for r in rows}
1077
+ except sqlite3.Error as exc:
1078
+ logger.debug("CacheDB.kv_counters_load failed: %s", exc)
1079
+ return {}
1080
+
1052
1081
  # ---- convenience / non-contract helpers ----
1053
1082
 
1054
1083
  def entry_exists(self, cache_key: str, tenant_id: str = _DEFAULT_TENANT) -> bool:
@@ -120,6 +120,15 @@ _DDL_STATEMENTS: tuple[str, ...] = (
120
120
  updated_at REAL NOT NULL DEFAULT 0
121
121
  )
122
122
  """,
123
+ # M2: durable KV hit/miss counters for the slm_cache_* MCP tools. Kept in a
124
+ # separate table (not llmcache_metrics, whose columns mirror MetricsSnapshot
125
+ # exactly) so KV stats survive daemon restarts.
126
+ """
127
+ CREATE TABLE IF NOT EXISTS llmcache_kv_counters (
128
+ name TEXT PRIMARY KEY,
129
+ count INTEGER NOT NULL DEFAULT 0
130
+ )
131
+ """,
123
132
  )
124
133
 
125
134
 
@@ -170,6 +179,7 @@ def get_table_names() -> tuple[str, ...]:
170
179
  "llmcache_boundaries",
171
180
  "llmcache_centroids",
172
181
  "llmcache_metrics",
182
+ "llmcache_kv_counters",
173
183
  )
174
184
 
175
185
 
@@ -157,7 +157,7 @@ class PatternExtractor:
157
157
  all_patterns: list[PatternAssertion] = []
158
158
  all_patterns.extend(self._extract_from_core_memory(profile_id))
159
159
  all_patterns.extend(self._extract_from_behavioral(profile_id))
160
- all_patterns.extend(self._extract_from_cross_project())
160
+ all_patterns.extend(self._extract_from_cross_project(profile_id))
161
161
  all_patterns.extend(self._extract_from_workflows(profile_id))
162
162
  # v3.4.7: Extract from behavioral assertions (learned patterns)
163
163
  all_patterns.extend(self._extract_from_assertions(profile_id))
@@ -270,9 +270,12 @@ class PatternExtractor:
270
270
  ))
271
271
  return patterns
272
272
 
273
- def _extract_from_cross_project(self) -> list[PatternAssertion]:
274
- """Extract patterns from cross-project aggregator."""
273
+ def _extract_from_cross_project(
274
+ self, profile_id: str = "default"
275
+ ) -> list[PatternAssertion]:
276
+ """Extract patterns from cross-project aggregator, scoped to *profile_id*."""
275
277
  preferences = self._cross_project.get_preferences(
278
+ profile_id=profile_id,
276
279
  min_confidence=self._config.min_confidence,
277
280
  )
278
281
  patterns: list[PatternAssertion] = []
@@ -314,7 +314,7 @@ class AgenticRetriever:
314
314
  entity = self._db.get_entity_by_name(name, profile_id)
315
315
  if entity:
316
316
  try:
317
- aliases = self._db.get_aliases_for_entity(entity.entity_id)
317
+ aliases = self._db.get_aliases_for_entity(entity.entity_id, profile_id)
318
318
  for a in aliases[:2]:
319
319
  sub_queries.append(f"{a.alias} {' '.join(action_words)}")
320
320
  except Exception:
@@ -80,13 +80,26 @@ class BM25Channel:
80
80
  def document_count(self) -> int:
81
81
  return len(self._corpus)
82
82
 
83
- def ensure_loaded(self, profile_id: str) -> None:
83
+ def ensure_loaded(
84
+ self,
85
+ profile_id: str,
86
+ include_global: bool | None = None,
87
+ include_shared: bool | None = None,
88
+ ) -> None:
84
89
  """Cold-load BM25 tokens from DB for a profile (once).
85
90
 
86
- Idempotent: subsequent calls for the same profile are no-ops.
91
+ Idempotent: subsequent calls for the same profile/scope are no-ops.
92
+
93
+ Args:
94
+ profile_id: Profile to load.
95
+ include_global: Include global-scope facts. Falls back to the
96
+ instance attribute when not supplied.
97
+ include_shared: Include shared-scope facts. Same fallback.
87
98
  """
88
- include_global = bool(getattr(self, "include_global", False))
89
- include_shared = bool(getattr(self, "include_shared", False))
99
+ if include_global is None:
100
+ include_global = bool(getattr(self, "include_global", False))
101
+ if include_shared is None:
102
+ include_shared = bool(getattr(self, "include_shared", False))
90
103
  scope_key = (profile_id, include_global, include_shared)
91
104
  if scope_key == self._loaded_scope_key:
92
105
  return
@@ -174,7 +187,12 @@ class BM25Channel:
174
187
  self._db.store_bm25_tokens(fact_id, profile_id, tokens)
175
188
 
176
189
  def _fts5_search(
177
- self, query: str, profile_id: str, top_k: int = 30,
190
+ self,
191
+ query: str,
192
+ profile_id: str,
193
+ top_k: int = 30,
194
+ include_global: bool | None = None,
195
+ include_shared: bool | None = None,
178
196
  ) -> list[tuple[str, float]]:
179
197
  """v3.5.0: SQLite FTS5 keyword search (C-level indexed, scales to millions).
180
198
 
@@ -187,6 +205,10 @@ class BM25Channel:
187
205
  Raises (OperationalError) if the FTS5 table is absent — the caller
188
206
  then falls back to the legacy in-memory rank_bm25 path.
189
207
  """
208
+ if include_global is None:
209
+ include_global = bool(getattr(self, "include_global", False))
210
+ if include_shared is None:
211
+ include_shared = bool(getattr(self, "include_shared", False))
190
212
  tokens = tokenize(query)
191
213
  if not tokens:
192
214
  return []
@@ -195,8 +217,8 @@ class BM25Channel:
195
217
  match_expr = " OR ".join('"' + t.replace('"', "") + '"' for t in tokens)
196
218
  where, params = _scope_where(
197
219
  profile_id,
198
- include_global=bool(getattr(self, "include_global", False)),
199
- include_shared=bool(getattr(self, "include_shared", False)),
220
+ include_global=include_global,
221
+ include_shared=include_shared,
200
222
  prefix="af",
201
223
  )
202
224
  sql = (
@@ -214,13 +236,39 @@ class BM25Channel:
214
236
  if not fid:
215
237
  continue
216
238
  out.append((fid, -float(d.get("rank", 0.0))))
217
- return out
239
+
240
+ # T3b: UNION fact-expansion (alias / paraphrase) matches so a query for
241
+ # a synonym matches a fact that only used the canonical term. Additive —
242
+ # direct content hits stay primary; an alias-only hit is added at a
243
+ # discount so it ranks below content matches. Fail-open if the expansion
244
+ # FTS is absent (legacy DB).
245
+ content_ids = {fid for fid, _ in out}
246
+ try:
247
+ exp_sql = (
248
+ "SELECT af.fact_id AS fact_id, bm25(fact_expansion_fts) AS rank "
249
+ "FROM fact_expansion_fts "
250
+ "JOIN atomic_facts af ON af.fact_id = fact_expansion_fts.fact_id "
251
+ f"WHERE fact_expansion_fts MATCH ? AND {where} "
252
+ "ORDER BY rank LIMIT ?"
253
+ )
254
+ for r in self._db.execute(exp_sql, (match_expr, *params, int(top_k))):
255
+ d = dict(r)
256
+ fid = d.get("fact_id")
257
+ if fid and fid not in content_ids:
258
+ out.append((fid, -float(d.get("rank", 0.0)) * 0.85))
259
+ except Exception as exc: # pragma: no cover — legacy/missing expansion FTS
260
+ logger.debug("Expansion FTS search skipped: %s", exc)
261
+
262
+ out.sort(key=lambda x: x[1], reverse=True)
263
+ return out[:top_k]
218
264
 
219
265
  def search(
220
266
  self,
221
267
  query: str,
222
268
  profile_id: str,
223
269
  top_k: int = 30,
270
+ include_global: bool | None = None,
271
+ include_shared: bool | None = None,
224
272
  ) -> list[tuple[str, float]]:
225
273
  """Search BM25 index for matching facts.
226
274
 
@@ -230,10 +278,17 @@ class BM25Channel:
230
278
  query: Search query text.
231
279
  profile_id: Scope to this profile.
232
280
  top_k: Maximum results.
281
+ include_global: Include global-scope facts. Falls back to the
282
+ instance attribute when not supplied.
283
+ include_shared: Include shared-scope facts. Same fallback.
233
284
 
234
285
  Returns:
235
286
  List of (fact_id, bm25_score) sorted by score descending.
236
287
  """
288
+ if include_global is None:
289
+ include_global = bool(getattr(self, "include_global", False))
290
+ if include_shared is None:
291
+ include_shared = bool(getattr(self, "include_shared", False))
237
292
  # v3.5.0: FTS5 fast path — C-level indexed, ~ms, scales to millions.
238
293
  # The legacy in-memory rank_bm25 path rebuilt the whole index over the
239
294
  # entire corpus on every corpus change (11s+ at 17.5k facts, does not
@@ -241,13 +296,16 @@ class BM25Channel:
241
296
  # Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
242
297
  # unavailable (raises) — e.g. a pre-FTS legacy DB.
243
298
  try:
244
- return self._fts5_search(query, profile_id, top_k)
299
+ return self._fts5_search(
300
+ query, profile_id, top_k,
301
+ include_global=include_global, include_shared=include_shared,
302
+ )
245
303
  except Exception as exc: # pragma: no cover — legacy/missing FTS table
246
304
  logger.debug(
247
305
  "BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
248
306
  )
249
307
 
250
- self.ensure_loaded(profile_id)
308
+ self.ensure_loaded(profile_id, include_global=include_global, include_shared=include_shared)
251
309
 
252
310
  if not self._corpus:
253
311
  return []