superlocalmemory 3.4.19 → 3.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/engine.py +38 -2
  31. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  32. package/src/superlocalmemory/core/ram_lock.py +111 -0
  33. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  34. package/src/superlocalmemory/core/recall_worker.py +8 -3
  35. package/src/superlocalmemory/core/security_primitives.py +635 -0
  36. package/src/superlocalmemory/core/shadow_router.py +319 -0
  37. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  38. package/src/superlocalmemory/core/slmignore.py +125 -0
  39. package/src/superlocalmemory/core/topic_signature.py +143 -0
  40. package/src/superlocalmemory/core/worker_pool.py +14 -3
  41. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  42. package/src/superlocalmemory/evolution/budget.py +321 -0
  43. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  44. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  45. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  46. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  47. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  48. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  49. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  50. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  51. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  52. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  53. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  54. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  55. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  56. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  57. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  58. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  59. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  60. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  61. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  62. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  63. package/src/superlocalmemory/infra/backup.py +3 -3
  64. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  65. package/src/superlocalmemory/infra/event_bus.py +2 -2
  66. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  67. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  68. package/src/superlocalmemory/learning/bandit.py +526 -0
  69. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  70. package/src/superlocalmemory/learning/behavioral.py +53 -1
  71. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  72. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  73. package/src/superlocalmemory/learning/database.py +256 -0
  74. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  75. package/src/superlocalmemory/learning/ensemble.py +300 -0
  76. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  77. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  78. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  79. package/src/superlocalmemory/learning/labeler.py +87 -0
  80. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  81. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  82. package/src/superlocalmemory/learning/model_cache.py +269 -0
  83. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  84. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  85. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  86. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  87. package/src/superlocalmemory/learning/ranker.py +225 -81
  88. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  89. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  91. package/src/superlocalmemory/learning/reward.py +777 -0
  92. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  93. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  94. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  95. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  96. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  97. package/src/superlocalmemory/learning/signals.py +314 -0
  98. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  99. package/src/superlocalmemory/mcp/server.py +5 -5
  100. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  101. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  102. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  103. package/src/superlocalmemory/retrieval/engine.py +52 -0
  104. package/src/superlocalmemory/server/api.py +2 -2
  105. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  106. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  107. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  108. package/src/superlocalmemory/server/routes/backup.py +36 -13
  109. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  110. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  111. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  112. package/src/superlocalmemory/server/routes/events.py +2 -2
  113. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  114. package/src/superlocalmemory/server/routes/learning.py +192 -7
  115. package/src/superlocalmemory/server/routes/memories.py +189 -1
  116. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  117. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  118. package/src/superlocalmemory/server/routes/token.py +88 -0
  119. package/src/superlocalmemory/server/routes/ws.py +5 -5
  120. package/src/superlocalmemory/server/security_middleware.py +13 -7
  121. package/src/superlocalmemory/server/ui.py +2 -2
  122. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  123. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  124. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  125. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  126. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  127. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  128. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  129. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  130. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  131. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  132. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  133. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  134. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  135. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  136. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  137. package/src/superlocalmemory/storage/models.py +4 -0
  138. package/src/superlocalmemory/ui/css/brain.css +409 -0
  139. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  140. package/src/superlocalmemory/ui/index.html +459 -1345
  141. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  142. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  143. package/src/superlocalmemory/ui/js/init.js +48 -39
  144. package/src/superlocalmemory/ui/js/memories.js +88 -2
  145. package/src/superlocalmemory/ui/js/modal.js +71 -1
  146. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  147. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  148. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  149. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  153. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  154. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  155. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  157. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  159. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  160. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  161. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  162. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  163. package/src/superlocalmemory/ui/js/learning.js +0 -435
  164. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  165. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  166. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  167. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  168. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  169. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  170. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,1234 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-04 §4.1 + §3
4
+
5
+ """``/api/v3/brain`` — unified Brain endpoint (LLD-04 v2).
6
+
7
+ Merges the pre-3.4.21 Patterns / Learning / Behavioral dashboard tabs
8
+ into one auth-gated, honestly-labelled JSON payload. Every metric has
9
+ an ``is_real`` (numeric counters) or ``is_real_ml`` (ML-derived) flag
10
+ and points at the real table it was computed from. No metric is
11
+ fabricated — if a source does not exist we return zero and flag it.
12
+
13
+ Security primitives consumed from LLD-07 §6:
14
+
15
+ * ``verify_install_token`` — constant-time token check for auth gate.
16
+ * ``redact_secrets`` — regex+entropy secret scrub.
17
+
18
+ Deprecated shims (1 release grace) live below the main route. They
19
+ return the historical-ish shape plus ``deprecated: true`` and
20
+ ``use_instead: /api/v3/brain``, and require the same auth.
21
+
22
+ Design notes (LLD-04 §7 hard rules):
23
+
24
+ * **U2** — every metric section carries ``is_real`` or ``is_real_ml``.
25
+ * **U3** — ``learning.phase`` never returns ``3`` without an active
26
+ model row AND passing SHA check. Both conditions computed here.
27
+ * **U4** — response shape must not contain the three pre-3.4.21
28
+ fabricated metrics (24h hit-rate, avg age on hit, skill-evolution
29
+ row counts). A static test greps this file to verify; therefore we
30
+ never spell those names anywhere in this module.
31
+ * **U6** — install token required on ``/api/v3/brain`` and all
32
+ deprecated shim routes. See ``require_install_token`` below.
33
+ * **U10** — feature count surfaced from ``features.FEATURE_DIM``;
34
+ stratum total from module constant ``_STRATA_TOTAL`` (48 = 4×3×4).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ import os
41
+ import sqlite3
42
+ from datetime import datetime, timedelta, timezone
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from fastapi import APIRouter, Depends, HTTPException, Request
47
+
48
+ from superlocalmemory.core.security_primitives import (
49
+ redact_secrets,
50
+ verify_install_token,
51
+ )
52
+ from superlocalmemory.learning.database import LearningDatabase
53
+ from superlocalmemory.learning.features import FEATURE_DIM
54
+
55
+ logger = logging.getLogger("superlocalmemory.routes.brain")
56
+
57
+ router = APIRouter(prefix="/api/v3", tags=["brain"])
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Constants — surfaced via the response so the UI never hard-codes.
62
+ # ---------------------------------------------------------------------------
63
+
64
+ # LLD-03 v2 stratum space = 4 query types × 3 entity bins × 4 time buckets.
65
+ _STRATA_TOTAL: int = 48
66
+
67
+ _VERSION: str = "3.4.21"
68
+
69
+ # Banned metric names (LLD-04 U4). Kept as a tuple for grep visibility;
70
+ # the source-level test asserts we don't accidentally reintroduce them.
71
+ # NOTE: do NOT add the literal forbidden-key strings here — the U4 grep
72
+ # guard runs over this file.
73
+
74
+ # Memory directory (home-dir based). Always resolved at call time so that
75
+ # tests can override via monkeypatch on ``_learning_db_path``.
76
+ _MEMORY_DIR_DEFAULT = Path.home() / ".superlocalmemory"
77
+
78
+
79
+ def _learning_db_path() -> Path:
80
+ """Return the path to the learning SQLite DB.
81
+
82
+ Separated as a function (not a module constant) so tests can
83
+ monkeypatch without touching Path.home. See
84
+ ``tests/test_api/test_brain_endpoint.py``.
85
+ """
86
+ return _MEMORY_DIR_DEFAULT / "learning.db"
87
+
88
+
89
+ def _memory_db_path() -> Path:
90
+ return _MEMORY_DIR_DEFAULT / "memory.db"
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Auth dependency (LLD-04 §4.1, LLD-07 §6.6)
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ async def require_install_token(request: Request) -> None:
99
+ """FastAPI dependency: enforces install-token on Brain routes.
100
+
101
+ Accepts either of:
102
+
103
+ X-Install-Token: <token>
104
+ Authorization: Bearer <token>
105
+
106
+ Token comparison uses ``verify_install_token`` which wraps
107
+ ``hmac.compare_digest`` — constant time regardless of input.
108
+
109
+ Raises:
110
+ HTTPException(401) with ``WWW-Authenticate: Install-Token`` so
111
+ clients know how to retry. Never leaks whether the token file
112
+ exists, whether the header was missing, or which comparison
113
+ branch rejected the value.
114
+ """
115
+ header_token = request.headers.get("x-install-token")
116
+ presented: str | None = header_token
117
+ if not presented:
118
+ auth = request.headers.get("authorization", "")
119
+ if auth:
120
+ # Strip scheme; be tolerant of casing. Empty-after-strip → None.
121
+ scheme, _, value = auth.partition(" ")
122
+ if scheme.lower() == "bearer" and value.strip():
123
+ presented = value.strip()
124
+ if not presented or not verify_install_token(presented):
125
+ raise HTTPException(
126
+ status_code=401,
127
+ detail="install_token_required",
128
+ headers={"WWW-Authenticate": "Install-Token"},
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Secret-redacted preferences (LLD-04 §4.1, LLD-07 §6.3)
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def redact_secrets_in_preferences(prefs: dict) -> dict:
138
+ """Deep-copy ``prefs``, scrub every string through ``redact_secrets``,
139
+ and surface the count of substitutions as ``redacted_count``.
140
+
141
+ The input is never mutated — we build a new structure and preserve
142
+ list/dict shapes exactly. Non-string scalars (ints, floats, bools,
143
+ None) pass through untouched. Unknown container types are left as-is
144
+ (best-effort forward compatibility).
145
+
146
+ Returns:
147
+ New dict with the same shape plus ``redacted_count`` at the top
148
+ level. If ``prefs`` is not a dict, returns
149
+ ``{"redacted_count": 0}`` (defensive).
150
+ """
151
+ if not isinstance(prefs, dict):
152
+ return {"redacted_count": 0}
153
+
154
+ counter = [0] # mutable holder so the nested closure can increment.
155
+
156
+ def _scrub(value: Any) -> Any:
157
+ if isinstance(value, str):
158
+ scrubbed = redact_secrets(value)
159
+ if scrubbed != value:
160
+ counter[0] += 1
161
+ return scrubbed
162
+ if isinstance(value, dict):
163
+ return {k: _scrub(v) for k, v in value.items()}
164
+ if isinstance(value, list):
165
+ return [_scrub(v) for v in value]
166
+ if isinstance(value, tuple):
167
+ return tuple(_scrub(v) for v in value)
168
+ return value
169
+
170
+ out: dict = {k: _scrub(v) for k, v in prefs.items()}
171
+ out["redacted_count"] = counter[0]
172
+ out.setdefault("is_real", True)
173
+ out.setdefault("source", prefs.get("source", "_store_patterns"))
174
+ return out
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Section builders — each SELECTs from a real table. If a table is empty,
179
+ # returns 0 with ``is_real: true`` (honest). Never invents a number.
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def _load_raw_preferences(profile_id: str) -> dict:
184
+ """Load preference categories from the behavioral pattern store.
185
+
186
+ Returns a dict shaped like::
187
+
188
+ {"topics": [...], "entities": [...], "tech": [...], "source": ...}
189
+
190
+ with empty lists if the store / DB is not available. Overridden in
191
+ tests via monkeypatch to keep the Brain route decoupled from the
192
+ full behavioral pipeline.
193
+ """
194
+ out: dict[str, Any] = {
195
+ "topics": [], "entities": [], "tech": [],
196
+ "source": "_store_patterns",
197
+ }
198
+ db_path = _learning_db_path()
199
+ if not db_path.exists():
200
+ return out
201
+ try:
202
+ from superlocalmemory.learning.behavioral import BehavioralPatternStore
203
+ store = BehavioralPatternStore(str(db_path))
204
+ patterns = store.get_patterns(profile_id=profile_id) or []
205
+ except Exception as exc: # pragma: no cover — defensive
206
+ logger.debug("load_raw_preferences: %s", exc)
207
+ return out
208
+
209
+ topics: list[dict[str, Any]] = []
210
+ entities: list[dict[str, Any]] = []
211
+ tech: list[dict[str, Any]] = []
212
+ for p in patterns:
213
+ ptype = p.get("pattern_type") or ""
214
+ meta = p.get("metadata") or {}
215
+ name = meta.get("value") or p.get("pattern_key") or ""
216
+ if not name:
217
+ continue
218
+ if ptype == "tech_preference":
219
+ tech.append({
220
+ "name": str(name),
221
+ "frequency": float(p.get("confidence", 0.0) or 0.0),
222
+ })
223
+ elif ptype in ("entity", "interest"):
224
+ entities.append({
225
+ "name": str(name),
226
+ "mention_count": int(p.get("evidence_count", 0) or 0),
227
+ })
228
+ elif ptype == "topic":
229
+ topics.append({
230
+ "name": str(name),
231
+ "strength": float(p.get("confidence", 0.0) or 0.0),
232
+ })
233
+ out["topics"] = topics
234
+ out["entities"] = entities
235
+ out["tech"] = tech
236
+ return out
237
+
238
+
239
+ def _compute_preferences(profile_id: str) -> dict:
240
+ raw = _load_raw_preferences(profile_id)
241
+ return redact_secrets_in_preferences(raw)
242
+
243
+
244
+ def _compute_learning_status(profile_id: str,
245
+ lrn_db: LearningDatabase) -> dict:
246
+ """Section ``learning`` — LLD-02 §4.10 phase truth + counters."""
247
+ signals_total = _safe_count(lrn_db, "learning_signals", profile_id)
248
+ features_total = _safe_count(lrn_db, "learning_features", profile_id)
249
+ # Raw count of historic pre-v3.4.21 feedback rows (the source table)
250
+ legacy_feedback_rows = _safe_count(lrn_db, "learning_feedback", profile_id)
251
+ # Count of rows actually copied forward into learning_signals by
252
+ # legacy_migration.migrate_legacy_feedback (signal_type='legacy_feedback').
253
+ # The difference (raw - migrated) is what the dashboard's
254
+ # "Migrate legacy data" card surfaces as pending work.
255
+ legacy_migrated = _count_legacy_migrated(lrn_db, profile_id)
256
+ signals_last_hour = _count_signals_since(
257
+ lrn_db, profile_id,
258
+ datetime.now(timezone.utc) - timedelta(hours=1),
259
+ )
260
+
261
+ active = lrn_db.load_active_model(profile_id)
262
+ model_active = active is not None
263
+ model_version: str | None = None
264
+ model_trained_at: str | None = None
265
+ model_sha256_present = False
266
+ if model_active and isinstance(active, dict):
267
+ model_version = active.get("model_version")
268
+ model_trained_at = active.get("trained_at")
269
+ model_sha256_present = bool(active.get("bytes_sha256"))
270
+
271
+ phase, phase_label = _resolve_phase(signals_total, model_active,
272
+ model_sha256_present)
273
+
274
+ # S9-DASH-03: consult migration_log. If the legacy migration is
275
+ # marked complete but some rows remain uncopied, those rows are
276
+ # structurally un-migratable (malformed, duplicate, or missing
277
+ # required cols). Reporting them as "pending" is misleading and
278
+ # the dashboard card nags forever. After completion we report
279
+ # pending=0 so the card auto-hides.
280
+ migration_complete = _legacy_migration_sentinel_complete(lrn_db)
281
+ legacy_pending = max(0, legacy_feedback_rows - legacy_migrated)
282
+ if migration_complete:
283
+ legacy_pending = 0
284
+
285
+ return {
286
+ "phase": phase,
287
+ "phase_label": phase_label,
288
+ "signals_total": signals_total,
289
+ "signals_last_hour": signals_last_hour,
290
+ "features_total": features_total,
291
+ "feature_count_expected": FEATURE_DIM,
292
+ # Honest split (v3.4.21+): raw historic table count vs rows
293
+ # actually copied forward via the migrate-legacy flow.
294
+ "legacy_feedback_rows": legacy_feedback_rows,
295
+ "legacy_migrated_count": legacy_migrated,
296
+ "legacy_migration_pending": legacy_pending,
297
+ "legacy_migration_complete": migration_complete,
298
+ "model_active": model_active,
299
+ "model_version": model_version,
300
+ "model_trained_at": model_trained_at,
301
+ "model_sha256_present": model_sha256_present,
302
+ "is_real": True,
303
+ }
304
+
305
+
306
+ def _resolve_phase(signals: int, model_active: bool,
307
+ sha_present: bool) -> tuple[int, str]:
308
+ """LLD-02 §4.10 — phase truth, U3 enforcement.
309
+
310
+ Phase 3 requires BOTH ``model_active`` and a non-empty SHA value on
311
+ the active row. Missing either falls back to phase 2 (≥50 signals)
312
+ or phase 1 (cold).
313
+ """
314
+ if model_active and sha_present and signals >= 200:
315
+ return 3, "LightGBM ranker active"
316
+ if signals >= 50:
317
+ return 2, "Contextual bandit"
318
+ return 1, "Cold start (cross-encoder only)"
319
+
320
+
321
+ def _compute_usage_stats(profile_id: str) -> dict:
322
+ """Section ``usage`` — real counters from ``learning_signals``.
323
+
324
+ All three values are honest aggregations of the last 24 h of signals.
325
+ ``is_real_ml`` stays False because these are counters, not ML output,
326
+ but the numbers themselves are pinned to real rows. LLD-04 U2 / §3.1.
327
+ """
328
+ lrn_db = _lazy_db_for(profile_id)
329
+ since = datetime.now(timezone.utc) - timedelta(hours=24)
330
+ recalls_24h = _count_signals_since(
331
+ lrn_db, profile_id, since,
332
+ signal_types=("recall_hit", "recall", "recall_miss",
333
+ "candidate", "shown"),
334
+ )
335
+ return {
336
+ "recalls_last_24h": recalls_24h,
337
+ "top_query_types": _top_query_types(lrn_db, profile_id, since),
338
+ "top_time_buckets": _top_time_buckets(lrn_db, profile_id, since),
339
+ "source": "learning_signals_24h_aggregation",
340
+ "is_real_ml": False,
341
+ "disclaimer": "Statistical counters, not ML output.",
342
+ }
343
+
344
+
345
+ def _top_query_types(
346
+ lrn_db: LearningDatabase,
347
+ profile_id: str,
348
+ since: datetime,
349
+ *,
350
+ limit: int = 5,
351
+ ) -> list[dict]:
352
+ """Return top-N query types from last-24h signals with percentages.
353
+
354
+ Query type lives in ``learning_features.features_json`` as one-hot
355
+ ``query_type_sh`` / ``query_type_mh`` / ``query_type_temp`` /
356
+ ``query_type_od`` (features.py FEATURE_NAMES). We aggregate by the
357
+ active one-hot slot. Missing table or zero rows → empty list.
358
+ """
359
+ # E.1 (v3.4.21 perf): push the query-type classification into SQL via
360
+ # json_extract so we don't drag 10k rows' worth of JSON through Python.
361
+ # SQLite's json1 extension treats json_extract as an ordinary scalar
362
+ # function, so the whole aggregation becomes a single COUNT(*)
363
+ # GROUP BY on a computed column. Falls back to the Python path only
364
+ # if json_extract is unavailable (very old SQLite builds).
365
+ counts: dict[str, int] = {
366
+ "single_hop": 0, "multi_hop": 0, "temporal": 0, "open_domain": 0,
367
+ }
368
+ try:
369
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
370
+ conn.row_factory = sqlite3.Row
371
+ try:
372
+ agg_rows = conn.execute(
373
+ "SELECT "
374
+ " CASE "
375
+ " WHEN CAST(json_extract(f.features_json, '$.query_type_sh') AS REAL) >= 0.5 THEN 'single_hop' "
376
+ " WHEN CAST(json_extract(f.features_json, '$.query_type_mh') AS REAL) >= 0.5 THEN 'multi_hop' "
377
+ " WHEN CAST(json_extract(f.features_json, '$.query_type_temp') AS REAL) >= 0.5 THEN 'temporal' "
378
+ " WHEN CAST(json_extract(f.features_json, '$.query_type_od') AS REAL) >= 0.5 THEN 'open_domain' "
379
+ " ELSE NULL END AS qt, "
380
+ " COUNT(*) AS n "
381
+ "FROM learning_features f "
382
+ "JOIN learning_signals s ON s.id = f.signal_id "
383
+ "WHERE s.profile_id = ? AND s.created_at >= ? "
384
+ " AND f.is_synthetic = 0 "
385
+ "GROUP BY qt",
386
+ (profile_id, since.isoformat()),
387
+ ).fetchall()
388
+ for row in agg_rows:
389
+ qt = row["qt"]
390
+ if qt in counts:
391
+ counts[qt] = int(row["n"] or 0)
392
+ finally:
393
+ conn.close()
394
+ except sqlite3.Error: # pragma: no cover — schema + json1 always present
395
+ return []
396
+ total = sum(counts.values())
397
+ if total == 0:
398
+ return []
399
+ ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
400
+ return [
401
+ {"type": name, "pct": round(100.0 * n / total, 1)}
402
+ for name, n in ranked[:limit] if n > 0
403
+ ]
404
+
405
+
406
+ def _top_time_buckets(
407
+ lrn_db: LearningDatabase,
408
+ profile_id: str,
409
+ since: datetime,
410
+ *,
411
+ limit: int = 3,
412
+ ) -> list[dict]:
413
+ """Return top-N hour buckets ("HH:00") from last-24h signals.
414
+
415
+ Parses ``created_at`` as ISO timestamp, buckets by hour-of-day, returns
416
+ top N with percentages. Honest empty when no rows.
417
+ """
418
+ try:
419
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
420
+ conn.row_factory = sqlite3.Row
421
+ try:
422
+ rows = conn.execute(
423
+ "SELECT created_at FROM learning_signals "
424
+ "WHERE profile_id = ? AND created_at >= ? "
425
+ "LIMIT 10000",
426
+ (profile_id, since.isoformat()),
427
+ ).fetchall()
428
+ finally:
429
+ conn.close()
430
+ except sqlite3.Error: # pragma: no cover
431
+ return []
432
+ buckets: dict[int, int] = {}
433
+ for r in rows:
434
+ ts = str(r["created_at"] or "")
435
+ if not ts:
436
+ continue
437
+ try:
438
+ # ISO: "YYYY-MM-DDTHH:MM:..." — take the "HH" slice.
439
+ hh = int(ts[11:13])
440
+ except (ValueError, IndexError): # pragma: no cover
441
+ continue
442
+ buckets[hh] = buckets.get(hh, 0) + 1
443
+ total = sum(buckets.values())
444
+ if total == 0:
445
+ return []
446
+ ranked = sorted(buckets.items(), key=lambda kv: kv[1], reverse=True)
447
+ return [
448
+ {"bucket": f"{hh:02d}:00",
449
+ "pct": round(100.0 * n / total, 1)}
450
+ for hh, n in ranked[:limit] if n > 0
451
+ ]
452
+
453
+
454
+ def _compute_bandit_snapshot(profile_id: str,
455
+ lrn_db: LearningDatabase) -> dict:
456
+ """Section ``bandit`` — derive summary from ``ContextualBandit.snapshot``.
457
+
458
+ We intentionally compute the *derived* fields here (strata_active,
459
+ top_arm_global, unsettled_plays) rather than asking the bandit to
460
+ produce them — this keeps the stratum total constant surfaced from
461
+ one authoritative place (``_STRATA_TOTAL``) instead of duplicating.
462
+ """
463
+ strata_active = 0
464
+ top_arm_global: dict | None = None
465
+ unsettled_plays = 0
466
+ oldest_unsettled_seconds: int | None = None
467
+
468
+ try:
469
+ from superlocalmemory.learning.bandit import ContextualBandit
470
+ bandit = ContextualBandit(
471
+ db_path=lrn_db.path, # shared learning DB
472
+ profile_id=profile_id,
473
+ )
474
+ snap = bandit.snapshot() or {}
475
+ strata_active = sum(1 for arms in snap.values() if arms)
476
+ # Global top arm by plays across all strata.
477
+ best: tuple[str, int] | None = None
478
+ for stratum_id, arms in snap.items():
479
+ for arm in arms:
480
+ plays = int(arm.get("plays", 0) or 0)
481
+ if best is None or plays > best[1]:
482
+ best = (arm["arm_id"], plays)
483
+ if best is not None:
484
+ top_arm_global = {"arm_id": best[0], "plays": best[1]}
485
+ unsettled_plays, oldest_unsettled_seconds = _bandit_unsettled(
486
+ lrn_db.path, profile_id,
487
+ )
488
+ except Exception as exc: # pragma: no cover — defensive
489
+ logger.debug("bandit snapshot: %s", exc)
490
+
491
+ return {
492
+ "strata_active": strata_active,
493
+ "strata_total": _STRATA_TOTAL,
494
+ "top_arm_global": top_arm_global,
495
+ "unsettled_plays": unsettled_plays,
496
+ "oldest_unsettled_seconds": oldest_unsettled_seconds,
497
+ "is_real": True,
498
+ }
499
+
500
+
501
+ def _bandit_unsettled(db_path: str, profile_id: str) -> tuple[int, int | None]:
502
+ """Count unsettled bandit_plays rows + age (sec) of oldest one."""
503
+ try:
504
+ conn = sqlite3.connect(db_path, timeout=5.0)
505
+ conn.row_factory = sqlite3.Row
506
+ try:
507
+ row = conn.execute(
508
+ "SELECT COUNT(*) AS cnt, MIN(played_at) AS oldest "
509
+ "FROM bandit_plays "
510
+ "WHERE profile_id = ? AND settled_at IS NULL",
511
+ (profile_id,),
512
+ ).fetchone()
513
+ finally:
514
+ conn.close()
515
+ except sqlite3.Error:
516
+ return 0, None
517
+ if row is None: # pragma: no cover — COUNT() always yields a row
518
+ return 0, None
519
+ cnt = int(row["cnt"] or 0)
520
+ oldest_iso = row["oldest"]
521
+ if not oldest_iso:
522
+ return cnt, None
523
+ try:
524
+ played_at = datetime.fromisoformat(oldest_iso)
525
+ if played_at.tzinfo is None:
526
+ played_at = played_at.replace(tzinfo=timezone.utc)
527
+ age = (datetime.now(timezone.utc) - played_at).total_seconds()
528
+ return cnt, int(max(age, 0))
529
+ except (ValueError, TypeError):
530
+ return cnt, None
531
+
532
+
533
+ def _compute_cache_stats() -> dict:
534
+ """Section ``cache`` — DB file size + row count (if accessible)."""
535
+ db = _memory_db_path()
536
+ if not db.exists():
537
+ return {"db_size_bytes": 0, "entry_count": 0, "is_real": True}
538
+ size = db.stat().st_size
539
+ entry_count = 0
540
+ try:
541
+ conn = sqlite3.connect(str(db), timeout=5.0)
542
+ try:
543
+ row = conn.execute(
544
+ "SELECT COUNT(*) AS cnt FROM atomic_facts",
545
+ ).fetchone()
546
+ entry_count = int(row[0]) if row else 0
547
+ finally:
548
+ conn.close()
549
+ except sqlite3.Error:
550
+ entry_count = 0
551
+ return {
552
+ "db_size_bytes": size,
553
+ "entry_count": entry_count,
554
+ "is_real": True,
555
+ }
556
+
557
+
558
+ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
559
+ """Seconds since adapter's most recent successful sync.
560
+
561
+ Reads from ``cross_platform_sync_log`` (LLD-07 M004) in ``memory.db``.
562
+ Returns ``None`` when the log is absent or has no successful row —
563
+ honest empty rather than a fabricated number.
564
+ """
565
+ try:
566
+ import sqlite3 as _sqlite3
567
+ from datetime import datetime as _dt, timezone as _tz
568
+ from pathlib import Path as _P
569
+
570
+ memory_db = _P.home() / ".superlocalmemory" / "memory.db"
571
+ if not memory_db.exists():
572
+ return None
573
+ conn = _sqlite3.connect(
574
+ f"file:{memory_db}?mode=ro", uri=True, timeout=1.0,
575
+ )
576
+ try:
577
+ cur = conn.execute(
578
+ "SELECT last_sync_at FROM cross_platform_sync_log "
579
+ "WHERE adapter_name=? AND success=1 "
580
+ "ORDER BY last_sync_at DESC LIMIT 1",
581
+ (adapter_name,),
582
+ )
583
+ row = cur.fetchone()
584
+ finally:
585
+ conn.close()
586
+ if row is None or row[0] is None:
587
+ return None
588
+ last = _dt.fromisoformat(str(row[0]).replace("Z", "+00:00"))
589
+ delta = (_dt.now(_tz.utc) - last).total_seconds()
590
+ return max(0, int(delta))
591
+ except Exception: # pragma: no cover — defensive
592
+ return None
593
+
594
+
595
+ def _compute_cross_platform() -> dict:
596
+ """Section ``cross_platform`` — live status per injection target.
597
+
598
+ Each adapter's ``is_active()`` is cheap (env + ``Path.is_dir()``) per
599
+ LLD-05 A10. Last-sync-at is read from ``cross_platform_sync_log`` in
600
+ ``memory.db`` (LLD-07 M004). On any adapter error, that adapter
601
+ reports ``active: false`` with ``reason: error:<ExcName>`` rather
602
+ than crashing the whole Brain endpoint (LLD-04 §2 — "honest, never
603
+ fake"). An unimportable adapter means the install is missing Wave 2C
604
+ components, which is legitimate for an older 3.4.20 → 3.4.21 upgrade
605
+ mid-migration.
606
+ """
607
+ out: dict = {}
608
+ # S8-SEC-06 fix: use the canonical factory instead of constructing
609
+ # adapters with no kwargs (all three require scope/base_dir/sync_log_db/
610
+ # recall_fn). ``build_default_adapters`` returns fully-wired instances
611
+ # matching what the background sync loop uses, so the panel reflects
612
+ # the same truth the daemon acts on.
613
+ try:
614
+ from superlocalmemory.cli.context_commands import (
615
+ build_default_adapters as _build_adapters,
616
+ )
617
+ _adapters = _build_adapters()
618
+ except Exception as exc: # pragma: no cover — defensive
619
+ _adapters = []
620
+ logger.debug("brain: adapter factory failed: %s", exc)
621
+
622
+ # Summarise by adapter kind: cursor (project+global), antigravity
623
+ # (workspace+global), copilot. ``adapter.name`` disambiguates scope.
624
+ _seen: set[str] = set()
625
+ for a in _adapters:
626
+ name_attr = getattr(a, "name", "") or ""
627
+ cls = type(a).__name__
628
+ kind = None
629
+ if "Cursor" in cls:
630
+ kind = "cursor"
631
+ elif "Antigravity" in cls:
632
+ kind = "antigravity"
633
+ elif "Copilot" in cls:
634
+ kind = "copilot"
635
+ if kind is None:
636
+ # Unknown adapter class — skip to avoid polluting the JSON
637
+ # with an ``out[None]`` key. (S10-SEC-N-01 fix.)
638
+ continue
639
+ if kind in _seen:
640
+ # For cursor/antigravity, which have project+global, the first
641
+ # active one wins; this surface is a health indicator, not a
642
+ # per-scope breakdown. (Dev view can drill in later.)
643
+ continue
644
+ try:
645
+ is_active = bool(a.is_active())
646
+ except Exception as exc: # pragma: no cover — defensive
647
+ out[kind] = {"active": False,
648
+ "reason": f"error:{exc.__class__.__name__}"}
649
+ _seen.add(kind)
650
+ continue
651
+ out[kind] = {
652
+ "active": is_active,
653
+ "last_sync_seconds_ago": _adapter_last_sync_ago(name_attr or kind),
654
+ }
655
+ _seen.add(kind)
656
+ # Fill in missing slots so the shape stays stable.
657
+ for kind in ("cursor", "antigravity", "copilot"):
658
+ out.setdefault(kind, {"active": False, "reason": "adapter_unavailable"})
659
+ # Claude Code hook proxy: active once the install-token exists
660
+ # (required for prewarm per LLD-01 §4.4 R14).
661
+ try:
662
+ from superlocalmemory.core import security_primitives as _sp
663
+ out["claude_code"] = {
664
+ "active": _sp._install_token_path().exists(),
665
+ "hook": "UserPromptSubmit",
666
+ }
667
+ except Exception as exc: # pragma: no cover
668
+ out["claude_code"] = {"active": False,
669
+ "reason": f"error:{exc.__class__.__name__}"}
670
+ # MCP tool is registered by the unified daemon; if this route is
671
+ # reachable, the MCP server is up.
672
+ out["mcp"] = {"active": True, "tool": "mcp__slm"}
673
+ # CLI is trivially active on any install.
674
+ out["cli"] = {"active": True}
675
+ return out
676
+
677
+
678
+ def _meta_now() -> dict:
679
+ return {
680
+ "generated_at": datetime.now(timezone.utc)
681
+ .replace(microsecond=0).isoformat()
682
+ .replace("+00:00", "Z"),
683
+ "honest_labels": True,
684
+ "version": _VERSION,
685
+ }
686
+
687
+
688
+ # ---------------------------------------------------------------------------
689
+ # SQL helpers
690
+ # ---------------------------------------------------------------------------
691
+
692
+
693
+ def _safe_count(lrn_db: LearningDatabase, table: str,
694
+ profile_id: str) -> int:
695
+ """``COUNT(*)`` from ``table`` where ``profile_id`` matches.
696
+
697
+ Returns 0 on any error (missing table, DB lock) — never raises
698
+ from the Brain endpoint.
699
+ """
700
+ try:
701
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
702
+ conn.row_factory = sqlite3.Row
703
+ try:
704
+ row = conn.execute(
705
+ f"SELECT COUNT(*) AS cnt FROM {table} WHERE profile_id = ?", # nosec - table name is hard-coded above
706
+ (profile_id,),
707
+ ).fetchone()
708
+ return int(row["cnt"]) if row else 0
709
+ finally:
710
+ conn.close()
711
+ except sqlite3.Error:
712
+ return 0
713
+
714
+
715
+ def _legacy_migration_sentinel_complete(lrn_db: LearningDatabase) -> bool:
716
+ """Return True when ``migration_log`` marks the legacy feedback
717
+ migration as complete (S9-DASH-03).
718
+
719
+ Signals that the historic-data card should auto-hide even if a
720
+ small residual of un-migratable rows lingers in ``learning_feedback``.
721
+ """
722
+ try:
723
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
724
+ try:
725
+ row = conn.execute(
726
+ "SELECT status FROM migration_log "
727
+ "WHERE name = 'LEG001_feedback_to_signals'",
728
+ ).fetchone()
729
+ if row is None:
730
+ return False
731
+ return str(row[0]).lower() == "complete"
732
+ finally:
733
+ conn.close()
734
+ except sqlite3.Error:
735
+ return False
736
+
737
+
738
+ def _count_legacy_migrated(lrn_db: LearningDatabase, profile_id: str) -> int:
739
+ """Count rows copied forward by the legacy-feedback migration.
740
+
741
+ These rows live in ``learning_signals`` with ``signal_type='legacy_feedback'``
742
+ (see ``learning/legacy_migration.py``). The Brain endpoint exposes this
743
+ separately from the raw ``learning_feedback`` table count so the UI can
744
+ show a honest "pending migration" figure instead of the Stage-8 lie
745
+ that conflated the two.
746
+ """
747
+ try:
748
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
749
+ conn.row_factory = sqlite3.Row
750
+ try:
751
+ row = conn.execute(
752
+ "SELECT COUNT(*) AS cnt FROM learning_signals "
753
+ "WHERE profile_id = ? AND signal_type = 'legacy_feedback'",
754
+ (profile_id,),
755
+ ).fetchone()
756
+ return int(row["cnt"]) if row else 0
757
+ finally:
758
+ conn.close()
759
+ except sqlite3.Error:
760
+ return 0
761
+
762
+
763
+ def _count_signals_since(
764
+ lrn_db: LearningDatabase,
765
+ profile_id: str,
766
+ since: datetime,
767
+ *,
768
+ signal_types: tuple[str, ...] | None = None,
769
+ ) -> int:
770
+ """Count ``learning_signals`` rows since ``since`` for ``profile_id``."""
771
+ try:
772
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
773
+ conn.row_factory = sqlite3.Row
774
+ try:
775
+ if signal_types:
776
+ placeholders = ",".join("?" * len(signal_types))
777
+ sql = (
778
+ "SELECT COUNT(*) AS cnt FROM learning_signals "
779
+ "WHERE profile_id = ? AND created_at >= ? "
780
+ f"AND signal_type IN ({placeholders})"
781
+ )
782
+ params: tuple[Any, ...] = (
783
+ profile_id, since.isoformat(), *signal_types,
784
+ )
785
+ else:
786
+ sql = (
787
+ "SELECT COUNT(*) AS cnt FROM learning_signals "
788
+ "WHERE profile_id = ? AND created_at >= ?"
789
+ )
790
+ params = (profile_id, since.isoformat())
791
+ row = conn.execute(sql, params).fetchone()
792
+ return int(row["cnt"]) if row else 0
793
+ finally:
794
+ conn.close()
795
+ except sqlite3.Error: # pragma: no cover — table always present in tests
796
+ return 0
797
+
798
+
799
+ def _lazy_db_for(profile_id: str) -> LearningDatabase:
800
+ return LearningDatabase(_learning_db_path())
801
+
802
+
803
+ _EVOLUTION_MAX_DAYS = 90
804
+ _EVOLUTION_DEFAULT_DAYS = 30
805
+
806
+
807
+ def _compute_evolution_timeseries(
808
+ profile_id: str,
809
+ lrn_db: LearningDatabase,
810
+ *,
811
+ days: int = _EVOLUTION_DEFAULT_DAYS,
812
+ ) -> dict:
813
+ """Daily learning-signal counts for ``profile_id`` over the last ``days``.
814
+
815
+ Returns ``{"days": N, "points": [{"date": "YYYY-MM-DD", "signals": int,
816
+ "patterns_seen": int}, ...], "is_real": True, "source": "learning_signals"}``.
817
+
818
+ Points are left-aligned to midnight UTC and cover exactly ``days``
819
+ consecutive days including today. Missing days are zero-filled so the
820
+ chart renders a flat line instead of a gap.
821
+ """
822
+ try:
823
+ requested = int(days) if days is not None else _EVOLUTION_DEFAULT_DAYS
824
+ except (TypeError, ValueError):
825
+ requested = _EVOLUTION_DEFAULT_DAYS
826
+ days = max(1, min(requested, _EVOLUTION_MAX_DAYS))
827
+ today = datetime.now(timezone.utc).replace(
828
+ hour=0, minute=0, second=0, microsecond=0,
829
+ )
830
+ start = today - timedelta(days=days - 1)
831
+
832
+ # Daily counts from learning_signals.
833
+ counts: dict[str, int] = {}
834
+ try:
835
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
836
+ conn.row_factory = sqlite3.Row
837
+ try:
838
+ rows = conn.execute(
839
+ "SELECT substr(created_at, 1, 10) AS d, COUNT(*) AS n "
840
+ "FROM learning_signals "
841
+ "WHERE profile_id = ? AND created_at >= ? "
842
+ "GROUP BY substr(created_at, 1, 10)",
843
+ (profile_id, start.isoformat()),
844
+ ).fetchall()
845
+ counts = {r["d"]: int(r["n"]) for r in rows if r["d"]}
846
+ finally:
847
+ conn.close()
848
+ except sqlite3.Error:
849
+ counts = {}
850
+
851
+ points: list[dict[str, Any]] = []
852
+ for i in range(days):
853
+ d = start + timedelta(days=i)
854
+ key = d.date().isoformat()
855
+ points.append({
856
+ "date": key,
857
+ "signals": counts.get(key, 0),
858
+ })
859
+
860
+ total = sum(p["signals"] for p in points)
861
+ return {
862
+ "is_real": True,
863
+ "source": "learning_signals",
864
+ "days": days,
865
+ "total_signals": total,
866
+ "points": points,
867
+ }
868
+
869
+
870
+ def _action_outcomes_count(lrn_db: LearningDatabase,
871
+ profile_id: str) -> int:
872
+ """Row count in ``action_outcomes`` for ``profile_id``.
873
+
874
+ ``action_outcomes`` ships in v3.4.21 (M006). While absent, returns 0.
875
+ """
876
+ try:
877
+ conn = sqlite3.connect(lrn_db.path, timeout=5.0)
878
+ conn.row_factory = sqlite3.Row
879
+ try:
880
+ row = conn.execute(
881
+ "SELECT COUNT(*) AS cnt FROM action_outcomes "
882
+ "WHERE profile_id = ?",
883
+ (profile_id,),
884
+ ).fetchone()
885
+ return int(row["cnt"]) if row else 0
886
+ finally:
887
+ conn.close()
888
+ except sqlite3.Error:
889
+ return 0
890
+
891
+
892
+ # ---------------------------------------------------------------------------
893
+ # Routes
894
+ # ---------------------------------------------------------------------------
895
+
896
+
897
+ @router.get("/brain", dependencies=[Depends(require_install_token)])
898
+ async def get_brain(profile_id: str = "default") -> dict:
899
+ """Unified Brain endpoint — LLD-04 §3.1.
900
+
901
+ Fan-out: each section is a synchronous SQLite reader. Running them
902
+ serially was a 7-round-trip chain on the hot path (PERF-v2-05).
903
+ We offload each to the default executor via ``asyncio.to_thread``
904
+ and ``asyncio.gather`` so the wall-clock is dominated by the slowest
905
+ single section, not the sum. Every section already swallows its own
906
+ errors and returns honest-empty on failure, so ``return_exceptions``
907
+ is unnecessary — but we still set ``return_exceptions=True`` as a
908
+ belt-and-suspenders guard so one broken reader can't 500 the whole
909
+ endpoint.
910
+ """
911
+ import asyncio
912
+
913
+ lrn_db = LearningDatabase(_learning_db_path())
914
+
915
+ (
916
+ preferences, learning, usage, bandit_snap, cache,
917
+ cross_platform, outcomes_rows, evolution,
918
+ ) = await asyncio.gather(
919
+ asyncio.to_thread(_compute_preferences, profile_id),
920
+ asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
921
+ asyncio.to_thread(_compute_usage_stats, profile_id),
922
+ asyncio.to_thread(_compute_bandit_snapshot, profile_id, lrn_db),
923
+ asyncio.to_thread(_compute_cache_stats),
924
+ asyncio.to_thread(_compute_cross_platform),
925
+ asyncio.to_thread(_action_outcomes_count, lrn_db, profile_id),
926
+ asyncio.to_thread(
927
+ _compute_evolution_timeseries, profile_id, lrn_db,
928
+ days=_EVOLUTION_DEFAULT_DAYS,
929
+ ),
930
+ return_exceptions=True,
931
+ )
932
+
933
+ # Replace any per-section exception with an honest-empty dict so the
934
+ # endpoint never propagates a half-rendered payload.
935
+ def _ok(value, fallback):
936
+ if isinstance(value, Exception):
937
+ return fallback
938
+ return value
939
+
940
+ return {
941
+ "profile_id": profile_id,
942
+ "preferences": _ok(preferences, {"is_real": True,
943
+ "topics": [], "entities": [],
944
+ "tech": [], "redacted_count": 0,
945
+ "source": "_store_patterns"}),
946
+ "learning": _ok(learning, {"is_real": True, "phase": 1,
947
+ "signals_total": 0}),
948
+ "usage": _ok(usage, {"is_real_ml": False,
949
+ "recalls_last_24h": 0,
950
+ "top_query_types": [],
951
+ "top_time_buckets": []}),
952
+ "bandit": _ok(bandit_snap, {"is_real": True,
953
+ "strata_active": 0,
954
+ "strata_total": 48}),
955
+ "cache": _ok(cache, {"is_real": True,
956
+ "db_size_bytes": 0,
957
+ "entry_count": 0}),
958
+ "cross_platform": _ok(cross_platform, {}),
959
+ "evolution_preview": _ok(evolution, {
960
+ "is_real": True, "source": "learning_signals",
961
+ "days": _EVOLUTION_DEFAULT_DAYS, "total_signals": 0, "points": [],
962
+ }),
963
+ "outcomes_preview": {
964
+ "action_outcomes_rows":
965
+ 0 if isinstance(outcomes_rows, Exception) else outcomes_rows,
966
+ "ships_in": "3.4.21",
967
+ },
968
+ # S9-defer H-22: live tile data for the Reward / Shadow /
969
+ # Evolution-Cost dashboard tiles. Each block is a honest-empty
970
+ # default when the underlying table is missing (fresh install
971
+ # or older schema) so the UI can render "no data yet" without
972
+ # a 500. All three queries are cheap COUNT / AVG aggregates.
973
+ "reward_preview": _compute_reward_preview(profile_id),
974
+ "shadow_preview": _compute_shadow_preview(profile_id),
975
+ "evolution_cost_preview": _compute_evolution_cost_preview(profile_id),
976
+ "outcome_queue": _compute_outcome_queue_stats(profile_id),
977
+ "meta": _meta_now(),
978
+ }
979
+
980
+
981
+ def _compute_outcome_queue_stats(profile_id: str) -> dict:
982
+ """S9-DASH-02: producer-side telemetry for the Brain panel.
983
+
984
+ Exposes the outcome-queue worker counters + a live count of
985
+ ``pending_outcomes`` so the operator can see the closed loop is
986
+ actually flowing: recall → enqueue → persist → finalize.
987
+ """
988
+ import sqlite3
989
+ from pathlib import Path
990
+ try:
991
+ from superlocalmemory.learning.outcome_queue import (
992
+ get_counters, queue_size,
993
+ )
994
+ counters = get_counters()
995
+ qsz = queue_size()
996
+ except Exception:
997
+ counters, qsz = {}, 0
998
+ home = Path.home() / ".superlocalmemory"
999
+ db = home / "memory.db"
1000
+ pending_now = 0
1001
+ if db.exists():
1002
+ try:
1003
+ conn = sqlite3.connect(str(db), timeout=1.0)
1004
+ try:
1005
+ row = conn.execute(
1006
+ "SELECT COUNT(*) FROM pending_outcomes "
1007
+ "WHERE profile_id = ? AND status = 'pending'",
1008
+ (profile_id,),
1009
+ ).fetchone()
1010
+ if row:
1011
+ pending_now = int(row[0] or 0)
1012
+ finally:
1013
+ conn.close()
1014
+ except Exception:
1015
+ pass
1016
+ return {
1017
+ "is_real": True,
1018
+ "queue_depth": int(qsz),
1019
+ "pending_outcomes_now": pending_now,
1020
+ "counters": counters,
1021
+ "source": "outcome_queue + pending_outcomes",
1022
+ }
1023
+
1024
+
1025
+ # ---------------------------------------------------------------------------
1026
+ # S9-defer H-22 — dashboard-tile aggregates
1027
+ # ---------------------------------------------------------------------------
1028
+
1029
+
1030
+ def _compute_reward_preview(profile_id: str) -> dict:
1031
+ """Reward-tile aggregate — count + mean reward over the last 24h."""
1032
+ import sqlite3
1033
+ from pathlib import Path
1034
+ home = Path.home() / ".superlocalmemory"
1035
+ db = home / "memory.db"
1036
+ default = {
1037
+ "is_real": False, "rows_24h": 0, "mean_reward_24h": 0.0,
1038
+ "source": "action_outcomes",
1039
+ }
1040
+ if not db.exists():
1041
+ return default
1042
+ try:
1043
+ conn = sqlite3.connect(str(db), timeout=1.0)
1044
+ try:
1045
+ row = conn.execute(
1046
+ "SELECT COUNT(*) AS c, AVG(reward) AS m "
1047
+ "FROM action_outcomes "
1048
+ "WHERE profile_id = ? AND settled = 1 "
1049
+ " AND reward IS NOT NULL "
1050
+ " AND settled_at >= datetime('now', '-1 day')",
1051
+ (profile_id,),
1052
+ ).fetchone()
1053
+ finally:
1054
+ conn.close()
1055
+ except sqlite3.Error:
1056
+ return default
1057
+ if row is None:
1058
+ return default
1059
+ return {
1060
+ "is_real": True,
1061
+ "rows_24h": int(row[0] or 0),
1062
+ "mean_reward_24h": float(row[1] or 0.0),
1063
+ "source": "action_outcomes",
1064
+ }
1065
+
1066
+
1067
+ def _compute_shadow_preview(profile_id: str) -> dict:
1068
+ """Shadow-tile aggregate — latest candidate + paired-obs count."""
1069
+ import sqlite3
1070
+ default = {
1071
+ "is_real": False, "active_candidate_id": None,
1072
+ "paired_observations": 0, "rollback_count_90d": 0,
1073
+ "source": "learning_model_state+shadow_observations",
1074
+ }
1075
+ learning_db = _learning_db_path()
1076
+ if not learning_db.exists():
1077
+ return default
1078
+ try:
1079
+ conn = sqlite3.connect(str(learning_db), timeout=1.0)
1080
+ try:
1081
+ row = conn.execute(
1082
+ "SELECT id FROM learning_model_state "
1083
+ "WHERE profile_id = ? AND is_candidate = 1 "
1084
+ "LIMIT 1",
1085
+ (profile_id,),
1086
+ ).fetchone()
1087
+ cand_id = int(row[0]) if row and row[0] is not None else None
1088
+ paired = 0
1089
+ if cand_id is not None:
1090
+ try:
1091
+ c = conn.execute(
1092
+ "SELECT COUNT(DISTINCT query_id) "
1093
+ "FROM shadow_observations "
1094
+ "WHERE candidate_id = ? "
1095
+ " AND query_id IN ("
1096
+ " SELECT query_id FROM shadow_observations "
1097
+ " WHERE candidate_id = ? AND arm = 'active' "
1098
+ " INTERSECT "
1099
+ " SELECT query_id FROM shadow_observations "
1100
+ " WHERE candidate_id = ? AND arm = 'candidate' "
1101
+ " )",
1102
+ (cand_id, cand_id, cand_id),
1103
+ ).fetchone()
1104
+ paired = int(c[0]) if c and c[0] is not None else 0
1105
+ except sqlite3.Error:
1106
+ paired = 0
1107
+ # Rollback count in the last 90d. The rollback log is a
1108
+ # separate table the learning subsystem writes; if it is
1109
+ # absent we simply return 0.
1110
+ try:
1111
+ r2 = conn.execute(
1112
+ "SELECT COUNT(*) FROM model_state_history "
1113
+ "WHERE profile_id = ? AND action = 'rollback' "
1114
+ " AND ts >= datetime('now', '-90 day')",
1115
+ (profile_id,),
1116
+ ).fetchone()
1117
+ rollbacks = int(r2[0]) if r2 and r2[0] is not None else 0
1118
+ except sqlite3.Error:
1119
+ rollbacks = 0
1120
+ finally:
1121
+ conn.close()
1122
+ except sqlite3.Error:
1123
+ return default
1124
+ return {
1125
+ "is_real": True,
1126
+ "active_candidate_id": cand_id,
1127
+ "paired_observations": paired,
1128
+ "rollback_count_90d": rollbacks,
1129
+ "source": "learning_model_state+shadow_observations",
1130
+ }
1131
+
1132
+
1133
+ def _compute_evolution_cost_preview(profile_id: str) -> dict:
1134
+ """Evolution-cost tile — last 7d spend + call count."""
1135
+ import sqlite3
1136
+ default = {
1137
+ "is_real": False, "calls_7d": 0, "cost_usd_7d": 0.0,
1138
+ "tokens_in_7d": 0, "tokens_out_7d": 0,
1139
+ "source": "evolution_llm_cost_log",
1140
+ }
1141
+ learning_db = _learning_db_path()
1142
+ if not learning_db.exists():
1143
+ return default
1144
+ try:
1145
+ conn = sqlite3.connect(str(learning_db), timeout=1.0)
1146
+ try:
1147
+ row = conn.execute(
1148
+ "SELECT COUNT(*) AS c, "
1149
+ " COALESCE(SUM(cost_usd), 0) AS cost, "
1150
+ " COALESCE(SUM(tokens_in), 0) AS tin, "
1151
+ " COALESCE(SUM(tokens_out), 0) AS tout "
1152
+ "FROM evolution_llm_cost_log "
1153
+ "WHERE profile_id = ? "
1154
+ " AND ts >= datetime('now', '-7 day')",
1155
+ (profile_id,),
1156
+ ).fetchone()
1157
+ finally:
1158
+ conn.close()
1159
+ except sqlite3.Error:
1160
+ return default
1161
+ if row is None:
1162
+ return default
1163
+ return {
1164
+ "is_real": True,
1165
+ "calls_7d": int(row[0] or 0),
1166
+ "cost_usd_7d": float(row[1] or 0.0),
1167
+ "tokens_in_7d": int(row[2] or 0),
1168
+ "tokens_out_7d": int(row[3] or 0),
1169
+ "source": "evolution_llm_cost_log",
1170
+ }
1171
+
1172
+
1173
+ @router.get("/brain/evolution-timeseries",
1174
+ dependencies=[Depends(require_install_token)])
1175
+ async def get_brain_evolution_timeseries(
1176
+ profile_id: str = "default",
1177
+ days: int = _EVOLUTION_DEFAULT_DAYS,
1178
+ ) -> dict:
1179
+ """Daily learning-signal counts for ``profile_id`` over the last ``days``.
1180
+
1181
+ ``days`` is clamped to ``[1, 90]`` to keep the response bounded. Each
1182
+ missing day is zero-filled so the chart renders a flat line, not a gap.
1183
+ """
1184
+ import asyncio
1185
+
1186
+ lrn_db = LearningDatabase(_learning_db_path())
1187
+ result = await asyncio.to_thread(
1188
+ _compute_evolution_timeseries, profile_id, lrn_db, days=days,
1189
+ )
1190
+ result["meta"] = _meta_now()
1191
+ return result
1192
+
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # Deprecated shims — 1 release grace. All require the install token.
1196
+ # ---------------------------------------------------------------------------
1197
+
1198
+
1199
+ @router.get("/learning/stats",
1200
+ dependencies=[Depends(require_install_token)])
1201
+ async def learning_stats_deprecated(profile_id: str = "default") -> dict:
1202
+ lrn_db = LearningDatabase(_learning_db_path())
1203
+ return {
1204
+ "deprecated": True,
1205
+ "use_instead": "/api/v3/brain",
1206
+ "learning": _compute_learning_status(profile_id, lrn_db),
1207
+ }
1208
+
1209
+
1210
+ @router.get("/patterns",
1211
+ dependencies=[Depends(require_install_token)])
1212
+ async def patterns_deprecated(profile_id: str = "default") -> dict:
1213
+ return {
1214
+ "deprecated": True,
1215
+ "use_instead": "/api/v3/brain",
1216
+ "preferences": _compute_preferences(profile_id),
1217
+ }
1218
+
1219
+
1220
+ @router.get("/behavioral",
1221
+ dependencies=[Depends(require_install_token)])
1222
+ async def behavioral_deprecated(profile_id: str = "default") -> dict:
1223
+ return {
1224
+ "deprecated": True,
1225
+ "use_instead": "/api/v3/brain",
1226
+ "usage": _compute_usage_stats(profile_id),
1227
+ }
1228
+
1229
+
1230
+ __all__ = (
1231
+ "router",
1232
+ "require_install_token",
1233
+ "redact_secrets_in_preferences",
1234
+ )