superlocalmemory 3.8.0 → 3.8.2

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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -9,27 +9,81 @@ Routes: /api/learning/status, /api/feedback, /api/feedback/dwell,
9
9
  /api/learning/retrain
10
10
  Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
11
11
  """
12
- import shutil
13
12
  import logging
13
+ import shutil
14
+ import sqlite3
15
+ from contextlib import closing
14
16
  from datetime import datetime, timezone
15
17
  from pathlib import Path
16
18
 
17
- from fastapi import APIRouter
19
+ from fastapi import APIRouter, Request
18
20
  from fastapi.concurrency import run_in_threadpool
19
21
 
20
- from .helpers import get_active_profile, MEMORY_DIR
22
+ from .helpers import MEMORY_DIR, get_active_profile
23
+ from .learning_telemetry import ReadOnlyRankerStore
24
+ from .learning_telemetry import (
25
+ load_model_state as _load_model_state,
26
+ )
27
+ from .learning_telemetry import (
28
+ load_source_quality_state as _load_source_quality_state,
29
+ )
30
+ from .learning_telemetry import (
31
+ sqlite_status as _sqlite_status,
32
+ )
21
33
 
22
34
  logger = logging.getLogger("superlocalmemory.routes.learning")
23
35
  router = APIRouter()
24
36
 
25
37
  LEARNING_DB = MEMORY_DIR / "learning.db"
26
38
 
39
+ # F10 fix: track which profiles have completed their initial cache-bypass check
40
+ # per daemon start. First call uses use_cache=False (tamper detection);
41
+ # subsequent calls use use_cache=True so tab navigation does not re-deserialize
42
+ # a LightGBM model on every dashboard request.
43
+ _ranker_initial_check_done: set[str] = set()
44
+
45
+
46
+ def _require_write(request: Request) -> None:
47
+ from superlocalmemory.access.rbac import Permission
48
+ from superlocalmemory.server.rbac_enforce import require_permission
49
+
50
+ require_permission(request, Permission.WRITE, profile=get_active_profile())
51
+
52
+
53
+ def _require_delete(request: Request) -> None:
54
+ from superlocalmemory.access.rbac import Permission
55
+ from superlocalmemory.server.rbac_enforce import require_permission
56
+
57
+ require_permission(request, Permission.DELETE, profile=get_active_profile())
58
+
59
+
60
+ def _require_manage(request: Request) -> None:
61
+ from superlocalmemory.server.rbac_enforce import require_manage
62
+
63
+ require_manage(request, profile=get_active_profile())
64
+
27
65
 
28
66
  # ---------------------------------------------------------------------------
29
67
  # LLD-02 §4.10 — Dashboard phase truth
30
68
  # ---------------------------------------------------------------------------
31
69
 
32
70
 
71
+ def _phase_payload(
72
+ phase: int,
73
+ key: str,
74
+ label: str,
75
+ model_active: bool,
76
+ signals: int,
77
+ gates: dict,
78
+ status: str,
79
+ ) -> dict:
80
+ return {
81
+ "phase": phase, "key": key, "label": label,
82
+ "model_active": model_active, "signals": signals,
83
+ "gates": gates, "status": status,
84
+ }
85
+
86
+
33
87
  def _compute_ranker_phase(
34
88
  profile_id: str,
35
89
  *,
@@ -41,60 +95,79 @@ def _compute_ranker_phase(
41
95
  SHA-256 verification on the model_cache load. Tampered bytes fall
42
96
  back to phase 2.
43
97
  """
44
- from superlocalmemory.learning.database import LearningDatabase
45
- from superlocalmemory.learning.model_cache import load_active, invalidate
98
+ from superlocalmemory.learning.model_cache import load_active
99
+ from superlocalmemory.learning.ranker import (
100
+ PHASE_2_THRESHOLD,
101
+ PHASE_3_THRESHOLD,
102
+ )
46
103
 
47
104
  db_path = Path(learning_db_path) if learning_db_path else LEARNING_DB
105
+ gates = {
106
+ "rule_based_min_signals": PHASE_2_THRESHOLD,
107
+ "ml_model_min_signals": PHASE_3_THRESHOLD,
108
+ "ml_model_requires_verified_active_model": True,
109
+ }
48
110
  if not db_path.exists():
49
- return {
50
- "phase": 1,
51
- "label": "Cold start (cross-encoder only)",
52
- "model_active": False,
53
- "signals": 0,
54
- }
111
+ return _phase_payload(
112
+ 1, "baseline", "Cold start (cross-encoder only)", False, 0,
113
+ gates, "missing_database",
114
+ )
55
115
 
56
- db = LearningDatabase(db_path)
116
+ try:
117
+ db = ReadOnlyRankerStore(db_path)
118
+ except Exception as exc:
119
+ logger.warning("ranker database unavailable: %s", exc)
120
+ status = (
121
+ _sqlite_status(exc)
122
+ if isinstance(exc, sqlite3.Error) else "query_error"
123
+ )
124
+ return _phase_payload(
125
+ 1, "baseline", "Cold start (cross-encoder only)", False, 0,
126
+ gates, status,
127
+ )
128
+
129
+ signal_status = "available"
57
130
  try:
58
131
  signals = db.count_signals(profile_id)
59
132
  except Exception as exc:
60
133
  logger.warning("count_signals failed: %s", exc)
61
134
  signals = 0
135
+ signal_status = (
136
+ "missing_table"
137
+ if isinstance(exc, sqlite3.OperationalError)
138
+ and "no such table" in str(exc).lower()
139
+ else (
140
+ _sqlite_status(exc)
141
+ if isinstance(exc, sqlite3.Error) else "query_error"
142
+ )
143
+ )
62
144
 
63
- # Force a cache-bypass load — the dashboard read is rare and we want
64
- # tamper detection to surface immediately.
65
- invalidate(profile_id)
66
145
  try:
67
- model = load_active(db, profile_id, use_cache=False)
146
+ # F10 fix: first call per daemon start per profile must bypass the cache
147
+ # so a tampered model file is detected before being served. Subsequent
148
+ # calls reuse the cache so tab navigation does not re-deserialize a
149
+ # LightGBM model on every dashboard request.
150
+ bypass_cache = profile_id not in _ranker_initial_check_done
151
+ if bypass_cache:
152
+ _ranker_initial_check_done.add(profile_id)
153
+ model = load_active(db, profile_id, use_cache=not bypass_cache)
68
154
  except Exception as exc:
69
155
  logger.warning("load_active failed: %s", exc)
70
156
  model = None
71
157
 
72
158
  active = model is not None
73
159
 
74
- if active and signals >= 200:
75
- return {
76
- "phase": 3,
77
- "label": "LightGBM ranker active",
78
- "model_active": True,
79
- "signals": signals,
80
- }
81
- if signals >= 50:
82
- return {
83
- "phase": 2,
84
- "label": "Contextual bandit",
85
- "model_active": False,
86
- "signals": signals,
87
- }
88
- return {
89
- "phase": 1,
90
- "label": "Cold start (cross-encoder only)",
91
- "model_active": False,
92
- "signals": signals,
93
- }
160
+ if active and signals >= PHASE_3_THRESHOLD:
161
+ phase = (3, "ml_model", "LightGBM ranker active", True)
162
+ elif signals >= PHASE_2_THRESHOLD:
163
+ phase = (2, "rule_based", "Contextual bandit", False)
164
+ else:
165
+ phase = (1, "baseline", "Cold start (cross-encoder only)", False)
166
+ return _phase_payload(*phase, signals, gates, signal_status)
94
167
 
95
168
 
96
169
  @router.get("/api/learning/ranker_phase")
97
- async def ranker_phase():
170
+ def ranker_phase():
98
171
  """Dashboard endpoint — LLD-02 §4.10 phase truth."""
99
172
  try:
100
173
  profile = get_active_profile()
@@ -106,14 +179,14 @@ async def ranker_phase():
106
179
  LEARNING_AVAILABLE = False
107
180
  BEHAVIORAL_AVAILABLE = False
108
181
  try:
109
- from superlocalmemory.learning.feedback import FeedbackCollector
110
182
  from superlocalmemory.learning.engagement import EngagementTracker
111
- from superlocalmemory.learning.ranker import AdaptiveRanker
183
+ from superlocalmemory.learning.feedback import FeedbackCollector
184
+ from superlocalmemory.learning.ranker import AdaptiveRanker # noqa: F401
112
185
  LEARNING_AVAILABLE = True
113
186
  except ImportError as e:
114
187
  logger.warning("V3 learning primary import failed: %s", e)
115
188
  try:
116
- from superlocalmemory.learning.adaptive import AdaptiveLearner
189
+ from superlocalmemory.learning.adaptive import AdaptiveLearner # noqa: F401
117
190
  LEARNING_AVAILABLE = True
118
191
  except ImportError as e2:
119
192
  logger.warning("V3 learning fallback import failed: %s", e2)
@@ -149,30 +222,73 @@ def _get_engagement() -> "EngagementTracker | None":
149
222
  return _engagement
150
223
 
151
224
 
225
+ def _source_quality_repair_telemetry(request: Request) -> dict:
226
+ status = getattr(
227
+ request.app.state, "source_quality_repair_status", None,
228
+ )
229
+ if not isinstance(status, dict):
230
+ return {
231
+ "state": "not_scheduled",
232
+ "source": "daemon_app_state",
233
+ "last_error": None,
234
+ }
235
+ return {
236
+ "state": str(status.get("state") or "unknown"),
237
+ "source": str(status.get("source") or "daemon_app_state"),
238
+ "batch_size": int(status.get("batch_size") or 0),
239
+ "profiles_total": len(status.get("profiles") or []),
240
+ "profiles_complete": len(status.get("completed_profiles") or []),
241
+ "batches_completed": int(status.get("batches_completed") or 0),
242
+ "scanned": int(status.get("scanned") or 0),
243
+ "observations": int(status.get("observations") or 0),
244
+ "last_error": status.get("last_error"),
245
+ }
246
+
247
+
152
248
  @router.get("/api/learning/status")
153
- async def learning_status():
249
+ def learning_status(request: Request):
154
250
  """Get comprehensive learning system status for dashboard."""
251
+ repair_telemetry = _source_quality_repair_telemetry(request)
155
252
  if not LEARNING_AVAILABLE:
156
253
  return {
157
254
  "available": False, "ranking_phase": None,
255
+ "ranker_phase": None, "ranking_phase_gates": {},
158
256
  "stats": None, "tech_preferences": [], "workflow_patterns": [],
159
257
  "source_scores": {}, "engagement": None,
258
+ "telemetry_status": {
259
+ "source_quality_repair": repair_telemetry["state"],
260
+ },
261
+ "source_quality_repair": repair_telemetry,
160
262
  "message": "Learning features not installed.",
161
263
  }
162
264
 
163
- result = {"available": True}
265
+ result = {
266
+ "available": True,
267
+ "telemetry_status": {
268
+ "source_quality_repair": repair_telemetry["state"],
269
+ },
270
+ "source_quality_repair": repair_telemetry,
271
+ }
164
272
 
165
273
  try:
166
274
  active_profile = get_active_profile()
167
275
  result["active_profile"] = active_profile
276
+ ranker_state = _compute_ranker_phase(active_profile)
277
+ result["ranker_phase"] = ranker_state
278
+ result["ranking_phase"] = ranker_state["key"]
279
+ result["ranking_phase_gates"] = ranker_state["gates"]
280
+ result["telemetry_status"]["ranker"] = ranker_state.get(
281
+ "status", "available",
282
+ )
168
283
 
169
284
  # Real signal count from V3.1 learning_feedback table
170
285
  signal_count = 0
171
286
  unique_queries = 0
172
287
  try:
173
- from superlocalmemory.learning.feedback import FeedbackCollector
174
- from pathlib import Path
175
288
  import sqlite3 as _sqlite3
289
+
290
+ from superlocalmemory.learning.feedback import FeedbackCollector
291
+
176
292
  learning_db = LEARNING_DB
177
293
  if learning_db.exists():
178
294
  collector = FeedbackCollector(learning_db)
@@ -194,25 +310,25 @@ async def learning_status():
194
310
  except Exception:
195
311
  pass
196
312
 
197
- # Ranking phase based on real signal count
198
- if signal_count >= 200:
199
- result["ranking_phase"] = "ml_model"
200
- elif signal_count >= 20:
201
- result["ranking_phase"] = "rule_based"
202
- else:
203
- result["ranking_phase"] = "baseline"
204
-
205
313
  # Feedback stats — merge old system + new V3.1 signals
206
- stats_dict = {"feedback_count": signal_count, "unique_queries": unique_queries, "active_profile": active_profile}
314
+ stats_dict = {
315
+ "feedback_count": signal_count,
316
+ "unique_queries": unique_queries,
317
+ "ranker_signal_count": ranker_state["signals"],
318
+ "active_profile": active_profile,
319
+ }
207
320
  feedback = _get_feedback()
208
321
  if feedback:
209
322
  try:
210
323
  old_stats = feedback.get_feedback_summary(active_profile)
211
324
  if isinstance(old_stats, dict):
212
- old_stats["feedback_count"] = signal_count
213
- old_stats["unique_queries"] = unique_queries
214
- old_stats["active_profile"] = active_profile
215
- stats_dict = old_stats
325
+ stats_dict = {
326
+ **old_stats,
327
+ "feedback_count": signal_count,
328
+ "unique_queries": unique_queries,
329
+ "ranker_signal_count": ranker_state["signals"],
330
+ "active_profile": active_profile,
331
+ }
216
332
  except Exception as exc:
217
333
  logger.debug("feedback summary: %s", exc)
218
334
 
@@ -252,21 +368,37 @@ async def learning_status():
252
368
  # Tech preferences + workflow patterns from V3.1 behavioral store
253
369
  try:
254
370
  from superlocalmemory.learning.behavioral import BehavioralPatternStore
255
- from pathlib import Path
371
+
256
372
  learning_db = LEARNING_DB
257
373
  if learning_db.exists():
258
374
  store = BehavioralPatternStore(str(learning_db))
259
375
  all_patterns = store.get_patterns(profile_id=active_profile)
260
376
  tech = [
261
- {"key": "tech", "value": p.get("metadata", {}).get("value", p.get("pattern_key", "")),
262
- "confidence": p.get("confidence", 0), "evidence": p.get("evidence_count", 0)}
263
- for p in all_patterns if p.get("pattern_type") == "tech_preference"
377
+ {
378
+ "type": "tech_preference",
379
+ "key": p.get("pattern_key", ""),
380
+ "value": p.get("metadata", {}).get(
381
+ "value", p.get("pattern_key", ""),
382
+ ),
383
+ "confidence": p.get("confidence", 0),
384
+ "evidence_count": p.get("evidence_count", 0),
385
+ # Compatibility alias for pre-3.8 dashboard clients.
386
+ "evidence": p.get("evidence_count", 0),
387
+ }
388
+ for p in all_patterns
389
+ if p.get("pattern_type") == "tech_preference"
264
390
  ]
391
+ workflow_types = {
392
+ "temporal", "workflow", "session_activity",
393
+ "co_retrieval_clusters",
394
+ }
265
395
  workflows = [
266
396
  {"type": p.get("pattern_type"), "key": p.get("pattern_key", ""),
267
397
  "value": p.get("metadata", {}).get("value", ""),
268
- "confidence": p.get("confidence", 0)}
269
- for p in all_patterns if p.get("pattern_type") in ("temporal", "interest")
398
+ "confidence": p.get("confidence", 0),
399
+ "evidence_count": p.get("evidence_count", 0)}
400
+ for p in all_patterns
401
+ if p.get("pattern_type") in workflow_types
270
402
  ]
271
403
  result["tech_preferences"] = tech
272
404
  result["workflow_patterns"] = workflows
@@ -276,10 +408,6 @@ async def learning_status():
276
408
  db_size = os.path.getsize(str(learning_db)) // 1024 if learning_db.exists() else 0
277
409
  stats_dict["db_size_kb"] = db_size
278
410
  stats_dict["transferable_patterns"] = len(all_patterns)
279
- stats_dict["models_trained"] = 1 if signal_count >= 200 else 0
280
- stats_dict["tracked_sources"] = len(set(
281
- p.get("pattern_type") for p in all_patterns
282
- ))
283
411
  else:
284
412
  result["tech_preferences"] = []
285
413
  result["workflow_patterns"] = []
@@ -288,9 +416,22 @@ async def learning_status():
288
416
  result["tech_preferences"] = []
289
417
  result["workflow_patterns"] = []
290
418
  result["pattern_error"] = str(exc)
291
- result["source_scores"] = {}
419
+ source_state = _load_source_quality_state(
420
+ active_profile, LEARNING_DB,
421
+ )
422
+ model_state = _load_model_state(active_profile, LEARNING_DB)
423
+ result["source_scores"] = source_state["scores"]
424
+ result["source_scores_source"] = "learning.db:source_quality"
425
+ result["source_scores_are_posterior"] = True
426
+ result["telemetry_status"]["source_quality"] = source_state["status"]
427
+ result["telemetry_status"]["model_state"] = model_state["status"]
428
+ stats_dict["tracked_sources"] = source_state["tracked_sources"]
429
+ stats_dict["models_trained"] = model_state["models_trained"]
430
+ stats_dict["models_active_verified"] = int(
431
+ bool(ranker_state["model_active"]),
432
+ )
292
433
 
293
- except Exception as e:
434
+ except Exception:
294
435
  logger.exception("Error getting learning status")
295
436
  result["error"] = "Internal server error"
296
437
 
@@ -302,8 +443,9 @@ async def learning_status():
302
443
  # ============================================================================
303
444
 
304
445
  @router.post("/api/feedback")
305
- async def record_feedback(data: dict):
446
+ def record_feedback(request: Request, data: dict):
306
447
  """Record explicit feedback from dashboard (thumbs up/down, pin)."""
448
+ _require_write(request)
307
449
  if not LEARNING_AVAILABLE:
308
450
  return {"success": False, "error": "Learning system not available"}
309
451
 
@@ -333,14 +475,15 @@ async def record_feedback(data: dict):
333
475
  "message": f"Feedback recorded: {feedback_type} for memory #{memory_id}",
334
476
  "feedback_id": row_id,
335
477
  }
336
- except Exception as e:
478
+ except Exception:
337
479
  logger.exception("Error recording feedback")
338
480
  return {"success": False, "error": "Internal server error"}
339
481
 
340
482
 
341
483
  @router.post("/api/feedback/dwell")
342
- async def record_dwell(data: dict):
484
+ def record_dwell(request: Request, data: dict):
343
485
  """Record dwell time feedback from dashboard modal."""
486
+ _require_write(request)
344
487
  if not LEARNING_AVAILABLE:
345
488
  return {"success": False, "error": "Learning system not available"}
346
489
 
@@ -378,13 +521,13 @@ async def record_dwell(data: dict):
378
521
  "message": f"Dwell feedback recorded: {feedback_type} ({dwell_seconds:.1f}s)",
379
522
  "feedback_id": row_id,
380
523
  }
381
- except Exception as e:
524
+ except Exception:
382
525
  logger.exception("Error recording dwell")
383
526
  return {"success": False, "error": "Internal server error"}
384
527
 
385
528
 
386
529
  @router.get("/api/feedback/stats")
387
- async def feedback_stats():
530
+ def feedback_stats():
388
531
  """Get feedback signal statistics for dashboard progress bar."""
389
532
  if not LEARNING_AVAILABLE:
390
533
  return {
@@ -413,9 +556,12 @@ async def feedback_stats():
413
556
  "progress": round(progress, 1), "target": target,
414
557
  "by_channel": by_channel, "by_type": by_type, "available": True,
415
558
  }
416
- except Exception as e:
559
+ except Exception:
417
560
  logger.exception("Error getting feedback stats")
418
- return {"total_signals": 0, "ranking_phase": "baseline", "progress": 0, "error": "Internal server error"}
561
+ return {
562
+ "total_signals": 0, "ranking_phase": "baseline",
563
+ "progress": 0, "error": "Internal server error",
564
+ }
419
565
 
420
566
 
421
567
  # ============================================================================
@@ -424,7 +570,7 @@ async def feedback_stats():
424
570
 
425
571
 
426
572
  @router.delete("/api/patterns/delete")
427
- async def delete_pattern(data: dict) -> dict:
573
+ def delete_pattern(request: Request, data: dict) -> dict:
428
574
  """S9-DASH-04: delete a single auto-detected pattern by key.
429
575
 
430
576
  Body: ``{pattern_type: str, pattern_key: str}``
@@ -432,6 +578,7 @@ async def delete_pattern(data: dict) -> dict:
432
578
  Returns ``{success: bool, deleted: int}``. The pattern is scoped
433
579
  to the active profile so cross-profile deletion is impossible.
434
580
  """
581
+ _require_delete(request)
435
582
  if not BEHAVIORAL_AVAILABLE:
436
583
  return {"success": False, "error": "Behavioral engine not available"}
437
584
  ptype = (data or {}).get("pattern_type", "")
@@ -459,7 +606,7 @@ async def delete_pattern(data: dict) -> dict:
459
606
 
460
607
 
461
608
  @router.get("/api/patterns")
462
- async def get_patterns():
609
+ def get_patterns():
463
610
  """Get learned behavioral patterns for the Patterns dashboard tab.
464
611
 
465
612
  v3.4.1: This endpoint was MISSING — patterns.js calls /api/patterns
@@ -510,43 +657,74 @@ async def get_patterns():
510
657
  result["pattern_error"] = str(exc)
511
658
 
512
659
  # Learning signal stats (feedback count, co-retrieval, channel credits)
513
- try:
514
- from superlocalmemory.learning.signals import LearningSignals
515
- signals = LearningSignals(str(LEARNING_DB))
516
- result["signal_stats"] = signals.get_signal_stats(active_profile)
517
- except Exception as exc:
518
- logger.debug("Signal stats unavailable: %s", exc)
660
+ if LEARNING_AVAILABLE:
661
+ try:
662
+ from superlocalmemory.learning.signals import LearningSignals
663
+ signals = LearningSignals(str(LEARNING_DB))
664
+ result["signal_stats"] = signals.get_signal_stats(active_profile)
665
+ except Exception as exc:
666
+ logger.debug("Signal stats unavailable: %s", exc)
519
667
 
520
668
  # Graph intelligence contribution to learning (v3.4.1)
521
669
  try:
522
670
  import sqlite3 as _sqlite3
671
+
523
672
  from superlocalmemory.server.routes.helpers import DB_PATH
524
673
  if DB_PATH.exists():
525
- conn = _sqlite3.connect(str(DB_PATH))
526
- conn.row_factory = _sqlite3.Row
527
- row = conn.execute(
528
- "SELECT COUNT(*) AS cnt, COUNT(DISTINCT community_id) AS communities, "
529
- "ROUND(AVG(pagerank_score), 4) AS avg_pagerank "
530
- "FROM fact_importance WHERE profile_id = ?",
531
- (active_profile,),
532
- ).fetchone()
533
- if row:
534
- d = dict(row)
535
- result["graph_intelligence"] = {
536
- "facts_analyzed": d.get("cnt", 0),
537
- "communities_detected": d.get("communities", 0),
538
- "avg_pagerank": float(d.get("avg_pagerank", 0) or 0),
539
- }
540
- conn.close()
674
+ with closing(_sqlite3.connect(str(DB_PATH))) as conn:
675
+ conn.row_factory = _sqlite3.Row
676
+ row = conn.execute(
677
+ "SELECT COUNT(*) AS cnt, "
678
+ "COUNT(DISTINCT community_id) AS communities, "
679
+ "ROUND(AVG(pagerank_score), 4) AS avg_pagerank "
680
+ "FROM fact_importance WHERE profile_id = ?",
681
+ (active_profile,),
682
+ ).fetchone()
683
+ if row:
684
+ d = dict(row)
685
+ result["graph_intelligence"] = {
686
+ "facts_analyzed": d.get("cnt", 0),
687
+ "communities_detected": d.get("communities", 0),
688
+ "avg_pagerank": float(
689
+ d.get("avg_pagerank", 0) or 0,
690
+ ),
691
+ }
541
692
  except Exception:
542
693
  pass
543
694
 
695
+ all_patterns = [
696
+ pattern
697
+ for category_patterns in patterns.values()
698
+ for pattern in category_patterns
699
+ ]
700
+ confidences = [
701
+ float(pattern["confidence"])
702
+ for pattern in all_patterns
703
+ if pattern.get("confidence") is not None
704
+ ]
705
+ result.update({
706
+ "total_patterns": len(all_patterns),
707
+ "pattern_types": [
708
+ category
709
+ for category, category_patterns in patterns.items()
710
+ if category_patterns
711
+ ],
712
+ "confidence_stats": {
713
+ "avg": (
714
+ sum(confidences) / len(confidences)
715
+ if confidences else 0.0
716
+ ),
717
+ "min": min(confidences) if confidences else 0.0,
718
+ "max": max(confidences) if confidences else 0.0,
719
+ },
720
+ })
544
721
  return result
545
722
 
546
723
 
547
724
  @router.post("/api/learning/backup")
548
- async def learning_backup():
725
+ def learning_backup(request: Request):
549
726
  """Backup learning.db to a timestamped file."""
727
+ _require_manage(request)
550
728
  try:
551
729
  if not LEARNING_DB.exists():
552
730
  return {"success": False, "error": "No learning.db found"}
@@ -561,14 +739,15 @@ async def learning_backup():
561
739
  "path": str(backup_path),
562
740
  "message": f"Learning DB backed up to {backup_name}",
563
741
  }
564
- except Exception as e:
742
+ except Exception:
565
743
  logger.exception("Error backing up learning DB")
566
744
  return {"success": False, "error": "Internal server error"}
567
745
 
568
746
 
569
747
  @router.post("/api/learning/reset")
570
- async def learning_reset():
748
+ def learning_reset(request: Request):
571
749
  """Reset all learning data for the active profile. Memories preserved."""
750
+ _require_delete(request)
572
751
  if not LEARNING_AVAILABLE:
573
752
  return {"success": False, "error": "Learning system not available"}
574
753
  try:
@@ -587,13 +766,14 @@ async def learning_reset():
587
766
 
588
767
 
589
768
  @router.post("/api/learning/retrain")
590
- async def learning_retrain(data: dict | None = None):
769
+ async def learning_retrain(request: Request, data: dict | None = None):
591
770
  """Force a retrain of the LightGBM ranker.
592
771
 
593
772
  Body (optional, JSON):
594
773
  ``{"include_synthetic": bool}`` — when True, migrated legacy rows
595
774
  (``is_synthetic=1``) participate in training. Default False.
596
775
  """
776
+ _require_manage(request)
597
777
  if not LEARNING_AVAILABLE:
598
778
  return {"success": False, "error": "Learning system not available"}
599
779
  include_synthetic = bool(
@@ -647,7 +827,7 @@ async def learning_retrain(data: dict | None = None):
647
827
 
648
828
 
649
829
  @router.post("/api/learning/migrate-legacy")
650
- async def learning_migrate_legacy():
830
+ def learning_migrate_legacy(request: Request):
651
831
  """Copy ``learning_feedback`` rows into LLD-02 tables for training.
652
832
 
653
833
  Idempotent: subsequent calls detect the migration_log sentinel and
@@ -655,6 +835,7 @@ async def learning_migrate_legacy():
655
835
  with ``is_synthetic=1`` to preserve provenance; the trainer must be
656
836
  invoked with ``include_synthetic=True`` to use them.
657
837
  """
838
+ _require_manage(request)
658
839
  if not LEARNING_AVAILABLE:
659
840
  return {"success": False, "error": "Learning system not available"}
660
841
  try: