superlocalmemory 3.4.18 → 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 +35 -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/embeddings.py +8 -2
- 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/retrieval/reranker.py +4 -2
- 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
|
@@ -11,7 +11,7 @@ SQLite is the single source of truth for profiles. profiles.json
|
|
|
11
11
|
is kept in sync as a cache for backward compatibility.
|
|
12
12
|
"""
|
|
13
13
|
import logging
|
|
14
|
-
from datetime import datetime
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
15
|
|
|
16
16
|
from fastapi import APIRouter, HTTPException
|
|
17
17
|
|
|
@@ -106,7 +106,7 @@ async def switch_profile(name: str):
|
|
|
106
106
|
# Update last_used in profiles.json
|
|
107
107
|
json_config = _load_profiles_json()
|
|
108
108
|
if name in json_config.get('profiles', {}):
|
|
109
|
-
json_config['profiles'][name]['last_used'] = datetime.now().isoformat()
|
|
109
|
+
json_config['profiles'][name]['last_used'] = datetime.now(timezone.utc).isoformat()
|
|
110
110
|
_save_profiles_json(json_config)
|
|
111
111
|
|
|
112
112
|
count = _get_memory_count(name)
|
|
@@ -115,7 +115,7 @@ async def switch_profile(name: str):
|
|
|
115
115
|
await ws_manager.broadcast({
|
|
116
116
|
"type": "profile_switched", "profile": name,
|
|
117
117
|
"previous": previous, "memory_count": count,
|
|
118
|
-
"timestamp": datetime.now().isoformat(),
|
|
118
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
119
119
|
})
|
|
120
120
|
|
|
121
121
|
return {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""GET /internal/token — serve install token to the local dashboard.
|
|
2
|
+
|
|
3
|
+
Non-technical user fix: the browser dashboard runs on the same machine
|
|
4
|
+
that owns the install-token file. Forcing the user to open a terminal
|
|
5
|
+
and paste ``cat ~/.superlocalmemory/.install_token`` into a browser
|
|
6
|
+
prompt is pure UX friction with no real security gain — anyone who can
|
|
7
|
+
open the dashboard can read the token file (both are user-owned on
|
|
8
|
+
loopback). Non-browser clients (Cursor, Antigravity, Copilot, MCP, CLI)
|
|
9
|
+
continue to read the token file directly and send ``X-Install-Token``
|
|
10
|
+
on their requests; their security model is unchanged.
|
|
11
|
+
|
|
12
|
+
Gates (narrower than prewarm's 4):
|
|
13
|
+
1. Loopback-only client (127.0.0.1 / ::1).
|
|
14
|
+
2. Origin header, if present, must be a loopback URL.
|
|
15
|
+
3. No install-token requirement — this endpoint PROVIDES the token.
|
|
16
|
+
|
|
17
|
+
On gate failure or unreadable token file, responds with a fixed-tag
|
|
18
|
+
error and non-200 status. Never echoes attacker-supplied material.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Request
|
|
27
|
+
from fastapi.responses import JSONResponse
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(tags=["internal"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_ALLOWED_ORIGIN_PREFIXES = (
|
|
35
|
+
"http://127.0.0.1",
|
|
36
|
+
"http://localhost",
|
|
37
|
+
"http://[::1]",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _origin_is_loopback(origin: str) -> bool:
|
|
42
|
+
"""Return True iff ``origin`` is absent or a loopback URL."""
|
|
43
|
+
if not origin:
|
|
44
|
+
return True
|
|
45
|
+
return any(origin.startswith(p) for p in _ALLOWED_ORIGIN_PREFIXES)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("/internal/token")
|
|
49
|
+
async def get_token(request: Request) -> JSONResponse:
|
|
50
|
+
"""Return the install token for browser-based local dashboard use."""
|
|
51
|
+
try:
|
|
52
|
+
from superlocalmemory.core.security_primitives import (
|
|
53
|
+
_install_token_path,
|
|
54
|
+
)
|
|
55
|
+
from superlocalmemory.hooks.prewarm_auth import is_loopback
|
|
56
|
+
except Exception as exc: # pragma: no cover
|
|
57
|
+
logger.debug("token: primitives unimportable: %s", exc)
|
|
58
|
+
return JSONResponse({"error": "server_error"}, status_code=500)
|
|
59
|
+
|
|
60
|
+
client_host = request.client.host if request.client else ""
|
|
61
|
+
if not is_loopback(client_host):
|
|
62
|
+
return JSONResponse({"error": "loopback only"}, status_code=403)
|
|
63
|
+
|
|
64
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
65
|
+
origin = headers.get("origin", "")
|
|
66
|
+
if not _origin_is_loopback(origin):
|
|
67
|
+
return JSONResponse(
|
|
68
|
+
{"error": "origin not allowed"}, status_code=403,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
tok_path = _install_token_path()
|
|
73
|
+
tok = Path(tok_path).read_text(encoding="utf-8").strip()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
logger.debug("token: file read failed: %s", exc)
|
|
76
|
+
return JSONResponse(
|
|
77
|
+
{"error": "token_unavailable"}, status_code=500,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not tok:
|
|
81
|
+
return JSONResponse(
|
|
82
|
+
{"error": "token_unavailable"}, status_code=500,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return JSONResponse({"token": tok})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ("router",)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Routes: /ws/updates
|
|
8
8
|
"""
|
|
9
9
|
from typing import Set
|
|
10
|
-
from datetime import datetime
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
13
13
|
|
|
@@ -49,7 +49,7 @@ async def websocket_updates(websocket: WebSocket):
|
|
|
49
49
|
await websocket.send_json({
|
|
50
50
|
"type": "connected",
|
|
51
51
|
"message": "WebSocket connection established",
|
|
52
|
-
"timestamp": datetime.now().isoformat(),
|
|
52
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
while True:
|
|
@@ -59,14 +59,14 @@ async def websocket_updates(websocket: WebSocket):
|
|
|
59
59
|
if data.get('type') == 'ping':
|
|
60
60
|
await websocket.send_json({
|
|
61
61
|
"type": "pong",
|
|
62
|
-
"timestamp": datetime.now().isoformat(),
|
|
62
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
elif data.get('type') == 'get_stats':
|
|
66
66
|
await websocket.send_json({
|
|
67
67
|
"type": "stats_update",
|
|
68
68
|
"message": "Use /api/stats endpoint for stats",
|
|
69
|
-
"timestamp": datetime.now().isoformat(),
|
|
69
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
except WebSocketDisconnect:
|
|
@@ -75,7 +75,7 @@ async def websocket_updates(websocket: WebSocket):
|
|
|
75
75
|
await websocket.send_json({
|
|
76
76
|
"type": "error",
|
|
77
77
|
"message": str(e),
|
|
78
|
-
"timestamp": datetime.now().isoformat(),
|
|
78
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
finally:
|
|
@@ -33,17 +33,23 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
|
33
33
|
# Enable browser XSS filter (legacy, but doesn't hurt)
|
|
34
34
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
35
35
|
|
|
36
|
-
# Content Security Policy
|
|
37
|
-
#
|
|
38
|
-
#
|
|
36
|
+
# Content Security Policy (v3.4.21 — vendored assets, no CDN hosts).
|
|
37
|
+
# All Bootstrap/D3/Sigma/graphology/Inter assets ship locally under
|
|
38
|
+
# /static/vendor/, so we drop every CDN host from the allow-list.
|
|
39
|
+
# 'unsafe-inline' stays on script-src/style-src for the legacy inline
|
|
40
|
+
# click handlers in index.html — migrating those to addEventListener
|
|
41
|
+
# is tracked as a separate backlog item. img-src drops the https:
|
|
42
|
+
# wildcard now that nothing remote loads.
|
|
39
43
|
csp_directives = [
|
|
40
44
|
"default-src 'self'",
|
|
41
|
-
"script-src 'self' 'unsafe-inline'
|
|
42
|
-
"style-src 'self' 'unsafe-inline'
|
|
43
|
-
"font-src 'self'
|
|
44
|
-
"img-src 'self' data:
|
|
45
|
+
"script-src 'self' 'unsafe-inline'",
|
|
46
|
+
"style-src 'self' 'unsafe-inline'",
|
|
47
|
+
"font-src 'self'",
|
|
48
|
+
"img-src 'self' data:",
|
|
45
49
|
"connect-src 'self' ws://localhost:* ws://127.0.0.1:*",
|
|
46
50
|
"frame-ancestors 'none'",
|
|
51
|
+
"base-uri 'self'",
|
|
52
|
+
"form-action 'self'",
|
|
47
53
|
]
|
|
48
54
|
response.headers["Content-Security-Policy"] = "; ".join(csp_directives)
|
|
49
55
|
|
|
@@ -20,7 +20,7 @@ All route handlers live in routes/ directory:
|
|
|
20
20
|
import logging
|
|
21
21
|
import sys
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from datetime import datetime
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
24
|
|
|
25
25
|
logger = logging.getLogger(__name__)
|
|
26
26
|
|
|
@@ -201,7 +201,7 @@ def create_app() -> FastAPI:
|
|
|
201
201
|
"status": "healthy",
|
|
202
202
|
"version": SLM_VERSION,
|
|
203
203
|
"database": "connected" if DB_PATH.exists() else "missing",
|
|
204
|
-
"timestamp": datetime.now().isoformat(),
|
|
204
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
# ========================================================================
|
|
@@ -233,6 +233,90 @@ async def lifespan(application: FastAPI):
|
|
|
233
233
|
engine = None
|
|
234
234
|
config = None
|
|
235
235
|
|
|
236
|
+
# H-21 (Stage 8) — first-boot-after-upgrade notice. Compare the cached
|
|
237
|
+
# version marker against the current package version; if they differ
|
|
238
|
+
# (fresh install or upgrade), log a one-time banner with a link to the
|
|
239
|
+
# CHANGELOG. Non-fatal; any filesystem error is swallowed.
|
|
240
|
+
try:
|
|
241
|
+
from pathlib import Path as _VP
|
|
242
|
+
try:
|
|
243
|
+
from importlib.metadata import version as _pkg_version
|
|
244
|
+
_slm_version = _pkg_version("superlocalmemory")
|
|
245
|
+
except Exception:
|
|
246
|
+
_slm_version = "unknown"
|
|
247
|
+
_version_marker = _VP.home() / ".superlocalmemory" / ".last_version"
|
|
248
|
+
_prev = None
|
|
249
|
+
if _version_marker.exists():
|
|
250
|
+
try:
|
|
251
|
+
_prev = _version_marker.read_text(encoding="utf-8").strip()
|
|
252
|
+
except OSError:
|
|
253
|
+
_prev = None
|
|
254
|
+
# S9-SKEP-15: the version marker is written AFTER the migration
|
|
255
|
+
# block succeeds (see below). A failed migration must NOT cause
|
|
256
|
+
# the next successful start to skip the upgrade banner — the
|
|
257
|
+
# banner is the operator's cue that a new version just landed.
|
|
258
|
+
_want_write_marker = _prev != _slm_version
|
|
259
|
+
if _want_write_marker:
|
|
260
|
+
if _prev is None:
|
|
261
|
+
logger.info(
|
|
262
|
+
"[slm] first boot on v%s — run `slm status` to see your "
|
|
263
|
+
"memory overview. Changelog: "
|
|
264
|
+
"https://github.com/qualixar/superlocalmemory/blob/main/CHANGELOG.md",
|
|
265
|
+
_slm_version,
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
logger.info(
|
|
269
|
+
"[slm] upgraded %s → %s. Data migrations run in a moment; "
|
|
270
|
+
"your 18k+ atomic facts are preserved. Changelog: "
|
|
271
|
+
"https://github.com/qualixar/superlocalmemory/blob/main/CHANGELOG.md",
|
|
272
|
+
_prev, _slm_version,
|
|
273
|
+
)
|
|
274
|
+
except Exception as _exc: # pragma: no cover — never block startup
|
|
275
|
+
logger.debug("version-banner skipped: %s", _exc)
|
|
276
|
+
_want_write_marker = False
|
|
277
|
+
_version_marker = None
|
|
278
|
+
_slm_version = None
|
|
279
|
+
|
|
280
|
+
# LLD-06 §7.3 / LLD-07 §4.1 — run additive schema migrations BEFORE
|
|
281
|
+
# engine init so later queries see the expected columns/tables.
|
|
282
|
+
# Non-fatal: any failure here is logged and the daemon still starts.
|
|
283
|
+
try:
|
|
284
|
+
from pathlib import Path as _P
|
|
285
|
+
from superlocalmemory.storage.migration_runner import apply_all
|
|
286
|
+
_home = _P.home() / ".superlocalmemory"
|
|
287
|
+
_learning_db = _home / "learning.db"
|
|
288
|
+
_memory_db = _home / "memory.db"
|
|
289
|
+
_result = apply_all(_learning_db, _memory_db)
|
|
290
|
+
_applied = _result.get("applied", [])
|
|
291
|
+
_failed = _result.get("failed", [])
|
|
292
|
+
if _applied:
|
|
293
|
+
logger.info("migrations applied: %s", _applied)
|
|
294
|
+
if _failed:
|
|
295
|
+
logger.warning("migrations failed (non-fatal): %s", _failed)
|
|
296
|
+
application.state.migration_result = _result
|
|
297
|
+
# S9-SKEP-15: only commit the new `.last_version` AFTER migrations
|
|
298
|
+
# complete with zero failures. A partial upgrade (schema didn't
|
|
299
|
+
# land) must retain the old marker so the next successful start
|
|
300
|
+
# still fires the upgrade banner — otherwise the operator loses
|
|
301
|
+
# the one signal that tells them a version just changed.
|
|
302
|
+
if (
|
|
303
|
+
_want_write_marker
|
|
304
|
+
and _version_marker is not None
|
|
305
|
+
and _slm_version is not None
|
|
306
|
+
and not _failed
|
|
307
|
+
):
|
|
308
|
+
try:
|
|
309
|
+
_version_marker.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
_version_marker.write_text(_slm_version, encoding="utf-8")
|
|
311
|
+
except OSError:
|
|
312
|
+
pass # non-fatal
|
|
313
|
+
except Exception as _exc:
|
|
314
|
+
logger.warning("migration runner crashed (non-fatal): %s", _exc)
|
|
315
|
+
application.state.migration_result = {
|
|
316
|
+
"applied": [], "skipped": [], "failed": [],
|
|
317
|
+
"details": {"_crash": str(_exc)},
|
|
318
|
+
}
|
|
319
|
+
|
|
236
320
|
try:
|
|
237
321
|
from superlocalmemory.core.config import SLMConfig
|
|
238
322
|
from superlocalmemory.core.engine import MemoryEngine
|
|
@@ -254,6 +338,47 @@ async def lifespan(application: FastAPI):
|
|
|
254
338
|
application.state.config = config
|
|
255
339
|
logger.info("Unified daemon: MemoryEngine initialized (mode=%s)", config.mode.value)
|
|
256
340
|
|
|
341
|
+
# LLD-07 §4 — deferred migrations (e.g. M006 reward column) need to
|
|
342
|
+
# run AFTER MemoryEngine.initialize() has bootstrapped runtime tables
|
|
343
|
+
# like action_outcomes. Non-fatal by contract.
|
|
344
|
+
try:
|
|
345
|
+
from superlocalmemory.storage.migration_runner import apply_deferred
|
|
346
|
+
_deferred = apply_deferred(_learning_db, _memory_db)
|
|
347
|
+
_d_applied = _deferred.get("applied", [])
|
|
348
|
+
_d_failed = _deferred.get("failed", [])
|
|
349
|
+
if _d_applied:
|
|
350
|
+
logger.info("deferred migrations applied: %s", _d_applied)
|
|
351
|
+
if _d_failed:
|
|
352
|
+
logger.warning(
|
|
353
|
+
"deferred migrations failed (non-fatal, trainer falls "
|
|
354
|
+
"back to position proxy): %s", _d_failed,
|
|
355
|
+
)
|
|
356
|
+
# Merge into the migration result already on app state so the
|
|
357
|
+
# dashboard sees one consolidated picture.
|
|
358
|
+
_mr = getattr(application.state, "migration_result", None) or {
|
|
359
|
+
"applied": [], "skipped": [], "failed": [], "details": {},
|
|
360
|
+
}
|
|
361
|
+
_mr.setdefault("applied", []).extend(_d_applied)
|
|
362
|
+
_mr.setdefault("skipped", []).extend(_deferred.get("skipped", []))
|
|
363
|
+
_mr.setdefault("failed", []).extend(_d_failed)
|
|
364
|
+
_mr.setdefault("details", {}).update(_deferred.get("details", {}))
|
|
365
|
+
application.state.migration_result = _mr
|
|
366
|
+
except Exception as _dexc: # pragma: no cover — defensive
|
|
367
|
+
logger.warning(
|
|
368
|
+
"deferred migration runner crashed (non-fatal): %s", _dexc,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# S9-DASH-02: start the outcome-queue worker so recall →
|
|
372
|
+
# pending_outcomes is actually produced. Before v3.4.21 this
|
|
373
|
+
# producer had zero callers and the closed-loop pipeline was
|
|
374
|
+
# dark. Worker drains at 250 ms cadence; one SQLite INSERT per
|
|
375
|
+
# event via EngagementRewardModel.record_recall.
|
|
376
|
+
try:
|
|
377
|
+
from superlocalmemory.learning.outcome_queue import start_worker
|
|
378
|
+
start_worker(_memory_db)
|
|
379
|
+
except Exception as _oqexc: # pragma: no cover — defensive
|
|
380
|
+
logger.debug("outcome_queue start failed (non-fatal): %s", _oqexc)
|
|
381
|
+
|
|
257
382
|
# Set up observe buffer
|
|
258
383
|
_observe_buffer.set_engine(engine)
|
|
259
384
|
|
|
@@ -332,6 +457,41 @@ async def lifespan(application: FastAPI):
|
|
|
332
457
|
if enable_legacy:
|
|
333
458
|
asyncio.create_task(_start_legacy_redirect(_DEFAULT_PORT, _LEGACY_PORT))
|
|
334
459
|
|
|
460
|
+
# V3.4.21 LLD-02: signal-worker background drainer (S8-SK-01 fix).
|
|
461
|
+
# Without this, ``signals.enqueue`` fills a bounded queue and drops
|
|
462
|
+
# silently after ~250 recalls — learning_signals never populates,
|
|
463
|
+
# Phase 3 never activates, the whole Living Brain stays cold.
|
|
464
|
+
if os.environ.get("SLM_SIGNALS_ENABLED", "1") != "0":
|
|
465
|
+
try:
|
|
466
|
+
from superlocalmemory.learning import signal_worker as _sw
|
|
467
|
+
from pathlib import Path as _P
|
|
468
|
+
_learning_db = _P.home() / ".superlocalmemory" / "learning.db"
|
|
469
|
+
_sw.start(_learning_db)
|
|
470
|
+
application.state.signal_worker_started = True
|
|
471
|
+
logger.info("signal_worker started on %s", _learning_db)
|
|
472
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
473
|
+
logger.warning("signal_worker failed to start: %s", exc)
|
|
474
|
+
application.state.signal_worker_started = False
|
|
475
|
+
|
|
476
|
+
# V3.4.21 LLD-05: cross-platform adapter sync loop
|
|
477
|
+
if os.environ.get("SLM_CROSS_PLATFORM_SYNC_DISABLED", "").lower() not in ("1", "true"):
|
|
478
|
+
try:
|
|
479
|
+
from superlocalmemory.cli.context_commands import build_default_adapters
|
|
480
|
+
from superlocalmemory.hooks.sync_loop import schedule as _schedule_sync
|
|
481
|
+
_schedule_sync(build_default_adapters())
|
|
482
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
483
|
+
logger.warning("cross-platform sync loop failed to start: %s", exc)
|
|
484
|
+
|
|
485
|
+
# V3.4.21 LLD-03: bandit reward proxy settler + retention sweep loops
|
|
486
|
+
if os.environ.get("SLM_BANDIT_DISABLED", "0") != "1":
|
|
487
|
+
try:
|
|
488
|
+
from superlocalmemory.server.bandit_loops import (
|
|
489
|
+
schedule_bandit_loops,
|
|
490
|
+
)
|
|
491
|
+
schedule_bandit_loops(application, config)
|
|
492
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
493
|
+
logger.warning("bandit loops failed to start: %s", exc)
|
|
494
|
+
|
|
335
495
|
global _start_time
|
|
336
496
|
_start_time = time.monotonic()
|
|
337
497
|
_last_activity = time.monotonic()
|
|
@@ -341,8 +501,110 @@ async def lifespan(application: FastAPI):
|
|
|
341
501
|
|
|
342
502
|
yield
|
|
343
503
|
|
|
344
|
-
#
|
|
504
|
+
# S9-W4 C2: symmetric shutdown. Prior version only flushed the
|
|
505
|
+
# observe-buffer + signal_worker + engine. The following long-lived
|
|
506
|
+
# subsystems lived on ``application.state`` but were never
|
|
507
|
+
# explicitly cancelled / joined, so uvicorn's
|
|
508
|
+
# ``timeout_graceful_shutdown=10`` silently killed live threads
|
|
509
|
+
# mid-commit: HealthMonitor probes, MeshBroker cleanup thread,
|
|
510
|
+
# bandit settler asyncio tasks, and the process-wide cost-log
|
|
511
|
+
# connection cache. A WAL commit interrupted mid-flight could
|
|
512
|
+
# leave ``evolution_llm_cost_log`` with torn rows.
|
|
513
|
+
#
|
|
514
|
+
# New policy: every subsystem that stored a handle on
|
|
515
|
+
# ``application.state`` MUST be stopped here, in reverse start
|
|
516
|
+
# order. Each stop is wrapped in try/except so one failure does
|
|
517
|
+
# not skip the rest.
|
|
345
518
|
_observe_buffer.flush_sync()
|
|
519
|
+
|
|
520
|
+
# S9-DASH-02: stop outcome-queue worker (final drain on graceful
|
|
521
|
+
# shutdown). Any events left unpersisted are logged but not
|
|
522
|
+
# replayed — signal capture is not load-bearing on correctness.
|
|
523
|
+
try:
|
|
524
|
+
from superlocalmemory.learning.outcome_queue import stop_worker
|
|
525
|
+
_oq_remaining = stop_worker(timeout_s=2.0)
|
|
526
|
+
if _oq_remaining:
|
|
527
|
+
logger.info(
|
|
528
|
+
"outcome_queue shutdown: %d events dropped on flush",
|
|
529
|
+
_oq_remaining,
|
|
530
|
+
)
|
|
531
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
532
|
+
logger.warning("outcome_queue stop failed: %s", exc)
|
|
533
|
+
|
|
534
|
+
# Cancel bandit asyncio tasks (LLD-03). ``bandit_loops`` stashes
|
|
535
|
+
# them at ``application.state.bandit_tasks``; if the attr is
|
|
536
|
+
# missing we skip.
|
|
537
|
+
_bandit_tasks = getattr(application.state, "bandit_tasks", None)
|
|
538
|
+
if _bandit_tasks:
|
|
539
|
+
try:
|
|
540
|
+
for _t in _bandit_tasks:
|
|
541
|
+
try:
|
|
542
|
+
_t.cancel()
|
|
543
|
+
except Exception: # pragma: no cover
|
|
544
|
+
pass
|
|
545
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
546
|
+
logger.warning("bandit_tasks cancel failed: %s", exc)
|
|
547
|
+
|
|
548
|
+
# Stop HealthMonitor (health_monitor.py owns a daemon thread).
|
|
549
|
+
_health = getattr(application.state, "health_monitor", None)
|
|
550
|
+
if _health is not None:
|
|
551
|
+
try:
|
|
552
|
+
stop_fn = getattr(_health, "stop", None)
|
|
553
|
+
if callable(stop_fn):
|
|
554
|
+
stop_fn()
|
|
555
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
556
|
+
logger.warning("health_monitor stop failed: %s", exc)
|
|
557
|
+
|
|
558
|
+
# Stop MeshBroker cleanup thread.
|
|
559
|
+
_mesh = getattr(application.state, "mesh_broker", None)
|
|
560
|
+
if _mesh is not None:
|
|
561
|
+
try:
|
|
562
|
+
stop_fn = getattr(_mesh, "stop_cleanup", None)
|
|
563
|
+
if callable(stop_fn):
|
|
564
|
+
stop_fn()
|
|
565
|
+
else: # pragma: no cover — older broker versions
|
|
566
|
+
stop_fn = getattr(_mesh, "stop", None)
|
|
567
|
+
if callable(stop_fn):
|
|
568
|
+
stop_fn()
|
|
569
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
570
|
+
logger.warning("mesh_broker stop failed: %s", exc)
|
|
571
|
+
|
|
572
|
+
# LLD-02 SW3: flush pending signals to DB before closing. Bounded 3 s
|
|
573
|
+
# to keep daemon shutdown snappy; drops + counts anything unwritten.
|
|
574
|
+
if getattr(application.state, "signal_worker_started", False):
|
|
575
|
+
try:
|
|
576
|
+
from superlocalmemory.learning import signal_worker as _sw
|
|
577
|
+
_sw.stop(timeout=3.0)
|
|
578
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
579
|
+
logger.warning("signal_worker shutdown flush failed: %s", exc)
|
|
580
|
+
|
|
581
|
+
# Close the process-wide evolution cost-log connection cache
|
|
582
|
+
# BEFORE engine.close so fsyncs land under our own control, not
|
|
583
|
+
# under uvicorn's SIGTERM timeout. ``_close_cost_conns`` is
|
|
584
|
+
# idempotent — the atexit hook is still registered but won't
|
|
585
|
+
# re-close since the cache is cleared.
|
|
586
|
+
try:
|
|
587
|
+
from superlocalmemory.evolution import llm_dispatch as _ld
|
|
588
|
+
_ld._close_cost_conns()
|
|
589
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
590
|
+
logger.warning("evolution cost-conn cache close failed: %s", exc)
|
|
591
|
+
|
|
592
|
+
# Drop the trigram cache conn symmetrically.
|
|
593
|
+
try:
|
|
594
|
+
from superlocalmemory.learning import trigram_index as _ti
|
|
595
|
+
_ti._reset_cache_conn()
|
|
596
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
597
|
+
logger.warning("trigram cache conn close failed: %s", exc)
|
|
598
|
+
|
|
599
|
+
# Flush the perf-log fd explicitly (the atexit hook still fires
|
|
600
|
+
# but explicit close here is cheap insurance against uvicorn
|
|
601
|
+
# killing the process before atexit runs).
|
|
602
|
+
try:
|
|
603
|
+
from superlocalmemory.hooks._outcome_common import _perf_log_flush
|
|
604
|
+
_perf_log_flush()
|
|
605
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
606
|
+
logger.warning("perf_log flush failed: %s", exc)
|
|
607
|
+
|
|
346
608
|
if engine is not None:
|
|
347
609
|
try:
|
|
348
610
|
engine.close()
|
|
@@ -408,6 +670,51 @@ def create_app() -> FastAPI:
|
|
|
408
670
|
except ImportError:
|
|
409
671
|
pass
|
|
410
672
|
|
|
673
|
+
# -- Brain route (LLD-04 v2: /api/v3/brain + deprecated shims) --
|
|
674
|
+
try:
|
|
675
|
+
from superlocalmemory.server.routes.brain import (
|
|
676
|
+
router as brain_router,
|
|
677
|
+
)
|
|
678
|
+
from superlocalmemory.server.middleware.security_headers import (
|
|
679
|
+
SecurityHeadersMiddleware as StrictSecurityHeadersMiddleware,
|
|
680
|
+
)
|
|
681
|
+
application.include_router(brain_router)
|
|
682
|
+
# Strict CSP / XFO / XCTO / Referrer-Policy — applies to every
|
|
683
|
+
# response including the Brain route. Added as the outermost
|
|
684
|
+
# middleware so it overrides the legacy security_middleware's
|
|
685
|
+
# looser CSP on requests that pass through this strict wall.
|
|
686
|
+
application.add_middleware(StrictSecurityHeadersMiddleware)
|
|
687
|
+
except ImportError as exc: # pragma: no cover — defensive wiring
|
|
688
|
+
logger.warning("brain router not wired: %s", exc)
|
|
689
|
+
|
|
690
|
+
# -- Prewarm route (LLD-01 §4.4 — S8-SK-02 fix) --
|
|
691
|
+
# POST /internal/prewarm populates active_brain_cache after every
|
|
692
|
+
# tool_use. Without this handler, the async hook POSTs to a 404 and
|
|
693
|
+
# the cache never gets populated, which made every UserPromptSubmit
|
|
694
|
+
# a structural miss. All 4 auth gates applied inside the route.
|
|
695
|
+
try:
|
|
696
|
+
from superlocalmemory.server.routes.prewarm import (
|
|
697
|
+
router as prewarm_router,
|
|
698
|
+
)
|
|
699
|
+
application.include_router(prewarm_router)
|
|
700
|
+
except ImportError as exc: # pragma: no cover — defensive wiring
|
|
701
|
+
logger.warning("prewarm router not wired: %s", exc)
|
|
702
|
+
|
|
703
|
+
# -- Token route — auto-inject install token into the local dashboard --
|
|
704
|
+
# GET /internal/token returns the install token to loopback+origin-
|
|
705
|
+
# scoped browser callers so brain.js (and any future token-gated
|
|
706
|
+
# dashboard fetch) can include X-Install-Token without ever asking
|
|
707
|
+
# the non-technical user to paste it. Non-browser clients (MCP, CLI,
|
|
708
|
+
# IDE adapters) keep reading ~/.superlocalmemory/.install_token
|
|
709
|
+
# directly and sending the header themselves.
|
|
710
|
+
try:
|
|
711
|
+
from superlocalmemory.server.routes.token import (
|
|
712
|
+
router as token_router,
|
|
713
|
+
)
|
|
714
|
+
application.include_router(token_router)
|
|
715
|
+
except ImportError as exc: # pragma: no cover — defensive wiring
|
|
716
|
+
logger.warning("token router not wired: %s", exc)
|
|
717
|
+
|
|
411
718
|
# -- Daemon-specific routes --
|
|
412
719
|
_register_daemon_routes(application)
|
|
413
720
|
|
|
@@ -427,9 +734,18 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
427
734
|
_write_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
|
428
735
|
_read_limiter = RateLimiter(max_requests=120, window_seconds=60)
|
|
429
736
|
|
|
737
|
+
# S9-DASH-09: loopback (127.0.0.1 / ::1) is always the dashboard
|
|
738
|
+
# itself — it legitimately makes many rapid reads (Brain + tabs +
|
|
739
|
+
# polling). Rate-limiting our own UI produces 429s that cascade
|
|
740
|
+
# into blank panels. CORS already restricts origins to localhost,
|
|
741
|
+
# so we don't lose the anti-abuse posture for external callers.
|
|
742
|
+
_LOOPBACK_IPS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
743
|
+
|
|
430
744
|
@application.middleware("http")
|
|
431
745
|
async def rate_limit_middleware(request, call_next):
|
|
432
746
|
client_ip = request.client.host if request.client else "unknown"
|
|
747
|
+
if client_ip in _LOOPBACK_IPS:
|
|
748
|
+
return await call_next(request)
|
|
433
749
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
434
750
|
limiter = _write_limiter if is_write else _read_limiter
|
|
435
751
|
allowed, remaining = limiter.is_allowed(client_ip)
|
|
@@ -586,14 +902,30 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
586
902
|
}
|
|
587
903
|
|
|
588
904
|
@application.get("/recall")
|
|
589
|
-
async def recall(
|
|
905
|
+
async def recall(
|
|
906
|
+
request: Request,
|
|
907
|
+
q: str = "", query: str = "", limit: int = 20,
|
|
908
|
+
session_id: str = "",
|
|
909
|
+
):
|
|
590
910
|
_update_activity()
|
|
591
911
|
search_query = q or query # Accept both ?q= and ?query= for compatibility
|
|
592
912
|
engine = _get_engine_or_503()
|
|
593
913
|
if not search_query:
|
|
594
914
|
return {"results": [], "count": 0, "query_type": "none", "retrieval_time_ms": 0}
|
|
915
|
+
# S9-DASH-02: session_id for the outcome-queue producer.
|
|
916
|
+
# Priority: ?session_id= > X-SLM-Session-Id header > synthetic
|
|
917
|
+
# "http:<ts>". Without a session_id the recall still works
|
|
918
|
+
# (outcome just can't be hook-matched).
|
|
919
|
+
effective_sid = session_id
|
|
920
|
+
if not effective_sid:
|
|
921
|
+
effective_sid = request.headers.get("X-SLM-Session-Id", "")
|
|
922
|
+
if not effective_sid:
|
|
923
|
+
import time as _t
|
|
924
|
+
effective_sid = f"http:{int(_t.time() * 1000)}"
|
|
595
925
|
try:
|
|
596
|
-
response = engine.recall(
|
|
926
|
+
response = engine.recall(
|
|
927
|
+
search_query, limit=limit, session_id=effective_sid,
|
|
928
|
+
)
|
|
597
929
|
results = [
|
|
598
930
|
{
|
|
599
931
|
"content": r.fact.content,
|