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.
- package/CHANGELOG.md +24 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- 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
|
-
|
|
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
|