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
@@ -276,6 +276,18 @@ class MemoryEngine:
276
276
  )
277
277
  self._llm = None
278
278
 
279
+ # H-03 (3.7.9): surface mode-capability degradation explicitly at
280
+ # startup. validate_mode_config existed but was never called, so Mode B
281
+ # silently using rule-based extraction after Ollama vanished (update,
282
+ # restart, port conflict) went unwarned. Each mode only checks its own
283
+ # capabilities, so passing the single llm-availability signal is safe.
284
+ from superlocalmemory.core.modes import validate_mode_config
285
+ _llm_up = getattr(self, "_llm", None) is not None
286
+ for _warning in validate_mode_config(
287
+ self._config.mode, has_ollama=_llm_up, has_cloud_llm=_llm_up,
288
+ ):
289
+ logger.warning("Mode config: %s", _warning)
290
+
279
291
  from superlocalmemory.trust.scorer import TrustScorer
280
292
  from superlocalmemory.trust.provenance import ProvenanceTracker
281
293
  from superlocalmemory.compliance.eu_ai_act import EUAIActChecker
@@ -374,7 +386,9 @@ class MemoryEngine:
374
386
  return
375
387
 
376
388
  base_dir = self._config.base_dir
377
- pending = get_pending(base_dir, limit=20)
389
+ # Only drain items enqueued under THIS engine's profile — a queued
390
+ # memory must never materialize under a profile it was not written for.
391
+ pending = get_pending(base_dir, limit=20, profile_id=self.profile_id)
378
392
  if not pending:
379
393
  return
380
394
 
@@ -599,6 +613,7 @@ class MemoryEngine:
599
613
  *,
600
614
  include_global: bool | None = None,
601
615
  include_shared: bool | None = None,
616
+ window: str | tuple[str, str] | None = None,
602
617
  ) -> RecallResponse:
603
618
  """Recall relevant facts for a query.
604
619
 
@@ -650,6 +665,7 @@ class MemoryEngine:
650
665
  fast=fast,
651
666
  include_global=include_global,
652
667
  include_shared=include_shared,
668
+ window=window,
653
669
  )
654
670
  except Exception as exc:
655
671
  from superlocalmemory.infra.local_diagnostics import record_operation
@@ -11,9 +11,13 @@ implementation of queryable projection and complete derivation.
11
11
  from __future__ import annotations
12
12
 
13
13
  import hashlib
14
+ import logging
15
+ import os
14
16
  import uuid
15
17
  from typing import TYPE_CHECKING
16
18
 
19
+ logger = logging.getLogger(__name__)
20
+
17
21
  from superlocalmemory.core.ingestion_command import (
18
22
  IngestionCommand,
19
23
  IngestionOperation,
@@ -30,6 +34,21 @@ if TYPE_CHECKING:
30
34
  _PREBUILT_FACT_KEY = "_slm_prebuilt_fact_v1"
31
35
 
32
36
 
37
+ def _pii_redaction_enabled(engine: "MemoryEngine") -> bool:
38
+ """C4: opt-in PII redaction on ingest.
39
+
40
+ On when the engine config sets ``pii_redaction`` truthy OR the
41
+ ``SLM_PII_REDACTION`` env var is set (1/on/true/yes). Default OFF — personal
42
+ use is unchanged; team/company operators opt in.
43
+ """
44
+ cfg = getattr(engine, "_config", None)
45
+ if cfg is not None and getattr(cfg, "pii_redaction", False):
46
+ return True
47
+ return os.environ.get("SLM_PII_REDACTION", "").strip().lower() in (
48
+ "1", "on", "true", "yes",
49
+ )
50
+
51
+
33
52
  def content_passes_admission(content: str) -> bool:
34
53
  """Return whether raw content is eligible to reach durable ingestion.
35
54
 
@@ -140,6 +159,16 @@ def canonical_store(
140
159
  error=ValueError("content rejected by local admission policy"),
141
160
  )
142
161
  return []
162
+ # C4: opt-in PII redaction. When enabled (config.pii_redaction or
163
+ # SLM_PII_REDACTION), scrub personal identifiers BEFORE the content is
164
+ # extracted, embedded, or persisted — nothing sensitive ever reaches disk.
165
+ if _pii_redaction_enabled(engine):
166
+ from superlocalmemory.core.pii import redact_pii
167
+
168
+ scrubbed, n_pii = redact_pii(content)
169
+ if n_pii:
170
+ content = scrubbed
171
+ logger.info("PII redaction: scrubbed %d identifier(s) on ingest", n_pii)
143
172
  try:
144
173
  command = build_engine_ingestion_command(engine)
145
174
  receipt = command.submit(IngestionRequest(
@@ -588,6 +588,19 @@ def init_retrieval(
588
588
  except Exception as exc:
589
589
  logger.debug("Forgetting filter registration failed: %s", exc)
590
590
 
591
+ # Phase 4 (T1): Register bi-temporal validity filter. Drops superseded /
592
+ # system-invalidated facts from retrieval so contradicted memories never
593
+ # resurface. Pure SQL, safe in all modes; no-op until a fact is invalidated.
594
+ try:
595
+ from superlocalmemory.retrieval.temporal_validity_filter import (
596
+ register_temporal_validity_filter,
597
+ )
598
+ register_temporal_validity_filter(
599
+ engine._registry, db, config.temporal_validator,
600
+ )
601
+ except Exception as exc:
602
+ logger.debug("Temporal validity filter registration failed: %s", exc)
603
+
591
604
  return engine
592
605
 
593
606
 
@@ -0,0 +1,178 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Entity-community backbone (Wave Q) — the single principled clustering spine.
6
+
7
+ Best-in-market memory graphs (GraphRAG, Graphiti/Zep) cluster the ENTITY
8
+ graph, not the raw fact/chunk graph. SLM's fact graph is capped-sparse
9
+ (5 edges/entity), so a fact-level clustering fragments. This module builds an
10
+ entity co-occurrence graph (two entities are linked when they appear together
11
+ in a fact, weighted by co-occurrence count) and runs Louvain community
12
+ detection over it — the correct target and, since entities are far fewer than
13
+ facts, a cheaper computation.
14
+
15
+ The resulting entity communities are the shared backbone for:
16
+ - Q2 community summaries (one synthesized report per community), and
17
+ - Q3 progressive abstraction (scenario/persona tiers + drill-down).
18
+
19
+ Runs in the background (consolidation lane), never on the hot recall path.
20
+ Fail-open and idempotent: a recompute fully replaces a profile's rows.
21
+
22
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
23
+ License: AGPL-3.0-or-later
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ from collections import Counter, defaultdict
31
+ from typing import Any
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class EntityCommunityBuilder:
37
+ """Build + persist entity communities via Louvain over co-occurrence."""
38
+
39
+ def __init__(
40
+ self,
41
+ db: Any,
42
+ min_community_size: int = 2,
43
+ resolution: float = 1.0,
44
+ seed: int = 42,
45
+ ) -> None:
46
+ self._db = db
47
+ self._min_size = max(2, int(min_community_size))
48
+ self._resolution = float(resolution)
49
+ self._seed = int(seed)
50
+
51
+ # ------------------------------------------------------------------
52
+ # Detection
53
+ # ------------------------------------------------------------------
54
+
55
+ def _cooccurrence(self, profile_id: str) -> Counter:
56
+ """Count entity pairs that co-occur within a fact (per profile)."""
57
+ rows = self._db.execute(
58
+ "SELECT canonical_entities_json FROM atomic_facts "
59
+ "WHERE profile_id = ?",
60
+ (profile_id,),
61
+ )
62
+ weights: Counter = Counter()
63
+ for row in rows:
64
+ raw = dict(row).get("canonical_entities_json")
65
+ if not raw:
66
+ continue
67
+ try:
68
+ parsed = json.loads(raw)
69
+ except (ValueError, TypeError):
70
+ continue
71
+ if not isinstance(parsed, list):
72
+ continue
73
+ ents = sorted({str(e).strip() for e in parsed if str(e).strip()})
74
+ for i in range(len(ents)):
75
+ for j in range(i + 1, len(ents)):
76
+ weights[(ents[i], ents[j])] += 1
77
+ return weights
78
+
79
+ def detect(self, profile_id: str) -> dict[str, int]:
80
+ """Return {entity_id -> community_id}; communities below min size drop."""
81
+ weights = self._cooccurrence(profile_id)
82
+ if not weights:
83
+ return {}
84
+
85
+ import networkx as nx
86
+
87
+ g = nx.Graph()
88
+ for (a, b), w in weights.items():
89
+ g.add_edge(a, b, weight=w)
90
+
91
+ try:
92
+ from networkx.algorithms.community import louvain_communities
93
+
94
+ communities = louvain_communities(
95
+ g, weight="weight", resolution=self._resolution, seed=self._seed,
96
+ )
97
+ except Exception as exc: # pragma: no cover - fallback path
98
+ logger.debug(
99
+ "Louvain unavailable/failed (%s); using connected components",
100
+ exc,
101
+ )
102
+ communities = nx.connected_components(g)
103
+
104
+ result: dict[str, int] = {}
105
+ cid = 0
106
+ for comm in communities:
107
+ members = list(comm)
108
+ if len(members) < self._min_size:
109
+ continue
110
+ for node in members:
111
+ result[node] = cid
112
+ cid += 1
113
+ return result
114
+
115
+ # ------------------------------------------------------------------
116
+ # Persistence
117
+ # ------------------------------------------------------------------
118
+
119
+ def compute_and_store(self, profile_id: str) -> dict[str, int]:
120
+ """Detect communities and replace this profile's stored rows."""
121
+ mapping = self.detect(profile_id)
122
+ try:
123
+ self._db.execute(
124
+ "DELETE FROM entity_communities WHERE profile_id = ?",
125
+ (profile_id,),
126
+ )
127
+ for entity_id, community_id in mapping.items():
128
+ self._db.execute(
129
+ "INSERT OR REPLACE INTO entity_communities "
130
+ "(profile_id, entity_id, community_id, computed_at) "
131
+ "VALUES (?, ?, ?, datetime('now'))",
132
+ (profile_id, entity_id, community_id),
133
+ )
134
+ except Exception as exc:
135
+ logger.debug("entity_communities persist failed: %s", exc)
136
+
137
+ return {
138
+ "entity_count": len(mapping),
139
+ "community_count": len(set(mapping.values())),
140
+ }
141
+
142
+ # ------------------------------------------------------------------
143
+ # Read API
144
+ # ------------------------------------------------------------------
145
+
146
+ def get_communities(self, profile_id: str) -> dict[int, list[str]]:
147
+ """Return {community_id -> [entity_id, ...]} for a profile."""
148
+ try:
149
+ rows = self._db.execute(
150
+ "SELECT entity_id, community_id FROM entity_communities "
151
+ "WHERE profile_id = ? ORDER BY community_id",
152
+ (profile_id,),
153
+ )
154
+ except Exception as exc:
155
+ logger.debug("get_communities failed: %s", exc)
156
+ return {}
157
+ out: dict[int, list[str]] = defaultdict(list)
158
+ for row in rows:
159
+ d = dict(row)
160
+ out[int(d["community_id"])].append(str(d["entity_id"]))
161
+ return dict(out)
162
+
163
+ def get_community_for_entity(
164
+ self, entity_id: str, profile_id: str,
165
+ ) -> int | None:
166
+ """Return the community id for one entity, or None."""
167
+ try:
168
+ rows = self._db.execute(
169
+ "SELECT community_id FROM entity_communities "
170
+ "WHERE profile_id = ? AND entity_id = ?",
171
+ (profile_id, entity_id),
172
+ )
173
+ except Exception as exc:
174
+ logger.debug("get_community_for_entity failed: %s", exc)
175
+ return None
176
+ for row in rows:
177
+ return int(dict(row)["community_id"])
178
+ return None
@@ -211,6 +211,43 @@ class GraphAnalyzer:
211
211
  result[node] = comm_id
212
212
  return result
213
213
 
214
+ def detect_communities_louvain(
215
+ self,
216
+ graph: Any = None,
217
+ profile_id: str = "",
218
+ ) -> dict[str, int]:
219
+ """Detect communities via Louvain (modularity-optimizing).
220
+
221
+ Higher quality than Label Propagation (deterministic with a seed,
222
+ no giant-community collapse), pure-Python via networkx — no extra
223
+ binary deps. Falls back to Label Propagation if unavailable.
224
+ """
225
+ import networkx as nx
226
+
227
+ if graph is None:
228
+ graph = self._build_networkx_graph(profile_id)
229
+ if graph.number_of_nodes() == 0:
230
+ return {}
231
+
232
+ undirected = graph.to_undirected()
233
+ try:
234
+ from networkx.algorithms.community import louvain_communities
235
+
236
+ communities = louvain_communities(
237
+ undirected, weight="weight", seed=42,
238
+ )
239
+ except Exception as exc:
240
+ logger.debug(
241
+ "Louvain unavailable/failed (%s); using Label Propagation", exc,
242
+ )
243
+ return self.detect_communities(graph, profile_id)
244
+
245
+ result: dict[str, int] = {}
246
+ for comm_id, community in enumerate(communities):
247
+ for node in community:
248
+ result[node] = comm_id
249
+ return result
250
+
214
251
  # ── v3.4.1: Leiden Community Detection ────────────────────────
215
252
 
216
253
  def detect_communities_leiden(
@@ -234,9 +271,9 @@ class GraphAnalyzer:
234
271
  import igraph
235
272
  except ImportError:
236
273
  logger.info(
237
- "leidenalg not installed, using Label Propagation fallback",
274
+ "leidenalg not installed, using Louvain fallback",
238
275
  )
239
- return self.detect_communities(graph, profile_id)
276
+ return self.detect_communities_louvain(graph, profile_id)
240
277
 
241
278
  # Convert DiGraph -> undirected -> igraph
242
279
  undirected = graph.to_undirected()
@@ -350,8 +350,12 @@ def _cap_node_degree(
350
350
  Algorithm (single-pass window function — no Python loops):
351
351
  1. ROW_NUMBER() OVER (PARTITION BY source_id ORDER BY weight DESC) ranks
352
352
  every edge per node in one full table scan.
353
- 2. Edges with rn > max_degree are deleted in a single DELETE statement.
354
- Requires SQLite 3.25+ (window functions). System is on 3.53.1.
353
+ 2. Excess edge IDs are collected in a reusable temp table.
354
+ 3. A single DELETE statement removes them; rowcount is returned.
355
+ 4. The temp table is created once and cleared via DELETE (not DROP) —
356
+ DROP TABLE acquires an EXCLUSIVE lock that conflicts with concurrent
357
+ writers, causing "database is locked". Using CREATE...IF NOT EXISTS
358
+ plus DELETE FROM avoids that conflict while preserving rowcount.
355
359
  """
356
360
  # gi-03: cap BOTH out-degree (PARTITION BY source_id) AND in-degree
357
361
  # (PARTITION BY target_id). Previously only out-degree was capped, so hub
@@ -379,9 +383,13 @@ def _cap_node_degree(
379
383
  )
380
384
  return excess
381
385
 
382
- # Step 1: collect edges exceeding the cap in either direction (one pass).
383
- c.execute("DROP TABLE IF EXISTS _slm_cap_del")
384
- c.execute("CREATE TEMP TABLE _slm_cap_del (edge_id TEXT PRIMARY KEY)")
386
+ # CREATE IF NOT EXISTS + DELETE FROM instead of DROP + CREATE.
387
+ # DROP TABLE acquires EXCLUSIVE which conflicts with concurrent writers.
388
+ # CREATE IF NOT EXISTS is idempotent; DELETE FROM clears prior contents.
389
+ c.execute(
390
+ "CREATE TEMP TABLE IF NOT EXISTS _slm_cap_del (edge_id TEXT PRIMARY KEY)"
391
+ )
392
+ c.execute("DELETE FROM _slm_cap_del")
385
393
  c.execute(
386
394
  """
387
395
  INSERT OR IGNORE INTO _slm_cap_del (edge_id)
@@ -396,7 +404,6 @@ def _cap_node_degree(
396
404
  (profile_id, max_degree, max_degree),
397
405
  )
398
406
 
399
- # Step 2: delete the over-cap edges (single DELETE).
400
407
  c.execute(
401
408
  """
402
409
  DELETE FROM graph_edges
@@ -407,8 +414,6 @@ def _cap_node_degree(
407
414
  )
408
415
  deleted = c.rowcount
409
416
 
410
- c.execute("DROP TABLE IF EXISTS _slm_cap_del")
411
-
412
417
  logger.info(
413
418
  "_cap_node_degree: deleted %d low-weight edges (max_degree=%d, in+out capped)",
414
419
  deleted, max_degree,
@@ -0,0 +1,138 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Fact-augmented key expansion (Phase 4, T3b).
6
+
7
+ Generates *alternate keys* for a fact — synonyms, aliases, and paraphrases —
8
+ that get indexed in ``fact_expansion_fts`` and UNION'd into BM25 retrieval, so a
9
+ query for "automobile" or "the Big Apple" can match a fact that only says "car"
10
+ or "NYC".
11
+
12
+ Two tiers, matching the rest of SLM:
13
+ * Mode A (zero-LLM): pulls the fact's resolved entities' canonical names and
14
+ aliases from SLM's own entity graph — no model, no cost, and it reuses the
15
+ entity resolution already done at ingest.
16
+ * Mode B/C: additionally asks the LLM for a few paraphrase keywords (own
17
+ prompt, fail-open, bounded).
18
+
19
+ Keys already present in the fact's content are dropped — indexing them twice
20
+ adds nothing. Returns a single space-joined string ready for the FTS row.
21
+
22
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
23
+ License: AGPL-3.0-or-later
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ if TYPE_CHECKING:
32
+ from superlocalmemory.storage.database import DatabaseManager
33
+ from superlocalmemory.storage.models import AtomicFact
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Bound the LLM enrichment so a pathological response can't bloat the index.
38
+ _MAX_LLM_KEYS = 8
39
+ _MAX_KEY_LEN = 60
40
+
41
+
42
+ class KeyExpander:
43
+ """Produces alternate search keys for a fact (T3b)."""
44
+
45
+ __slots__ = ("_db", "_llm")
46
+
47
+ def __init__(self, db: DatabaseManager, llm: Any = None) -> None:
48
+ self._db = db
49
+ self._llm = llm
50
+
51
+ def expand(self, fact: AtomicFact, profile_id: str, mode: str = "a") -> str:
52
+ """Return space-joined alternate keys for ``fact`` (may be empty)."""
53
+ keys: set[str] = set()
54
+ keys |= self._alias_keys(fact, profile_id)
55
+ if mode in ("b", "c") and self._llm_available():
56
+ keys |= self._llm_keys(fact)
57
+
58
+ content_low = (getattr(fact, "content", "") or "").lower()
59
+ cleaned = {
60
+ k.strip() for k in keys
61
+ if k and k.strip() and len(k.strip()) <= _MAX_KEY_LEN
62
+ and k.strip().lower() not in content_low
63
+ }
64
+ return " ".join(sorted(cleaned))
65
+
66
+ # -- Mode A: entity aliases from SLM's own entity graph ------------------
67
+
68
+ def _alias_keys(self, fact: AtomicFact, profile_id: str) -> set[str]:
69
+ out: set[str] = set()
70
+ for name in (getattr(fact, "canonical_entities", None) or []):
71
+ if not name:
72
+ continue
73
+ try:
74
+ ent = self._db.get_entity_by_name(name, profile_id)
75
+ except Exception:
76
+ ent = None
77
+ if ent is None:
78
+ continue
79
+ canonical = getattr(ent, "canonical_name", None)
80
+ if canonical:
81
+ out.add(canonical)
82
+ entity_id = getattr(ent, "entity_id", None)
83
+ if not entity_id:
84
+ continue
85
+ try:
86
+ for alias in self._db.get_aliases_for_entity(entity_id, profile_id):
87
+ a = getattr(alias, "alias", None)
88
+ if a:
89
+ out.add(a)
90
+ except Exception:
91
+ continue
92
+ return out
93
+
94
+ # -- Mode B/C: LLM paraphrases (own prompt, fail-open) -------------------
95
+
96
+ def _llm_available(self) -> bool:
97
+ if self._llm is None:
98
+ return False
99
+ check = getattr(self._llm, "is_available", None)
100
+ try:
101
+ return bool(check()) if callable(check) else bool(check)
102
+ except Exception:
103
+ return False
104
+
105
+ def _llm_keys(self, fact: AtomicFact) -> set[str]:
106
+ content = getattr(fact, "content", "") or ""
107
+ if not content:
108
+ return set()
109
+ prompt = (
110
+ "Give 3-6 short alternative search terms (synonyms, aliases, or "
111
+ "paraphrases) that a person might use to look up the memory below. "
112
+ "Output ONLY a comma-separated list, no numbering, no explanation.\n\n"
113
+ f"Memory: {content}"
114
+ )
115
+ raw = self._invoke_llm(prompt)
116
+ if not raw:
117
+ return set()
118
+ seen: list[str] = []
119
+ for part in raw.replace("\n", ",").split(","):
120
+ p = part.strip()
121
+ if p and p not in seen:
122
+ seen.append(p)
123
+ if len(seen) >= _MAX_LLM_KEYS:
124
+ break
125
+ return set(seen)
126
+
127
+ def _invoke_llm(self, prompt: str) -> str:
128
+ """Call whatever generation method the injected LLM exposes; fail-open."""
129
+ for meth in ("generate", "complete", "chat"):
130
+ fn = getattr(self._llm, meth, None)
131
+ if callable(fn):
132
+ try:
133
+ out = fn(prompt)
134
+ return out if isinstance(out, str) else str(out or "")
135
+ except Exception as exc:
136
+ logger.debug("KeyExpander LLM (%s) failed: %s", meth, exc)
137
+ return ""
138
+ return ""
@@ -127,6 +127,7 @@ def run_maintenance(
127
127
  "sheaf_checked": 0,
128
128
  "entity_summaries_consolidated": 0, # V3.4.40
129
129
  "orphan_metadata_gc": 0, # v3.6.4 (P1-3)
130
+ "expansion_backfilled": 0, # T3b
130
131
  }
131
132
 
132
133
  # P1-3 (embeddings-vector-02): sweep orphaned embedding_metadata left by
@@ -141,6 +142,28 @@ def run_maintenance(
141
142
  if not facts:
142
143
  return counts
143
144
 
145
+ # T3b: backfill fact-expansion alt-keys (Mode A, entity-alias based) for
146
+ # facts stored before expansion existed. Bounded per run + skips already-
147
+ # populated and entity-less facts, so it converges without re-work churn.
148
+ try:
149
+ from superlocalmemory.core.key_expander import KeyExpander
150
+ populated = {
151
+ dict(r)["fact_id"]
152
+ for r in db.execute("SELECT DISTINCT fact_id FROM fact_expansion_fts")
153
+ }
154
+ expander = KeyExpander(db)
155
+ for f in facts:
156
+ if counts["expansion_backfilled"] >= 500:
157
+ break
158
+ if f.fact_id in populated or not f.canonical_entities:
159
+ continue
160
+ alt = expander.expand(f, profile_id, mode="a")
161
+ if alt:
162
+ db.upsert_fact_expansion(f.fact_id, alt)
163
+ counts["expansion_backfilled"] += 1
164
+ except Exception as exc: # pragma: no cover — legacy DB / missing FTS
165
+ logger.debug("expansion backfill skipped: %s", exc)
166
+
144
167
  # 1a. Backfill: seed uninitialized facts with metadata-aware positions (B+C)
145
168
  if config.math.langevin_persist_positions:
146
169
  try:
@@ -109,7 +109,7 @@ MODE_C = ModeCapabilities(
109
109
  data_stays_local=False,
110
110
  description=(
111
111
  "FULL POWER — UNRESTRICTED. Best embeddings (text-embedding-3-large, 3072-dim). "
112
- "Best LLMs (GPT-5.2, Claude Opus). Agentic multi-round retrieval. "
112
+ "Best configured cloud LLMs (e.g. GPT-5, Claude Opus 4). Agentic multi-round retrieval. "
113
113
  "Cohere reranker option. No EU restriction. Target: 90%+"
114
114
  ),
115
115
  )
@@ -59,7 +59,7 @@ def delete_fact_authorized(
59
59
  if not rows:
60
60
  return {"ok": False, "error": f"Memory {fact_id} not found"}
61
61
  content_preview = dict(rows[0]).get("content", "")[:80]
62
- engine._db.delete_fact(fact_id)
62
+ engine._db.delete_fact(fact_id, profile_id=profile_id)
63
63
  try:
64
64
  from superlocalmemory.core.backend_orchestrator import get_orchestrator
65
65
  orchestrator = get_orchestrator()
@@ -120,7 +120,7 @@ def update_fact_authorized(
120
120
  updates["fisher_variance"] = fisher_variance
121
121
  except Exception as exc:
122
122
  logger.warning("UPDATE embedding refresh failed: %s", exc)
123
- engine._db.update_fact(fact_id, updates)
123
+ engine._db.update_fact(fact_id, updates, profile_id=profile_id)
124
124
  try:
125
125
  from superlocalmemory.core.backend_orchestrator import get_orchestrator
126
126
  orchestrator = get_orchestrator()