superlocalmemory 3.4.19 → 3.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) 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 +4 -3
  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 +254 -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/skills/slm-build-graph/SKILL.md +423 -0
  124. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
  125. package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
  126. package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
  127. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
  128. package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
  129. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
  130. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  131. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  132. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  133. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  134. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  135. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  136. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  137. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  138. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  139. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  140. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  141. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  142. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  143. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  144. package/src/superlocalmemory/storage/models.py +4 -0
  145. package/src/superlocalmemory/ui/css/brain.css +409 -0
  146. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  147. package/src/superlocalmemory/ui/index.html +459 -1345
  148. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  149. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  150. package/src/superlocalmemory/ui/js/init.js +48 -39
  151. package/src/superlocalmemory/ui/js/memories.js +88 -2
  152. package/src/superlocalmemory/ui/js/modal.js +71 -1
  153. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  154. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  155. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  156. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  157. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  158. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  159. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  160. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  161. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  162. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  163. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  164. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  165. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  166. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  167. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  168. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  169. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  170. package/src/superlocalmemory/ui/js/learning.js +0 -435
  171. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  172. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  173. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  174. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  175. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  176. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  177. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -11,7 +11,7 @@ import gzip
11
11
  import json
12
12
  import logging
13
13
  from typing import Optional
14
- from datetime import datetime
14
+ from datetime import datetime, timezone
15
15
 
16
16
  from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
17
17
  from fastapi.responses import StreamingResponse
@@ -79,14 +79,14 @@ async def export_memories(
79
79
  else:
80
80
  content = json.dumps({
81
81
  "version": "3.0.0",
82
- "exported_at": datetime.now().isoformat(),
82
+ "exported_at": datetime.now(timezone.utc).isoformat(),
83
83
  "total_memories": len(memories),
84
84
  "filters": {"category": category, "project_name": project_name},
85
85
  "memories": memories,
86
86
  }, indent=2)
87
87
  media_type = "application/json"
88
88
 
89
- ts = datetime.now().strftime('%Y%m%d_%H%M%S')
89
+ ts = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
90
90
  if len(content) > 10000:
91
91
  compressed = gzip.compress(content.encode())
92
92
  return StreamingResponse(
@@ -167,7 +167,7 @@ async def import_memories(request: Request, file: UploadFile = File(...)):
167
167
  if ws_manager:
168
168
  await ws_manager.broadcast({
169
169
  "type": "memory_added", "memory_id": imported,
170
- "timestamp": datetime.now().isoformat(),
170
+ "timestamp": datetime.now(timezone.utc).isoformat(),
171
171
  })
172
172
 
173
173
  except Exception as e:
@@ -12,7 +12,7 @@ import threading
12
12
  import queue as _queue
13
13
  import logging
14
14
  from typing import Optional, Set
15
- from datetime import datetime
15
+ from datetime import datetime, timezone
16
16
 
17
17
  from fastapi import APIRouter, HTTPException, Query
18
18
  from fastapi.responses import StreamingResponse
@@ -136,7 +136,7 @@ async def event_stream(
136
136
 
137
137
  # 3. Keepalive + sleep
138
138
  if not drained:
139
- yield f": keepalive {datetime.now().isoformat()}\n\n"
139
+ yield f": keepalive {datetime.now(timezone.utc).isoformat()}\n\n"
140
140
  await asyncio.sleep(1)
141
141
  finally:
142
142
  with _sse_queues_lock:
@@ -257,7 +257,7 @@ def ensure_profile_in_json(name: str, description: str = "") -> None:
257
257
  profiles[name] = {
258
258
  'name': name,
259
259
  'description': description or f'Memory profile: {name}',
260
- 'created_at': datetime.now().isoformat(),
260
+ 'created_at': datetime.now(timezone.utc).isoformat(),
261
261
  'last_used': None,
262
262
  }
263
263
  config['profiles'] = profiles
@@ -11,7 +11,7 @@ Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
11
11
  """
12
12
  import shutil
13
13
  import logging
14
- from datetime import datetime
14
+ from datetime import datetime, timezone
15
15
  from pathlib import Path
16
16
 
17
17
  from fastapi import APIRouter
@@ -23,6 +23,84 @@ router = APIRouter()
23
23
 
24
24
  LEARNING_DB = MEMORY_DIR / "learning.db"
25
25
 
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # LLD-02 §4.10 — Dashboard phase truth
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _compute_ranker_phase(
33
+ profile_id: str,
34
+ *,
35
+ learning_db_path: Path | None = None,
36
+ ) -> dict:
37
+ """Return {phase, label, model_active, signals} — LLD-02 §4.10.
38
+
39
+ Phase 3 requires BOTH an active (is_active=1) row AND a successful
40
+ SHA-256 verification on the model_cache load. Tampered bytes fall
41
+ back to phase 2.
42
+ """
43
+ from superlocalmemory.learning.database import LearningDatabase
44
+ from superlocalmemory.learning.model_cache import load_active, invalidate
45
+
46
+ db_path = Path(learning_db_path) if learning_db_path else LEARNING_DB
47
+ if not db_path.exists():
48
+ return {
49
+ "phase": 1,
50
+ "label": "Cold start (cross-encoder only)",
51
+ "model_active": False,
52
+ "signals": 0,
53
+ }
54
+
55
+ db = LearningDatabase(db_path)
56
+ try:
57
+ signals = db.count_signals(profile_id)
58
+ except Exception as exc:
59
+ logger.warning("count_signals failed: %s", exc)
60
+ signals = 0
61
+
62
+ # Force a cache-bypass load — the dashboard read is rare and we want
63
+ # tamper detection to surface immediately.
64
+ invalidate(profile_id)
65
+ try:
66
+ model = load_active(db, profile_id, use_cache=False)
67
+ except Exception as exc:
68
+ logger.warning("load_active failed: %s", exc)
69
+ model = None
70
+
71
+ active = model is not None
72
+
73
+ if active and signals >= 200:
74
+ return {
75
+ "phase": 3,
76
+ "label": "LightGBM ranker active",
77
+ "model_active": True,
78
+ "signals": signals,
79
+ }
80
+ if signals >= 50:
81
+ return {
82
+ "phase": 2,
83
+ "label": "Contextual bandit",
84
+ "model_active": False,
85
+ "signals": signals,
86
+ }
87
+ return {
88
+ "phase": 1,
89
+ "label": "Cold start (cross-encoder only)",
90
+ "model_active": False,
91
+ "signals": signals,
92
+ }
93
+
94
+
95
+ @router.get("/api/learning/ranker_phase")
96
+ async def ranker_phase():
97
+ """Dashboard endpoint — LLD-02 §4.10 phase truth."""
98
+ try:
99
+ profile = get_active_profile()
100
+ except Exception:
101
+ profile = "default"
102
+ return _compute_ranker_phase(profile)
103
+
26
104
  # Feature detection
27
105
  LEARNING_AVAILABLE = False
28
106
  BEHAVIORAL_AVAILABLE = False
@@ -341,6 +419,42 @@ async def feedback_stats():
341
419
  # PATTERNS ENDPOINT (v3.4.1 — CRITICAL FIX: frontend calls /api/patterns)
342
420
  # ============================================================================
343
421
 
422
+
423
+ @router.delete("/api/patterns/delete")
424
+ async def delete_pattern(data: dict) -> dict:
425
+ """S9-DASH-04: delete a single auto-detected pattern by key.
426
+
427
+ Body: ``{pattern_type: str, pattern_key: str}``
428
+
429
+ Returns ``{success: bool, deleted: int}``. The pattern is scoped
430
+ to the active profile so cross-profile deletion is impossible.
431
+ """
432
+ if not BEHAVIORAL_AVAILABLE:
433
+ return {"success": False, "error": "Behavioral engine not available"}
434
+ ptype = (data or {}).get("pattern_type", "")
435
+ pkey = (data or {}).get("pattern_key", "")
436
+ if not ptype or not pkey:
437
+ return {
438
+ "success": False,
439
+ "error": "pattern_type and pattern_key are required",
440
+ }
441
+ try:
442
+ profile = get_active_profile()
443
+ store = BehavioralPatternStore(str(LEARNING_DB))
444
+ deleted = store.delete_pattern_by_key(
445
+ profile_id=profile,
446
+ pattern_type=ptype,
447
+ pattern_key=pkey,
448
+ )
449
+ return {
450
+ "success": True, "deleted": int(deleted),
451
+ "active_profile": profile,
452
+ }
453
+ except Exception as exc: # noqa: BLE001
454
+ logger.error("delete_pattern failed: %s", exc)
455
+ return {"success": False, "error": str(exc)}
456
+
457
+
344
458
  @router.get("/api/patterns")
345
459
  async def get_patterns():
346
460
  """Get learned behavioral patterns for the Patterns dashboard tab.
@@ -434,7 +548,7 @@ async def learning_backup():
434
548
  if not LEARNING_DB.exists():
435
549
  return {"success": False, "error": "No learning.db found"}
436
550
 
437
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
551
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
438
552
  backup_name = f"learning.db.backup_{timestamp}"
439
553
  backup_path = MEMORY_DIR / backup_name
440
554
  shutil.copy2(str(LEARNING_DB), str(backup_path))
@@ -451,15 +565,86 @@ async def learning_backup():
451
565
 
452
566
  @router.post("/api/learning/reset")
453
567
  async def learning_reset():
454
- """Reset all learning data. Memories preserved."""
568
+ """Reset all learning data for the active profile. Memories preserved."""
455
569
  if not LEARNING_AVAILABLE:
456
570
  return {"success": False, "error": "Learning system not available"}
457
- return {"status": "not_implemented", "message": "Coming soon"}
571
+ try:
572
+ from superlocalmemory.learning.database import LearningDatabase
573
+ db = LearningDatabase(LEARNING_DB)
574
+ profile_id = get_active_profile() or "default"
575
+ db.reset(profile_id=profile_id)
576
+ return {
577
+ "success": True,
578
+ "message": "Learning data reset. Memories preserved.",
579
+ "profile_id": profile_id,
580
+ }
581
+ except Exception as exc: # noqa: BLE001
582
+ logger.error("learning_reset failed: %s", exc)
583
+ return {"success": False, "error": str(exc)}
458
584
 
459
585
 
460
586
  @router.post("/api/learning/retrain")
461
- async def learning_retrain():
462
- """Force retrain the ranking model."""
587
+ async def learning_retrain(data: dict | None = None):
588
+ """Force a retrain of the LightGBM ranker.
589
+
590
+ Body (optional, JSON):
591
+ ``{"include_synthetic": bool}`` — when True, migrated legacy rows
592
+ (``is_synthetic=1``) participate in training. Default False.
593
+ """
463
594
  if not LEARNING_AVAILABLE:
464
595
  return {"success": False, "error": "Learning system not available"}
465
- return {"status": "not_implemented", "message": "Coming soon"}
596
+ include_synthetic = bool(
597
+ data and data.get("include_synthetic")
598
+ ) if isinstance(data, dict) else False
599
+ try:
600
+ from superlocalmemory.learning.consolidation_worker import (
601
+ _retrain_ranker_impl,
602
+ )
603
+ profile_id = get_active_profile() or "default"
604
+ trained = _retrain_ranker_impl(
605
+ LEARNING_DB,
606
+ profile_id,
607
+ include_synthetic=include_synthetic,
608
+ )
609
+ if trained:
610
+ return {
611
+ "success": True,
612
+ "trained": True,
613
+ "profile_id": profile_id,
614
+ "include_synthetic": include_synthetic,
615
+ }
616
+ return {
617
+ "success": True,
618
+ "trained": False,
619
+ "profile_id": profile_id,
620
+ "include_synthetic": include_synthetic,
621
+ "message": (
622
+ "Not enough training rows yet. Keep using SLM, or run "
623
+ "legacy migration + retry with include_synthetic=true."
624
+ ),
625
+ }
626
+ except Exception as exc: # noqa: BLE001
627
+ logger.error("learning_retrain failed: %s", exc)
628
+ return {"success": False, "error": str(exc)}
629
+
630
+
631
+ @router.post("/api/learning/migrate-legacy")
632
+ async def learning_migrate_legacy():
633
+ """Copy ``learning_feedback`` rows into LLD-02 tables for training.
634
+
635
+ Idempotent: subsequent calls detect the migration_log sentinel and
636
+ return ``already_done=True`` without re-copying. The rows are written
637
+ with ``is_synthetic=1`` to preserve provenance; the trainer must be
638
+ invoked with ``include_synthetic=True`` to use them.
639
+ """
640
+ if not LEARNING_AVAILABLE:
641
+ return {"success": False, "error": "Learning system not available"}
642
+ try:
643
+ from superlocalmemory.learning.legacy_migration import (
644
+ migrate_legacy_feedback,
645
+ )
646
+ stats = migrate_legacy_feedback(LEARNING_DB)
647
+ return {"success": True, **stats}
648
+ except Exception as exc: # noqa: BLE001
649
+ logger.error("learning_migrate_legacy failed: %s", exc)
650
+ return {"success": False, "error": str(exc)}
@@ -211,8 +211,22 @@ async def get_memories(
211
211
  tags: Optional[str] = None,
212
212
  limit: int = Query(50, ge=1, le=200),
213
213
  offset: int = Query(0, ge=0),
214
+ filter: Optional[str] = Query(
215
+ None,
216
+ description="Named filter: 'high_reward' | 'being_forgotten'",
217
+ ),
214
218
  ):
215
- """List memories with optional filtering and pagination."""
219
+ """List memories with optional filtering and pagination.
220
+
221
+ S9-DASH-07: ``filter`` enables dashboard "learning-visible" views:
222
+
223
+ * ``high_reward``: facts cited by ``action_outcomes`` with
224
+ ``reward >= 0.7`` in the last 30 days. Surfaces what the ranker
225
+ is actually learning from.
226
+ * ``being_forgotten``: facts in ``archive_status='archived'`` OR
227
+ with ``lifecycle='cold'`` AND no positive reward in 60 days.
228
+ Makes "memory decay" tangible to the operator.
229
+ """
216
230
  try:
217
231
  conn = get_db_connection()
218
232
  conn.row_factory = dict_factory
@@ -277,6 +291,56 @@ async def get_memories(
277
291
  query += " AND tags LIKE ?"
278
292
  params.append(f'%{tag}%')
279
293
 
294
+ # S9-DASH-07: named filters — "high_reward" and "being_forgotten".
295
+ # Only supported on the v3 (atomic_facts) path — v2 fallback
296
+ # ignores the flag silently.
297
+ if filter and use_v3:
298
+ if filter == "high_reward":
299
+ query += (
300
+ " AND fact_id IN ("
301
+ " SELECT DISTINCT json_each.value"
302
+ " FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
303
+ " WHERE action_outcomes.reward >= 0.7"
304
+ " AND datetime(action_outcomes.settled_at) >= "
305
+ " datetime('now', '-30 day')"
306
+ ")"
307
+ )
308
+ count_base += (
309
+ " AND fact_id IN ("
310
+ " SELECT DISTINCT json_each.value"
311
+ " FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
312
+ " WHERE action_outcomes.reward >= 0.7"
313
+ " AND datetime(action_outcomes.settled_at) >= "
314
+ " datetime('now', '-30 day')"
315
+ ")"
316
+ )
317
+ elif filter == "being_forgotten":
318
+ # Cold / archived + no recent positive reward.
319
+ query += (
320
+ " AND ("
321
+ " archive_status = 'archived' OR "
322
+ " (lifecycle = 'cold' AND fact_id NOT IN ("
323
+ " SELECT DISTINCT json_each.value"
324
+ " FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
325
+ " WHERE action_outcomes.reward >= 0.5"
326
+ " AND datetime(action_outcomes.settled_at) >= "
327
+ " datetime('now', '-60 day')"
328
+ " ))"
329
+ ")"
330
+ )
331
+ count_base += (
332
+ " AND ("
333
+ " archive_status = 'archived' OR "
334
+ " (lifecycle = 'cold' AND fact_id NOT IN ("
335
+ " SELECT DISTINCT json_each.value"
336
+ " FROM action_outcomes, json_each(action_outcomes.fact_ids_json)"
337
+ " WHERE action_outcomes.reward >= 0.5"
338
+ " AND datetime(action_outcomes.settled_at) >= "
339
+ " datetime('now', '-60 day')"
340
+ " ))"
341
+ ")"
342
+ )
343
+
280
344
  query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
281
345
  params.extend([limit, offset])
282
346
 
@@ -569,6 +633,130 @@ async def delete_memory(request: Request, fact_id: str):
569
633
  raise HTTPException(status_code=500, detail=f"Delete error: {str(e)}")
570
634
 
571
635
 
636
+ @router.post("/api/memories/{fact_id}/forget")
637
+ async def forget_memory(request: Request, fact_id: str):
638
+ """S9-DASH-08: soft-forget a fact — flip archive_status='archived'.
639
+
640
+ Non-destructive: the row stays in ``atomic_facts`` for audit and
641
+ can be un-archived later. Default recall paths filter it out.
642
+ The fact's payload is ALSO copied into ``memory_archive`` so a
643
+ future ``slm restore`` can bring it back.
644
+ """
645
+ import json as _json
646
+ try:
647
+ conn = get_db_connection()
648
+ conn.row_factory = dict_factory
649
+ cursor = conn.cursor()
650
+ active_profile = get_active_profile()
651
+ cursor.execute(
652
+ "SELECT fact_id, content, importance, confidence, "
653
+ " canonical_entities_json, embedding, created_at "
654
+ "FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
655
+ (fact_id, active_profile),
656
+ )
657
+ row = cursor.fetchone()
658
+ if not row:
659
+ conn.close()
660
+ raise HTTPException(status_code=404, detail="Memory not found")
661
+ # Archive copy — payload_json small enough for the canonical row.
662
+ payload = {
663
+ "fact_id": row["fact_id"],
664
+ "content": row["content"],
665
+ "canonical_entities_json": row.get("canonical_entities_json"),
666
+ "importance": row.get("importance"),
667
+ "confidence": row.get("confidence"),
668
+ "created_at": row.get("created_at"),
669
+ }
670
+ from datetime import datetime, timezone
671
+ archived_at = datetime.now(timezone.utc).isoformat()
672
+ import uuid as _uuid
673
+ cursor.execute(
674
+ "INSERT INTO memory_archive "
675
+ "(archive_id, fact_id, profile_id, payload_json, archived_at, reason) "
676
+ "VALUES (?, ?, ?, ?, ?, ?)",
677
+ (str(_uuid.uuid4()), fact_id, active_profile,
678
+ _json.dumps(payload), archived_at, "user_forget_dashboard"),
679
+ )
680
+ cursor.execute(
681
+ "UPDATE atomic_facts SET archive_status = 'archived' "
682
+ "WHERE fact_id = ?",
683
+ (fact_id,),
684
+ )
685
+ conn.commit()
686
+ conn.close()
687
+ return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
688
+ except HTTPException:
689
+ raise
690
+ except Exception as e:
691
+ raise HTTPException(status_code=500, detail=f"Forget error: {str(e)}")
692
+
693
+
694
+ @router.post("/api/memories/{fact_id}/merge")
695
+ async def merge_memory(request: Request, fact_id: str):
696
+ """S9-DASH-08: merge this fact into another (keep the other).
697
+
698
+ Body: ``{into: <kept_fact_id>}``.
699
+
700
+ Writes a ``memory_merge_log`` row (M011) for provenance and marks
701
+ the loser's ``merged_into`` column. The loser is archived so it
702
+ no longer appears in default recall. The winner is untouched.
703
+ """
704
+ try:
705
+ body = await request.json()
706
+ kept = str((body or {}).get("into", "")).strip()
707
+ if not kept:
708
+ raise HTTPException(400, "Body field 'into' is required")
709
+ # S9-AUDIT: cap length defensively — fact_ids are UUID-v4 36 chars.
710
+ if len(kept) > 200:
711
+ raise HTTPException(400, "'into' exceeds 200-char limit")
712
+ if kept == fact_id:
713
+ raise HTTPException(400, "Cannot merge a fact into itself")
714
+ conn = get_db_connection()
715
+ conn.row_factory = dict_factory
716
+ cursor = conn.cursor()
717
+ active_profile = get_active_profile()
718
+ # Both must belong to the active profile.
719
+ cursor.execute(
720
+ "SELECT fact_id FROM atomic_facts "
721
+ "WHERE fact_id IN (?, ?) AND profile_id = ?",
722
+ (fact_id, kept, active_profile),
723
+ )
724
+ found = {r["fact_id"] for r in cursor.fetchall()}
725
+ if fact_id not in found or kept not in found:
726
+ conn.close()
727
+ raise HTTPException(
728
+ 404,
729
+ "Both fact_ids must exist in the active profile",
730
+ )
731
+ from datetime import datetime, timezone
732
+ merged_at = datetime.now(timezone.utc).isoformat()
733
+ cursor.execute(
734
+ "INSERT INTO memory_merge_log "
735
+ "(kept_fact_id, merged_fact_id, profile_id, reason, merged_at) "
736
+ "VALUES (?, ?, ?, ?, ?)",
737
+ (kept, fact_id, active_profile,
738
+ "user_merge_dashboard", merged_at),
739
+ )
740
+ cursor.execute(
741
+ "UPDATE atomic_facts "
742
+ "SET merged_into = ?, archive_status = 'archived' "
743
+ "WHERE fact_id = ?",
744
+ (kept, fact_id),
745
+ )
746
+ conn.commit()
747
+ conn.close()
748
+ return {
749
+ "success": True,
750
+ "merged": fact_id,
751
+ "into": kept,
752
+ "merged_at": merged_at,
753
+ }
754
+ except HTTPException:
755
+ raise
756
+ except Exception as e:
757
+ raise HTTPException(status_code=500, detail=f"Merge error: {str(e)}")
758
+
759
+
572
760
  @router.patch("/api/memories/{fact_id}")
573
761
  async def edit_memory(request: Request, fact_id: str):
574
762
  """Edit the content of a specific memory (atomic fact)."""
@@ -0,0 +1,171 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.22 — LLD-01 §4.4 / §4.5
4
+
5
+ """POST /internal/prewarm — populates the context cache for a session.
6
+
7
+ S8-SK-02 fix: Wave 2A shipped ``hooks/prewarm_auth.authorize`` (gates
8
+ loopback → origin → install-token → body-size) and unit-tested it, but
9
+ no FastAPI route mounted it. The hot-path ``post_tool_async_hook`` POSTs
10
+ to ``/internal/prewarm`` after every tool call to refresh the
11
+ ``active_brain_cache`` row for the current session/topic. Without a
12
+ route registered here, those POSTs 404'd silently, the cache never
13
+ populated, and every ``UserPromptSubmit`` ended up a structural miss.
14
+
15
+ Design notes
16
+ ------------
17
+ * All 4 gates from LLD-01 §4.4 run before any engine work: loopback
18
+ peer, absence of browser ``Origin`` header, valid install-token, body
19
+ <= ``MAX_BODY_BYTES``. On any gate failure we return the decision's
20
+ status code with ``application/json`` error envelope and do not touch
21
+ the engine. This is LLD-07 SEC-HR-03 applied at the edge.
22
+ * The route is async; the actual cache write (``ContextCache.upsert``)
23
+ is synchronous SQLite and runs on the default executor via
24
+ ``asyncio.to_thread`` so we never block the event loop.
25
+ * Body schema is intentionally narrow: ``{"session_id": str,
26
+ "prompt": str, "content": str, "fact_ids": list[str]}``. Missing or
27
+ wrong-type fields produce 400.
28
+ * Never raises past this function. Any unexpected exception is caught,
29
+ logged at ``debug`` to avoid log flooding under a hostile peer, and
30
+ returned as 500 JSON. The hot path ``post_tool_async_hook`` treats
31
+ any non-2xx as "fire-and-forget, try again later", so degradation is
32
+ graceful.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import logging
39
+ import time
40
+
41
+ from fastapi import APIRouter, Request
42
+ from fastapi.responses import JSONResponse
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ router = APIRouter(tags=["internal"])
47
+
48
+
49
+ _ALLOWED_BODY_KEYS = frozenset({"session_id", "prompt", "content", "fact_ids"})
50
+
51
+
52
+ @router.post("/internal/prewarm")
53
+ async def prewarm(request: Request) -> JSONResponse:
54
+ """Write (or refresh) a context-cache entry for the caller's session.
55
+
56
+ Gates (LLD-01 §4.4):
57
+ 1. Loopback-only client (127.0.0.1 / ::1 / localhost).
58
+ 2. Reject browser-originated calls (any ``Origin`` header).
59
+ 3. Install-token present and constant-time-verified.
60
+ 4. Body <= ``MAX_BODY_BYTES``.
61
+
62
+ On success, returns ``{"ok": true}``. On any failure, returns the
63
+ AuthDecision's status with a terse JSON body. Never exposes engine
64
+ error detail to the caller.
65
+ """
66
+ # Gates 1-3 first (cheap; reject hostile peers before reading body).
67
+ try:
68
+ from superlocalmemory.hooks.prewarm_auth import (
69
+ MAX_BODY_BYTES,
70
+ authorize,
71
+ check_body_size,
72
+ )
73
+ except Exception as exc: # pragma: no cover — primitives always present
74
+ logger.debug("prewarm: auth primitives unimportable: %s", exc)
75
+ return JSONResponse({"error": "server_error"}, status_code=500)
76
+
77
+ client_host = request.client.host if request.client else ""
78
+ headers = {k.lower(): v for k, v in request.headers.items()}
79
+
80
+ decision = authorize(client_host=client_host, headers=headers)
81
+ if not decision.allowed:
82
+ return JSONResponse(
83
+ {"error": decision.reason}, status_code=decision.status,
84
+ )
85
+
86
+ # Gate 4 — read body with a hard size cap. FastAPI/Starlette has no
87
+ # cheap way to check the Content-Length up front in all servers, so
88
+ # we read at most MAX_BODY_BYTES+1 and reject if we got more.
89
+ try:
90
+ body_bytes = await request.body()
91
+ except Exception as exc: # pragma: no cover
92
+ logger.debug("prewarm: body read failed: %s", exc)
93
+ return JSONResponse({"error": "bad_body"}, status_code=400)
94
+ ok, reason = check_body_size(body_bytes)
95
+ if not ok:
96
+ return JSONResponse({"error": reason}, status_code=413)
97
+
98
+ try:
99
+ import json as _json
100
+ payload = _json.loads(body_bytes or b"{}")
101
+ except Exception:
102
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
103
+
104
+ if not isinstance(payload, dict):
105
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
106
+
107
+ # Narrow contract: reject unknown keys to keep the surface small.
108
+ # S10-SEC-N-02: fixed error tag, never echo attacker-supplied keys.
109
+ unknown = set(payload.keys()) - _ALLOWED_BODY_KEYS
110
+ if unknown:
111
+ return JSONResponse(
112
+ {"error": "unknown_keys"}, status_code=400,
113
+ )
114
+
115
+ session_id = payload.get("session_id")
116
+ prompt = payload.get("prompt")
117
+ content = payload.get("content")
118
+ fact_ids = payload.get("fact_ids") or []
119
+ if not isinstance(session_id, str) or not session_id:
120
+ return JSONResponse({"error": "session_id_required"}, status_code=400)
121
+ if not isinstance(prompt, str) or not prompt:
122
+ return JSONResponse({"error": "prompt_required"}, status_code=400)
123
+ if not isinstance(content, str) or not content:
124
+ return JSONResponse({"error": "content_required"}, status_code=400)
125
+ if not isinstance(fact_ids, list) or not all(
126
+ isinstance(f, str) for f in fact_ids
127
+ ):
128
+ return JSONResponse({"error": "fact_ids_list"}, status_code=400)
129
+
130
+ try:
131
+ topic_sig = await asyncio.to_thread(_compute_topic_sig, prompt)
132
+ await asyncio.to_thread(
133
+ _upsert_cache,
134
+ session_id=session_id,
135
+ topic_sig=topic_sig,
136
+ content=content,
137
+ fact_ids=fact_ids,
138
+ )
139
+ except Exception as exc: # pragma: no cover — defensive
140
+ logger.debug("prewarm: upsert failed: %s", exc)
141
+ return JSONResponse({"error": "upsert_failed"}, status_code=500)
142
+
143
+ return JSONResponse({"ok": True})
144
+
145
+
146
+ def _compute_topic_sig(prompt: str) -> str:
147
+ """Lazy import so module import is free of hot-path SLM modules."""
148
+ from superlocalmemory.core.topic_signature import compute_topic_signature
149
+ return compute_topic_signature(prompt)
150
+
151
+
152
+ def _upsert_cache(
153
+ *, session_id: str, topic_sig: str,
154
+ content: str, fact_ids: list[str],
155
+ ) -> None:
156
+ from superlocalmemory.core.context_cache import CacheEntry, ContextCache
157
+ cache = ContextCache()
158
+ try:
159
+ cache.upsert(CacheEntry(
160
+ session_id=session_id,
161
+ topic_sig=topic_sig,
162
+ content=content,
163
+ fact_ids=tuple(fact_ids),
164
+ provenance="prewarm_post_tool",
165
+ computed_at=int(time.time()),
166
+ ))
167
+ finally:
168
+ cache.close()
169
+
170
+
171
+ __all__ = ("router",)