superlocalmemory 3.4.19 → 3.4.22
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 +4 -3
- 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 +254 -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/skills/slm-build-graph/SKILL.md +423 -0
- package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
- package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
- package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
- package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
- package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
- 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,183 @@
|
|
|
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.22 — LLD-05 §7
|
|
4
|
+
|
|
5
|
+
"""MCP proactive-context tool — ``prestage_context``.
|
|
6
|
+
|
|
7
|
+
LLD-05 §7. Exposes a single MCP tool that returns top-K redacted memories
|
|
8
|
+
for a given query. Guardrails:
|
|
9
|
+
- Rate limit 30 calls / minute (token bucket; A11).
|
|
10
|
+
- Every returned text passes through ``redact_secrets`` (A9).
|
|
11
|
+
- JSON response size bound ≤ 16 KB.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from threading import Lock
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from superlocalmemory.core.security_primitives import redact_secrets
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
MAX_CALLS_PER_MINUTE = 30
|
|
29
|
+
MAX_RESPONSE_BYTES = 16 * 1024 # 16 KB
|
|
30
|
+
WINDOW_SECONDS = 60.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _RateLimiter:
|
|
35
|
+
"""Simple fixed-window rate limiter. Thread-safe.
|
|
36
|
+
|
|
37
|
+
Keyed by session id. Clock is injectable for deterministic tests.
|
|
38
|
+
"""
|
|
39
|
+
max_calls: int = MAX_CALLS_PER_MINUTE
|
|
40
|
+
window: float = WINDOW_SECONDS
|
|
41
|
+
now_fn: Callable[[], float] = time.monotonic
|
|
42
|
+
_buckets: dict[str, tuple[float, int]] = field(default_factory=dict)
|
|
43
|
+
_lock: Lock = field(default_factory=Lock)
|
|
44
|
+
|
|
45
|
+
def allow(self, key: str) -> bool:
|
|
46
|
+
with self._lock:
|
|
47
|
+
now = self.now_fn()
|
|
48
|
+
start, count = self._buckets.get(key, (now, 0))
|
|
49
|
+
if now - start >= self.window:
|
|
50
|
+
# Reset window.
|
|
51
|
+
self._buckets[key] = (now, 1)
|
|
52
|
+
return True
|
|
53
|
+
if count >= self.max_calls:
|
|
54
|
+
return False
|
|
55
|
+
self._buckets[key] = (start, count + 1)
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._buckets.clear()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_DEFAULT_LIMITER = _RateLimiter()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# RecallFn for the tool. Callers inject a real recall engine; tests inject fakes.
|
|
67
|
+
PrestageRecallFn = Callable[[str, int, str], list[dict]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iso_now() -> str:
|
|
71
|
+
return datetime.now(timezone.utc).isoformat()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cap_memory(memory: dict, *, max_text_bytes: int = 2048) -> dict:
|
|
75
|
+
"""Ensure each memory is bounded and redacted."""
|
|
76
|
+
text = memory.get("text", "")
|
|
77
|
+
if not isinstance(text, str):
|
|
78
|
+
text = str(text)
|
|
79
|
+
text = redact_secrets(text)
|
|
80
|
+
if len(text.encode("utf-8")) > max_text_bytes:
|
|
81
|
+
text = text.encode("utf-8")[:max_text_bytes].decode("utf-8", "ignore")
|
|
82
|
+
score = float(memory.get("score", 0.0) or 0.0)
|
|
83
|
+
return {
|
|
84
|
+
"id": str(memory.get("id", "")),
|
|
85
|
+
"text": text,
|
|
86
|
+
"score": score,
|
|
87
|
+
"source": str(memory.get("source", "recall")),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prestage_context(
|
|
92
|
+
query: str,
|
|
93
|
+
*,
|
|
94
|
+
limit: int = 5,
|
|
95
|
+
profile_id: str = "default",
|
|
96
|
+
session_id: str = "default",
|
|
97
|
+
recall_fn: PrestageRecallFn,
|
|
98
|
+
limiter: _RateLimiter | None = None,
|
|
99
|
+
) -> dict:
|
|
100
|
+
"""Proactive-context tool body.
|
|
101
|
+
|
|
102
|
+
Pure function: takes an injected recall_fn + limiter. The MCP server
|
|
103
|
+
wrapper in the session process wires the real engine and shares a
|
|
104
|
+
single limiter instance.
|
|
105
|
+
"""
|
|
106
|
+
_limiter = limiter or _DEFAULT_LIMITER
|
|
107
|
+
if not _limiter.allow(session_id):
|
|
108
|
+
return {
|
|
109
|
+
"error": "rate_limit_exceeded",
|
|
110
|
+
"memories": [],
|
|
111
|
+
"generated_at": _iso_now(),
|
|
112
|
+
"limit": limit,
|
|
113
|
+
"truncated_count": 0,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if not isinstance(query, str) or not query.strip():
|
|
117
|
+
return {
|
|
118
|
+
"error": "empty_query",
|
|
119
|
+
"memories": [],
|
|
120
|
+
"generated_at": _iso_now(),
|
|
121
|
+
"limit": limit,
|
|
122
|
+
"truncated_count": 0,
|
|
123
|
+
}
|
|
124
|
+
limit = max(1, min(int(limit), 50))
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
raw = recall_fn(query, limit, profile_id) or []
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.warning("prestage_context recall failed: %s", exc)
|
|
130
|
+
return {
|
|
131
|
+
"error": "recall_error",
|
|
132
|
+
"memories": [],
|
|
133
|
+
"generated_at": _iso_now(),
|
|
134
|
+
"limit": limit,
|
|
135
|
+
"truncated_count": 0,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
capped = [_cap_memory(m) for m in raw if isinstance(m, dict)]
|
|
139
|
+
capped = capped[:limit]
|
|
140
|
+
|
|
141
|
+
# Enforce total response size cap (A11/16 KB).
|
|
142
|
+
response = {
|
|
143
|
+
"memories": capped,
|
|
144
|
+
"generated_at": _iso_now(),
|
|
145
|
+
"limit": limit,
|
|
146
|
+
"truncated_count": 0,
|
|
147
|
+
}
|
|
148
|
+
encoded = json.dumps(response).encode("utf-8")
|
|
149
|
+
truncated = 0
|
|
150
|
+
while len(encoded) > MAX_RESPONSE_BYTES and response["memories"]:
|
|
151
|
+
response["memories"].pop()
|
|
152
|
+
truncated += 1
|
|
153
|
+
response["truncated_count"] = truncated
|
|
154
|
+
encoded = json.dumps(response).encode("utf-8")
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_prestage_tool(server, recall_fn: PrestageRecallFn,
|
|
159
|
+
*, session_id_fn: Callable[[], str] | None = None
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Register the ``prestage_context`` tool on an MCP server."""
|
|
162
|
+
limiter = _RateLimiter()
|
|
163
|
+
|
|
164
|
+
@server.tool()
|
|
165
|
+
async def prestage_context_tool( # pragma: no cover — MCP wiring
|
|
166
|
+
query: str,
|
|
167
|
+
limit: int = 5,
|
|
168
|
+
profile_id: str = "default",
|
|
169
|
+
) -> dict:
|
|
170
|
+
"""Proactively return top-K memories for a query."""
|
|
171
|
+
session_id = session_id_fn() if session_id_fn else "default"
|
|
172
|
+
return prestage_context(
|
|
173
|
+
query, limit=limit, profile_id=profile_id,
|
|
174
|
+
session_id=session_id, recall_fn=recall_fn, limiter=limiter,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
__all__ = (
|
|
179
|
+
"MAX_CALLS_PER_MINUTE",
|
|
180
|
+
"MAX_RESPONSE_BYTES",
|
|
181
|
+
"prestage_context",
|
|
182
|
+
"register_prestage_tool",
|
|
183
|
+
)
|
|
@@ -35,45 +35,59 @@ def _emit_event(event_type: str, payload: dict | None = None,
|
|
|
35
35
|
pass
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def _record_recall_hits(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
def _record_recall_hits(
|
|
39
|
+
get_engine: Callable,
|
|
40
|
+
query: str,
|
|
41
|
+
results: list[dict],
|
|
42
|
+
*,
|
|
43
|
+
query_id: str = "",
|
|
44
|
+
fact_ids_candidates: list[str] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Record honest shown-state signals (LLD-02 §4.9).
|
|
47
|
+
|
|
48
|
+
v3.4.22: No more fake positives. For every candidate we enqueue a
|
|
49
|
+
``shown`` / ``not_shown`` flip based on whether it was returned in the
|
|
50
|
+
top-K presented to the user. Outcome/reward arrives in v3.4.22 via the
|
|
51
|
+
action-outcomes pipeline.
|
|
52
|
+
|
|
53
|
+
Non-blocking: all work funnels through ``signals.enqueue_shown_flip``
|
|
54
|
+
(module-level queue + background drain). Failures are swallowed —
|
|
55
|
+
signal quality is never load-bearing on recall correctness.
|
|
43
56
|
"""
|
|
44
57
|
try:
|
|
45
58
|
from pathlib import Path
|
|
59
|
+
from superlocalmemory.learning.signals import (
|
|
60
|
+
LearningSignals,
|
|
61
|
+
enqueue_shown_flip,
|
|
62
|
+
)
|
|
63
|
+
|
|
46
64
|
engine = get_engine()
|
|
47
65
|
pid = engine.profile_id
|
|
48
66
|
slm_dir = Path.home() / ".superlocalmemory"
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
|
|
68
|
+
shown_ids = [r.get("fact_id", "") for r in results[:10]
|
|
69
|
+
if r.get("fact_id")]
|
|
70
|
+
candidates = (fact_ids_candidates
|
|
71
|
+
if fact_ids_candidates is not None
|
|
72
|
+
else shown_ids)
|
|
73
|
+
if not candidates:
|
|
51
74
|
return
|
|
52
75
|
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
profile_id=pid, query=query,
|
|
59
|
-
fact_ids_returned=fact_ids, fact_ids_available=fact_ids,
|
|
60
|
-
)
|
|
61
|
-
except Exception:
|
|
62
|
-
pass
|
|
76
|
+
# Shown-flip enqueue per §4.9. No synthetic positives.
|
|
77
|
+
shown_set = set(shown_ids)
|
|
78
|
+
if query_id:
|
|
79
|
+
for fid in candidates:
|
|
80
|
+
enqueue_shown_flip(query_id, fid, shown=(fid in shown_set))
|
|
63
81
|
|
|
64
|
-
#
|
|
82
|
+
# Legacy zero-cost signals — unchanged (co-retrieval + confidence).
|
|
65
83
|
try:
|
|
66
|
-
from superlocalmemory.learning.signals import LearningSignals
|
|
67
84
|
signals = LearningSignals(slm_dir / "learning.db")
|
|
68
|
-
signals.record_co_retrieval(pid,
|
|
85
|
+
signals.record_co_retrieval(pid, shown_ids)
|
|
69
86
|
except Exception:
|
|
70
87
|
pass
|
|
71
|
-
|
|
72
|
-
# 3. Confidence boost (accessed facts get +0.02, cap 1.0)
|
|
73
88
|
try:
|
|
74
|
-
from superlocalmemory.learning.signals import LearningSignals
|
|
75
89
|
mem_db = str(slm_dir / "memory.db")
|
|
76
|
-
for fid in
|
|
90
|
+
for fid in shown_ids[:5]:
|
|
77
91
|
LearningSignals.boost_confidence(mem_db, fid)
|
|
78
92
|
except Exception:
|
|
79
93
|
pass
|
|
@@ -150,14 +164,65 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
150
164
|
return {"success": False, "error": str(exc)}
|
|
151
165
|
|
|
152
166
|
@server.tool()
|
|
153
|
-
async def recall(
|
|
154
|
-
|
|
167
|
+
async def recall(
|
|
168
|
+
query: str, limit: int = 10, agent_id: str = "mcp_client",
|
|
169
|
+
session_id: str = "",
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
|
|
172
|
+
|
|
173
|
+
S9-DASH-02: optional ``session_id`` threads through to the
|
|
174
|
+
engine's outcome-queue so PostToolUse / Stop hooks can attach
|
|
175
|
+
engagement signals to this recall. Claude Code should pass its
|
|
176
|
+
``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
|
|
177
|
+
learning for this recall" — the recall itself always works.
|
|
178
|
+
"""
|
|
155
179
|
import asyncio
|
|
156
180
|
try:
|
|
157
181
|
from superlocalmemory.core.worker_pool import WorkerPool
|
|
158
182
|
pool = WorkerPool.shared()
|
|
183
|
+
# S9-DASH-10: priority for session_id, so engagement
|
|
184
|
+
# signals land on the right pending_outcome:
|
|
185
|
+
# 1. Explicit ``session_id`` tool-call argument.
|
|
186
|
+
# 2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` env var.
|
|
187
|
+
# 3. Most-recent-active Claude session from the hook
|
|
188
|
+
# registry (last 60s). This catches the common case
|
|
189
|
+
# where Claude Code's hooks ran the UserPromptSubmit
|
|
190
|
+
# hook right before invoking the MCP tool.
|
|
191
|
+
# 4. Stable per-agent fallback ``mcp:<agent_id>`` — the
|
|
192
|
+
# Stop hook will NOT match this, so the reaper
|
|
193
|
+
# settles it at neutral 0.5.
|
|
194
|
+
effective_sid = session_id
|
|
195
|
+
if not effective_sid:
|
|
196
|
+
import os as _os
|
|
197
|
+
effective_sid = (
|
|
198
|
+
_os.environ.get("SLM_SESSION_ID")
|
|
199
|
+
or _os.environ.get("CLAUDE_SESSION_ID")
|
|
200
|
+
or ""
|
|
201
|
+
)
|
|
202
|
+
if not effective_sid:
|
|
203
|
+
try:
|
|
204
|
+
from superlocalmemory.hooks.session_registry import (
|
|
205
|
+
lookup_by_parent,
|
|
206
|
+
most_recent_active,
|
|
207
|
+
)
|
|
208
|
+
# Parent-PID lookup is collision-free across multiple
|
|
209
|
+
# parallel Claude sessions (each MCP server's parent
|
|
210
|
+
# is the IDE that spawned it).
|
|
211
|
+
effective_sid = (
|
|
212
|
+
lookup_by_parent(within_seconds=60)
|
|
213
|
+
or most_recent_active(
|
|
214
|
+
agent_type="claude", within_seconds=60,
|
|
215
|
+
)
|
|
216
|
+
or ""
|
|
217
|
+
)
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
if not effective_sid:
|
|
221
|
+
effective_sid = f"mcp:{agent_id}"
|
|
159
222
|
# V3.3.19: Run in thread pool to avoid blocking MCP event loop
|
|
160
|
-
result = await asyncio.to_thread(
|
|
223
|
+
result = await asyncio.to_thread(
|
|
224
|
+
pool.recall, query, limit=limit, session_id=effective_sid,
|
|
225
|
+
)
|
|
161
226
|
if result.get("ok"):
|
|
162
227
|
# Record implicit feedback: every returned result is a recall_hit
|
|
163
228
|
try:
|
|
@@ -123,12 +123,18 @@ class SoftPromptGenerator:
|
|
|
123
123
|
self,
|
|
124
124
|
patterns: list[PatternAssertion],
|
|
125
125
|
profile_id: str,
|
|
126
|
+
*,
|
|
127
|
+
high_reward_source_ids: set[str] | None = None,
|
|
126
128
|
) -> list[SoftPromptTemplate]:
|
|
127
129
|
"""Master generation pipeline: filter, group, render, budget-trim.
|
|
128
130
|
|
|
129
131
|
Args:
|
|
130
132
|
patterns: Extracted pattern assertions.
|
|
131
133
|
profile_id: Target profile.
|
|
134
|
+
high_reward_source_ids: Optional v3.4.22 (LLD-12 §6) filter —
|
|
135
|
+
when provided, only patterns whose source_ids intersect
|
|
136
|
+
this set are considered. When None (default), behaviour
|
|
137
|
+
matches pre-v3.4.22 and every pattern flows through.
|
|
132
138
|
|
|
133
139
|
Returns:
|
|
134
140
|
List of SoftPromptTemplate, ordered by category priority,
|
|
@@ -140,6 +146,13 @@ class SoftPromptGenerator:
|
|
|
140
146
|
p for p in patterns if p.category.value in enabled
|
|
141
147
|
]
|
|
142
148
|
|
|
149
|
+
# v3.4.22 (LLD-12 §6): reward-aware filter — opt-in only.
|
|
150
|
+
if high_reward_source_ids is not None:
|
|
151
|
+
filtered = [
|
|
152
|
+
p for p in filtered
|
|
153
|
+
if set(p.source_ids) & high_reward_source_ids
|
|
154
|
+
]
|
|
155
|
+
|
|
143
156
|
# Group by category
|
|
144
157
|
grouped: dict[str, list[PatternAssertion]] = defaultdict(list)
|
|
145
158
|
for p in filtered:
|
|
@@ -705,3 +705,55 @@ class RetrievalEngine:
|
|
|
705
705
|
trust_score=raw_trust,
|
|
706
706
|
))
|
|
707
707
|
return results
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ---------------------------------------------------------------------------
|
|
711
|
+
# apply_channel_weights (LLD-03 §5.5 — module-level pure helper)
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
_CHANNEL_KEYS: tuple[str, ...] = (
|
|
716
|
+
"semantic", "bm25", "entity_graph", "temporal",
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def apply_channel_weights(
|
|
721
|
+
candidates: list[RetrievalResult],
|
|
722
|
+
weights: dict[str, float] | None,
|
|
723
|
+
) -> list[RetrievalResult]:
|
|
724
|
+
"""Re-score candidates under a bandit-chosen weight bundle.
|
|
725
|
+
|
|
726
|
+
Multiplies each candidate's ``channel_scores[ch]`` by ``weights[ch]``
|
|
727
|
+
and applies ``cross_encoder_bias`` to the final score. Preserves order;
|
|
728
|
+
callers reorder via ensemble_rerank.
|
|
729
|
+
|
|
730
|
+
Returns a NEW list with new ``RetrievalResult`` instances — never mutates
|
|
731
|
+
input. Unknown / missing weights default to 1.0.
|
|
732
|
+
|
|
733
|
+
Safe against ``weights=None`` (returns input unchanged) and empty lists.
|
|
734
|
+
"""
|
|
735
|
+
if not candidates or not weights:
|
|
736
|
+
return list(candidates)
|
|
737
|
+
|
|
738
|
+
ce_bias = float(weights.get("cross_encoder_bias", 1.0))
|
|
739
|
+
out: list[RetrievalResult] = []
|
|
740
|
+
for c in candidates:
|
|
741
|
+
original_cs = c.channel_scores or {}
|
|
742
|
+
new_cs: dict[str, float] = dict(original_cs)
|
|
743
|
+
base = 0.0
|
|
744
|
+
for ch in _CHANNEL_KEYS:
|
|
745
|
+
raw = float(original_cs.get(ch, 0.0))
|
|
746
|
+
w = float(weights.get(ch, 1.0))
|
|
747
|
+
scaled = raw * w
|
|
748
|
+
new_cs[ch] = scaled
|
|
749
|
+
base += scaled
|
|
750
|
+
new_score = (base if base > 0.0 else float(c.score)) * ce_bias
|
|
751
|
+
out.append(RetrievalResult(
|
|
752
|
+
fact=c.fact,
|
|
753
|
+
score=new_score,
|
|
754
|
+
channel_scores=new_cs,
|
|
755
|
+
confidence=c.confidence,
|
|
756
|
+
evidence_chain=c.evidence_chain,
|
|
757
|
+
trust_score=c.trust_score,
|
|
758
|
+
))
|
|
759
|
+
return out
|
|
@@ -219,14 +219,14 @@ def create_app() -> FastAPI:
|
|
|
219
219
|
@application.get("/health")
|
|
220
220
|
async def health_check():
|
|
221
221
|
"""Health check."""
|
|
222
|
-
from datetime import datetime
|
|
222
|
+
from datetime import datetime, timezone
|
|
223
223
|
engine = application.state.engine
|
|
224
224
|
return {
|
|
225
225
|
"status": "healthy",
|
|
226
226
|
"version": SLM_VERSION,
|
|
227
227
|
"engine": "initialized" if engine else "unavailable",
|
|
228
228
|
"database": "connected" if DB_PATH.exists() else "missing",
|
|
229
|
-
"timestamp": datetime.now().isoformat(),
|
|
229
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
@application.on_event("startup")
|
|
@@ -0,0 +1,140 @@
|
|
|
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.22 — LLD-03 §3.5 + §3.6
|
|
4
|
+
|
|
5
|
+
"""Background schedulers for the v3.4.22 contextual bandit.
|
|
6
|
+
|
|
7
|
+
Two asyncio tasks, both registered in the daemon lifespan:
|
|
8
|
+
|
|
9
|
+
1. Reward-proxy settler — every 60 s (``SLM_BANDIT_REWARD_WINDOW_SEC``),
|
|
10
|
+
calls ``reward_proxy.settle_stale_plays`` for the configured profile(s).
|
|
11
|
+
2. Retention sweep — every 24 h
|
|
12
|
+
(``SLM_BANDIT_PLAYS_RETENTION_INTERVAL_SEC``), calls
|
|
13
|
+
``bandit.retention_sweep`` with the configured horizon
|
|
14
|
+
(``SLM_BANDIT_PLAYS_RETENTION_DAYS``, default 7).
|
|
15
|
+
|
|
16
|
+
Both honour ``SLM_BANDIT_DISABLED=1`` (caller checks before scheduling).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_REWARD_INTERVAL = float(
|
|
30
|
+
os.environ.get("SLM_BANDIT_REWARD_WINDOW_SEC", "60"),
|
|
31
|
+
)
|
|
32
|
+
_RETENTION_INTERVAL = float(
|
|
33
|
+
os.environ.get("SLM_BANDIT_PLAYS_RETENTION_INTERVAL_SEC", "86400"),
|
|
34
|
+
)
|
|
35
|
+
_RETENTION_DAYS = int(
|
|
36
|
+
os.environ.get("SLM_BANDIT_PLAYS_RETENTION_DAYS", "7"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _learning_db(config: Any) -> Path:
|
|
41
|
+
if config is not None:
|
|
42
|
+
cand = getattr(config, "learning_db_path", None)
|
|
43
|
+
if cand is not None:
|
|
44
|
+
return Path(cand)
|
|
45
|
+
return Path.home() / ".superlocalmemory" / "learning.db"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _memory_db(config: Any) -> Path:
|
|
49
|
+
if config is not None:
|
|
50
|
+
cand = getattr(config, "db_path", None)
|
|
51
|
+
if cand is not None:
|
|
52
|
+
return Path(cand)
|
|
53
|
+
return Path.home() / ".superlocalmemory" / "memory.db"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _profile_id(config: Any) -> str:
|
|
57
|
+
if config is not None:
|
|
58
|
+
pid = getattr(config, "default_profile", None)
|
|
59
|
+
if isinstance(pid, str) and pid:
|
|
60
|
+
return pid
|
|
61
|
+
return "default"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _reward_proxy_loop(
|
|
65
|
+
learning_db: Path, memory_db: Path, profile_id: str,
|
|
66
|
+
interval_sec: float,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Run the proxy settler on a steady interval. Never raises."""
|
|
69
|
+
from superlocalmemory.learning.reward_proxy import settle_stale_plays
|
|
70
|
+
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
await asyncio.sleep(interval_sec)
|
|
74
|
+
# The settler is synchronous + fast; run in a thread to avoid
|
|
75
|
+
# blocking the event loop on unusual DB lock stalls.
|
|
76
|
+
n = await asyncio.to_thread(
|
|
77
|
+
settle_stale_plays,
|
|
78
|
+
profile_id, learning_db, memory_db,
|
|
79
|
+
)
|
|
80
|
+
if n:
|
|
81
|
+
logger.debug("bandit.reward_proxy settled=%d", n)
|
|
82
|
+
except asyncio.CancelledError: # pragma: no cover — lifecycle
|
|
83
|
+
raise
|
|
84
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
85
|
+
logger.warning("bandit.reward_proxy loop: %s", exc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _retention_loop(
|
|
89
|
+
learning_db: Path, interval_sec: float, retention_days: int,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Run retention_sweep on a 24h cadence. Never raises."""
|
|
92
|
+
from superlocalmemory.learning.bandit import retention_sweep
|
|
93
|
+
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
await asyncio.sleep(interval_sec)
|
|
97
|
+
deleted = await asyncio.to_thread(
|
|
98
|
+
retention_sweep, learning_db, retention_days,
|
|
99
|
+
)
|
|
100
|
+
logger.info(
|
|
101
|
+
"bandit_plays_retention_sweep tick: deleted=%d", deleted,
|
|
102
|
+
)
|
|
103
|
+
except asyncio.CancelledError: # pragma: no cover — lifecycle
|
|
104
|
+
raise
|
|
105
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
106
|
+
logger.warning("bandit.retention loop: %s", exc)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def schedule_bandit_loops(application: Any, config: Any) -> None:
|
|
110
|
+
"""Register both background tasks with the FastAPI app state.
|
|
111
|
+
|
|
112
|
+
Tasks are stored on ``application.state.bandit_tasks`` so the daemon's
|
|
113
|
+
shutdown path can cancel them cleanly (if added).
|
|
114
|
+
"""
|
|
115
|
+
learning = _learning_db(config)
|
|
116
|
+
memory = _memory_db(config)
|
|
117
|
+
profile = _profile_id(config)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
loop = asyncio.get_event_loop()
|
|
121
|
+
except RuntimeError: # pragma: no cover — defensive
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
tasks = []
|
|
125
|
+
tasks.append(loop.create_task(
|
|
126
|
+
_reward_proxy_loop(learning, memory, profile, _REWARD_INTERVAL),
|
|
127
|
+
))
|
|
128
|
+
tasks.append(loop.create_task(
|
|
129
|
+
_retention_loop(learning, _RETENTION_INTERVAL, _RETENTION_DAYS),
|
|
130
|
+
))
|
|
131
|
+
if hasattr(application, "state"):
|
|
132
|
+
application.state.bandit_tasks = tasks
|
|
133
|
+
logger.info(
|
|
134
|
+
"bandit loops scheduled: reward=%.0fs, retention=%.0fs, "
|
|
135
|
+
"retention_days=%d",
|
|
136
|
+
_REWARD_INTERVAL, _RETENTION_INTERVAL, _RETENTION_DAYS,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = ("schedule_bandit_loops",)
|
|
@@ -0,0 +1,11 @@
|
|
|
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.22 — LLD-04 §4.2
|
|
4
|
+
|
|
5
|
+
"""FastAPI middleware — strict security headers for the Brain UI (LLD-04 v2).
|
|
6
|
+
|
|
7
|
+
The existing ``server/security_middleware.py`` is kept for legacy routes
|
|
8
|
+
that still rely on permissive CSP (``'unsafe-inline'`` + CDNs). The
|
|
9
|
+
middleware in this subpackage enforces the v3.4.22 policy: no inline
|
|
10
|
+
scripts / styles, no nonces, no CDN sources.
|
|
11
|
+
"""
|