superlocalmemory 3.4.19 → 3.4.21

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 (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,133 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-03 §5.2
4
+
5
+ """Per-(profile, stratum) posterior LRU cache for the contextual bandit.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-03-contextual-bandit-and-ensemble.md``
8
+ Section 5.2.
9
+
10
+ Key design:
11
+ - Loader runs OUTSIDE the lock so DB reads never serialise across strata.
12
+ - ``get`` is thread-safe; concurrent misses may double-load for the same
13
+ key (acceptable because DB read is idempotent and cheap).
14
+ - ``invalidate`` drops a single key — called on every successful bandit
15
+ ``update`` for that stratum (hard rule B5).
16
+ - Max ~256 (profile, stratum) entries ≈ 80 KB RAM steady state.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+ from typing import Callable
23
+
24
+ _PosteriorMap = dict[str, tuple[float, float]]
25
+ _CacheKey = tuple[str, str]
26
+
27
+
28
+ class _BanditCache:
29
+ """Thread-safe LRU keyed by ``(profile_id, stratum)``.
30
+
31
+ Values are ``{arm_id: (alpha, beta)}`` dicts. Entries are loaded on demand
32
+ via the caller-supplied ``loader`` and evicted LRU-style once size > max.
33
+ """
34
+
35
+ def __init__(self, max_entries: int = 256) -> None:
36
+ if max_entries < 1:
37
+ raise ValueError("max_entries must be >= 1")
38
+ self._store: dict[_CacheKey, _PosteriorMap] = {}
39
+ self._order: list[_CacheKey] = []
40
+ self._lock = threading.Lock()
41
+ self._max = int(max_entries)
42
+
43
+ def get(
44
+ self,
45
+ profile_id: str,
46
+ stratum: str,
47
+ loader: Callable[[str, str], _PosteriorMap],
48
+ ) -> _PosteriorMap:
49
+ """Return the posterior map for the key, loading if absent.
50
+
51
+ The loader is called exactly once per cache-miss path; the DB read
52
+ runs outside the lock to keep contention bounded.
53
+ """
54
+ key = (profile_id, stratum)
55
+ with self._lock:
56
+ if key in self._store:
57
+ self._touch_locked(key)
58
+ return self._store[key]
59
+
60
+ # Miss — load outside the lock.
61
+ data = loader(profile_id, stratum) or {}
62
+
63
+ with self._lock:
64
+ # Another thread may have populated in the meantime.
65
+ if key in self._store:
66
+ self._touch_locked(key)
67
+ return self._store[key]
68
+ self._store[key] = data
69
+ self._order.append(key)
70
+ self._evict_if_needed_locked()
71
+ return data
72
+
73
+ def invalidate(self, profile_id: str, stratum: str) -> None:
74
+ """Drop a single (profile, stratum) entry. Safe when absent."""
75
+ key = (profile_id, stratum)
76
+ with self._lock:
77
+ self._store.pop(key, None)
78
+ try:
79
+ self._order.remove(key)
80
+ except ValueError:
81
+ pass
82
+
83
+ def clear(self) -> None:
84
+ """Drop all entries — used in tests + daemon shutdown."""
85
+ with self._lock:
86
+ self._store.clear()
87
+ self._order.clear()
88
+
89
+ def size(self) -> int:
90
+ """Current entry count — primarily for tests / introspection."""
91
+ with self._lock:
92
+ return len(self._store)
93
+
94
+ # ------------------------------------------------------------------
95
+ # Internal (lock-held) helpers
96
+ # ------------------------------------------------------------------
97
+
98
+ def _touch_locked(self, key: _CacheKey) -> None:
99
+ try:
100
+ self._order.remove(key)
101
+ except ValueError: # pragma: no cover — invariant: in store => in order
102
+ pass
103
+ self._order.append(key)
104
+
105
+ def _evict_if_needed_locked(self) -> None:
106
+ while len(self._order) > self._max:
107
+ oldest = self._order.pop(0)
108
+ self._store.pop(oldest, None)
109
+
110
+
111
+ # Module-level shared cache instance — the bandit module pulls this via
112
+ # ``get_shared_cache`` so tests can clear between runs.
113
+ _SHARED: _BanditCache | None = None
114
+ _SHARED_LOCK = threading.Lock()
115
+
116
+
117
+ def get_shared_cache(max_entries: int = 256) -> _BanditCache:
118
+ """Return the process-wide bandit cache, creating it on first call."""
119
+ global _SHARED
120
+ with _SHARED_LOCK:
121
+ if _SHARED is None:
122
+ _SHARED = _BanditCache(max_entries=max_entries)
123
+ return _SHARED
124
+
125
+
126
+ def reset_shared_cache() -> None:
127
+ """Drop the shared cache — TEST-ONLY helper."""
128
+ global _SHARED
129
+ with _SHARED_LOCK:
130
+ _SHARED = None
131
+
132
+
133
+ __all__ = ("_BanditCache", "get_shared_cache", "reset_shared_cache")
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import json
24
24
  import logging
25
+ import re
25
26
  import sqlite3
26
27
  import threading
27
28
  from datetime import datetime, timezone
@@ -33,6 +34,13 @@ logger = logging.getLogger(__name__)
33
34
  # Minimum observations before emitting a pattern
34
35
  MIN_EVIDENCE = 3
35
36
 
37
+ # S9-DASH-01: filter orphan entity_ids that leak into patterns. The
38
+ # canonical_entities primary key is 16 hex chars; some historic rows
39
+ # also carry 17 (off-by-one). Any pattern value matching this shape
40
+ # with no accompanying ``metadata.value`` is skipped at read time so
41
+ # the dashboard no longer shows raw ids as "preferences".
42
+ _HEX_ID_RE = re.compile(r"^[0-9a-f]{15,20}$")
43
+
36
44
  # Transfer eligibility thresholds
37
45
  TRANSFER_MIN_CONFIDENCE = 0.3
38
46
  TRANSFER_MIN_EVIDENCE = 2
@@ -176,7 +184,24 @@ class BehavioralPatternStore:
176
184
  params.append(limit)
177
185
 
178
186
  rows = conn.execute(query, params).fetchall()
179
- return [self._row_to_dict(r) for r in rows]
187
+ out: List[Dict[str, Any]] = []
188
+ for r in rows:
189
+ d = self._row_to_dict(r)
190
+ # S9-DASH-01: historic rows wrote raw hex entity_ids
191
+ # (16-20 hex chars, no row in canonical_entities) into
192
+ # ``pattern_key``/``metadata.value``. They surface as
193
+ # "entity_preferences: ea701bf01f1ff4df8" on the
194
+ # dashboard, which is noise. Skip them at read time so
195
+ # existing installs don't require a destructive DB
196
+ # cleanup migration.
197
+ key = d.get("pattern_key") or ""
198
+ meta_val = (d.get("metadata") or {}).get("value") or ""
199
+ if _HEX_ID_RE.match(str(key).split(":", 1)[-1]):
200
+ continue
201
+ if _HEX_ID_RE.match(str(meta_val)):
202
+ continue
203
+ out.append(d)
204
+ return out
180
205
  finally:
181
206
  conn.close()
182
207
 
@@ -285,6 +310,33 @@ class BehavioralPatternStore:
285
310
  finally:
286
311
  conn.close()
287
312
 
313
+ def delete_pattern_by_key(
314
+ self,
315
+ profile_id: str,
316
+ pattern_type: str,
317
+ pattern_key: str,
318
+ ) -> int:
319
+ """S9-DASH-04: delete a single pattern row by its user-visible
320
+ identity ``(profile_id, pattern_type, pattern_key)``.
321
+
322
+ Used by the dashboard "Delete pattern" button so an operator
323
+ can kill a wrong auto-detected pattern without wiping the full
324
+ table. Returns 1 if a row was deleted, 0 if no match.
325
+ """
326
+ with self._lock:
327
+ conn = self._connect()
328
+ try:
329
+ cur = conn.execute(
330
+ "DELETE FROM _store_patterns "
331
+ "WHERE profile_id = ? AND pattern_type = ? "
332
+ "AND pattern_key = ?",
333
+ (profile_id, pattern_type, pattern_key),
334
+ )
335
+ conn.commit()
336
+ return cur.rowcount
337
+ finally:
338
+ conn.close()
339
+
288
340
  # ------------------------------------------------------------------
289
341
  # Internal helpers
290
342
  # ------------------------------------------------------------------
@@ -0,0 +1,381 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — F4.A Stage-8 H-01 fix
4
+
5
+ """ConsolidationWorker — background memory maintenance lifecycle.
6
+
7
+ Runs periodically (every 6 hours or on-demand) to:
8
+ 1. Decay confidence on unused facts (floor 0.1).
9
+ 2. Deduplicate near-identical facts via HNSW (LLD-12).
10
+ 3. Mine behavioural patterns (``pattern_miner.generate_patterns``).
11
+ 4. Recompute graph intelligence.
12
+ 5. Auto-retrain the adaptive ranker — online (LLD-10) or legacy
13
+ cold-start gated by Stage-8 H-07.
14
+ 6. Compile entity truth blocks (v3.4.3).
15
+
16
+ The class itself is kept lean: every heavy helper lives in a dedicated
17
+ module (``pattern_miner``, ``ranker_retrain_online``,
18
+ ``ranker_retrain_legacy``).
19
+
20
+ Contract refs:
21
+ - LLD-10 §2 + §3 — online retrain orchestration.
22
+ - LLD-12 §2 — HNSW dedup path.
23
+ - Stage 8 H-01 + H-07 — file split + legacy gating.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import os
31
+ import sqlite3
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __all__ = ("ConsolidationWorker",)
38
+
39
+
40
+ class ConsolidationWorker:
41
+ """Background memory maintenance worker.
42
+
43
+ Call :py:meth:`run` periodically or via the dashboard button. All
44
+ operations are safe — they improve quality without losing data.
45
+ """
46
+
47
+ def __init__(
48
+ self, memory_db: str | Path, learning_db: str | Path,
49
+ ) -> None:
50
+ self._memory_db = str(memory_db)
51
+ self._learning_db = str(learning_db)
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public API
55
+ # ------------------------------------------------------------------
56
+
57
+ def run(self, profile_id: str, dry_run: bool = False) -> dict:
58
+ """Run full consolidation cycle. Returns stats."""
59
+ stats = {
60
+ "decayed": 0,
61
+ "deduped": 0,
62
+ "retrained": False,
63
+ "signal_count": 0,
64
+ "ranker_phase": 1,
65
+ "timestamp": datetime.now(timezone.utc).isoformat(),
66
+ }
67
+
68
+ # 1. Confidence decay on unused facts.
69
+ try:
70
+ from superlocalmemory.learning.signals import LearningSignals
71
+ decayed = LearningSignals.decay_confidence(
72
+ self._memory_db, profile_id, rate=0.001,
73
+ )
74
+ stats["decayed"] = decayed
75
+ if not dry_run:
76
+ logger.info(
77
+ "Confidence decay: %d facts affected", decayed,
78
+ )
79
+ except Exception as exc:
80
+ logger.debug("Decay failed: %s", exc)
81
+
82
+ # 2. Deduplication (HNSW + fallback).
83
+ try:
84
+ deduped = self._deduplicate(profile_id, dry_run)
85
+ stats["deduped"] = deduped
86
+ except Exception as exc:
87
+ logger.debug("Dedup failed: %s", exc)
88
+
89
+ # 3. Behavioural patterns.
90
+ try:
91
+ patterns = self._generate_patterns(profile_id, dry_run)
92
+ stats["patterns_generated"] = patterns
93
+ except Exception as exc:
94
+ logger.debug("Pattern generation failed: %s", exc)
95
+
96
+ # 4. Recompute graph intelligence (v3.4.2).
97
+ try:
98
+ from superlocalmemory.core.graph_analyzer import GraphAnalyzer
99
+ conn_ga = sqlite3.connect(self._memory_db, timeout=10)
100
+ conn_ga.execute("PRAGMA busy_timeout=5000")
101
+ conn_ga.row_factory = sqlite3.Row
102
+
103
+ class _DBProxy:
104
+ """Minimal DB proxy for GraphAnalyzer compatibility."""
105
+
106
+ def __init__(self, connection: sqlite3.Connection) -> None:
107
+ self._conn = connection
108
+
109
+ def execute(self, sql: str, params: tuple = ()) -> list:
110
+ cursor = self._conn.execute(sql, params)
111
+ if sql.strip().upper().startswith(
112
+ ("INSERT", "UPDATE", "DELETE", "ALTER", "CREATE"),
113
+ ):
114
+ self._conn.commit()
115
+ return []
116
+ return cursor.fetchall()
117
+
118
+ ga = GraphAnalyzer(_DBProxy(conn_ga))
119
+ if not dry_run:
120
+ ga_result = ga.compute_and_store(profile_id)
121
+ stats["graph_nodes"] = ga_result.get("node_count", 0)
122
+ stats["graph_communities"] = ga_result.get(
123
+ "community_count", 0,
124
+ )
125
+ logger.info(
126
+ "Graph analysis: %d nodes, %d communities",
127
+ stats["graph_nodes"], stats["graph_communities"],
128
+ )
129
+ conn_ga.close()
130
+ except Exception as exc:
131
+ logger.debug("Graph analysis failed: %s", exc)
132
+
133
+ # 5. Ranker retrain — online (LLD-10) or legacy cold-start.
134
+ #
135
+ # Gating (Stage-8 H-07): once a profile has an active model the
136
+ # online path wins unconditionally. The legacy cold-start path
137
+ # only fires when there is NO active model (``_should_retrain``
138
+ # returns False because no active row exists) AND the raw
139
+ # signal_count crosses 200. Partial unique indexes M009 keep
140
+ # both paths from racing.
141
+ try:
142
+ from superlocalmemory.learning.feedback import FeedbackCollector
143
+ collector = FeedbackCollector(Path(self._learning_db))
144
+ signal_count = collector.get_feedback_count(profile_id)
145
+ stats["signal_count"] = signal_count
146
+ stats["ranker_phase"] = (
147
+ 1 if signal_count < 50 else (2 if signal_count < 200 else 3)
148
+ )
149
+
150
+ if not dry_run:
151
+ # Late import — the shim hosts ``_run_shadow_cycle`` so
152
+ # that monkey-patches on ``consolidation_worker`` reach
153
+ # into the orchestrator's helper lookups.
154
+ from superlocalmemory.learning import consolidation_worker \
155
+ as _shim
156
+ if self._should_retrain(profile_id):
157
+ stats["online_retrain"] = _shim._run_shadow_cycle(
158
+ memory_db_path=self._memory_db,
159
+ learning_db_path=self._learning_db,
160
+ profile_id=profile_id,
161
+ )
162
+ elif signal_count >= 200:
163
+ # Cold-start only: no active model yet.
164
+ retrained = self._retrain_ranker(
165
+ profile_id, signal_count,
166
+ )
167
+ stats["retrained"] = retrained
168
+ except Exception as exc:
169
+ logger.debug("Retrain check failed: %s", exc)
170
+
171
+ # 6. Entity compilation (v3.4.3).
172
+ if not dry_run:
173
+ try:
174
+ from superlocalmemory.learning.entity_compiler import (
175
+ EntityCompiler,
176
+ )
177
+ from superlocalmemory.core.config import SLMConfig
178
+ config = SLMConfig.load()
179
+ compiler = EntityCompiler(self._memory_db, config)
180
+ ec_result = compiler.compile_all(profile_id)
181
+ stats["entities_compiled"] = ec_result.get("compiled", 0)
182
+ if ec_result["compiled"] > 0:
183
+ logger.info(
184
+ "Entity compilation: %d entities compiled",
185
+ ec_result["compiled"],
186
+ )
187
+ except Exception as exc:
188
+ logger.debug("Entity compilation failed: %s", exc)
189
+
190
+ return stats
191
+
192
+ # ------------------------------------------------------------------
193
+ # Private helpers
194
+ # ------------------------------------------------------------------
195
+
196
+ def _deduplicate(self, profile_id: str, dry_run: bool) -> int:
197
+ """Find and mark near-duplicate facts.
198
+
199
+ v3.4.21 (LLD-12): prefer HNSW ANN + entity-overlap dedup with a
200
+ reversible merge log. On any error (missing schema columns,
201
+ hnswlib unavailable, RAM budget exceeded) fall back to the
202
+ legacy prefix dedup so existing deployments keep working.
203
+
204
+ Never DELETEs from atomic_facts — merges flip archive_status
205
+ and write memory_merge_log rows.
206
+ """
207
+ # v3.4.21 preferred path: HNSW + memory_merge (LLD-12).
208
+ try:
209
+ from superlocalmemory.learning.hnsw_dedup import (
210
+ HnswDeduplicator,
211
+ )
212
+ from superlocalmemory.learning.memory_merge import apply_merges
213
+
214
+ dedup = HnswDeduplicator(memory_db_path=self._memory_db)
215
+ candidates = dedup.find_merge_candidates(profile_id)
216
+ if not candidates:
217
+ return 0
218
+ if dry_run:
219
+ return len(candidates)
220
+ applied = apply_merges(
221
+ self._memory_db, candidates, profile_id=profile_id,
222
+ )
223
+ return applied
224
+ except sqlite3.OperationalError as exc:
225
+ # Schema predates M011 — fall through to legacy path.
226
+ logger.debug("hnsw dedup schema missing, fallback: %s", exc)
227
+ except Exception as exc:
228
+ logger.debug("hnsw dedup unexpected error, fallback: %s", exc)
229
+
230
+ # Legacy fallback (pre-v3.4.21 behaviour).
231
+ # S9-defer H-P-09: hard cap on the fallback scan so a profile
232
+ # with 5M+ atomic_facts cannot OOM the consolidation worker
233
+ # when hnswlib is unavailable. The fallback is a prefix-match
234
+ # heuristic anyway; scanning more than 100k rows via this
235
+ # code path is noise, not signal. The cap can be raised via
236
+ # env var for benchmarking.
237
+ _LEGACY_DEDUP_SCAN_CAP = int(
238
+ os.environ.get("SLM_LEGACY_DEDUP_SCAN_CAP", "100000")
239
+ )
240
+ try:
241
+ conn = sqlite3.connect(self._memory_db, timeout=10)
242
+ conn.execute("PRAGMA busy_timeout=5000")
243
+ conn.row_factory = sqlite3.Row
244
+
245
+ rows = conn.execute(
246
+ "SELECT fact_id, content FROM atomic_facts "
247
+ "WHERE profile_id = ? ORDER BY created_at LIMIT ?",
248
+ (profile_id, _LEGACY_DEDUP_SCAN_CAP),
249
+ ).fetchall()
250
+
251
+ seen_prefixes: dict[str, str] = {}
252
+ duplicates = []
253
+
254
+ for r in rows:
255
+ d = dict(r)
256
+ prefix = d["content"][:100].strip().lower()
257
+ if prefix in seen_prefixes:
258
+ duplicates.append(d["fact_id"])
259
+ else:
260
+ seen_prefixes[prefix] = d["fact_id"]
261
+
262
+ if duplicates and not dry_run:
263
+ for fid in duplicates:
264
+ conn.execute(
265
+ "UPDATE atomic_facts "
266
+ "SET confidence = MAX(0.1, confidence * 0.5) "
267
+ "WHERE fact_id = ?",
268
+ (fid,),
269
+ )
270
+ conn.commit()
271
+
272
+ conn.close()
273
+ return len(duplicates)
274
+ except Exception:
275
+ return 0
276
+
277
+ def _generate_patterns(
278
+ self, profile_id: str, dry_run: bool = False,
279
+ ) -> int:
280
+ """Back-compat shim delegating to ``pattern_miner.generate_patterns``.
281
+
282
+ Preserved so the MCP ``run_maintenance`` tool and any external
283
+ caller that bound this method directly keeps working.
284
+ """
285
+ from superlocalmemory.learning.pattern_miner import generate_patterns
286
+ return generate_patterns(
287
+ self._memory_db, self._learning_db, profile_id, dry_run,
288
+ )
289
+
290
+ def _retrain_ranker(self, profile_id: str, signal_count: int) -> bool:
291
+ """Legacy cold-start retrain. DEPRECATED.
292
+
293
+ Delegates to the deprecated legacy impl; a one-shot
294
+ ``DeprecationWarning`` fires on first invocation per process.
295
+ """
296
+ try:
297
+ from superlocalmemory.learning import consolidation_worker \
298
+ as _shim
299
+ return _shim._retrain_ranker_impl(self._learning_db, profile_id)
300
+ except Exception as exc:
301
+ logger.debug("Retrain failed: %s", exc)
302
+ return False
303
+
304
+ # ------------------------------------------------------------------
305
+ # LLD-10 — online retrain trigger
306
+ # ------------------------------------------------------------------
307
+
308
+ def _should_retrain(self, profile_id: str) -> bool:
309
+ """Return True if the outcome-count or 24h trigger has fired.
310
+
311
+ Reads ``learning_model_state.metadata_json`` on the active row.
312
+ Honours ``metadata_json.retrain_disabled_until`` (post-rollback
313
+ cooldown). No DB writes — pure SELECT + JSON parse.
314
+ """
315
+ from superlocalmemory.learning.ranker_retrain_online import (
316
+ RETRAIN_NEW_OUTCOMES_THRESHOLD,
317
+ RETRAIN_HOURS_THRESHOLD,
318
+ )
319
+ try:
320
+ conn = sqlite3.connect(self._learning_db, timeout=5)
321
+ try:
322
+ conn.row_factory = sqlite3.Row
323
+ row = conn.execute(
324
+ "SELECT metadata_json FROM learning_model_state "
325
+ "WHERE profile_id = ? AND is_active = 1 LIMIT 1",
326
+ (profile_id,),
327
+ ).fetchone()
328
+ finally:
329
+ conn.close()
330
+ except sqlite3.Error as exc:
331
+ logger.debug("_should_retrain sqlite error: %s", exc)
332
+ return False
333
+
334
+ if row is None:
335
+ # No active model yet — let the legacy cold-start path
336
+ # (signal_count >= 200) drive first training.
337
+ return False
338
+
339
+ try:
340
+ meta = json.loads(row["metadata_json"] or "{}")
341
+ except (TypeError, ValueError):
342
+ meta = {}
343
+
344
+ now = datetime.now(timezone.utc)
345
+
346
+ # Cooldown: honour retrain_disabled_until (post-rollback).
347
+ disabled_until = meta.get("retrain_disabled_until")
348
+ if disabled_until:
349
+ try:
350
+ dt = datetime.fromisoformat(disabled_until)
351
+ if dt.tzinfo is None:
352
+ dt = dt.replace(tzinfo=timezone.utc)
353
+ if dt > now:
354
+ return False
355
+ except (TypeError, ValueError):
356
+ pass # malformed → ignore the cooldown
357
+
358
+ # Trigger A — outcome-count delta.
359
+ try:
360
+ new_outcomes = int(
361
+ meta.get("new_outcomes_since_last_retrain", 0) or 0,
362
+ )
363
+ except (TypeError, ValueError):
364
+ new_outcomes = 0
365
+ if new_outcomes >= RETRAIN_NEW_OUTCOMES_THRESHOLD:
366
+ return True
367
+
368
+ # Trigger B — hours since last retrain.
369
+ last = meta.get("last_retrain_at")
370
+ if last:
371
+ try:
372
+ last_dt = datetime.fromisoformat(last)
373
+ if last_dt.tzinfo is None:
374
+ last_dt = last_dt.replace(tzinfo=timezone.utc)
375
+ hours = (now - last_dt).total_seconds() / 3600.0
376
+ if hours >= RETRAIN_HOURS_THRESHOLD:
377
+ return True
378
+ except (TypeError, ValueError):
379
+ pass
380
+
381
+ return False