superlocalmemory 3.4.19 → 3.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -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
- # Note: 'unsafe-inline' is needed for Bootstrap and inline scripts
38
- # For production, consider moving inline scripts to separate files
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' https://cdn.jsdelivr.net https://unpkg.com https://d3js.org",
42
- "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com",
43
- "font-src 'self' https://cdn.jsdelivr.net",
44
- "img-src 'self' data: https:",
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
- # Shutdown
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(q: str = "", query: str = "", limit: int = 20):
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(search_query, limit=limit)
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,