superlocalmemory 3.7.8 → 3.8.1

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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  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/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  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 +827 -72
  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 +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -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/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -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,
@@ -24,6 +24,7 @@ from typing import Any, Callable
24
24
  from superlocalmemory.storage.database import DatabaseManager
25
25
 
26
26
  _MATERIALIZATION_LOCKS = tuple(threading.RLock() for _ in range(64))
27
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS = 10
27
28
 
28
29
 
29
30
  def _materialization_lock(operation_id: str) -> threading.RLock:
@@ -51,6 +52,10 @@ class OperationInProgress(RuntimeError):
51
52
  """Another live lease owner is materializing this operation."""
52
53
 
53
54
 
55
+ class LeaseLost(OperationInProgress):
56
+ """The materializer no longer owns its durable operation lease."""
57
+
58
+
54
59
  def _canonical_json(value: Any) -> str:
55
60
  return json.dumps(value, sort_keys=True, separators=(",", ":"))
56
61
 
@@ -284,12 +289,19 @@ class IngestionOperationRepository:
284
289
  self._from_row(row)
285
290
  for row in self.db.execute(
286
291
  "SELECT * FROM ingestion_operations "
287
- "WHERE (state='queryable' AND created_at <= "
292
+ "WHERE attempt_count < ? AND ("
293
+ "(state='queryable' AND created_at <= "
288
294
  "strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) "
289
295
  "OR (state='failed' AND next_retry_at <= ?) "
290
- "OR (state='enriching' AND lease_expires_at <= ?) "
296
+ "OR (state='enriching' AND lease_expires_at <= ?)) "
291
297
  "ORDER BY created_at, rowid LIMIT ?",
292
- (grace_modifier, now, now, max(1, int(limit))),
298
+ (
299
+ _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS,
300
+ grace_modifier,
301
+ now,
302
+ now,
303
+ max(1, int(limit)),
304
+ ),
293
305
  )
294
306
  ]
295
307
 
@@ -415,6 +427,27 @@ class IngestionOperationRepository:
415
427
  )
416
428
  return operation
417
429
 
430
+ def renew_enriching_lease(
431
+ self,
432
+ operation_id: str,
433
+ *,
434
+ owner: str,
435
+ lease_seconds: float,
436
+ ) -> bool:
437
+ """Extend a live lease only while the same owner still holds it."""
438
+ rows = self.db.execute(
439
+ "UPDATE ingestion_operations SET lease_expires_at=?, "
440
+ "updated_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') "
441
+ "WHERE operation_id=? AND state='enriching' AND lease_owner=? "
442
+ "RETURNING operation_id",
443
+ (
444
+ time.time() + max(1.0, float(lease_seconds)),
445
+ operation_id,
446
+ owner,
447
+ ),
448
+ )
449
+ return bool(rows)
450
+
418
451
  def finish_enriching(
419
452
  self,
420
453
  operation_id: str,
@@ -469,6 +502,7 @@ class MaterializationResult:
469
502
 
470
503
  fact_ids: tuple[str, ...]
471
504
  derivation_state: dict[str, bool]
505
+ last_error: str = ""
472
506
 
473
507
 
474
508
  QueryableWriter = Callable[[IngestionRequest, str], list[str]]
@@ -500,6 +534,47 @@ class IngestionCommand:
500
534
  self._lease_seconds = max(1.0, float(lease_seconds))
501
535
  self._owner = f"ingestion-worker:{uuid.uuid4().hex}"
502
536
 
537
+ def _run_with_lease_heartbeat(
538
+ self,
539
+ operation_id: str,
540
+ callback: Callable[[], Any],
541
+ ) -> Any:
542
+ """Run slow work while periodically renewing its owner-bound lease."""
543
+ stop = threading.Event()
544
+ lost = threading.Event()
545
+ interval = min(30.0, max(0.1, self._lease_seconds / 3.0))
546
+
547
+ def heartbeat() -> None:
548
+ while not stop.wait(interval):
549
+ try:
550
+ renewed = self.repository.renew_enriching_lease(
551
+ operation_id,
552
+ owner=self._owner,
553
+ lease_seconds=self._lease_seconds,
554
+ )
555
+ except sqlite3.Error:
556
+ continue
557
+ if not renewed:
558
+ lost.set()
559
+ return
560
+
561
+ thread = threading.Thread(
562
+ target=heartbeat,
563
+ name=f"ingestion-lease-heartbeat:{operation_id[:8]}",
564
+ daemon=True,
565
+ )
566
+ thread.start()
567
+ try:
568
+ result = callback()
569
+ finally:
570
+ stop.set()
571
+ thread.join(timeout=max(1.0, interval * 2))
572
+ if lost.is_set():
573
+ raise LeaseLost(
574
+ f"ingestion lease lost for operation {operation_id}"
575
+ )
576
+ return result
577
+
503
578
  def submit(self, request: IngestionRequest) -> IngestionOperation:
504
579
  operation, _created = self.submit_with_status(request)
505
580
  return operation
@@ -548,27 +623,41 @@ class IngestionCommand:
548
623
  )
549
624
  if enriching.state is IngestionState.COMPLETE:
550
625
  return enriching
551
- if enriching.final_fact_ids:
626
+ if (
627
+ enriching.final_fact_ids
628
+ and all(enriching.derivation_state.values())
629
+ ):
552
630
  return self._project_and_complete(enriching)
553
631
  try:
632
+ # Materialization is a durable saga, not one long SQLite
633
+ # transaction. Extractors, embedders, and local model calls can
634
+ # take minutes on a mature installation; keeping a write
635
+ # transaction open across that work blocks every interactive
636
+ # remember/update/delete and makes the dashboard appear dead.
637
+ #
638
+ # The materializer commits its relational checkpoints in short
639
+ # database operations. The operation lease and derivation state
640
+ # remain the recovery boundary, and only the final state-machine
641
+ # checkpoint is grouped atomically below.
642
+ materialized = self._run_with_lease_heartbeat(
643
+ operation_id,
644
+ lambda: self._materializer(enriching),
645
+ )
646
+ if isinstance(materialized, MaterializationResult):
647
+ fact_ids = tuple(materialized.fact_ids)
648
+ derivation_state = dict(materialized.derivation_state)
649
+ materialization_error = materialized.last_error
650
+ else:
651
+ fact_ids = tuple(materialized)
652
+ derivation_state = {"materializer": True}
653
+ materialization_error = ""
654
+ if not fact_ids:
655
+ raise RuntimeError("materialization produced no final facts")
656
+ incomplete = sorted(
657
+ name for name, complete in derivation_state.items()
658
+ if not complete
659
+ )
554
660
  with self.repository.db.transaction():
555
- materialized = self._materializer(enriching)
556
- if isinstance(materialized, MaterializationResult):
557
- fact_ids = tuple(materialized.fact_ids)
558
- derivation_state = dict(materialized.derivation_state)
559
- else:
560
- fact_ids = tuple(materialized)
561
- derivation_state = {"materializer": True}
562
- if not fact_ids:
563
- raise RuntimeError("materialization produced no final facts")
564
- incomplete = sorted(
565
- name for name, complete in derivation_state.items()
566
- if not complete
567
- )
568
- if incomplete:
569
- raise RuntimeError(
570
- "incomplete derivation stages: " + ", ".join(incomplete)
571
- )
572
661
  checkpointed = self.repository.checkpoint_enriching(
573
662
  operation_id,
574
663
  final_fact_ids=fact_ids,
@@ -577,6 +666,21 @@ class IngestionCommand:
577
666
  lease_owner=self._owner,
578
667
  lease_seconds=self._lease_seconds,
579
668
  )
669
+ if materialization_error or incomplete:
670
+ error = materialization_error or (
671
+ "incomplete derivation stages: " + ", ".join(incomplete)
672
+ )
673
+ return self.repository.finish_enriching(
674
+ operation_id,
675
+ owner=self._owner,
676
+ target=IngestionState.FAILED,
677
+ final_fact_ids=fact_ids,
678
+ derivation_version=self._derivation_version,
679
+ derivation_state=derivation_state,
680
+ last_error=error,
681
+ )
682
+ except LeaseLost:
683
+ raise
580
684
  except Exception as exc:
581
685
  return self.repository.finish_enriching(
582
686
  operation_id,
@@ -598,10 +702,13 @@ class IngestionCommand:
598
702
  owner=self._owner,
599
703
  lease_seconds=self._lease_seconds,
600
704
  )
601
- projection_state = (
602
- dict(self._projector(operation))
603
- if self._projector is not None
604
- else {}
705
+ projection_state = self._run_with_lease_heartbeat(
706
+ operation.operation_id,
707
+ lambda: (
708
+ dict(self._projector(operation))
709
+ if self._projector is not None
710
+ else {}
711
+ ),
605
712
  )
606
713
  combined = {**operation.derivation_state, **projection_state}
607
714
  incomplete = sorted(
@@ -619,6 +726,8 @@ class IngestionCommand:
619
726
  derivation_version=self._derivation_version,
620
727
  derivation_state=combined,
621
728
  )
729
+ except LeaseLost:
730
+ raise
622
731
  except Exception as exc:
623
732
  return self.repository.finish_enriching(
624
733
  operation.operation_id,
@@ -128,20 +128,25 @@ def _load_injection_config():
128
128
 
129
129
 
130
130
  def estimate_tokens(text: str) -> int:
131
- """chars/4 heuristic. Optional tiktoken if installed (best-effort).
132
-
133
- tiktoken is an optional dependency (pip install superlocalmemory[injection]).
134
- Falls back to chars/4 if tiktoken is not installed or raises any error.
135
- All exception types (MemoryError, SystemError etc.) are subclasses of
136
- Exception in Python 3.11, so the bare except Exception covers all paths.
131
+ """Estimate context tokens without loading native code by default.
132
+
133
+ The stable default is the deterministic ``chars / 4`` heuristic. Loading
134
+ the optional native ``tiktoken`` extension in a long-lived daemon that also
135
+ hosts torch/scipy workers can terminate the whole process on a native fault;
136
+ Python exception handling cannot recover from SIGBUS/SIGSEGV. Users who
137
+ explicitly need exact OpenAI token counts can opt in with
138
+ ``SLM_INJECTION_EXACT_TOKENS=1``.
137
139
  """
138
140
  if not text:
139
141
  return 0
142
+ heuristic = max(1, len(text) // 4)
143
+ if os.environ.get("SLM_INJECTION_EXACT_TOKENS", "0") != "1":
144
+ return heuristic
140
145
  try:
141
146
  import tiktoken
142
147
  return len(tiktoken.get_encoding("cl100k_base").encode(text))
143
148
  except Exception:
144
- return max(1, len(text) // 4)
149
+ return heuristic
145
150
 
146
151
 
147
152
  def resolve_budget(mode: str, cfg) -> int:
@@ -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:
@@ -8,7 +8,8 @@ V3.3.13: Periodically triggers Langevin/Ebbinghaus/Sheaf maintenance
8
8
  so users don't need to call run_maintenance manually.
9
9
 
10
10
  Configurable interval via ForgettingConfig.scheduler_interval_minutes.
11
- Defaults to 30 min. Disabled during benchmarks (no config.forgetting.enabled).
11
+ Defaults to 30 min. Optional forgetting/math work follows
12
+ ``config.forgetting.enabled``; tier evaluation and bounded housekeeping do not.
12
13
 
13
14
  Part of Qualixar | Author: Varun Pratap Bhardwaj
14
15
  License: AGPL-3.0-or-later
@@ -79,12 +80,21 @@ class MaintenanceScheduler:
79
80
  if not self._running:
80
81
  return
81
82
  for profile_id in self._profile_ids():
82
- try:
83
- from superlocalmemory.core.maintenance import run_maintenance
84
- counts = run_maintenance(self._db, self._config, profile_id)
85
- logger.info("Scheduled maintenance complete for %s: %s", profile_id, counts)
86
- except Exception as exc:
87
- logger.warning("Scheduled maintenance failed for %s: %s", profile_id, exc)
83
+ if self._config.forgetting.enabled:
84
+ try:
85
+ from superlocalmemory.core.maintenance import run_maintenance
86
+ counts = run_maintenance(self._db, self._config, profile_id)
87
+ logger.info(
88
+ "Scheduled maintenance complete for %s: %s",
89
+ profile_id,
90
+ counts,
91
+ )
92
+ except Exception as exc:
93
+ logger.warning(
94
+ "Scheduled maintenance failed for %s: %s",
95
+ profile_id,
96
+ exc,
97
+ )
88
98
 
89
99
  # V3.4.11: Graph pruning (remove orphan edges)
90
100
  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()